Functional Core, Imperative Shell
Functional Core, Imperative Shell
An architecture in which all decisions are made by pure functions over immutable data ("the core"), while all I/O, mutation, and effects happen at the program's edges ("the shell"). Also called the ImPureIm sandwich — the program is a thin imperative top, a thick pure middle, and a thin imperative bottom. The pattern predates Rust by decades — Gary Bernhardt coined the modern phrasing in 2012, Mark Seemann formalized the sandwich, and the Haskell community has lived in it since the language existed — but it earns a Rust page because the borrow checker pushes you toward it whether you planned to or not.
The shape
[ I/O: read inputs, deserialize ] ← imperative shell (top)
│
▼
[ Pure transformation: types, ]
[ enums, exhaustive matching, ] ← functional core
[ no side effects, no I/O ]
│
▼
[ I/O: write outputs, persist ] ← imperative shell (bottom)
The pure core is testable without mocks (it takes data in, returns data out), reasonable in isolation (no hidden state), and easy to parallelize (no shared mutation). The shell is small, blunt, and ideally a single layer thick — the place where async, retries, transactions, and time live.
Why Rust amplifies the payoff
Rust's borrow checker rejects shared mutable state aggressively. In a language where every function might mutate anything, "push effects to the edge" is good advice; in Rust, code that doesn't push effects to the edge tends not to compile without Arc<Mutex<…>> constructions that signal the design is fighting the language. The result is that Rust programmers tend to adopt the pattern by accident — &self methods that take input and return output, &mut self reserved for genuine state transitions, I/O concentrated in a few async fn handlers.
For high-level-rust specifically, the sandwich is the recommended discipline:
- Pure functions get to return owned values without lifetime complications.
- Cheap-clone discipline (see clone-cost-stratification) is easiest to maintain when functions are pure — there's no aliasing question to answer.
- Testing the core needs no test doubles, no async test harness, no
tokio::test. - The shell is where
Arc<dyn Trait>services live; the core depends on them only through values it receives.
Where it interacts with other Rust ideas
- parse-dont-validate — happens at the top of the sandwich. Parsing produces refined types that flow through the pure core; the bottom of the sandwich serializes them back.
- type-driven-development — illegal-states-unrepresentable is a property of the core's data model. Typestate transitions are pure functions consuming
self. - railway-oriented-programming —
Result<T, E>chains are a functional core;?is the pure-side propagation; the shell decides what to do with the finalErr. - tokio handlers — typical
axumshape is a thinasync fnthat does I/O, hands data to a synchronous pure function, then does more I/O. Effectively a built-in sandwich. - deterministic-simulation-testing — only feasible when the core is pure; the shell is what you replace with a deterministic mock harness.
Where it breaks down
Streaming and incremental computation. When the output must be produced before the input is complete (a parser that emits tokens as bytes arrive, a video decoder), the strict input-transform-output sandwich becomes awkward. Generators and iterator chains let you keep the logic pure while threading effects through a Stream, but the boundary blurs.
Genuinely stateful systems. Game engines (see ecs-pattern), simulators, and long-lived in-memory caches have a state-update loop where the "core" mutates the world. The sandwich applies recursively — each frame is pure(world, input) → world' — but at sub-frame scale you're back to imperative code.
Performance-critical paths. Pure-by-cloning costs allocations. When the perf budget is tight, you trade purity for in-place mutation. The discipline is to keep the mutation local — a function that takes &mut Buffer and modifies it is still pure modulo the buffer alias, which is the same trick Iterator::for_each uses internally.
The cross-language lineage
The pattern has many names because the same insight keeps being rediscovered:
- Functional core / imperative shell — Gary Bernhardt's 2012 talk; the most common modern name
- ImPureIm sandwich — Mark Seemann's framing; emphasizes the three-layer shape
- Hexagonal architecture / ports and adapters — Alistair Cockburn, 2005; the OO-flavored variant where the shell is composed of adapters
- Onion architecture — Jeffrey Palermo, 2008; same idea, different metaphor
- Clean architecture — Robert Martin, 2012; same again, with rule-numbered diagrams
IO a— Haskell, 1992; the type-level enforcement of the same separation
The Rust community has no canonical name; the underlying pattern is so default it rarely gets called out. But naming it matters when the discussion is about style choice — high-level-rust makes the sandwich an explicit pillar rather than an accident.
Related
- high-level-rust — the style that names this as its second pillar
- clone-cost-stratification — the cost question this style makes most acute
- parse-dont-validate — the top-of-sandwich discipline
- type-driven-development — the data-model discipline of the core
- railway-oriented-programming — the error-flow discipline of the core
- deterministic-simulation-testing — the testing payoff
Linked from
Sources
- Raw/Rust/High-Level Rust: Getting 80% of the Benefits with 20% of the Pain.md