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 or Stream.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 the Logger 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:

logger context and sub-modules
The Logger module is the context. It works like an entry point for specialized functionality, defined in its underlying modules.
# 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.

livematch contexts and sub-modules
Matches and Teams contexts with their underlying modules

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.

It’s also worth mentioning the Matches.Events sub-context:

# lib/live_match/matches/events.ex
defmodule LiveMatch.Matches.Events do
  @moduledoc """
  LiveMatch.Matches.Events sub-context
  """

  # ...
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 those features would work better as a new context due to the number of functionalities like its CRUD and event broadcasting, but still be part of the Matches context. That’s why we call it a sub-context. It doesn’t make sense to move it away from there, considering they are still part of a match. 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 sub-context, we use the defdelegate macro:

defdelegate create_event(attrs), to: Events
defdelegate update_event(event, attrs), to: Events
defdelegate delete_event(event), to: Events

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{}

The teams’ context 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!