Inject from your controller to decouple your contexts in Phoenix

Apr 24, 2020

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 show an example here of how you can express how you want your context to behave from your controller.

Say you have a blog and your post’s have a comment section which means you will need to preload the comments on your post show controller. If you are new to Ecto, preloading is the process of expressing that you not only wish to query the resource, but also query for any of its specified associations. In this case that would be comments.

Naturally the first thing you do is look in your context for where the Ecto query is running. In our case for your blog post it would look something like this.

# lib/my_app/blog.ex

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

If you where to just slap your preload clause in here, your context would end up always preloading your associations even in cases where its not useful. For example if you want to delete your post this can be wasteful when using the get_post! function to look up the post, because you have no need for any of the post’s associations such as its tags or comments.

One way to solve this would be to just create another context function that does not preload and another that does, but thats not very dry. 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.

# lib/my_app_web/controller/post_controller.ex

def show(conn, %{"slug" => slug}) do
  post =
    Blog.get_post!(slug,
      preload: [Blog.tags_preload, Blog.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 Blog context. This is because I don’t want to expose my Repo module to the controller otherwise I would defeat the point of the context in the first place.

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 option. We will need to update our context function to handle the new option. We will also need to define our Blog preload functions that will be used when calling Blog.get_post!/2

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

# lib/my_app/blog.ex

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.

# my_app/lib/repo.ex

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 = Blog.get_post!(post_slug)

or

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

And thats it. Happy coding!