skip to content

Your renewal job loads accounts, decides, then charges cards — how do you arrange it so the decision stays pure?

level: middleimportance: must knowfreq 58%

answer

  1. read, decide, write
  2. values in, decisions out
  3. no reads in the middle phase
  4. the outer layer only dispatches
  5. gather the superset up front

basics

~20 s

Split the job into three phases: an outer layer reads everything the decision could need, a pure function turns those values plus the instant into decisions, and the outer layer carries each decision out. No reads or writes in the middle.

solid answer

~40 s

Arrange it as gather, decide, act. The outer layer reads the accounts due and takes one clock reading, then calls a function whose only inputs are those values. That function contains every business rule and returns data — charge this account this amount, notify that one — without touching anything outside its arguments. The outer layer then walks the returned decisions and performs the matching calls. The only branching left outside is dispatch on which kind of decision came back; a conditional out there that weighs an account's fields is a rule that escaped. When a rule needs a record nobody loaded, you either widen the up-front load or call the decision twice with more data — never let it fetch for itself.

code

pseudocode · 21 lines
pseudocode
// ---- shell: reads, then calls, then writes ----
function runRenewals()
    now      = currentInstant()          // one reading for the run
    accounts = loadAccountsDue(now)      // every input gathered first

    decisions = decide(accounts, now)    // pure call: values in, values out

    for each d in decisions              // dispatch only, no rules here
        if d.kind == "CHARGE" then charge(d.accountId, d.amount)
        if d.kind == "NOTIFY" then notify(d.accountId, d.reason)

// ---- core: no reads, no writes, no clock ----
function decide(accounts, now)
    decisions = []
    for each a in accounts
        if a.cancelledAt != null then continue
        if a.paidThrough < now then
            decisions.append({ kind: "CHARGE", accountId: a.id, amount: a.price })
        else if a.paidThrough < now + 3 days then
            decisions.append({ kind: "NOTIFY", accountId: a.id, reason: "expiring" })
    return decisions

go deeper

for a junior

Learn the shape and the order: gather everything first, decide over plain values, then act on what came back. The middle step is the one that must not touch the outside world.

for a middle

Explain what crosses each boundary and why the middle returns data instead of performing the work. Be able to name the only branching the outer layer is allowed.

for a senior

Show what you do when the rules need data nobody loaded — widening the gather or deciding in rounds — and be explicit about the round trip or over-fetch each choice costs.

for a principal

Decide where this arrangement is worth mandating and where it is ceremony. A job with dense rules over cheap data repays it immediately; a thin pass-through with one rule mostly pays the extra indirection.

## Three phases, in one direction The arrangement has a pure decision-making **core** wrapped in a thin **shell** that talks to the world, and work flows through it in one direction: 1. **Gather.** The shell performs every read the decision could need: the accounts due, their prices, one reading of the clock, any random values. Everything becomes plain data. 2. **Decide.** A pure function takes that data and returns more data: a list of decisions. It reads nothing, writes nothing, asks the time of nothing. 3. **Act.** The shell walks the returned decisions and performs the matching call for each — charge, notify, record. Everything that makes the program hard to test lives in phases 1 and 3, and every rule that makes it *correct* lives in phase 2. ## What is allowed where | phase | may do | may not do | |---|---|---| | gather (shell) | read stored records, take one clock reading, draw random values | weigh a business rule to decide what to read | | decide (core) | compare, compute, build result values | any read or write, any clock or random source | | act (shell) | perform charges, send notices, retry a failed call | invent or amend a decision | The act phase is the one that quietly breaks. Its branching should be **dispatch only**: look at which kind of decision came back and call the matching operation. The moment a condition out there reads an account's own fields, a rule has moved into the half that needs the real boundary to test. ## Why the middle returns data rather than doing the work If the decision performed the charge itself, it would be back to reading and writing, and the split would buy nothing. Returning data instead gives you four things: - The decision is checkable by comparing two values — the returned list against the expected list. - The same decision can be computed without being carried out, which is what a dry run is. - The decisions can be logged, counted or diffed before anything irreversible happens. - The shell stays small enough that its own correctness is a short list of claims: it loaded the right rows, and it performed everything it was handed. Keep what comes back concrete: *charge account 7 nine hundred*, *notify account 8*. The shell's job is a small switch over those kinds, not the interpretation of a general program. ## When the decision needs something nobody loaded This is where the arrangement is actually tested, and it has two honest answers: - **Widen the gather.** Load the superset the rules could need, and accept that some of it goes unused. Simple, and usually right when the extra data is small and bounded. - **Decide in rounds.** The core returns which further records it needs; the shell fetches them and calls the core again with more values. Every read still happens between two pure calls, never inside one. Costs an extra round trip and a core that can express "not enough yet". The tempting third answer — hand the core a function it can call to fetch what it is missing — undoes the whole arrangement. The read then happens inside the core, so the same arguments can still give different answers; the dependency is merely visible rather than absent. ## Checking that the split is still honest A quick review pass, in order of how often each one catches something: - Does any function reachable from the core read, write, sleep, or ask what time it is? - Does the shell contain a condition that is not a dispatch on the kind of decision returned? - Does the core take anything that is a source of values rather than a value? - Does a rule appear in both halves, so that changing it means editing two places? - Can you call the core from a test with nothing but literals, and get an answer? If the last one is a yes and the others are all no, the split is doing its job. Note what it has *not* done: the program still performs exactly the same reads and writes it always did. Isolation moves them to a place where they are few, visible and bracketed around the part where the thinking happens — it does not remove them, and the shell still needs its own, slower, checks.

  • What do you do when a rule needs a record the outer layer did not know to load?
    Two honest options. Widen the load so the superset is fetched up front, paying for data you may not use. Or decide in rounds: the core returns which further records it needs, the outer layer fetches them and calls the core again. Both keep every read outside the decision; the first costs an over-fetch, the second a round trip.
  • Does the outer layer need any branching of its own?
    Only dispatch — look at which kind of decision came back and perform the matching call. A conditional out there that weighs an account's own fields is a business rule that escaped the core, and it now lives in the half that needs the real boundary to test.
  • Where do failures of the charge itself belong?
    In the outer layer. The core's claim is that this account should be charged this amount; whether the attempt reached the other side, timed out or needs another go is a property of the call, not of the rule. Keeping that out of the core is what stops retry policy from tangling with pricing logic.

A courtroom: witnesses carry the evidence in, the judge weighs only what is on the table and states a verdict, and the bailiff goes out and enforces it. The judge never leaves the bench to fetch a missing document.

saying these in an interview costs you the question

  • Says the core is pure because it uses no global variables
  • Lets the core perform one extra read just this once
  • Puts the business rules outside and only arithmetic inside
  • Thinks pushing I/O outward removes it rather than relocating it
  • Has the outer layer second-guess the decision it was handed