Everything Part II has built rests on a quiet assumption: that the domain code is a function of its inputs and nothing else. A Quantity is positive by construction, a place step returns the same order for the same draft, an invariant means what it says — all of this holds only while the core stays pure. The moment a transition reaches out to a database, a payment gateway, or the system clock, the assumption breaks, and with it the guarantees.
Yet real systems must do those things. An order is worthless if no payment is charged and no shipment recorded. The task of this chapter is to reconcile the two: to let the system perform effects without letting effects into the core. The shape that achieves it is a functional core inside a thin imperative shell — the core decides, the shell acts.
6.1 How Effects Entangle
Here is the placement step from Chapter 5, written the way it is usually written — with the effects performed inline, right where the decision is made:
-- Impure: the core reaches into the world. Every guarantee of Part II is now void.
function place(d: Draft): Result[Placed, String]
do
if d.items.length > 0 then
let placed: Placed := create Placed.make(d.items, sum_of(d.items))
gateway.charge(placed.total) -- network, money, can fail
mailer.send_confirmation(d.customer_email) -- and if this throws mid-way?
database.save(placed) -- ordering, partial failure
result := create Ok[Placed, String].make(placed)
else
result := create Err[Placed, String].make("cannot place an empty order")
end
end
Three things went wrong the instant those effect calls appeared. The function is no longer testable in isolation — to check that an empty draft is rejected, you must now stand up a gateway, a mailer, and a database, or mock all three. It is no longer deterministic — the same draft can succeed today and fail tonight when the network is down, so its behaviour is a function of the world, not of its arguments. And it is no longer composable — half the effects may fire before the fourth one throws, leaving the system in a state no type describes: charged but not saved, or saved but not confirmed.
Notice that none of this is a problem with the decision the function makes. Deciding what to charge, whom to email, and what to save is pure reasoning about the draft. The trouble is only the doing. So separate them.
6.2 Decisions as Data
Instead of performing effects, the core describes them. It returns a value that says what should happen, and leaves the happening to someone else. An effect becomes data — a sum type, once again — and the core's job is to produce a list of these descriptions alongside the new state.
-- What the system should do, as values the core can compute and a test can inspect.
union Effect
Charge_Payment(amount: Money)
Send_Confirmation(recipient: Email_Address)
Record_Order(order: Placed)
end
Now place returns the placed order paired with the effects it decided on — and performs none of them:
class Placement
feature
order: Placed
effects: Array[Effect]
create make(o: Placed, fx: Array[Effect]) do order := o effects := fx end
end
-- Pure: computes a decision and a to-do list. Touches nothing.
function place(d: Draft): Result[Placement, String]
do
if d.items.length > 0 then
let placed: Placed := create Placed.make(d.items, sum_of(d.items))
let fx: Array[Effect] := []
fx.add(create Charge_Payment.make(placed.total))
fx.add(create Send_Confirmation.make(d.customer_email))
fx.add(create Record_Order.make(placed))
result := create Ok[Placement, String].make(create Placement.make(placed, fx))
else
result := create Err[Placement, String].make("cannot place an empty order")
end
end
This place is pure again. Give it a draft and it returns the same placement every time; it can be tested by inspecting the returned effect list — did it decide to charge the right amount? to email the right person? — with no gateway, no mailer, no clock. The decision has been fully recovered from the doing, and the decision is where all the domain logic lived.
6.3 The Shell That Acts
Something must still perform the effects, and that something is the shell: a thin outer layer that calls the pure core, takes the effects it returned, and runs them against the real world. It is the only part of the system that touches infrastructure, and it contains no domain logic — it interprets, it does not decide.
-- The imperative shell: the one place allowed to touch the world.
perform(e: Effect, gateway: Payment_Gateway, mailer: Mailer, store: Order_Store)
do
match e of
when Charge_Payment as c then
gateway.charge(c.amount)
when Send_Confirmation as s then
mailer.send(s.recipient)
when Record_Order as r then
store.save(r.order)
end
end
The match is exhaustive over Effect, so the day someone adds a new kind of effect to the core, the compiler marks this shell as incomplete (§3.4) — you cannot decide to do something the shell has forgotten how to perform. The domain and the infrastructure meet at exactly one seam, and the seam is a sum type: the vocabulary of things the core may ask for, and the shell knows how to grant.
The system as a whole now has a characteristic shape. Untrusted input is parsed at the boundary (Chapter 4); the pure core transforms trustworthy values and returns new state plus a list of effects (Chapters 5–6); the shell performs those effects and loops. Almost all the code, and all the interesting code, is pure. The impure part is small, mechanical, and the same in every workflow — which is exactly where you want the risk concentrated: in the thinnest, most boring layer.
6.4 Time, Identity, and Randomness Are Inputs
Effects are not only the obvious writes to gateways and databases. Reading the clock is an effect; so is generating an id, or drawing a random number. Each of these makes a function's result depend on something other than its arguments, and each entangles the core in the same way a database call does — quietly, because they look like innocent function calls rather than I/O.
The remedy is the same in spirit but simpler: pass them in. A transition that needs the current time takes a now: Instant parameter; one that needs a fresh order id takes it as an argument. The reading of the clock happens in the shell, where effects belong, and the pure core receives the reading as an ordinary value.
-- Time enters as an argument, not as a call to a global clock.
function place(d: Draft, now: Instant): Result[Placement, String]
-- ... placed_at := now, deadlines computed from now, all pure ...
This looks like a small bookkeeping change and is in fact a significant one. A workflow whose time is an input can be tested at any date — year-end, a leap second, the exact moment a promotion expires — by passing that instant, with no need to travel through time or freeze a global. Determinism is restored not by pretending the clock does not exist, but by moving the one place it is read out to the edge and letting its value flow in.
6.5 Two Kinds of Failure
Separating core from shell also sorts out failure, which until now we have treated as one thing. There are really two, and they belong on opposite sides of the boundary.
Domain failure is a legitimate outcome the business defines: an empty order cannot be placed, a discount cannot exceed a subtotal, a refund cannot outrun a payment. These are expected, they are part of the model, and — as Chapter 4 established — they belong in the types, as Result values the core returns. They are not errors in any exceptional sense; they are answers.
Effect failure is the world refusing to cooperate: the network times out, the disk is full, the gateway returns a 500. These are not domain outcomes and have no place in domain types — a payment gateway being unreachable says nothing about whether the order was valid. They arise only in the shell, where effects run, and are handled there, with the shell's own tools: retries, timeouts, circuit breakers, compensating actions. Keeping them out of the core keeps the domain's Result vocabulary about the domain, not about the reliability of infrastructure.
The line between them is one of the more clarifying distinctions in system design. When you find an Err("database unavailable") flowing through domain code, something has crossed the membrane that should not have — an effect failure wearing a domain failure's clothes. The fix is to push it back out to the shell.
6.6 Purity Is the Last Constraint
It is worth seeing this chapter as the completion of Part II rather than a separate concern. Making illegal states unrepresentable (Chapter 3), parsing at the boundary (Chapter 4), and modelling workflows as total functions (Chapter 5) all assumed a pure core. This chapter earns that assumption by naming what threatens it — effects, time, infrastructure failure — and moving each one out to the edge. Purity is not an aesthetic preference here; it is the constraint that makes the other constraints hold. An invariant you can trust, a transition you can test, a Result that means a domain outcome — all of them require that the core be a function of its inputs, and that is precisely what keeping effects at the edge guarantees.
With that, the core is as constrained as we can make it: correct by construction, airtight in its signatures, and pure enough to reason about. Part II is done. But a constrained core is only half a system, and a lonely one — a pile of trustworthy parts is not yet a working whole. The next question is how to assemble those parts: how to compose trustworthy pieces into larger behaviour without rewiring them, and how to add capability without editing what already works. That is Part III, and it is the bridge from constraint toward the flexibility the second half of the book is built to reach.