Liking cljdoc? Tell your friends :D

Functional Core / Imperative Shell

FC/IS is Wagoe’s core architectural concept. It draws a hard boundary between pure business logic and side effects.

The two zones

libs/{library}/src/wagoe/{library}/
├── core/       ← FUNCTIONAL CORE: pure functions only
├── shell/      ← IMPERATIVE SHELL: all side effects
├── ports.clj   ← interfaces between the zones
└── schema.clj  ← Malli validation schemas

Functional Core (core/)

Core’s result depends on its arguments alone, and core writes nothing the application can read back.

  • No reads and no writes: not the database, not a file, not a cache

  • No clock and no randomness (Instant/now, UUID/randomUUID)

  • No mutable state

  • No exception throwing (return error maps instead)

An effect is observational when the application has no read path to its sink. Logs, metrics and traces are, by construction. A write to the application’s own store never is, even an append-only table nothing reads today: a reader added later must not turn existing core code into a violation. So core returns an audit entry or an event as data, and the shell writes it.

Core does not log yet: there is no observation channel that returns nil, never throws, and is bound by the shell. bb check:fcis refuses a logging require in core, and warns on printing outside with-out-str and on tap> anywhere; from 2.0 that warning is an error.

For the clock it refuses Instant/now, UUID/randomUUID and System/currentTimeMillis, and warns on the other clock and random reads: (java.util.Date.), (java.util.Random.), System/nanoTime, Math/random, Calendar/getInstance, rand, rand-int, rand-nth, shuffle, random-uuid. A refused call the old line check missed, split from its paren or handed to apply or map, warns too, as does a printer or clock read written as a step of →, →>, some→, cond→ or doto. That warning too is an error from 2.0. The shell reads the value and passes it in. A namespace that must print or read the clock is exempted, except from the refused calls, with ^:wagoe/allow-print or ^:wagoe/allow-clock metadata, or an :allow-print or :allow-clock entry in .wagoe/check-fcis.edn with an :ns and a :why; an entry that exempts nothing fails the gate.

;; ✅ CORRECT — pure function in core/
(defn validate-email [email]
  (when (re-matches #".+@.+\..+" email)
    email))

;; ❌ WRONG — core/ has no observation channel to log through
(defn validate-email [email]
  (log/info "Validating" email)
  (when (re-matches #".+@.+\..+" email) email))

Imperative Shell (shell/)

  • All side effects: database queries, HTTP calls, logging, file I/O

  • Validation (calls Malli, can throw exceptions)

  • Delegates business logic to core functions

;; ✅ CORRECT — I/O in shell/
(defn create-user [this input]
  (let [[valid? errors data] (validate UserInput input)]  ; validation here
    (if valid?
      (let [user (user-core/prepare-user data)]  ; pure core function
        (persistence/insert-user! (:db this) user))  ; side effect here
      (throw (ex-info "Validation failed"
                      {:type :validation-error :errors errors})))))

Dependency rules

FromToAllowed?

Shell

Core

Yes

Core

Ports

Yes

Shell

Adapters

Yes

Core

Shell

Never

Core

Adapters

Never

The most common violation is when a core namespace requires a shell namespace (for logging or persistence). bb check:fcis catches this on every commit, but the structure is self-enforcing if you ask: "Can I test this function without any mocks?"

core/ and ports know no SQL

Building a HoneySQL map is pure, but it ties the core to a SQL store, and a SQL type in a port signature rules out any other store behind that port. So neither core/ nor ports.clj may require honey.sql, next.jdbc or wagoe.platform.database, and a port may not type an argument or return as a HoneySQL map or a next.jdbc datasource or transaction. A store handle such as tx may still pass through a port, as long as the port does not say what it is: "an open transaction of the store", not "a next.jdbc transaction". bb check:fcis fails on a require or a port arglist, and warns on a docstring. The few places where SQL is the point, such as the SQL implementation itself, are listed under :allow-sql in .wagoe/check-fcis.edn, each with a :why.

Why this matters

Tests without mocks

Core functions are plain Clojure functions. Testing them requires no database, no HTTP server, no mocks:

(deftest test-prepare-user
  (is (= {:name "Alice" :email "alice@example.com"}
         (user-core/prepare-user {:name "Alice" :email "alice@example.com"}))))

Predictable behaviour

When you see a function in core/, its result depends on its arguments and nothing else. It will not reach a database, a clock or Datadog.

Safe refactoring

Side effects are isolated in shell/. The core contains the rules. Swapping from PostgreSQL to DynamoDB means changing shell/persistence.clj, not touching any business logic.

Detecting violations

# FC/IS gate: core requires (an allowlist), throws, mutable state, non-determinism, SQL in core and ports; warns on printing
bb check:fcis

The rule to remember: a core/ namespace may require only pure libraries and its own core, schema and ports.

Can you improve this documentation? These fine people already did:
thijscreemers & Thijs Creemers
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close