Kill your Phoenix Context
Hey folks, welcome back to another blog post! :)
First, as always, the ceremonial dad joke:
What do you call a typo on a headstone?
A grave mistake.
Anyways! This post is a mini-rant about Phoenix Contexts and how not to use them. Enjoy!
Retraction
Watch out: I retracted this blog post because I changed my mind about Phoenix contexts lately. Read my retraction which includes my new favorite design here: Resurrect your Phoenix Context
The Problem
I have a personal pet peeve with Phoenix.Contexts and think that almost everybody (including myself) uses them wrong. Let me explain.
Let’s say that you generate a new context for a “thing” in your Phoenix application. Initially, your context contains only the standard set of CRUD operations (Create, Retrieve, Update, Delete). That’s great to get you started, but as you build your application, you start writing more complex code like intricate business rules, complex queries, permission checks, custom validations, etc. Now you face a problem. Where should you put this code? All this code is somewhat “thing” related, so you decide to put it into your context module, next to your CRUD operations. After all, even the Phoenix docs say that Contexts are dedicated modules that expose and group related functionality
.
You feel good about your decision. You gave some thought to how to organize your code, analysed the existing code structure, and made a decision. Congratjulations, you’re a Software Architect now.
Now, let’s forward in time a little bit. You keep on adding features and your application has grown a fair bit. You continue to move any “thing” related code to your context module. You see how the context module grows and grows and you start to feel uneasy about it. You find it harder to navigate the module. You can’t find the right functions without autocomplete or text search. Your test file is even larger than your context file and you lost an overview of what you’re actually testing a long time ago. Your context has become the dumping ground for everything even vaguely related to “thing”.
The (potential) Reason
Every single Phoenix project that I’ve worked on so far had this problem. Maybe the problem is me (probably). But when people ask me to analyse their projects, I often see the same (anti-)pattern. I think the problem is caused by two factors:
Phoenix-as-a-Role-Model
First, Phoenix follows the same pattern, so it’s easy to think that it’s a good pattern. For example, if you generate an authentication system with mix phx.gen.auth
, Phoenix creates a single context that holds all your “backend” authentication logic. Most of them are CRUD functions for your User
-schema, but it also contains complex business logic like delivering the confirmation email:
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do
{:error, :already_confirmed}
else
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
end
end
Having this complex business logic inside the context module isn’t wrong. It always depends on your project requirements and your own preference. My point is that it implies that it’s okay to put business logic here. So, when a developer looks for a good place to put their own business logic, they might decide on the context module without a second thought because after all Phoenix does it too! Even the Phoenix docs say that Contexts ... group related functionality
.
The docs also say to think of them as boundaries to decouple and isolate parts of your application.
. That guideline helps when we need to separate the code for different “things”, but it doesn’t imply that we need to decouple the code inside a single context as well. So, Phoenix and its docs don’t push back against this anti-pattern and even promote it a bit.
I just want to point out that I’m not criticising the work of the Phoenix team here. They do a great job of getting us started quickly with their amazing generators. My point is that after the initial kick-off, it’s our responsibility as developers to build upon that and to improve it. Sadly, we don’t always know how which brings me to the second factor.
You don’t know what you don’t know
The second factor is that developers often don’t know better. The internet is filled with tutorials about how to build stuff, but there are very few tutorials about how to structure your stuff. I wish the internet had as many code structure fanatics as it has home organization nerds. We’d all be showing off with pictures of our clean codebase on Pinterest.
And we would all know that dumping all your code into one module isn’t a great way of organizing your code. But that’s not the world we live in, so let me share with you my personal opinion on how you could structure your code better.
The Solution (Proposal)
My (very opinionated) solution proposal aims at breaking up your Phoenix Context into smaller components with the goal of making your overall codebase more maintainable. We’ll do so in four steps:
- Move CRUD operations into a
repository
- Move complex queries into
finders
- Move business logic into
services
- (Optional) Make your Context your internal Interface
Let’s go through them one-by-one. Here’s an example of a convoluted Phoenix Context that we need to break up:
defmodule App.Things do @moduledoc "The auto-generated Phoenix Context for Thing"
# Example CRUD operations
def create_thing(attrs), do: # Creates a thing
def update_thing(thing, attrs), do: # Updates a thing
# Example Business Logic
def create_thing_and_send_thing_created_email(attrs, user) do
with {:ok, thing} <- create_thing(attrs),
:ok <- deliver_thing_created(thing) do
:ok
end
end
# Example complex Query with joins, conditions, etc.
def list_things_that_are_relevant_to_customer_in_month(customer, month) do
query =
from(thing in Thing,
join: # join another thing,
where: # complex conditions
select: # custom select statement
)
Repo.all(query)
end
end
This context contains a lot of code that is related to “thing” but is unrelated to each other, so let’s break it up.
Repository
The first step is to refactor our CRUD operations using the repository pattern. A Repository
concentrates all CRUD operations for one schema or context. Usually, it only queries the database, but we could also add a caching mechanism here. Adding a repository is as simple as moving the CRUD operations to a new module called App.Things.ThingRepo
:
defmodule App.Things.ThingRepo do @moduledoc "The Repository for Thing"
use App, :repository # <- You can import Ecto.Query and alias App.Repo in here for easy reuse
# Example CRUD operations
def create_thing(attrs), do: # Creates a thing
def update_thing(thing, attrs), do: # Updates a thing
end
Now, with our CRUD operations out of the way, we can focus on the business logic and complex queries.
Finders
Next, we move our complex queries into dedicated finder
modules. We could also move the complex queries to our new repository, but isolating complex and very specific queries into their own modules keeps our repository clean and makes it easier to test them. For each query, we create a new module like this:
defmodule App.Things.Finders.ListThingsThatAreRelevantToCustomerInMonth do @moduledoc "The Query for listing all relevant Things for a Customer in a given Month"
use App, :finder # <- You can import Ecto.Query and alias App.Repo in here for easy reuse
# Note that all finders have the same public function called "find".
def find(customer, month) do
query =
from(thing in Thing,
join: # join another thing,
where: # complex conditions
select: # custom select statement
)
Repo.all(query)
end
end
Services
As a last step, we need to move our complex business logic into dedicated services
. We usually create one service per “use-case”. A use-case can be anything that your application user does, like creating or updating things, but it’s often better to use “business language” to describe a use-case. So, instead of calling a service CreateThing
, we use the user’s intent or end goal to describe the use-case. For example, a “thing” might be an order in our e-commerce system, so creating a thing means that a customer purchased a product and we should schedule the product for delivery. So, instead of calling the use-case CreateOrder
, we can call it CompleteOrderAndScheduleDelivery
.
Our service might look like this:
defmodule App.Things.Services.CompleteOrderAndScheduleDelivery do alias App.Things.ThingRepo
alias App.Things.Services.ScheduleOrderDelivery
# A Service from another Context
# You can replace this also with an internal interface
# as discussed in the next section
alias App.Notifications.Services.DeliverOrderScheduledEmail
def call(attrs, user) do
with {:ok, order} <- ThingRepo.create_order(attrs),
{:ok, delivery_date} <- ScheduleOrderDelivery.call(order),
:ok <- DeliverOrderScheduledEmail.call(order, delivery_date) do
:ok
end
end
end
Now, every use-case is encapsulated into a single service and we can easily test, update, and delete each use-case individually.
Context as Internal Interface
The last but optional step is to convert the Phoenix Context into an internal interface for all “thing” related calls. This isn’t always necessary, but if you develop a large application with many teams, it might help if every team exposes a single interface through which all other teams can call the team’s repositories, services, and finders. If you don’t offer such an interface, other teams will call these modules directly. If you don’t watch out, your system might become a giant ball of mud where a change to one service breaks ten services from other teams. Even worse, the team who owns that service won’t have the domain knowledge of the other teams, so they can’t change their own service without collaborating with the other teams.
If you fear that you might run into this situation, you can convert your Phoenix Context into an internal interface. In our situation, such an interface might look like this:
defmodule App.Things do @moduledoc "The internal interface for 'Thing'"
alias App.Things.ThingRepo
alias App.Things.Services
alias App.Things.Finders
# You can make explicit function calls or use defdelegate
#
# Note, that if you use defdelegate, you cannot change the
# parameters of the ThingRepo function without also changing
# the calling function outside of the Things context.
def create_thing(attrs), do: ThingRepo.create_thing(attrs)
defdelegate update_thing(thing, attrs), to: ThingRepo
# By creating a function for each service or finder
# you can easily control which services and finders
# you want to expose to other teams and which ones
# you rather keep internal.
#
# You don't have that control when you don't have such
# an interface and other teams can simply call your
# services or finders directly.
def complete_order_and_schedule_delivery(attrs, user) do
Services.CompleteOrderAndScheduleDelivery.call(attrs, user)
end
# By using an internal interface, you can add multiple
# functions for calling the same query. That's handy
# if you want to define default parameters without
# exposing what the default should be.
def list_things_that_are_relevant_to_customer_in_month(customer) do
month = Date.utc_today().month
list_things_that_are_relevant_to_customer_in_month(customer, month)
end
def list_things_that_are_relevant_to_customer_in_month(customer, month) do
Finders.ListThingsThatAreRelevantToCustomerInMonth.find(customer, month)
end
end
Conclusion
Please note that the proposed software design for replacing your Phoenix Contexts with services, finders, and repositories might not work for you or your project. My golden rule when it comes to software design is “Keep it super simple (KISS)”. So, take what helps and ignore what doesn’t as long as you stay vigilant about your code organization.
And that’s it! I hope you enjoyed this article! If you have questions or comments, let’s discuss them on BlueSky. Follow me on BlueSky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until the next time! Cheerio 👋