The coffee maker: an ALA model that nests cleanly inside LiveView
The thermometer is a data pipeline: readings in, a string out. Real control software has state, edge cases, and a hardware boundary. The classic teaching problem for exactly that is the Mark IV Special Coffee Maker, posed by Robert C. Martin in his books as an object-oriented design exercise. John Spray revisits it in ALA and makes a pointed argument: Martin’s “objects” were really collaborating peers passing messages, and a zero-coupled version reads better and tests trivially.
This post takes that ALA coffee maker and puts it in a Phoenix LiveView. The interesting result is a non-result: the pure model did not change to fit LiveView. The LiveView became just another application-layer host, the same role a supervised server or an IEx script plays. That is the payoff ALA promises for the web, on a problem small enough to hold in your head.
The problem, briefly
A coffee maker has a boiler that heats water, a warmer plate that holds the pot, and a front panel with a brew button and a done light. The behaviour is a handful of user stories: press the button with water in and the pot on the plate to start brewing; keep the boiler heating while brewing; open a relief valve if the pot is lifted; when the boiler runs dry, stop and light the done indicator; when the empty pot is replaced, reset. The hardware exposes sensors (button, boiler, plate) and actuators (boiler heater, warmer heater, relief valve, indicator).
Martin’s solution modelled boiler, plate, and light as objects that called each other and the hardware. Spray’s ALA reading is that those calls are peer dependencies, and that the boiler should not know a warmer plate exists.
The shape in ALA
Three layers, and the dependencies only ever point down.
- Application is
CoffeeMaker, The only module where the word “coffee” appears. It holds the machine’s state and expresses the user stories as wiring. - Domain abstractions are
Boiler,WarmerPlate,UserInterface. Each is a plain struct that knows its own concern and nothing else. None mentions coffee, or each other. - Foundation is the hardware boundary as pure data:
SensorReading(all inputs) andHardwareCommand(all outputs), plus a driver that does the actual I/O.
The move that makes the whole thing pure is turning the hardware boundary into data. Instead of each abstraction calling the hardware, all sensors are read once per cycle into a SensorReading, and all outputs are assembled into one HardwareCommand and written once:
defmodule SensorReading do # foundation: all inputs as one value
@type button_status :: :pushed | :not_pushed
@type boiler_status :: :empty | :not_empty
@type warmer_plate_status :: :warmer_empty | :pot_empty | :pot_not_empty
defstruct [:button_status, :boiler_status, :warmer_plate_status]
end
defmodule HardwareCommand do # foundation: all outputs as one value
@type on_off :: :on | :off
@type open_closed :: :open | :closed
defstruct boiler_heater: :off, warmer_heater: :off, relief_valve: :closed, indicator: :off
end
Nothing above these structs ever touches a real device. That is what lets the same model run against real hardware, a simulator, or a test.
The application is the wiring
CoffeeMaker takes a SensorReading and returns a HardwareCommand plus its next state. One cycle reads the three abstractions from the sensor value, applies the user-story logic, and assembles the command from the abstractions’ outputs:
def do_cycle(%CoffeeMaker{} = machine, %SensorReading{} = reading) do
ui = UserInterface.read(reading)
boiler = Boiler.read(reading)
warmer = WarmerPlate.read(reading)
{machine, ui, boiler, warmer} = apply_logic(machine, ui, boiler, warmer)
cmd = %HardwareCommand{
boiler_heater: Boiler.heater_cmd(boiler),
relief_valve: Boiler.valve_cmd(boiler),
warmer_heater: WarmerPlate.heater_cmd(warmer),
indicator: UserInterface.indicator_cmd(ui)
}
{:emit, cmd, machine}
end
The user stories are the logic, and they read almost like the spec. This is the part where a product person could check the behaviour:
# start brewing on button + water + pot
state = if ui.button and warmer.pot_on_plate and not boiler.empty, do: :brewing, else: machine.state
# heat the boiler while brewing
boiler = %{boiler | on: state == :brewing}
# open the relief valve whenever the pot is off the plate
boiler = %{boiler | open_steam_release_valve: not warmer.pot_on_plate}
# boiler ran dry (rising edge of empty) → done, light on
state = if boiler.empty and not machine.prev_boiler_empty, do: :brewed, else: state
Every line here is application knowledge. The boiler does not decide when brewing starts, and the UI does not know about the boiler. The composition connects them.
Abstractions keep their own invariants
The tempting mistake is to push the boiler’s safety rule up into the application. ALA says the opposite: a rule that belongs to the boiler lives in the boiler. The boiler enforces “the heater is off whenever I am empty or my valve is open,” and it does so itself:
defmodule Boiler do
defstruct on: false, empty: false, open_steam_release_valve: false
def read(%SensorReading{boiler_status: s}), do: %Boiler{empty: s == :empty}
# the safety invariant lives here, not in CoffeeMaker
def heater_cmd(%Boiler{on: on, empty: empty, open_steam_release_valve: open}) do
if on and not empty and not open, do: :on, else: :off
end
end
The application asks the boiler to heat; the boiler refuses when that would be unsafe. Knowledge sits where it belongs, and the application stays readable because it is not carrying the boiler’s rules.
Nesting it in LiveView
Here is the whole point. The LiveView does not restructure any of the above. It is an application-layer module that wires the pure CoffeeMaker to a SimulatedHardware (a foundation-layer model of the physical world, so the thing runs in a browser) and to Phoenix. The control loop is three lines: read sensors, tick the machine, write commands.
defmodule CoffeeLive do
use AlaLabWeb, :live_view
alias AlaLab.CoffeeMachineV3.CoffeeMaker
alias AlaLab.CoffeeMachineV3.Foundation.SimulatedHardware
def mount(_p, _s, socket) do
if connected?(socket), do: schedule_tick()
{:ok, assign(socket, machine: CoffeeMaker.new(), sim: SimulatedHardware.new())}
end
def handle_info(:tick, socket) do
schedule_tick()
%{machine: machine, sim: sim} = socket.assigns
reading = SimulatedHardware.to_sensor_reading(sim) # sensors → data
{cmd, machine} = CoffeeMaker.tick(machine, reading) # pure model, one step
sim = SimulatedHardware.tick(sim, cmd) # commands → world
{:noreply, assign(socket, machine: machine, sim: sim)}
end
def handle_event("brew", _p, socket),
do: {:noreply, update(socket, :sim, &SimulatedHardware.press_button/1)}
end
Read the layering off it. CoffeeLive calls CoffeeMaker.tick/2, a domain-named function that takes a SensorReading and returns a HardwareCommand. It never mentions Boiler, WarmerPlate, or UserInterface; those are internal to CoffeeMaker and stay hidden behind its boundary. Phoenix is a foundation-layer dependency the LiveView happens to sit on, exactly like SimulatedHardware. Swap the simulator for a real hardware driver and the machine logic does not notice, because both speak SensorReading and HardwareCommand.
Why this is the right step up from the thermometer
The thermometer showed ALA with no framework at all: pure functions over a struct, testable offline. The coffee maker keeps that purity and adds the two things the thermometer lacked, real state and a hardware boundary, and then shows the boundary being crossed by a web framework without the model bending. The LiveView is thin because everything it would otherwise carry (state, rules, I/O shape) already lives in layers below it.
The dividend shows up in tests. Because the boundary is data, a test constructs a SensorReading and pattern-matches the returned HardwareCommand. No Mox, no mock expectations, no process to start and tear down:
reading = %SensorReading{button_status: :pushed, boiler_status: :not_empty, warmer_plate_status: :pot_empty}
{cmd, _machine} = CoffeeMaker.tick(CoffeeMaker.new(), reading)
assert cmd.boiler_heater == :on
Run through ala_lint with a layer map, the coffee maker scores 99 out of 100 with zero peer coupling and zero upward edges. The CoffeeMaker composition, the three domain abstractions, and the hardware foundation each sit at their own altitude, and nothing reaches sideways. Under --strict it holds at 97, its only advisories being one thin pass-through and a lone literal.
That is the same purity that makes the V36 vernacular core fast to test, arriving here on a smaller problem. The coffee maker is a single-feature control app; the variants that follow take this shape to a multi-feature shopping page, where the hard part is no longer one boundary but keeping many features from reaching across to each other. The move is the same one Spray made on Martin’s coffee maker: find the peer calls, and replace them with abstractions below and wiring above.