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
FC/IS is Wagoe’s core architectural concept. It draws a hard boundary between pure business logic and side effects.
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
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))
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})))))
| From | To | Allowed? |
|---|---|---|
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?"
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.
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"}))))
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.
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.
# 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 CreemersEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |