jonnifyelixir
F5 / F5.08 / facade

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

ENROLL/2 · command Portal.enroll(user_id, course_id) → Engine.command(...) :ok | {:error} DELIVER_LESSON/2 · command Portal.deliver_lesson(enrollment_id, lesson_id) :ok | {:error} PROGRESS_OF/1 · query Portal.progress_of(enrollment_id) → Engine.query(...) {:ok, percent}

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

UI · LiveView Portal.enroll(u, c) FACADE Engine.command(...) ENGINE · call decide · evolve · reply RESULT :ok | {:error, %Error{}} one synchronous call; the UI waits for :ok or a typed error, nothing else

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

Related in this course