Organizing code takes a lot of work. It is easy to make a mess as we build and grow our applications. This post aims to dive into the concept of contexts in Elixir by using real-world examples and understanding how it can help us keep our codebases manageable.
Organizing Elixir code with Contexts
Contexts are a simple yet powerful technique to handle complexities in an Elixir codebase. They are all about organizing applications through namespaces that group together the business logic for a specific domain or feature of the application, allowing us to break down the code into smaller and simpler chunks. Each context corresponds to a particular scope of functionality, such as handling user authentication or managing product inventory. This strategy allows a more organized and modular structure for the application’s codebase.
The idea of splitting the codebase into contexts may sound natural to you as we go through this post. However, I frequently see people misusing it, leading to poorly designed software. The key is understanding what we should group and what needs to be exposed. The section Thinking About Design in the Phoenix documentation summarizes that idea by using Elixir’s standard logger as an example:
[…] anytime you call Elixir’s standard library, be it
Logger.info/1
orStream.map/2
, you are accessing different contexts. Internally, Elixir’s logger is made of multiple modules, but we never interact with those modules directly. We call theLogger
module the context, exactly because it exposes and groups all of the logging functionality.
If you are into design patterns, you may recognize that the idea of contexts is very similar to the Facade pattern from object-oriented programming. They talk about the same thing, as we can see in the following quote from Wikipedia:
The facade pattern (also spelled façade) is a software-design pattern commonly used in object-oriented programming. Analogous to a facade in architecture, a facade is an object that serves as a front-facing interface masking more complex underlying or structural code. A facade can:
- improve the readability and usability of a software library by masking interaction with more complex components behind a single (and often simplified) API;
- provide a context-specific interface to more generic functionality (complete with context-specific input validation);
- serve as a launching point for a broader refactor of monolithic or tightly-coupled systems in favor of more loosely-coupled code.
Both concepts fight against the same enemy, pieces of code too complex to understand and maintain. If we keep our codebase organized in small, meaningful modules and put what’s related behind the same context, we will be writing good software most of the time.
Using contexts
We have talked about many things so far. Now, let’s start analyzing real-world code. We will begin with Elixir’s Logger implementation, then go to the contexts we implemented in LiveMatch.
The Logger implementation
Below, we have a diagram representing Elixir’s Logger module. There are also some code snippets extracted from its implementation:
# lib/logger.ex
defmodule Logger do
@moduledoc ~S"""
A logger for Elixir applications.
"""
def configure(options) do
# ...
Logger.Config.configure(options)
# ...
end
def add_backend(backend, opts \\ []) do
# ...
case Logger.BackendSupervisor.watch(backend) do
# ...
end
end
def add_translator({mod, fun} = translator) when is_atom(mod) and is_atom(fun) do
Logger.Config.add_translator(translator)
end
end
# lib/logger/config.ex
defmodule Logger.Config do
@moduledoc false
def configure(options) do
# ...
end
# ...
end
# lib/logger/backend_supervisor.ex
defmodule Logger.BackendSupervisor do
@moduledoc false
def watch(backend) do
# ...
end
end
Notice how the diagram matches the code. We have the Logger module with functions exposing its functionalities - the public API - but delegating its calls to more specialized, internal modules.
Not all functions are like that. Some of them have their implementations in the Logger module itself. It depends on what you need to code. Sometimes the functionality you write is simple enough to keep its implementation in the base module, so keep it that way.
Multiple contexts in a Phoenix Application
Let’s raise the bar and see how we can use multiple contexts in a Phoenix application. We will take as an example our first project, LiveMatch, and analyze how we organized its contexts.
You can see how the diagram matches the code. Matches
and Teams
are currently our primary contexts in LiveMatch.
# lib/live_match/matches.ex
defmodule LiveMatch.Matches do
@moduledoc """
LiveMatch.Matches context
"""
# ...
defdelegate create_event(attrs), to: Events
defdelegate start_live(match, opts \\ []), to: MatchSupervisor, as: :start_live_match
defdelegate stop_live(match), to: MatchSupervisor, as: :stop_live_match
def live?(%Match{id: id}) do
# ...
end
def get_live_match_state(%Match{id: id} = match) do
state = MatchServer.get_match_state(id)
# ...
end
def score_match_goal(match, team) when team in [:home, :away] do
# ...
end
def list_matches(criterias \\ %{}) do
# ...
end
def create_match(attrs) do
# ...
end
def update_match(match, attrs) do
# ...
end
# ...
end
# lib/live_match/matches/match_supervisor.ex
defmodule LiveMatch.Matches.MatchSupervisor do
@moduledoc """
LiveMatch.Matches.MatchSupervisor module
"""
def start_live_match(match, opts \\ []) do
# spawns and supervises a new MatchServer process
end
def stop_live_match(match) do
# stops a MatchServer process
end
end
# lib/live_match/matches/match_server.ex
defmodule LiveMatch.Matches.MatchServer do
@moduledoc """
LiveMatch.Matches.MatchSupervisor module
It controls a match transmission
"""
# ...
end
The Matches
context is where we manage everything about a match in the app. Notice how it behaves, delegating some calls to its underlying modules. Let’s see some examples of how it works:
Getting all matches from today:
iex> LiveMatch.Matches.list_matches(%{period: :today})
[%Match{}, ...]
Starting/stopping a match:
iex> LiveMatch.Matches.start_live(match)
{:ok, #PID<0.110.0>}
iex> LiveMatch.Matches.stop_live(match)
:ok
Verifying if a match is live:
iex> LiveMatch.Matches.live?(match)
true
Scoring a goal for the home team:
iex> LiveMatch.Matches.score_match_goal(match, :home)
%Match{}
And so on.
Subcontexts
It’s also worth mentioning the Matches.Events
module that acts as a subcontext of Matches
context:
# lib/live_match/matches/events.ex
defmodule LiveMatch.Matches.Events do
@moduledoc """
LiveMatch.Matches.Events subcontext
"""
# ...
end
# lib/live_match/matches/events/event.ex
defmodule LiveMatch.Matches.Events.Event do
@moduledoc """
LiveMatch.Matches.Events.Event schema
It represents an event from a match
"""
# ...
end
We realized that match events functions would work better moving them to a new context due to the number of functionalities like its CRUD and event broadcasting, but still be part of the Matches
context, since it doesn’t make sense to move it away from there, considering they are still part of a match. That’s why we call it a subcontext. Also, by using it that way, we can still use the Matches context as the entry point for everything related to a match in the app.
Finally, to achieve that without creating wrapper functions delegating to our subcontext, we use the defdelegate
macro:
defdelegate create_event(attrs), to: Events
defdelegate update_event(event, attrs), to: Events
defdelegate delete_event(event), to: Events
For example, creating an event for a live match in the app is easy. We only need to know that the Matches
context has a function capable of doing that. It doesn’t matter how its implementation is for the outside.
iex> LiveMatch.Matches.create_event(%{
match_id: match.id,
time: 0,
type: :kick_off,
description: "First half begins.",
period: :first_half
})
%Matches.Events.Event{}
Teams context
The teams’ context, for now, is straightforward. It has its context module Teams
with CRUD functions and a Teams.Team
schema that maps a table from the database.
defmodule LiveMatch.Teams do
@moduledoc """
LiveMatch.Teams context
"""
# ...
def create_team(attrs) do
%Team{}
|> Team.changeset(attrs)
|> Repo.insert()
end
# ...
end
That’s it!
Last thoughts
These challenges are not unique to Elixir applications, and contexts are about software design, a vital topic within organizations working with software development.
Poor software design leads to wrong decisions that cost time and resources, such as rewriting the whole app in another framework or programming language – because we are tired of the messy code – by thinking the problem is in the tech stack or even breaking the monolith prematurely or unnecessarily into microservices.
But anyway, I hope the ideas shared here gave you insights to improve your code and new thoughts for your next mix new app
or mix phx.new app
.
Thanks for reading!
See you in the next post!