F5.08 · part 2 of 3
The engine facade
If ports are how the engine reaches the outside, the facade is how the outside reaches the engine
— the driving port. It is a single context module, Portal, whose
functions read like the domain rather than the runtime: enroll/2,
deliver_lesson/2, progress_of/1. A caller never writes
GenServer.call(Portal.Engine, {:command, {:enroll, …}}); it writes
Portal.enroll(user_id, course_id). Everything behind that door — the GenServer
from F5.06, the event log, the reducer — is free to change without touching a caller. The facade also keeps
the command/query split from F5.05 visible in its signatures: commands return :ok or a
typed error, queries return data. It is small on purpose; a boundary earns its keep by being narrow.
Three functions, one door. Select one to see its signature and the call it makes underneath.
One small surface
A facade function names an intention and hides a mechanism. enroll/2 says what the
caller wants; it does not reveal that a message is sent to a process, matched in a
handle_call, run through decide and
evolve, and answered. The surface stays the same shape whether the engine is one
process or a pool. Select a function.
The facade surface · select a function
function: enroll/2
it is: a command that returns :ok or an error
A good facade is boring to read and stable to depend on. The interesting machinery lives behind it, which is exactly where the UI should not have to look.
The facade in code
The Portal module is a handful of typed functions. Commands delegate to
Engine.command/1 and return :ok or a typed error; the query
delegates to Engine.query/1 and returns data. The specs make the command/query split
from F5.05 part of the public type, so a caller can see which functions change the world.
defmodule Portal do @moduledoc "The driving port: the only surface the web layer calls." alias Portal.{Engine, Error} # commands change state and return :ok or a typed error (CQS, F5.05) @spec enroll(String.t(), String.t()) :: :ok | {:error, Error.t()} def enroll(user_id, course_id), do: Engine.command({:enroll, user_id, course_id}) @spec deliver_lesson(String.t(), String.t()) :: :ok | {:error, Error.t()} def deliver_lesson(enrollment_id, lesson_id), do: Engine.command({:deliver, enrollment_id, lesson_id}) # a query returns data and changes nothing @spec progress_of(String.t()) :: {:ok, 0..100} | {:error, Error.t()} def progress_of(enrollment_id), do: Engine.query({:progress_of, enrollment_id}) end
The two helpers the facade leans on are the only place GenServer.call appears. They
send the message, then funnel the reply through one translation step — the error contract of F5.08.3 —
so the facade always returns the boundary's shapes and never a raw internal reason.
defmodule Portal.Engine do # the facade calls these; they are the only callers of GenServer.call def command(cmd), do: GenServer.call(__MODULE__, {:command, cmd}) |> to_contract() def query(q), do: GenServer.call(__MODULE__, {:query, q}) |> to_contract() # internal replies become the boundary contract (built in F5.08.3) defp to_contract(:ok), do: :ok defp to_contract({:ok, _data} = ok), do: ok defp to_contract({:error, reason}), do: {:error, Portal.Error.from(reason)} end
An enroll, end to end
One call crosses the boundary, runs the engine, and comes back as a result the caller can branch on. The UI
issues a single synchronous call and waits for either :ok or a typed error; it never
learns that a process, a contract, and a fold were involved.
enroll, across the boundary
The flow is four hops, but the caller sees one: a function in, a result out. The boundary collapses the runtime into a value.
Why a facade, not the GenServer
Calling the GenServer directly would work and cost less code today, but it pins every caller to three things
that should stay private: that the engine is a process, the exact message tuples it accepts, and the
raw shape of its replies. A facade buys back all three. The message shape can change without touching the UI.
The runtime can become a partitioned pool or move to another node and the signature Portal.enroll/2
is unchanged. And because the store is a port (F5.08.1), a facade-level test runs against the in-memory
adapter with no process tricks. The cost is one thin module; the return is a UI that depends on intentions, not
on internals.
name the intent
The UI calls Portal.enroll/2, not a message tuple aimed at a process.
hide the runtime
The GenServer, log, and reducer can change while the signature stays put.
Next: error contracts for the UI
— the to_contract/1 step above, made into a closed vocabulary the UI can render.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Alistair Cockburn — Hexagonal architecture — ports and adapters around a core.
- Elixir — Typespecs and behaviours — @callback defines the port contract.
- José Valim — Mocks and explicit contracts — test seams without mutating globals.