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/)

  • Pure functions only — given the same input, always the same output

  • No I/O of any kind (no println, no log/info, no jdbc/execute!)

  • No exception throwing (return error maps instead)

  • No java.util.Date/new, UUID/randomUUID, or any other non-deterministic calls

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

;; ❌ WRONG — side effect in core/
(defn validate-email [email]
  (log/info "Validating" email)  ; side effect!
  (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?"

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/, you know it has no surprises. It will not log to Datadog, write to disk, or call an external API.

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
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:
Thijs Creemers & thijscreemers
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