EffectsDOMAIN-SPECIFICGENERALCONTESTED

Effect Boundaries

Push effects to the edges so the middle can be reasoned about — and know the limit: some domains are effects all the way down, and there the honest design is to make each effect a modelled step rather than to pretend there is a pure core.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

How far can I push effects outward before the pushing costs more than it buys?

The requirement

A payments integration team is told to "apply functional core, imperative shell like the billing team did". Their service submits an instruction to a bank, polls for acknowledgement, reconciles a settlement file and sometimes issues a reversal. Every step is an effect and every decision depends on the previous effect's result.

The obvious build

Push everything to the edges. Wrap the bank client in an adapter, put the reconciliation logic in a pure function, and the service becomes a thin shell around a pure core just like the billing service.

Why it breaks

It works for the reconciliation arithmetic and nothing else. There is no single "gather, decide, act" shape here, because deciding what to do next requires having done the previous thing — the effect is in the middle of the decision by nature (Functional Core, Imperative Shell).

How it breaks as requirements change
  • It works for the reconciliation arithmetic and nothing else. There is no single "gather, decide, act" shape here, because deciding what to do next requires having done the previous thing — the effect is in the middle of the decision by nature (Functional Core, Imperative Shell).
  • Forcing the shape produces a shell containing a five-branch state machine, which is business logic in the untestable half. The pattern was applied and the goal was missed.
  • As reversals and partial settlements arrive, the shell grows the hardest logic in the system in the place with the fewest tests, and every new case is an integration test against a bank sandbox.
  • The team concludes the pattern does not work for them and abandons the parts that did — the reconciliation arithmetic goes back into the shell too, and now nothing is testable.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
Invariants
  • An instruction is submitted to the bank at most once per intent, across restarts and retries (Idempotency by Design).
  • Nothing is recorded as done that was not done, and nothing done is unrecorded.
  • The decision about the next step is reproducible from the recorded history, without re-contacting the bank.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The pure part owns whatever genuinely is a calculation: reconciliation arithmetic, matching rules, whether an amount is within tolerance.
  • A state machine over recorded history owns "what should happen next", and it is pure even though every input came from an effect (State Machines).
  • One adapter per external protocol owns performing a step and recording the result — nothing else may talk to the bank (Anti-Corruption Layer).
  • The persistent record owns being the source of truth about what has been done, because the process's memory does not survive a restart (Explicit State).
Boundaries
  • The boundary is not "the edge of the program". It is the edge of each *step*: every effect has a described input and a recorded outcome, and between those two the code is pure.
  • The second boundary is durability. A step that has been performed but not recorded is the dangerous state, and shrinking the window between them is the design problem (The Transactional Outbox is the backend mechanism).
  • The third is the process boundary. Once a decision spans a restart, "where the effects live" becomes "what the record says", and in-memory purity is irrelevant (Making an Existing Service Stateless).

Two shapes, and which domain you are in

The diagram shows the two arrangements side by side because the mistake in the requirement was choosing between them by analogy rather than by looking at the dependency between effects and decisions.

The test is one question: does deciding the next thing require having done the previous thing? If no, the top shape applies and the core is a function. If yes, the bottom shape applies and the core is a function over a *record* — still pure, still testable, and structured completely differently.

  • Top: effects at the edges, one pass, a plan in the middle (Functional Core, Imperative Shell).
  • Bottom: effects interleaved, one step per pass, purity in *choosing* rather than in doing.
  • Both have a pure decision. Only the first has a pure core in the usual sense.
  • The bottom shape is what makes an effectful domain replayable and auditable (Debuggability by Design).
Effects around a decision, and decisions between effects
plain dataa planone step, chosen purelyidempotency keyloop until terminalRules domain: gatherProtocol domain: recorded historyPure decisionPure: what is the next step?Perform effectsRecord intentAdapter performs one stepRecord outcome
UserLLMAgentToolDataDecisionHumanGuardrail

How far outward is far enough

Pushing effects outward has diminishing returns and then negative ones, and the point where it turns depends on what the effect is. The options below are ordered by how much machinery they require, and the right answer is usually the least machinery that makes the decisions testable.

An operation involves effects. How far do they get pushed?

Where should this effect live relative to the logic that needs it?

Leave it inline

when The operation is a handful of lines with no interesting decision — a health check, a cache warm, a straightforward read-modify-write with a transaction

cost Untestable without infrastructure, which is fine when there is nothing worth asserting about the decision. The risk is that a decision grows here later and nobody notices the threshold was crossed.

Inject the dependency

when The logic is interesting and the effect is one call — a repository, a mailer, a clock

cost A constructor parameter and a test double per collaborator. Cheap, and it leaves the test coupled to which calls the implementation makes rather than to what it decides (Mocking).

Gather, decide, act

when All inputs can be fetched before deciding, and the decision is the substance — pricing, entitlement, renewal

cost Fetching data some branches will not use, and a plan type to maintain (Functional Core, Imperative Shell).

Model each effect as a recorded step

when Decisions depend on the outcomes of previous effects, restarts must be survivable, or an audit trail is required — payments, provisioning, long-running workflows

cost A schema, a dispatcher, a migration story for in-flight work, and a doubled write on the hot path. Substantial, and only repaid where a crash window is expensive (Designing for Failure).

Adopt a durable-execution engine

when You have arrived at the previous option and the machinery is becoming the product

cost A large framework dependency, its operational surface, and its opinions about how your domain is shaped (What a Framework Charges).

One instruction, as a modelled effect

Writing the step out as states makes the crash windows visible, which is the thing a procedure hides. Each transition is a durable write, and each state answers the question a restarted process has to ask: what did we already do?

As always the forbidden list is the useful half. Every entry corresponds to a real production incident shape in payment integrations, and none of them are visible when the same logic is a sequence of awaits.

A bank instruction, from intent to settlement
IntendedSubmittedAcknowledgedSettled ·Rejected ·ReversingReversed ·
FromOnToGuardEffect
Intendedadapter call returns or times outSubmittedidempotency key recorded firstrecord the attempt, whatever the outcome
Submittedpoll returns a referenceAcknowledged
Submittedpoll is ambiguousSubmittedsame idempotency key, within retry budgetno second instruction is created (The Retry Is a Decision, Not a Reflex in Distributed Systems)
Acknowledgedsettlement file matches within toleranceSettled
Acknowledgedbank returns a rejection reasonRejected
Settleda reversal is decidedReversinga new instruction with its own key and lifecycle
Reversingthe compensating instruction settlesReversed
must be impossible
  • Intended → SettledRecording settlement without a bank confirmation is the audit failure the regulation exists to prevent, and it is exactly what an optimistic in-memory procedure does when it treats a successful call as a settled payment.
  • Submitted → IntendedGoing back to "not yet sent" after sending is how a crash produces a second instruction. Once anything has left the process, the record must never revert (Idempotency by Design).
  • Rejected → SubmittedRetrying a rejection is retrying a decline: the bank said no for a business reason and the answer will not change. This is the An Error Taxonomy That Survives Contact distinction enforced structurally.
  • Settled → RejectedA settled payment cannot become rejected; the correct path is Reversing, which is a new effect with its own record. Collapsing the two loses the fact that money moved twice (A Refund Is Not a Rollback in Distributed Systems).
  • Reversed → ReversingA second compensation for the same instruction double-refunds. The terminal states must be terminal, and enforcing that is a database constraint rather than a code convention (Database Constraints is the backend mechanism).

The two-write shape — record intent, act, record outcome — is what makes every one of these enforceable. A procedure that awaits the bank and then writes has no state between the two and therefore no way to distinguish "not sent" from "sent, unknown".

How to build it

Most important first.

  • Model each effect as a step with a name, an input, a recorded outcome and a set of legal successors, rather than as a line of code in a procedure.
  • Make "what next" a pure function of the recorded history. This is the version of a functional core that survives an irreducibly effectful domain, and it is where most of the benefit actually is.
  • Record intent before acting and outcome after, so a crash between them leaves a recoverable state rather than an unknown one (Designing for Failure).
  • Keep genuinely pure calculations pure and separate — matching, tolerance, arithmetic — even when they are a small fraction of the code. They are the part that will grow.
  • Give each effect an idempotency key derived from the intent, so a repeat after a crash is a no-op rather than a second payment (Idempotency Keys: The Mechanism is the API mechanism).
  • Accept the shape of the domain. A service whose difficulty is protocol choreography is not badly designed for having a large shell; it is badly designed if its choreography is not itself modelled (When Design Does Not Pay).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a bank's new "partially settled" outcome costs a state, its legal successors and a case in the pure decision function — a compile error at each site, no protocol code touched.
  • Adding a second bank costs an adapter and a dispatch entry. The choreography does not change if the protocol shape is the same, and it obviously does change if it is not — the design does not promise otherwise.
  • Answering "why did this instruction end up reversed" costs reading a record rather than reconstructing from logs, which is the change that makes support and audit cheap (Debuggability by Design).
  • What stays expensive: a change to the *ordering* of steps. Legal successors are encoded in the machine and in the recorded history, so reordering means a migration of in-flight instructions, not just a code change (Data Migration).
What the recommended approach costs
  • Modelling effects as steps buys replayability and auditability, and costs a schema, a dispatcher and a class of migration problem that a straight procedure does not have.
  • Recording before acting doubles the writes on the hot path, and in a high-volume system that is a real cost paid on every operation to protect against a rare crash.
  • The pure decision function is only as honest as the recorded history. If a step's outcome is summarised too aggressively, the machine is pure and wrong, which is worse than impure and right.

What can go wrong

Failure modes
  • The record is written after the effect, a crash lands between them, and the instruction is submitted twice. This is the failure the whole design exists to prevent and the one an incomplete version still has.
  • The step machine is pure but reads the bank's status codes directly, so a protocol change ripples into the domain and the adapter was decorative (Leaky Abstractions).
  • Purity is achieved for the calculation and the choreography stays implicit in a procedure, which means the ninety percent stayed untested and the ten percent got a test suite.
  • The mitigation fails on its own terms: modelling every effect as a step turns a three-line operation into a state machine with a table, and applied to a domain that did not need it, that is ceremony with a database schema (Over-Design and Under-Design).
Dependencies, and their direction
  • The step machine depends on the recorded history and on nothing external, which is what makes it testable and replayable (Deterministic Replay: Making the Schedule Reproducible in Concurrency & Parallelism).
  • Adapters depend on protocols; nothing depends on adapters except a dispatch table keyed by step name (Dependency Direction).
  • The whole design depends on durable storage being available before any effect is performed — a dependency that is easy to miss and that inverts the usual "domain first" instinct.
Misreads
  • "Push effects to the edges" means every program has a pure core. Some do not. In an irreducibly effectful domain the achievable version is a pure *decision* over a recorded history, and pretending otherwise produces logic in the shell (Functional Core, Imperative Shell).
  • "A large shell means bad design." A protocol integration is mostly shell because the problem is mostly protocol. Judge it on whether the choreography is modelled and testable, not on the ratio of pure to impure lines (Essential and Accidental Complexity).
  • "Adapters make the domain independent of the bank." Only of its vocabulary. Latency, ordering and the two-day settlement delay reach the domain no matter what the adapter does, because they are properties of the world (Leaky Abstractions).
  • "Record after acting — it is one fewer write." That is the double-payment bug, and it is not a performance optimisation, it is a correctness trade nobody made deliberately (The Dual Write Problem is the backend framing).
Smells this explains
  • temporal-coupling
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Test the step machine as a pure function over histories: given these recorded outcomes, the next step is this. No bank, no database, hundreds of cases (What a Unit Is).
  • Test each adapter against recorded protocol fixtures, including the malformed responses you have actually seen (Contract Tests).
  • Test crash recovery explicitly: kill between record-intent and perform, restart, assert exactly one submission. This is the test that justifies the design (Idempotency by Design).
  • One end-to-end run per happy path against the sandbox, and no more — they are slow, flaky and only prove the wiring (Where a Test Must Be Real).
How this design ages
  • The step machine grows states as the bank's reality turns out to be more complicated than the documentation, and that growth is visible and reviewable rather than accumulating as branches in a procedure.
  • The pressure that eventually forces a change is a second protocol whose choreography differs in shape, at which point one machine becomes two and the shared part is only the recording discipline.
  • It stops being right if the domain simplifies — the bank offers a synchronous API — and the whole machinery becomes a wrapper around one call. Deleting it then is the correct move and will feel like losing something (Speculative Generality).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • DOMAIN-SPECIFICIn a rules-heavy domain — pricing, entitlement, scheduling — the effects are a thin ring around a large decision and pushing them out is nearly free. In a protocol-integration domain the effects are the substance, and the achievable goal changes from "a pure core" to "a pure decision over a recorded history". Applying the first domain's pattern to the second is how the technique gets its bad reputation.
  • GENERALThe narrower claim — every effect should have a described input and a recorded outcome, with pure code in between — holds everywhere, including in domains with no pure core at all, because it is what makes an effect testable and replayable rather than merely isolated.
  • CONTESTEDThe strongest opposing view: modelling every effect as a persisted step is a workflow engine, and hand-rolling one is a well-known way to spend a year building infrastructure instead of features — teams who have done it often argue you should either use a durable-execution engine or write the straightforward procedure and accept the crash window. That is a serious position with a lot of wasted effort behind it. The counter is that the crash window in a payments domain is a double payment, and that the "straightforward procedure" reconstructs the same state machine implicitly, in branches, without the record.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — durable execution engines and workflow orchestrators are the off-the-shelf answer to this shape, and choosing between building and adopting one is a system-level decision rather than a code-structure one.