Skip to content

F3. Functional Core, Imperative Shell and the Interpreter Pattern

Diagnoses: D3. Tangled Computation and I/O Related fixes:

  • F4 (Parse, Don’t Validate) — the shell parses raw input into typed data; the core only ever sees the parsed form
  • F1 (Narrative Clarity) — when intent is returned as a plan, the plan is the narrative
  • F2 (Data Flow Primacy) — the plan object is data flowing from core to interpreter

The concept: Functional Core, Imperative Shell

Section titled “The concept: Functional Core, Imperative Shell”

(Gary Bernhardt, “Boundaries” talk, RubyConf 2012)

All decisions, validations, and transformations should happen in a pure core — functions that take data in and return data out, with no side effects. The shell is a thin, “stupid” layer that gathers input from the world, passes it to the core, and then mechanically applies the core’s output to the world (database writes, API calls, UI updates).

The core is trivially testable — it’s just data in, data out. The shell is so simple it barely needs tests.

A stronger version of this idea is the Interpreter Pattern (or Free Monad in Haskell): the core doesn’t just compute results, it builds a description of what should happen — an AST, a recipe, a plan. A separate interpreter then executes the plan. The description is pure and composable; the interpreter is mechanical. In everyday code, this is simply: “return a plan, then run the plan.”

The Interpreter Pattern goes beyond FC/IS in an important way: it makes intent visible as data. When a user interaction triggers a complex sequence of effects, the plan object is the narrative — you can inspect it, log it, test it, and trace it. This directly serves narrative clarity (see F1-narrative-clarity.md): instead of intent being implicit in a chain of function calls, it’s explicit in a data structure that says “here is what should happen, and in what order.” The interpreter is then so mechanical that it barely needs attention.

For any function that mixes logic and I/O:

  1. Extract the decision. Move the computation into a pure function that takes all necessary data as arguments and returns a result (or a plan/description of what to do).
  2. Push I/O to the edges. The caller gathers the inputs (database reads, API calls), passes them to the pure function, then applies the result (database writes, API calls).
  3. For complex multi-effect sequences, return a plan. When the core needs to describe multiple effects in a specific order, don’t have it execute them — have it return a data structure describing the effects. A separate, mechanical interpreter then executes the plan. This keeps the “what” (the plan) separate from the “how” (the execution), and the plan itself becomes a readable artifact.
  4. Test the core directly. The pure function can be tested with plain data — no mocks, no setup, no teardown. If the core returns a plan, test that the plan contains the right steps.

The boundary between core and shell should align with your module boundaries. The core is the “what should happen” module; the shell is the “make it happen” module.