~ / track H / applied patterns
Functional core, imperative shell
IntermediateA 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-ordermisbehaves, 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.