← Architecture
Organize an application Pure decisions, thin effects

Functional core, imperative shell

Decide with values. Act at the edge.

You have tested a reducer: state and an action in, the next state out, no mocks. Most server code is not written that way; the price and the card charge live in the same function. Let’s end an EV charging session both ways and see what a card terminal that times out does to each.

TypeScriptGoOne charging session, a core and a shell, two recorded builds.

01 / The prompt

“When a car unplugs, bill it and let it go.”

Dana’s car charged at bay 4 from 18:04: 23.6 kWh, full at 19:10, unplugged at 19:40. The station has to price the session, charge her card, release the cable, and email a receipt. Ask an agent for it and you get a handler that does exactly that, top to bottom, and works.

Then the card terminal takes three seconds to answer, after it has already taken the money. The question the prompt never asked is which parts of that handler decide things and which parts do things. A decision that only takes values can be tested with values and rerun safely. A decision buried between two network calls can do neither.

The shape has a name from a 2012 screencast, which describes a core that is “functional” wrapped in “a shell of imperative code”: testing the functional pieces “often naturally allows isolated testing with no test doubles”, and the shell ends up “with few conditionals” (Destroy All Software, Functional Core, Imperative Shell).

02 / Name the shape

Decide with values. Act at the edge.

A functional core is the code that decides: it takes plain values, including the time, and returns plain values, including what should happen next. The imperative shell around it reads the clock, talks to the terminal and the mailer, and carries out what the core returned.

If it decides, it takes values and returns values. If it touches the world, it decides nothing it could hand to the core.

Who owns each part of ending a session
WhatOwnerWhy
The priceCorekWh, times, and rates in; cents out.
What happens after the terminal answersCoreOwed amount and receipt wording are decisions, not I/O.
The charge’s keyCoreNamed once, with the charge, so every retry carries it.
The current timeShellRead once, when the car unplugs, and passed in.
Calling the terminal, connector, mailerShellThe only code that needs a network, and the only code that retries.
Whether a timeout means retryShellTransport policy; the core only needs the final answer.

Words to put in a prompt or a review

Pure function
Same inputs, same result, and nothing outside it changes.
Functional core
The code that decides, written as pure functions.
Imperative shell
The thin code that reads inputs, calls the core, and performs its results.
Effect as data
A description of something to do, such as “charge $17.91”, returned instead of done.
Idempotency key
A name for one charge, so a retry is recognized as the same charge.
Test double
A fake clock, terminal, or mailer a test needs when decisions touch them.
Where it meets other shapesHexagonal, and reducers

The shell here is the outside of Hexagonal / ports & adapters: adapters for the terminal and the mailer. The difference is inside: in hexagonal the core calls ports, and here the core does not call anything; it returns what to do. A Redux or useReducer reducer is the same idea on the client, and so is the (state, event) → (state, commands) shape of an Elm update.

03 / Follow one session

Watch the same charge go through two shapes.

Session S-318, unplugged at 19:40. First the core prices it; then the shell carries out what the core returns. Then the terminal charges the card and loses its reply, once in the tangled version and once with the core. Last, the nightly audit. Open Try it to choose what the terminal does.

Functional core, imperative shell

Who touched the card terminal?

Core only

  1. coreS-318 unplugged 19:40 → $17.91

…

01/ 05
The core prices it

The core prices it

S-318 unplugged 19:40 → $17.91

Reduced motion: choose a scene to see its completed state.

Read this scene

S-318 unplugged 19:40 → $17.91

core: S-318 unplugged 19:40 → $17.91.

Watch restarts the story when you come back. Step through keeps your step. Try it runs a fresh fake station for every session you end.

04 / Read the shape

Two pure decisions, one thin shell.

Basic form is the core: price, decideEnd, and decideAfterCharge. In the wild is the shell that runs it, beside the tangled version. At the call site is the second caller a core makes easy: an audit over a day’s log.

Notice that decideEnd returns the charge with a key, S-318. The retry is safe because the core named the charge before anything was sent.

The functional core: the price from plain values, and two decisions that return what should happen as data. No clock, no terminal, nothing to fake.

TypeScriptReading
charging.ts
export type Bill = { energy: number; idle: number; total: number };

// The price, from values only: no clock, no terminal, nothing to fake.
export function price(session: Session, unpluggedAt: string): Bill {
	const energy = Math.round(session.kwh * PRICES.centsPerKwh);
	const idleMinutes =
		session.fullAt === null
			? 0
			: Math.max(0, minutes(unpluggedAt) - minutes(session.fullAt) - PRICES.idleGraceMinutes);
	const idle = Math.min(PRICES.idleCapCents, Math.floor(idleMinutes) * PRICES.idleCentsPerMinute);
	return { energy, idle, total: energy + idle };
}

export type Effect =
	| { kind: 'charge'; key: string; card: string; cents: number }
	| { kind: 'release-cable'; connector: string }
	| { kind: 'email'; to: string; subject: string };

// Ending a session: the bill, and what should happen next, as data.
export function decideEnd(session: Session, unpluggedAt: string) {
	const bill = price(session, unpluggedAt);
	return {
		bill,
		effects: [
			{ kind: 'charge', key: session.id, card: session.card, cents: bill.total }
		] as Effect[]
	};
}

// The terminal answered. The cable is released either way: a car is never
// held hostage over a payment. What changes is the receipt and what is owed.
export function decideAfterCharge(session: Session, bill: Bill, answer: 'approved' | 'declined') {
	const owed = answer === 'declined' ? bill.total : 0;
	const subject =
		answer === 'approved'
			? `Receipt for ${session.id}: ${dollars(bill.total)}`
			: `Payment failed for ${session.id}: ${dollars(bill.total)} due in the app`;
	const effects: Effect[] = [
		{ kind: 'release-cable', connector: session.connector },
		{ kind: 'email', to: session.email, subject }
	];
	return { owed, effects };
}
GoAlongside
main.go
type Bill struct {
	Energy int `json:"energy"`
	Idle   int `json:"idle"`
	Total  int `json:"total"`
}

// Price computes the bill from values only: no clock, no terminal, nothing to fake.
func Price(s Session, unpluggedAt string) Bill {
	energy := int(math.Round(s.KWh * centsPerKWh))
	idleMinutes := 0
	if s.FullAt != nil {
		idleMinutes = max(0, minutes(unpluggedAt)-minutes(*s.FullAt)-idleGraceMinutes)
	}
	idle := min(idleCapCents, idleMinutes*idleCentsPerMinute)
	return Bill{energy, idle, energy + idle}
}

type Effect struct {
	Kind      string // "charge", "release-cable", or "email"
	Key, Card string
	Cents     int
	Connector string
	To        string
	Subject   string
}

// DecideEnd returns the bill, and what should happen next, as data.
func DecideEnd(s Session, unpluggedAt string) (Bill, []Effect) {
	bill := Price(s, unpluggedAt)
	return bill, []Effect{{Kind: "charge", Key: s.ID, Card: s.Card, Cents: bill.Total}}
}

// DecideAfterCharge answers the terminal. The cable is released either way: a
// car is never held hostage over a payment. What changes is the receipt and what is owed.
func DecideAfterCharge(s Session, bill Bill, answer string) (int, []Effect) {
	owed, subject := 0, fmt.Sprintf("Receipt for %s: %s", s.ID, Dollars(bill.Total))
	if answer == "declined" {
		owed, subject = bill.Total, fmt.Sprintf("Payment failed for %s: %s due in the app", s.ID, Dollars(bill.Total))
	}
	return owed, []Effect{
		{Kind: "release-cable", Connector: s.Connector},
		{Kind: "email", To: s.Email, Subject: subject},
	}
}
The behavior these examples promiseChecked by 15 shared scenarios
  • 42¢ per kWh, rounded to the nearest cent. After full, 10 free minutes, then 40¢ per whole minute, capped at $30.
  • The shell charges once, retries a timeout up to three attempts with the session id as the key, and treats three timeouts as declined.
  • The cable is released whatever the terminal says. A decline leaves the total owed and sends a payment-failed email.
  • The tangled version prices the same way, and retries without a key, so a lost reply charges twice.

Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.

Reading the TypeScriptA union of effects

Effect is a union on kind, so the shell’s loop handles each action it knows and the compiler flags one it does not. The core never receives the station, so it cannot call it by accident.

Reading the GoAn effect struct and a Station of functions

Effect is one struct with a Kind; Station is a struct of functions, so a test fills it with closures instead of a mocking library. math.Round rounds half away from zero, like the contract.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types charging.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/functional-core-imperative-shell

go 1.23
price: {"energy":991,"idle":800,"total":1791}
shell, terminal times out once: charged $17.91, cable released 1, 1 email
tangled, terminal times out once: charged $17.91 + $17.91, cable released 1, 1 email
audit: S-318 $17.91, S-319 $3.36

05 / Review the agent’s diff

“Callers can’t pass the wrong time now.”

A caller did pass the wrong unplug time, so removing the argument looks like a fix. Read what the price now depends on.

The agent’s pull request

“price() now reads the time itself, so callers can’t pass the wrong unplug time. All 18 tests pass.”

// core/price.ts
			(removed)export function price(session: Session, unpluggedAt: string): Bill {
			(added)export function price(session: Session): Bill {
			(added)  const unpluggedAt = new Date().toISOString().slice(0, 16); // callers kept passing it wrong
			  const energy = Math.round(session.kwh * PRICES.centsPerKwh);
			
You are reviewing this change. What do you do?

06 / How it fails

Effects fail. Decisions should not care.

Each row is a shared scenario unless it is marked as authored.

Failure modes of ending one session
What goes wrongWhat Dana seesCore and shellTangled
Duplicated: the terminal charges, its reply is lostOne charge, or twoOne: the retry carries key S-318Two: $35.82
Wrong: the card is declinedCable released; payment-failed email$17.91 owed$17.91 owed
Unreachable: the terminal never answersCable released after three attemptsTreated as declined; nothing chargedThe same
Slow clock: unplugged overnightThe idle fee stops at $30$39.91$39.91
Wrong time in a test or an audit (authored)A wrong billThe time is an argument; pass the one you meanOnly a faked clock can change it

The first row is a retry problem, and the core makes it solvable by naming the charge before the shell sends it. Retry, backoff, and idempotency and Pure functions cover both halves.

07 / Is it worth it?

Splitting the decision from the doing costs a few types. Here is what it buys.

The tangled handler is one function you can read top to bottom. Hold both against the changes.

The same four changes, made to each
ChangeTangledCore and shell
A second caller: the nightly auditCannot price without charging; copy the arithmetic outCalls price with the logged times
Replace a dependency: a new terminal vendorEdit the function that also pricesEdit the shell; the core is untouched
Change a rule: a peak rate from 17:00 to 20:00Tests need a fake clock set to each hourTests pass the hour as a value
A second team owns paymentsThey edit the pricing function’s fileThey own the shell’s terminal code

Before restructuring, decide what you will measure and the result you would accept:

  • Duplicate charges per thousand sessions, from the terminal’s own records.
  • Decision tests that need a fake: run the suite with networking blocked and a fixed clock, as section 08 does.
  • Audit mismatches: bills recomputed overnight that differ from what was charged.

This page did not run real stations, so it gives no production numbers.

08 / Ask for it

Two prompts, two builds, one slow terminal.

We sent two agents the same request at the same time, both running Claude Sonnet, with fake station hardware running. One prompt described the service. The other added an Architecture block: pricing and every decision as pure functions over plain values that return the actions as data, a thin shell that reads the clock and does the I/O, and core tests with no fakes. A script then ran each build against its own fake hardware, question by question.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt
Prices at 19:19, 19:40, and next morning$9.91, $17.91, $39.91$9.91, $17.91, $39.91
The terminal charges, but its reply is lostCharged $17.91; retry key end-S-318Charged $17.91; retry key end-session-S-318
The terminal declinesCable released; $17.91 owedCable released; $17.91 owed
The session is ended twiceCharged onceCharged once
Files that read the clockserver.tsserver.ts
Tests with networking blocked13 of 1911 of 15

Every behavior the checker could observe was the same. The plain prompt already produced a pure pricing module, read the clock once, and retried with an idempotency key. Two sentences in the shared prompt did most of the work: the prices were written as arithmetic on values, and the terminal’s description said it “accepts an optional Idempotency-Key header,” as a real payment API’s documentation does.

The difference is where the second decision lives: what is owed, and what the email says, once the terminal answers. In the plain build it sits in the HTTP handler, between hardware calls, so testing it takes a server and fake hardware. In the architecture build it is a pure function.

server.ts · plain prompt
const approved = await chargeCard(session.card, bill.total, `end-${session.id}`);

// Always release the cable, even when the card is declined.
await releaseConnector(session.connector);

const owed = approved ? 0 : bill.total;
const paymentStatus: EndResult["paymentStatus"] = approved ? "approved" : "declined";

const subject = approved
  ? `Receipt for session ${session.id}: energy $${toDollars(bill.energy)}, idle $${toDollars(bill.idle)}, total $${toDollars(bill.total)} charged to ${session.card}.`
  : `Payment failed for session ${session.id}: charge to ${session.card} was declined. Energy $${toDollars(bill.energy)}, idle $${toDollars(bill.idle)}, total $${toDollars(bill.total)} still owed.`;

await sendReceipt(session.email, subject);

const result: EndResult = { ...bill, owed, paymentStatus };
core.ts · architecture prompt
// Pure decision of what the receipt says, given whether the charge was approved.
export function buildReceipt(session: Session, bill: Bill, approved: boolean): Receipt {
  if (approved) {
    return {
      to: session.email,
      subject:
        `Receipt for session ${session.id}: energy $${dollars(bill.energy)} + ` +
        `idle $${dollars(bill.idle)} = $${dollars(bill.total)} charged to your card on file`,
      owed: 0,
    };
  }
  return {

So the line worth adding to a plain prompt is not “use a functional core”: every decision, including what happens after each external answer, is a function of values you can test without a server; name every charge before sending it. The runs show that when the API’s documentation offers a key, agents use it. When it does not, the second half of that line is what keeps a retry from charging twice.

How the runs were made and checkedOne run each, recorded as written
  • Both agents received the prompts word for word, in fresh contexts, in the same message, with fake station hardware already running.
  • The files each agent wrote are kept byte for byte, with checksums. For every question the checker restores a build and starts it with its own fake terminal, connector, and mailer, scripted for that question: a terminal that charges and answers after three seconds, one that declines, one that never answers.
  • The architecture agent kept a log and a process id in the system’s temporary folder, against the prompt. Both stopped their servers by process id.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Keep the clock and the network out of the core, by rule and by test.

A core erodes one convenience at a time: a new Date() here, a quick fetch there. Three checks.

  1. The language’s own door

    In Go, a core package that imports neither time.Now’s callers nor net/http is easy to see in its import list. TypeScript has no such door; a pure module looks like any other, which is why the next two checks matter more there.

  2. A rule a check enforces

    Forbid Date.now, new Date() with no arguments, process.env, and fetch inside core/ with a lint rule, and forbid core/ from importing the shell. Enforcement layer runs rules like this against real code. This rule was not run here; the checker in section 08 did the same scan by pattern.

  3. A check on what actually happens

    Run the core’s tests with networking blocked and a fixed clock: they should all pass. Then run the shell against a terminal that charges and loses its reply, and count the charges. The checker does both.

    check-runs.mjs
    /** One fresh build, one fresh station, and one or more end requests. */
    async function end(dir, { now = '2026-09-23T19:40', script = 'approve', times = 1 } = {}) {
    	const station = await startStation(script);
    	const server = await start(dir, now);
    	const replies = [];
    	const started = Date.now();
    	for (let i = 0; i < times; i++) {
    		const res = await fetch(`${BASE}/sessions/S-318/end`, { method: 'POST' });
    		replies.push({ status: res.status, body: await res.json().catch(() => null) });
    	}
    	const took = Date.now() - started;
    	await new Promise((r) => setTimeout(r, 300));
    	await stop(server);
    	await station.close();
    	return { replies, took, charges: station.record.charges, keys: station.record.keys, releases: station.record.releases, emails: station.record.emails };
    }
    
    /** Which source files read the clock, the environment, or the network. */
    function scan(dir) {
    	const out = {};
Your reducer is already a functional coreuseReducer and a Svelte update function are cores. The effect you run afterward is the shell.

Where it already is in your components

A useReducer reducer, or a Svelte function that returns the next state, takes state and an action and returns state: a functional core you already test without mocks. The component that dispatches and renders is the shell.

When you have to own it

The day a transition must also start something: charge a card, send a request. Keep the reducer pure by returning the effect as data, with a key, and let an effect in the component run it and dispatch the answer. The samples show a plain reducer, then one that returns a charge for the component to perform.

The station screen’s reducer: plain state and actions in, the next state out.

ReactAlready in your code
StationScreen.tsx
// StationScreen.tsx. The reducer is the core: plain state and an action in,
// the next state out, testable with no DOM and no fetch. The component only
// dispatches and renders.
import { useReducer } from 'react';

type State = { phase: 'charging' | 'full' | 'unplugged'; kwh: number };
type Action = { type: 'meter'; kwh: number } | { type: 'full' } | { type: 'unplug' };

export function reduce(state: State, action: Action): State {
	switch (action.type) {
		case 'meter':
			return state.phase === 'charging' ? { ...state, kwh: action.kwh } : state;
		case 'full':
			return { ...state, phase: 'full' };
		case 'unplug':
			return { ...state, phase: 'unplugged' };
	}
}

export function StationScreen() {
	const [state, dispatch] = useReducer(reduce, { phase: 'charging', kwh: 0 });
	return (
		<section>
			<p>
				{state.kwh.toFixed(1)} kWh · {state.phase}
			</p>
			<button onClick={() => dispatch({ type: 'unplug' })}>End session</button>
		</section>
	);
}

10 / Make the call

Split when a decision is worth testing without the world.

Keep it in one function when there is almost nothing to decide: a handler that forwards a request, or a script run once. A core with one line in it is ceremony.

Split into a core and a shell when the decision has rules worth testing, such as prices, limits, or state changes, or when it must run in more than one place, such as live and in an audit, or when an effect can be retried. Reopen it when the shell starts making decisions again: an if on a business value in the shell is a decision that escaped.

Take it with you

Explain it without saying “functional core” or “imperative shell”: “One part works out the bill and what should happen, from numbers and times it is given. The other part reads the clock, talks to the card reader, and does what the first part said.” Then find a function in your code that both decides something and sends something, and split it.

Paste into your next prompt, and fill in the blanks

Put <the pricing and every decision> in pure functions that take plain values,
including <the current time>, and return the result and the actions to take
as data: <charge, release, email>. That includes what happens after each external
answer. A thin shell reads the clock, calls <the terminal, the mailer>, and performs
those actions. Nothing in the core reads the clock, the environment, or the network.
Every action that may be retried carries a key the core chose.
Test the core with plain values and no fakes.
Connections to follow nextRelated lessons

Take the station into your editor. Add a peak rate from 17:00 to 20:00, and write its tests without a single fake.

Back to architecture →