A Deterministic Core
Same inputs, same outputs, every time. A domain core with no ambient time, randomness, I/O or global state can be tested exhaustively, replayed from a log, and reasoned about without running it.
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.
Why can I not reproduce this bug locally, and what would the code have to look like for the answer to be "paste the inputs and run it"?
A pricing bug appears for a handful of customers on the 1st of each month. Three attempts to fix it by inspection have failed, and nobody can trigger it on demand.
Keep the rule where it is, add logging around it, and read the logs until the pattern is obvious. It is the cheapest possible step and it works often enough that it is always the right thing to try first.
It works until the decision depends on something not in the log — the hour it ran, a rounding mode from config, a row that has since changed — and then the log shows the inputs you thought to print and not the ones that mattered.
- It works until the decision depends on something not in the log — the hour it ran, a rounding mode from config, a row that has since changed — and then the log shows the inputs you thought to print and not the ones that mattered.
- Each new hypothesis costs a deploy, because the only way to test it is to add another log line and wait for the 1st of the month. The feedback loop is a month long (CI Is a Feedback System).
- As soon as the fix lands there is no way to demonstrate it worked except waiting, so confidence never rises above "it has not recurred yet".
- The tests that exist need a database, so nobody writes the forty variations of the rule that would have found this, because forty database tests take four minutes and forty pure ones take forty milliseconds (Purity and Testing).
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 rule is genuinely complex — proration, tax, discounts, currency rounding — so "read it carefully" has already been tried by three people.
- The code is in production and cannot be rewritten; whatever is done has to be an extraction from the existing path (Finding Seams).
- The team cannot replay production traffic: there is no recording, and the inputs are partly personal data that cannot be copied to a laptop (Production Data in Lower Environments).
- For the same inputs the core produces the same output, on any machine, at any hour, in any order.
- The core reads nothing it was not given. No clock, no random source, no environment variable, no database, no cache (Hidden Global State).
- The core writes nothing. Every effect is a value it returns for someone else to perform (Side Effects).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The core owns computing decisions from values. It owns no I/O, no scheduling and no persistence (Functional Core, Imperative Shell).
- The shell owns gathering inputs, calling the core, and performing the effects the core described.
- The clock and the random source are inputs, supplied by the shell, not facilities the core reaches for (Time as a Dependency, Randomness as a Dependency).
- The recording of inputs — enough to replay a decision — is the shell's job too, and it is what turns determinism from a testing property into a debugging one.
- The seam runs where values stop and effects start. It is usually already implicit in the code and just needs naming, which is what makes this extraction cheaper than it looks (Finding Seams).
- The core boundary is also the replay boundary: if you can serialise everything that crosses it, you can reconstruct any past decision exactly (Deterministic Replay: Making the Schedule Reproducible).
- Not everything belongs inside. Pushing I/O out of a function whose entire purpose is I/O produces ceremony and no determinism (Over-Decomposition).
The same rule, reachable and not
The left version cannot be run without a database and cannot be run twice with the same result, because the answer depends on the hour. Every attempt to reproduce the reported bug is an attempt to recreate an entire environment at a particular instant.
The right version is a function of five values. Reproducing the bug means writing those five values into a test, which is a two-minute job — and the same shape makes forty edge cases affordable, which is what actually finds the bug (Purity and Testing).
async function prorate(subId: string): Promise<Money> {
const sub = await db.subscriptions.find(subId)
const plan = await db.plans.find(sub.planId)
const now = new Date() // which hour? which zone?
const rate = await taxApi.rateFor(sub.country)
const days = daysBetween(now, sub.renewalAt)
if (flags.isOn('new-rounding')) { /* ... */ } // and which flag state?
return round(plan.price * days / 30 * (1 + rate))
}type ProrationInput = {
price: Money; renewalAt: Instant; now: Instant
taxRate: Rate; rounding: RoundingMode
}
function prorate(i: ProrationInput): Money {
const days = daysBetween(i.now, i.renewalAt)
return round(i.price.times(days).dividedBy(30).times(i.taxRate.plusOne()),
i.rounding)
}
// the shell gathers, records the input set, calls, and performs effects.The left function has four hidden inputs — the two rows, the wall clock and the flag state — none of which appear in its signature, so nothing about it can be reproduced from a bug report. The right one has five declared inputs, so a failing case is five literals in a test file and a property test over a thousand generated cases costs milliseconds. The bug on the 1st of the month is a daysBetween boundary that is trivially findable once the date is a parameter, and effectively unreachable while it is new Date().
Effects as values
The second half of the split is less familiar and does more work than it looks: the core does not perform effects, it *describes* them. The shell decides how to perform them, in what order, and what to do when one fails.
This is what makes a decision auditable. The list of effects the core produced is exactly what should have happened, so an incident can compare it against what did happen — and that comparison is impossible when deciding and doing are the same statement (Effect Boundaries).
1type Effect =2 | { kind: 'charge'; amount: Money; subscriptionId: SubscriptionId }3 | { kind: 'email'; template: 'paused' | 'resumed'; to: CustomerId }4 | { kind: 'transition'; from: State; to: State; reason: ReasonCode }5 6// core: values in, decision + effects out. No awaits anywhere.7function decideBilling(i: BillingInput): { effects: Effect[]; reason: ReasonCode } {8 if (i.state === 'paused') return { effects: [], reason: 'skipped_paused' }9 if (!isDue(i.renewalAt, i.now)) return { effects: [], reason: 'not_due' }10 return { effects: [{ kind: 'charge', amount: prorate(i), subscriptionId: i.id }],11 reason: 'charged' }12}13 14// shell: performs them, in an order it owns, with the retries it owns.15for (const e of decideBilling(input).effects) await perform(e, ctx)Two things follow that are hard to get any other way. A test asserts on the returned effect list — no mocks, no spies, no assertion that a method was called (Mocking). And the effect list can be logged as the decision itself, so an incident compares what the system decided against what it did, which are different questions that usually get answered as one.
What it costs, scored
The split is not free and the column that pays for it is not simplicity. Gathering every input before the call means loading data the rule may not use; the shell absorbs all the ordering and failure logic and becomes the risky part; and there is one more hop between deciding and doing.
The scores below are a teaching model over a rules-heavy service. In a CRUD service the first row wins on every axis, and reading these numbers as universal is the most common way this idea gets misapplied.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| Rule reaches for its own data | Shortest to write and read in the small. Unreproducible by construction, so every bug costs a deploy cycle, and tests need a database, which caps how many of them get written. | |||||
| Rule takes injected repositories | The usual compromise. Testable with mocks, which means tests assert on calls rather than on outputs and then pin the implementation. Still not replayable, because the mock is deterministic and production is not. | |||||
| Pure core, effects as values | Reproducible from recorded inputs, exhaustively testable, and the decision is auditable separately from its execution. Costs eager input gathering and a larger, more procedural shell. |
caveat Scored for a service with dense business rules. Where the "rule" is a mapping between two payloads, the first row is correct and the third is ceremony — the axes cannot express that the whole comparison is conditional on there being a decision worth isolating. The operational score also hides a real risk: it assumes someone tests the shell, and the characteristic failure of this design is a beautifully tested core bolted to an untested one (Where a Test Must Be Real).
How to build it
Most important first.
- Make the core a function from values to values. Inputs in, a decision out, including a description of the effects to perform rather than the effects themselves (Functional Core, Imperative Shell).
- Pass time and randomness as arguments. This is the specific change that converts "cannot reproduce" into "paste these inputs" (Time as a Dependency).
- Return effects as data —
[{ charge: 1200 }, { email: "paused" }]— so the shell decides how and whether to perform them, and tests assert on the description (Effect Boundaries). - Record the inputs at the boundary for decisions that matter. A serialised input set is a reproducible bug report, and it is the difference between determinism as a testing nicety and as an operational tool.
- Extract, do not rewrite. Move the rule out from behind the I/O with characterization tests in place, so behaviour is held constant while structure changes (Characterization Tests).
- Be honest about the scope: this pays where the logic is complex and the inputs are few. A function that mostly moves data between two stores has no core to make deterministic (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.
- With a deterministic core, reproducing a reported bug costs pasting the recorded inputs into a test. The next pricing change costs writing the forty cases that describe it, in milliseconds each.
- Without one, every hypothesis costs a deploy and a wait, so the cost of a fix is measured in cycles of the thing that triggers it — here, a month.
- Extracting the core later costs characterization tests, an interface decision and a careful refactor of code nobody fully understands. That is real work, and it is a fraction of one month-long feedback cycle, which is the arithmetic that usually decides it (The Legacy Change Loop).
- What does not get cheaper: bugs in the shell. Ordering, retries and partial writes are exactly the things that were moved out, and they remain as hard as they were (Partial Failure).
- Every input the core needs must be gathered before it is called, which means loading things the rule may not use. That is a real performance cost and sometimes a real N+1 in disguise (N+1 as a Design Problem).
- The shell gets bigger and more procedural, and it is now the part with all the risk. Purity does not remove complexity; it relocates it somewhere it can be seen.
- Returning effects as data adds an interpretation layer, which is indirection between deciding and doing — a reader tracing "what actually sends the email" now has one more hop (What an Abstraction Costs).
What can go wrong
- The core is pure except for one call — a feature-flag lookup, a config read — and that one call reintroduces every problem it was extracted to solve (Feature Flags and What They Cost).
- Determinism holds for the core and the bug is in the shell, which is where the I/O ordering lives and where the harder bugs usually are (Concurrency by Design).
- Inputs are recorded but include a mutable object that is modified after recording, so the replay uses different values from the original run.
- Floating-point arithmetic makes "same inputs, same output" false across platforms in a way nobody expects, and money is where it hurts (Units in Names and Types).
- The mitigation fails too: input recording is added, becomes large, is sampled to control cost, and is then missing for precisely the rare decisions that need replaying (Sampling Without Throwing Away the Evidence).
- The core depends on nothing but its input types, which is what makes it testable — and it means the input types become the real interface of the module (Designing a Module Interface).
- The shell depends on the core, never the reverse. A core that calls back into the shell for a value has quietly reacquired every dependency it shed (Dependency Direction).
- Replay depends on the recorded inputs being complete. A missing field turns a deterministic core into one that is deterministic in principle and unreproducible in fact.
- "So make everything pure." Most code is not a decision procedure. A handler that reads a row, maps it and returns JSON has nothing to make deterministic, and wrapping it in this structure is pure cost (Over-Decomposition).
- "This means functional programming." It means separating decisions from effects, which is available in any paradigm — a class with no fields but its inputs is the same idea (Side Effects).
- "Deterministic means correct." It means reproducible. A deterministic core can be reliably, repeatably wrong, and that is still a large improvement because the wrongness is now findable (Testing as Design Feedback).
- "We already inject a repository, so it is deterministic." A mocked repository is deterministic in the test and not in production, which is the wrong direction: the goal is a core that has no repository at all (Mocking).
- feature-envy
Testing it, and how it ages
- Table-driven tests over the core, dozens of cases, no fixtures and no database. If a case needs setup beyond values, something is still inside that should not be (What a Unit Is).
- Property-based tests, which are only affordable against a deterministic function — a hundred generated cases against a database-backed rule is a slow suite nobody runs (Property-Based Testing).
- A replay test built from a real recorded input set, kept as a regression case. It is the most valuable test in the suite because it is the actual bug rather than a model of it.
- Test the shell separately for ordering and failure, with the core stubbed to return a fixed decision. Those are different concerns and mixing them is what made the original untestable (Test Doubles, Precisely).
- A deterministic core tends to grow into the place the domain vocabulary lives, because it is the only part of the system that can be read without knowing about infrastructure (Ubiquitous Language).
- It comes under pressure the first time a rule needs a lookup — a tax rate, an exchange rate. The move that preserves determinism is to pass the rate in; the move that destroys it is a repository call, and it is one line (Volatile Dependencies).
- It stops paying if the domain logic thins out. A core that is three lines of arithmetic surrounded by a resolver and a result type is ceremony, and the honest response is to inline it back (Over-Design and Under-Design).
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.
- GENERALThat a function reading nothing but its arguments can be re-run to the same result is a property of computation, so it holds regardless of language. What varies is how much the language helps you hold the line — a compiler that tracks effects makes the boundary checkable, and everywhere else it is a convention.
- DOMAIN-SPECIFICThe value tracks the density of business rules. Billing, scheduling, pricing, entitlement and anything with proration repay this heavily; an integration service whose job is mapping one payload to another has no core to extract, and forcing one produces indirection without a benefit.
- CONTESTEDThe strongest opposing case is that the functional-core split relocates rather than removes difficulty: the shell becomes a large procedural region holding all the ordering, retry and partial-failure logic, and that is where most production bugs actually live, so the split can produce a beautifully tested core attached to an untested mess. Practitioners who have seen that outcome argue for integration-level testing of the whole path instead, and they are right that the shell needs its own discipline — the split is not a substitute for testing it.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — property-based and exhaustive testing only become affordable against a deterministic function, and what to do with that affordability belongs there.
- — Programming Languages & Runtime Internals — effect systems, purity annotations and floating-point reproducibility across platforms decide how much of this the compiler can enforce for you.