Isolating code in contexts
Lasse Skindstad Ebert
Posted on October 20, 2019
This story was originally posted by me on Medium on 2017-12-15, but moved here since I'm closing my Medium account.
This blog post is about a design pattern that I know as “The Context Pattern”.
I am a software developer and my key focus is writing maintainable software. This is why I love software patterns. Patterns allow me to reason about my software on a more abstract level and they force me to write maintainable software from day one of a new project.
I use Elixir to illustrate my points, but I think the pattern can be adapted to most programming languages.
History of The Context Pattern
I like to think of The Context Pattern as a specialized form of The Facade Pattern.
The Facade pattern evolved in object oriented languages and was probably first formalized by the Gang Of Four.
The Context Pattern differs from The Facade Pattern in that it always isolates internal (not external) code, hiding away its complexity from the caller. It also isolates source code files in the file system from the rest of the code base.
The Phoenix framework called the pattern name with the release of Phoenix 1.3. Although Phoenix contexts are kind of Phoenix specific, I am using the pattern for non-Phoenix code too.
So what is it, exactly?
Disclaimer: This is my version of The Context Pattern. I’m not sure if the pattern has been formalized for general usage by anyone.
The pattern is simple and yet extremely powerful. You only have to do these steps to follow the pattern:
- Find code in your code base that belong together, i.e. has many internal dependencies.
- Move all code files into a specific directory in your project directory. Rename namespaces accordingly.
- Define a single entry point of all the functionality the code exposes.
The new group of code is then conceptually referred to as “context”.
This gives the immediate benefit of simplicity for the outside world. All the code not in the context only have to worry about the public API of the context, which is all defined in a single entry point.
Changing stuff later inside the context is now also simple. Everything can be changed as long as the public API remains unchanged.
While writing a context or refactoring existing code into a context, the concept of a context makes the developer worry about dependencies and interfaces. Only the needed amount is exposed to outside code.
Example time
Finally some code.
A while ago, I had to implement geolocation lookup functionality in an existing project. For a given city, I needed to know the lattitude and longitude coordinates.
I needed the following parts to complete the feature:
- A client that can talk to the Google Maps API
- A database model for long-term caching the API lookup
- A cache with logic to operate on the database model
I could probably write it all in one Elixir module, but that would be a big mess. It would not be testable and maintainable.
Furthermore, I needed different implementations of the client HTTP layer, since I wanted to swap it out with a mock when testing.
I ended up with the following directory structure:
lib/my_project/geolocation
├── cache.ex
├── city_location.ex
├── geolocation.ex
└── google_maps
├── client.ex
├── http.ex
└── http_mock.ex
The only thing that the outside code should call is the MyProject.Geolocation module which is in the file lib/my_project/geolocation/geolocation.ex
.
All the other modules are namesspaced under Geolocation
, e.g. MyProject.Geolocation.Cache
.
The Geolocation
module has a single function with this signature:
@type address :: {zip :: String.t, city :: String.t, country :: String.t}
@type geolocation :: {latitude :: number, longitude :: number}
@doc """
Finds the geolocation for the given address
"""
@spec lookup(address) :: {:ok, geolocation} | {:error, any}
def lookup(address) do
# Logic that calls the inner modules
end
What about OTP and GenServers?
I’m glad you asked :) The Cache in the example is actually a GenServer to synchronize cache lookups and avoid race situations.
So how does the main application know to start the Cache when it is not allowed to peak inside the context?
Elixir 1.5 to the rescue!
Elixir 1.5 introduced streamlined child specs, which allows any module to define a child spec, and that module can be added by itself to a supervisor.
That was a confusing explanation, so here is an example:
Instead of adding worker(MyProject.Geolocation.Cache, [])
as a child to my main supervisor, with Elixir 1.5 I just add MyProject.Geolocation
. I don’t even need to specify if that will start a worker or a supervisor:
children = [
# Many children here
# ...
MyProject.Geolocation
]
opts = [strategy: :one_for_one, name: MyProject.Supervisor]
Supervisor.start_link(children, opts)
Then in the Geolocation main entry module, I specify a child spec, which is a recipe to start a child under a supervisor:
alias MyProject.Geolocation.Cache
def child_spec([]) do
%{
id: __MODULE__,
start: {Cache, :start_link, []}
}
end
Nice! Geolocation now controls which parts of the geolocation context is started under the main supervisor.
If I later need to change it into a more complex scenario with a supervisor with multiple workers, I still only have to make changes to the context.
Child specs can also define restart strategy and more. For reference see the Supervisor docs.
What did I gain?
I implemented the geolocation functionality as a context, exposing only a single function (oh yes, and a child spec) to the outside world.
Changing the inner works of the context is simple.
Extracting the context as a standalone application or library is simple.
Calling the functionality of the context is simple.
Completely replacing the context with another context is simple.
Changing the database structure of the db model is simple.
I like simple.
The complete feature was implemented in 329 lines split into 6 files. Only 10 percent, 32 lines, of that is revealed as public interface. Here is the complete main Geolocation module:
defmodule Legolas.Geolocation do
@moduledoc """
Public interface for all geolocation related functionality
"""
alias Legolas.Geolocation.Cache
alias Legolas.Geolocation.CityLocation
alias Legolas.Geolocation.GoogleMaps.Client
@type address :: {zip :: String.t, city :: String.t, country :: String.t}
@type geolocation :: {latitude :: number, longitude :: number}
def child_spec([]) do
%{
id: __MODULE__,
start: {Cache, :start_link, []}
}
end
@doc """
Finds the geolocation for the given city address
"""
@spec lookup(address) :: {:ok, geolocation} | {:error, any}
def lookup(address) do
case Cache.fetch(address, fn -> Client.lookup(address) end) do
{:ok, %CityLocation{} = location} ->
{:ok, {location.latitude, location.longitude}}
{:error, reason} ->
{:error, reason}
end
end
end
And yes, I name the different repos in my project after LOTR characters.
I hope you enjoyed this blog post ;)
Posted on October 20, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.