Inject from your controller to decouple your contexts in Phoenix.

If you are new to phoenix and are coming from a framework like rails the context can sometimes throw you off a little bit specifically when it comes to the boundaries of your controller.

I will use this very blog as an example of how you can still use your controller to help express how you want your context to behave.

So say you have a blog such as this. Your posts have a comment section which means you will need to preload the comments on your show controller.

If you are new to Ecto preloading is the process of expressing that you not only wish to query the resource in question, but also query for any of its specified associations. In this case that would be comments.

So naturally the first thing you do is look in your context for something like.

  def get_post!(slug), do: Repo.get_by(Post, slug: slug)

If you think to define your preload clause in your context then you will end up always preloading your associations even in cases where its not useful. For example when I create a comment I use my get_post! context function to look up the associated post, but I have no need for any of the post’s associations such as its tags or other comments.

One way to solve this would be to just create another context function that does not preload and another that does.

A better way to solve this problem would be to specify how we would like our preloader to work in the place that is calling our context action and pass it as a argument.

Here’s how that may look from the place that calls the context function, in this case our show action in our controller.

  def show(conn, %{"slug" => slug}) do
    post =
      Social.get_post!(slug,
        preload: [Social.tags_preload, Social.approved_comments_preload]
      )
    render(conn, "show.html", post: post)
  end

As you can see in my show controller action I specify to preload the tags and comments via functions found in my Social context. This is because I don’t want to expose my Repo module to my controller otherwise I would defeat the point of the context in the first place.

Ok so back to our solution. Our context is now being called with two arguments. The first is the same as before, but the second is our new preloader options. We will need to update our context function to handle the new options. We will also need to define our Social preload functions that will be used when calling Social.get_post!/2

This is how we handle our new functions in our context.

  def get_post!(slug, options \\ []) do
    preload = Keyword.get(options, :preload, [])

    Post
    |> Repo.by_slug(slug)
    |> from(preload: ^preload)
    |> Repo.one!()
  end
  
  def tags_preload do
    :tags
  end

  def approved_comments_preload do
    {:comments, {Comment |> Repo.approved() |> Repo.order_by_oldest(), :user}}
  end

The first thing you see here is that I added an options list to my get_post!. Moving on we see we then use Keyword.get/3 to get our preload from our options Keyword list. It will default preload to an empty list given its not found.

After that we then define a few simple wrappers for our preload Ecto.Query.from/2 to abstract away our repo. To do that we create two more functions called tags_preload and approved_comments_preload.

From there on out it’s as simple as just creating another function in our context that we will use as a wrapper for our preload definition.

Note: Repo.approved() |> Repo.order_by_oldest() and Repo.by_slug(slug) are composable queries I have saved as common functions in my Repo. Here’s how they are defined if you are wondering.

  def approved(query) do
    from(q in query, where: q.approved == true)
  end

  def order_by_oldest(query) do
    from(q in query, order_by: [asc: q.inserted_at])
  end
    
    def by_slug(query, slug) do
    from(q in query,  where: q.slug == ^slug)
  end

So with all this I can now call my preload query at the place where I’m calling my context allowing my call to be that more expressive with minimal code duplication.

Such as:

    post = Social.get_post!(post_slug)

or

  post =
      Social.get_post!(slug,
        preload: [Social.tags_preload, Social.approved_comments_preload]
      )

And thats it. Happy coding!

c

Comments

7 months ago
Josh Chernoff

So after reading some feed back there is a little bit of an issue with this post. You are still coupling your repo to controller which what the context is also trying to hide from you. I will revise this post to show you how can still hide the repo behind the context but hopefully still allow for as expressive requests from your context.