~ / track I / applied patterns
Functional core, imperative shell
IntermediatepatternA 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.
In a Ring service the shell is the Web stack: Ring and Reitit
handler and middleware; Composing applications with components
wires {:keys [db]} into that shell; and Error handling: exceptions vs values
decides what crosses the boundary as a value versus an exception.
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.
In a Clojure service
An order-creation endpoint is the textbook FC/IS split. The Reitit route
coerces the JSON body into a plain map; handle-create-order is the thin
shell that destructures {:keys [db]} from the system map, calls the pure
decide-order core, and only then persists. Unit tests exercise
decide-order with example maps — no HTTP, no connection pool. One
integration test passes {:db :fake-pool} through the handler to verify
the shell wires I/O correctly. Every line that isn't talking to the outside
world belongs in the core.
(defn decide-order [order] ;; pure core (sketch)
(if (and (seq (:items order)) (pos? (:total order)))
{:status :accepted :order order}
{:status :rejected :reason :invalid-order}))
(defn handle-create-order [{:keys [db]} request] ;; thin shell (sketch)
(let [body (get-in request [:body])
result (decide-order body)]
(when (= :accepted (:status result))
(persist-order! db (:order result)))
{:status (if (= :accepted (:status result)) 201 400)
:body (select-keys result [:status :reason])}))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 the tangled discount! below into a pure decide core and a run!
shell. Use the fake db atom — no real JDBC. Edit decide and run! in the
Repl; run with Mod+Enter until every assert passes.
(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 reads the user, calls decide, writes
:new back to the db, and returns the discounted total.
Reference solution
(defn decide [{:keys [tier purchases total]}]
(let [rate (cond
(= tier :gold) 0.15
(> purchases 10) 0.10
:else 0.0)]
{:rate rate :new (* total (- 1 rate))}))
(defn run! [user-id total]
(let [u (db-get-user user-id)
{:keys [new]} (decide (assoc u :total total))]
(db-set-last-total! user-id new)
new))