Functional Core, Imperative Shell
Gather the inputs, decide with pure logic, then perform the effects the decision asked for. It makes the interesting part trivially testable, and it charges you for fetching data you might not need.
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.
How do I get the decision logic out of the I/O without ending up with a slower system and a worse database access pattern?
Renewal logic for subscriptions has grown to two hundred lines interleaved with six database calls, two provider calls and an email. The test for it takes eleven seconds, needs a database, and nobody has added a case to it in four months.
Keep the logic where the data is. Fetch what you need at the moment you need it — it reads naturally top to bottom, it fetches nothing wasted, and every branch has exactly the data that branch requires.
It reads naturally and it is untestable in the only way that matters: adding a case to the renewal rules means constructing a database state, which is why nobody has added one in four months. Test friction is the symptom the design is producing (Testing as Design Feedback).
- It reads naturally and it is untestable in the only way that matters: adding a case to the renewal rules means constructing a database state, which is why nobody has added one in four months. Test friction is the symptom the design is producing (Testing as Design Feedback).
- As rules accumulate, the interleaving means a rule change and a query change look identical in a diff, and a reviewer cannot tell whether a pull request altered behaviour or just data access (Review as Design Feedback — and Why It Arrives Too Late).
- The failure handling gets worse over time rather than better: when the fourth call is a provider that can time out, the first three have already written, and there is no name for the state the system is now in (Partial Failure).
- Reuse becomes impossible in the specific way that matters. The support tool wants to ask "what would renewal do for this subscription" without doing it, and there is no function that answers that question.
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.
- The renewal path runs for eighty thousand subscriptions a night, so anything that turns six queries into eighty thousand times six is not shippable (N+1 as a Design Problem).
- One of the six reads genuinely depends on an earlier decision — whether to look up a dunning history at all depends on whether the charge failed.
- The existing tests are the only description of the current behaviour, and they must keep passing through the change (Characterization Tests).
- A subscription is charged at most once per period, whatever path the code took (Idempotency by Design).
- The decision about what to do is reproducible from its inputs alone — same inputs, same plan, every time.
- No effect happens that the decision did not name.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The core owns deciding: given this subscription, this history, this instant and these rules, what should happen.
- The shell owns everything that touches the world: fetching the inputs, performing the effects the plan names, and handling their failures (Error Boundaries).
- The plan — a value describing the intended effects — owns being explicit enough that the shell needs no judgement.
- Nobody owns "a little bit of logic in the shell". The moment the shell branches on business rules, the split has failed and the untestable part is growing again.
- The boundary is the plan value. It is the interface between the two halves and its quality decides whether the split holds (Designing a Module Interface).
- Inputs cross inward as plain data; effects cross outward as descriptions, never as callbacks the core invokes.
- When a decision needs data that depends on an earlier decision, the boundary is crossed twice: decide, fetch, decide again. That is a two-phase core, not a reason to abandon the split.
Three phases, and the value in the middle
The diagram is worth reading in terms of what crosses each arrow rather than what sits in each box. Inward, plain data. Outward, a description of intended effects. Nothing crossing either arrow knows how the other half works, which is the property that makes the core importable from a test, a support tool and a dry-run mode without change.
The dashed path is the two-phase case, and it is drawn because leaving it out is how this pattern gets sold dishonestly. Real domains have decisions that determine what to read next, and the answer is to re-enter the core, not to move the query inside it.
- The core is a function. It has no fields, no constructor and no dependencies (Purity and Testing).
- The plan is printable, comparable and assertable — that is the test of whether it is a value or a disguised callback.
- The shell makes no business decisions. If it branches on a domain rule, the split has already failed.
- A dry-run mode is now free: run the core, print the plan, stop (Debuggability by Design).
The same renewal, split
The comparison below is deliberately small, because at two hundred lines nobody can see the shape. Notice that the second version has the same rules and the same effects; what moved is where the reads and writes happen relative to the decision.
The thing to watch is the second version's test. It constructs a subscription and a history, calls one function and asserts on a value — no database, no clock, no provider, and it runs in under a millisecond. That is the entire argument.
async function renew(id: SubId) {
const sub = await repo.find(id)
if (sub.status !== 'active') return
const invoice = await billing.createInvoice(sub) // effect #1
const result = await provider.charge(sub.card, invoice.total)
if (result.declined) {
const history = await repo.dunningHistory(id) // read, mid-flow
if (history.attempts >= 3) await repo.markCancelled(id)
else await mailer.sendDunning(sub, history.attempts + 1)
return
}
await repo.markPaid(id, result.chargeId)
await mailer.sendReceipt(sub, invoice)
}
// To test "3rd decline cancels", you need a database,
// a fake provider and a mail server. So nobody does.type Plan =
| { do: 'charge'; amount: Money }
| { do: 'dun'; attempt: number }
| { do: 'cancel'; reason: string }
| { do: 'skip'; reason: string }
// pure: no await, no repo, no clock read
function planRenewal(
sub: Subscription, history: Dunning, charge: ChargeOutcome | null, now: Instant,
): Plan {
if (sub.status !== 'active') return { do: 'skip', reason: sub.status }
if (charge === null) return { do: 'charge', amount: sub.price }
if (!charge.declined) return { do: 'skip', reason: 'paid' }
return history.attempts >= 3
? { do: 'cancel', reason: 'dunning-exhausted' }
: { do: 'dun', attempt: history.attempts + 1 }
}
// expect(planRenewal(sub, {attempts: 3}, declined, t)).toEqual({do:'cancel',...})The rule "the third decline cancels" is now a value comparison in a test that needs nothing. The cost is visible in the signature: the shell must supply history even on the path where the charge succeeds and it is never read — a real extra query on the happy path, which is exactly the over-fetch this pattern charges for and which a two-phase core would avoid at the cost of readability.
Priced: "cancel after three declines, unless the plan is annual"
This is the ordinary case — a rule change, no new data, no new effect — and it is the one that decides whether the split was worth it, because it is the change that happens twenty times a year.
Annual subscriptions get five dunning attempts instead of three before cancellation, and the fifth email has different copy.
One module and one test file, which sounds cheap — and the test needs a database, a fake provider and a mail stub, takes eleven seconds, and covering five attempt counts across two plan types means ten fixtures somebody has to build. The cost is not in the modules touched; it is in what it takes to believe the change is correct.
One branch in a pure function. Ten cases are ten lines of table-driven test running in single-digit milliseconds, and the shell is not touched at all because no new effect was introduced.
Plan type that every renewal change now has to keep in step. Avoiding the extra read means a two-phase core, which is harder to read than either version and is only worth it once the query actually hurts.How to build it
Most important first.
- Write the core first, as a function from inputs to a plan. If you cannot name its inputs, that is the finding — the logic depended on something nobody had made explicit (Side Effects).
- Make the plan a value:
{ charge: Money } | { dun: Reason } | { skip: Reason }, not a list of closures. A plan you can print is a plan you can test, log and diff (Explicit State). - Have the shell gather inputs in one batched read per collection, not one per entity — this is where the performance objection is answered (Cost-Aware Interfaces).
- For conditional data, run the core in two phases rather than pushing the fetch inward. Phase one returns either a plan or a request for more input; the shell serves it and re-enters.
- Keep the shell dumb enough to review in a minute. Its only decisions should be about failure and ordering, never about the business (Effect Boundaries).
- Move the existing tests down onto the core as you go, so the eleven-second suite becomes a millisecond suite one case at a time (Incremental Migration).
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.
- Adding a renewal rule costs one branch in the core and one test that runs in a millisecond with no database. That is the change this design exists to make cheap, and it is the change that arrives every sprint.
- Adding a new *effect* — "also notify the account manager" — costs a variant on the plan type and a case in the shell, with the compiler naming both sites.
- Answering "what would happen to this subscription" costs a function call, which is what makes the support tool and the dry-run mode nearly free once the split exists.
- What did not get cheaper, and this is the honest part: a rule that needs data nobody currently fetches now costs a change to the gathering step, the input type, the core and the tests — four places where the interleaved version had one. The split trades a cheap common change for a more expensive rare one.
- Gathering inputs up front means fetching data some branches will not use. In a nightly batch that is usually free; on a hot request path it is a real latency cost and sometimes decisive.
- The plan type is a second thing to maintain, and every change to what the system can do touches it. That is visibility, and visibility is not free.
- Two halves means two places to look. A reader tracing "why did this subscription get dunned" now reads the core, then the shell, then the plan type — three files where there used to be one function.
What can go wrong
- The shell starts branching. One
if (plan.charge && subscription.isTrial)and the untestable half has business logic again — this is the single most common way the pattern decays. - Gathering everything up front turns six targeted queries into a full table read, and the nightly job goes from four minutes to fifty. The pattern did that, and pretending otherwise is how it gets rolled back.
- The plan becomes a list of callbacks for convenience, at which point it cannot be logged, compared or asserted on, and the core is only nominally pure.
- The mitigation fails on its own terms: a two-phase core to avoid over-fetching is genuinely harder to read than either the naive version or the single-phase one, and a team that adopts it without needing it has bought complexity for a performance problem they did not have (Premature Optimization, Reclaimed).
- The core depends on plain data types and nothing else — no repository, no clock, no provider SDK. That is what makes it importable from a support tool, a simulator and a test.
- The shell depends on the core, the repositories and the adapters. The direction is strictly inward and never reverses (Dependency Direction).
- The plan type becomes a shared dependency of both halves and of every test, which makes changing its shape a real, visible cost (Stable Boundaries).
- "This means functional programming." It means one function that decides and one that acts. It works in Java, Go and Python with ordinary classes and structs, and no monads are involved anywhere (Purity and Testing).
- "The shell should be thin, so make it one function." Thin means "makes no business decisions", not "is short". A shell that batches reads, orders writes and handles four failure modes is legitimately a hundred lines.
- "Fetch everything, always." That is how the pattern acquires a reputation for being slow. Conditional data wants a two-phase core, and the cost of that phase is the honest price of avoiding the over-fetch (Designing for Cost).
- "The core cannot log." It can, and the argument against is theology. What it cannot do is read the world or change it in a way anything else observes (Side Effects).
- feature-envy
- long-functions
Testing it, and how it ages
- Test the core exhaustively with constructed inputs: no database, no clock, no network, milliseconds per case (What a Unit Is).
- Property-test that the core is deterministic and that no input produces a plan with two conflicting effects (Property-Based Testing).
- Test the shell separately with a stub core: given this plan, were these effects performed, in this order, with this failure behaviour (Test Doubles, Precisely).
- One end-to-end test per plan variant, to catch the wiring. It is slow, there are five of them, and they are the only slow tests left.
- The core grows steadily and stays testable; the shell stays roughly the same size for years, which is the signature of the split working.
- The first real pressure is the conditional fetch, which is what pushes a single-phase core into two phases. Expect it around the point where the plan has five variants.
- It stops being right when the operation becomes genuinely interactive — a workflow that must read after each external response — at which point the plan becomes a state machine and the shell becomes an interpreter (State Machines).
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.
- GENERALSeparating deciding from doing is expressible in any language with functions and data — the shape is identical in Go with structs, in Java with records, and in TypeScript with unions; only the syntax for the plan type changes.
- DOMAIN-SPECIFICIt pays where the decision is complicated and the effects are simple — billing, scheduling, entitlement, risk. It pays very little where the effects are the complexity and the decision is one line: a file-sync tool, an ETL step, a proxy. Those are shell all the way down and the core would be an empty ceremony.
- SCALE-SPECIFICGathering inputs up front is free in a batch job over eighty thousand rows with a batched read and expensive on a request path where one of six reads is a 40ms provider call that the plan usually does not need. The same design is obviously right in one and genuinely questionable in the other.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the reason to do this is a test suite that people actually extend, and what makes a suite extendable is that domain's subject.