Jul 22 2019

Contexts with Some Light Phoenix

There once was a professor who ran a school for gifted youngsters. He had the unfortunate problem that his teachers would frequently disappear. Sometimes to space or different time periods or living islands. The result was that classes would be canceled suddenly until a substitute teacher, such as a clone or an alternate universe counterpart, could be found. This, to say nothing of temporal distortions, made it difficult for his students to know their class schedule at any given time. So, the professor set his IT staff the task of developing XOCA, X’s Online Curriculum App.

The development team decided to use Phoenix to write this application, as they had some experience with it, but with the 1.2 version. Wanting to keep up to date, and knowing that thematically phoenixes must be renewed, they began this project with the latest version. To get something running quickly, they were about to naively use generators to set up a user resource, but a problem came up immediately: contexts.

To a developer coming from a Rails background or even older versions of Phoenix, requiring a context in order to use the built-in generators may seem like unnecessary design work. You may be used to having modules grouped by role, or not be overly concerned with building a monolith, since the initial scope of the application is small. But generators are one way of enforcing convention or best practices, and in this case, it’s meant to make you think about your design.

In a Phoenix application, a context is a module that groups and encapsulates similar functionality. The generator will make a directory that contains the code for the context as well as a module that acts as the public API. These correspond to “bounded contexts” in domain driven design.

lib
└── xoca
   └── registration
      ├── class.ex
      ├── course.ex
      ├── registration.ex
      ├── student.ex
      └── teacher.ex

For those of us that don’t have telepathy, we need to communicate with human language. An effective team will have a common language spoken by domain experts, engineers, and clients. This is called a ubiquitous language. Conceptually, the extent where a ubiquitous language applies is a bounded context. By modeling the problem domain into multiple bounded contexts, ambiguity is reduced and terms can be reused.

Take, for instance, the term “class”. In the schedule context, it might mean an instructional lesson that takes place during a certain time of the day. In a registration context, it may mean a set of lessons that are held throughout the semester. Discrepancies could potentially be resolved with more precise naming, but this comes with some caveats. Firstly, naming is hard. Additionally, this increases the vocabulary needed to describe the domain model. By using bounded contexts, teams can use more “natural” language, so long as it is consistent within a context.

defmodule Xoca.Schedule.Class do
use Ecto.Schema
import Ecto.Changeset
alias Xoca.Schedule.Room
schema "schedule_classes" do
field :end_time, :utc_datetime
field :start_time, :utc_datetime
belongs_to :room, Room
timestamps()
end
@doc false
def changeset(class, attrs) do
class
|> cast(attrs, [:room_id, :start_time, :end_time])
|> validate_required([:room_id, :start_time, :end_time])
|> assoc_constraint(:room)
end
end
defmodule Xoca.Registration.Class do
use Ecto.Schema
import Ecto.Changeset
alias Xoca.Registration.Course
alias Xoca.Registration.Teacher
schema "registration_classes" do
belongs_to :course, Course
belongs_to :teacher, Teacher
timestamps()
end
@doc false
def changeset(class, attrs) do
class
|> cast(attrs, [:course_id, :teacher_id])
|> validate_required([:course_id, :teacher_id])
|> assoc_constraint(:course)
|> assoc_constraint(:teacher)
end
end

This is all well and good, but what if you want to use the same concept in multiple contexts? One way is to model the concept once, and just use it in any context that needs it. This is actually the method demonstrated in the Phoenix hexdocs. A mapping of this sort is called a shared kernel, and unfortunately, it introduces some coupling between the contexts.

Another approach is to use a public API from one context to request the resource, then convert it into a domain model in the requesting context. When you use a conversion like this, it is called an anti-corruption layer. This decreases coupling, since any changes upstream only require changes in the anti-corruption layer, so long as the domain model in the downstream context is unchanged.

defmodule Xoca.Teaching.OfficeHours do
use Ecto.Schema
import Ecto.Changeset
schema "teaching_office_hours" do
field :end_time, :utc_datetime
field :start_time, :utc_datetime
timestamps()
end
@doc false
def changeset(office_hours, attrs) do
office_hours
|> cast(attrs, [:start_time, :end_time])
|> validate_required([:start_time, :end_time])
end
end
defmodule Xoca.Schedule.OfficeHours do
defstruct start_time: nil, end_time: nil
end
defmodule Xoca.Schedule do
...
alias Xoca.Schedule.OfficeHours
def office_hours_from_id(id) do
fetched_hours = Xoca.Teaching.get_office_hours(id)
case fetched_hours do
%_{start_time: _, end_time: _} ->
fetched_hours
|> Map.from_struct
|> (fn(map) -> struct(OfficeHours, map) end).()
_ ->
nil
end
end
end

One more approach is to use a single table for the concepts, and use different schemas in different contexts. Each context can request and manipulate records without involving the other, but this couples both contexts strongly to the database table. There are undoubtedly more techniques, but these are some of the simpler ones.

defmodule Xoca.Teaching.OfficeHours do
use Ecto.Schema
import Ecto.Changeset
schema "office_hours" do
field :end_time, :utc_datetime
field :start_time, :utc_datetime
timestamps()
end
@doc false
def changeset(office_hours, attrs) do
office_hours
|> cast(attrs, [:start_time, :end_time])
|> validate_required([:start_time, :end_time])
end
end
defmodule Xoca.Schedule.OfficeHours do
use Ecto.Schema
import Ecto.Changeset
schema "office_hours" do
field :end_time, :utc_datetime
field :start_time, :utc_datetime
timestamps()
end
@doc false
def changeset(office_hours, attrs) do
office_hours
|> cast(attrs, [:start_time, :end_time])
|> validate_required([:start_time, :end_time])
end
end

While a fledgling Phoenix developer may be put off by the weight placed on domain driven design right out of the gate, contexts are worth getting familiar with. Separating an application into contexts can reduce unnecessary coupling and prevent ambiguity. Ultimately, this will help with maintainability, whether for class scheduling or, mutatis muntandis, whatever Phoenix app you are developing.

David Dufresne

Share: