~ / track I / applied patterns

Functional core, imperative shell

Intermediatepattern

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. 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-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.

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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
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))
 status: new