Jul 22 2019

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.