We now have a constrained core (Chapter 3) and a boundary that feeds it only trustworthy values (Chapter 4). This chapter makes the core act. A workflow — placing an order, shipping it, refunding it — is where a domain earns its keep, and it is also where models most often turn back into mud: a single mutable object with a status flag, pushed through a long method full of branches that each assume some earlier branch already ran.
The alternative is to see a workflow for what it is: a series of transitions from one precise state to the next, each of which can be written as a function. When the states are real types and the transitions are real functions, the process stops being a tangle of flags and becomes a pipeline you can read off the signatures alone.
5.1 The Tangled Version
Here is the process modelled on the bag-of-fields Order from §3.1 — one object, a status string, one method to move it along:
-- One mutable object, one status flag, and every illegal path left open.
advance(o: Order)
do
if o.status = "draft" then
o.status := "placed"
elseif o.status = "placed" then
o.status := "shipped"
end
end
Everything wrong with this is invisible until it bites. Nothing stops a caller from setting status to "shipped" directly, skipping "placed" entirely. The method assumes the fields relevant to each stage were filled by whoever set the previous status, but that assumption is nowhere stated and nowhere checked. There is no point in the code where "this is now a placed order, with everything a placed order requires" is a fact you can rely on — only a string that claims it. The transitions are not functions; they are mutations, and mutations leave no signature to read.
5.2 Transitions as Functions
Now model the same process on the sum type from §3.2. Each transition becomes a function from one shape to the next, and the signature carries the whole contract of the step.
-- Draft -> Placed. Can fail (an empty draft), so it returns a Result.
function place(d: Draft): Result[Placed, String]
do
if d.items.length > 0 then
result := create Ok[Placed, String].make(
create Placed.make(d.items, sum_of(d.items)))
else
result := create Err[Placed, String].make("cannot place an empty order")
end
end
-- Placed -> Shipped. Cannot fail once you hold a real Placed, so it is total.
function ship(p: Placed, tracking: Tracking_Id): Shipped
do
result := create Shipped.make(p.items, tracking)
ensure
items_preserved: result.items = p.items
end
Read the signatures before the bodies. place takes a Draft and returns a Result[Placed, String]: the input type says it operates on drafts and nothing else; the Result says it might fail. ship takes a Placed and returns a Shipped with no Result in sight: it cannot fail, because by the time you hold a Placed, everything shipping needs is already guaranteed. The presence or absence of Result in the return type is itself documentation — it tells the caller, at a glance, whether this step is one that can go wrong.
And look at what the type of ship forbids: you cannot call it on a Draft, because ship demands a Placed and the compiler will not coerce one into the other. The illegal transition "ship an order that was never placed" — the exact bug the tangled advance left wide open — is now a type error. The state machine's legal edges are encoded as the argument types of its transition functions, and the illegal edges simply do not typecheck.
5.3 Making the Signature Correct
The discipline that makes this work is totality: a function should produce a valid result for every input its type admits, with no hidden ways out — no exception thrown for a case the signature does not mention, no nil returned where a value was promised. A total function's type is the whole truth about it.
When a step genuinely can fail, totality does not mean pretending it cannot; it means making the failure part of the return type, as place does with Result. The dishonest alternative is a function typed Draft -> Placed that throws when the draft is empty — its signature promises a Placed it cannot always deliver, and the caller who trusts the signature is ambushed at runtime. Honesty is: if you can fail, say so in the type; if you cannot, prove it by taking a precise enough input that failure is impossible. ship takes the second road — it demands a Placed precisely so that it can be total.
This is where the parsing discipline of Chapter 4 pays off inside the core. Because untrusted values were converted to precise types at the boundary, the transitions do not have to defend against malformed input; they can be total over the precise types they receive. A weak input type forces a function to handle cases it should never see and pushes failure into its body; a precise input type lets the function be total and pushes failure out to the one place — the parser — that is supposed to own it.
5.4 The Pipeline
With the steps as functions, the whole workflow is their composition. Data flows in one direction; each stage consumes the trustworthy output of the last and produces the input of the next; failure short-circuits exactly as it did for parsers in §4.5.
-- The whole process, read top to bottom: place, then ship.
function fulfill(d: Draft, tracking: Tracking_Id): Result[Shipped, String]
do
match place(d) of
when Ok as placed then
result := create Ok[Shipped, String].make(ship(placed.value, tracking))
when Err as e then
result := create Err[Shipped, String].make(e.error) -- placing failed; stop here
end
end
The pipeline reads as the process itself: place the draft; if that succeeded, ship what came back; if it failed, carry the reason out and go no further. There is no shared mutable object threaded through the steps, no status flag anyone can set out of order, no stage that runs on the assumption that a previous stage left things tidy. Each arrow in the state diagram is one function, and the diagram and the code are the same shape.
Because ship receives placed.value — a genuine Placed produced by place — it never runs on anything but a fully-formed placed order. The pipeline cannot be assembled in the wrong order, because the types would not line up: ship's output is a Shipped, which is not what place accepts, so "ship then place" does not even compose. The legal sequence is the only one that typechecks.
5.5 Decisions Stay In, Effects Stay Out
A real place does more than check for emptiness — it prices the order, and pricing is where marketing's ever-changing rules live. It is tempting to reach out from inside place and consult a database, a tax service, a clock. Resist it. The transition should remain a pure function of its inputs: give it the same draft and the same prices and it returns the same placed order, every time, with no reference to the outside world.
Keeping the workflow pure is what keeps it testable and legible. A pure transition can be checked by handing it values and inspecting the result — no database to stand up, no clock to freeze, no network to mock. It is also what lets contracts mean something on a transition: an ensure that no money is created or destroyed across a step is a claim you can actually rely on only if the step has no hidden inputs. In this chapter place uses sum_of(d.items) as the order's total precisely because that is a pure function of the draft; when genuine pricing rules enter, they will arrive as an input to the step, computed at the open edge and handed in — not fetched from inside it.
That separation — decisions computed purely inside the core, effects performed at the edge — is important enough to be the whole of the next chapter. For now the rule is simply: a transition takes everything it needs as arguments, and touches nothing else.
5.6 The Shape of the Whole
Step back and look at what the process has become. The states are types; the transitions are functions between them; the legal paths are exactly the compositions that typecheck; the failures are visible in the return types; and the invariants of Chapter 3 ride along inside each state, so that every value in the pipeline is well-formed by construction. The workflow is no longer a thing that happens to a mutable object — it is a transformation of values, and you can reason about it the way you reason about arithmetic.
This is the constraint tradition at the scale of a process rather than a single value. Chapter 3 made the illegal states unrepresentable; this chapter made the illegal transitions unrepresentable, by encoding the state machine in the argument types of its steps. Between them, the order core is now the part of the system you are least afraid of, from the shape of a single quantity up to the flow of the whole fulfillment process.
One assumption still stands unpaid, though — the same one that opened Chapter 4, now wearing different clothes. These pure transformations have to be driven by something that does touch the world: reads the order off a queue, calls the payment gateway, writes the shipment to a database, asks what time it is. If those effects leak into the core, everything we just built comes apart. Keeping them out — pushing I/O, time, and failure to the boundary so the domain stays pure — is the last move of Part II, and the subject of the next chapter.