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