~ / track H / applied patterns

Functional core, imperative shell

Intermediate

A practical FP architecture says: push pure logic to the center, push I/O and side effects to the edges. The "core" is functions of data → data, trivially testable. The "shell" is the thin layer that reads from the outside world, calls the core, writes the results back. Everyone in the system benefits — testing, debugging, parallelism, future-you.

Gary Bernhardt coined the phrase in 2012; the idea was already folklore in Lisp and Haskell circles. It's the architectural shape behind hexagonal architecture, ports-and-adapters, and Clojure-style "data driven" services.

A concrete shape

A typical request flow becomes three layers:

[shell]   read inputs (HTTP body, DB row, file, message)
          ↓
[core]    pure computation: validate, decide, transform
          ↓
[shell]   write outputs (DB update, HTTP response, log line)

In code:

(defn handler [request]                ;; shell — sees the world
  (let [order      (read-order request)            ;; impure
        result     (process-order order)           ;; pure core
        persisted  (save! result)                  ;; impure
        response   (response-for result)]          ;; pure
    persisted
    response))

process-order is a pure function of order → {:status, :items, ...}. You test it with example data. save! and read-order are the shell, tested with integration tests at the boundary.

Why this matters

  • Tests are cheap. The core can be exercised with example values, no fixtures, no mocks, no time / random / DB.
  • Debugging is data inspection. When process-order misbehaves, you log the input data at the boundary and replay it in a REPL. No reproducing side effects.
  • Refactoring is safer. Pure functions can be split, renamed, recombined with confidence; mock-heavy tests break the moment you touch internals.
  • The shell stays thin. When you spot logic inside the shell — a branch, a transformation, a calculation — that's a signal to move it into the core.

A small case study

Before: a single function tangled together I/O and decisions:

(defn approve! [user-id]                                    ;; everything mixed
  (let [u (db/get-user user-id)]
    (if (and (>= (:age u) 18) (= (:country u) :br))
      (do (db/set-status! user-id :approved)
          (audit/log :approved user-id))
      (do (db/set-status! user-id :rejected)
          (audit/log :rejected user-id)))))

After: a pure decision plus a thin shell:

(defn decide [user]                                         ;; pure core
  (if (and (>= (:age user) 18) (= (:country user) :br))
    {:status :approved}
    {:status :rejected}))

(defn approve! [user-id]                                    ;; shell
  (let [u        (db/get-user user-id)
        decision (decide u)]
    (db/set-status! user-id (:status decision))
    (audit/log     (:status decision) user-id)))

Tests against decide exercise every branch with one-liner inputs. The shell, now small and obvious, gets one integration test.

How to tell the shell from the core

Heuristics:

  • Does the function read anything that's not in its arguments? If yes, it's in the shell.
  • Does the function cause anything observable from outside the function? If yes, shell.
  • Could you compute the result by hand on paper, given only the arguments? If yes, it's a candidate for the core.

When in doubt, move it into the core and pass it the data it needs as arguments. Almost always possible; almost always worth it.

Beware: the shell wants to grow

A common failure mode: the shell accumulates branches, transformations, exception handlers — and slowly becomes a parallel core that nobody tests. Audit the shell periodically; ask whether each line is interacting with the outside world, and move everything else inward.

Check yourself

? quiz

A function reads a config file, applies business rules to the parsed data, then writes a report file. Where should the business rules live?

Exercise

Refactor this tangled function into a decide core and a run! shell:

(defn discount! [user-id total]
  (let [u    (db/get-user user-id)
        rate (cond
               (= (:tier u) :gold)   0.15
               (> (:purchases u) 10) 0.10
               :else                 0.0)
        new  (* total (- 1 rate))]
    (db/set-last-total! user-id new)
    new))

The pure decide should take {:tier ... :purchases ... :total ...} and return {:rate ... :new ...}. The shell handles the DB read and write.

 status: new