Your First Module
After bb scaffold generate and bb scaffold integrate, Boundary gives you a module that already fits the framework. The point is to create the right shape of files, not just a set of files.
What you get
For a product module with name, sku, and price fields, the scaffolder creates the familiar FC/IS layout:
libs/product/
├── src/product/
│ ├── core/
│ │ ├── product.clj ← Pure business rules
│ │ └── ui.clj ← UI helpers and presentation logic
│ ├── shell/
│ │ ├── persistence.clj ← Database translation and queries
│ │ ├── service.clj ← Orchestration and validation
│ │ ├── http.clj ← Request and response handlers
│ │ └── web_handlers.clj
│ ├── ports.clj ← Protocol definitions
│ └── schema.clj ← Malli validation schemas
├── test/product/
│ ├── core/product_test.clj ← Unit tests
│ ├── shell/service_test.clj ← Integration tests
│ └── shell/product_repository_test.clj ← Contract tests
└── migrations/005_create_products.sql
If you have seen one Boundary module, you already know the basic map of the next one.
The schema
schema.clj defines the shape of the entity and the input data using Malli:
(def Product
[:map
[:id :uuid]
[:name [:string {:min 1 :max 200}]]
[:sku [:string {:min 1 :max 50}]]
[:price [:decimal {:min 0}]]
[:created-at inst?]
[:updated-at inst?]])
(def ProductInput
[:map
[:name [:string {:min 1 :max 200}]]
[:sku [:string {:min 1 :max 50}]]
[:price [:decimal {:min 0}]]])
|
Note
|
All keys are kebab-case internally. Conversion to snake_case for the database or camelCase for the API happens only at the boundary. See Conventions.
|
The core
core/product.clj contains only pure functions:
(ns boundary.product.core.product
(:require [boundary.product.schema :as schema]
[boundary.core.utils.validation :as v]))
(defn prepare-product
"Validates input and returns a product entity ready for persistence."
[input now]
(let [entity (merge {:id (random-uuid)
:created-at now
:updated-at now}
input)]
(v/validate-with-transform schema/Product entity)))
No I/O. No side effects. Easy to test without mocks.
The ports
ports.clj defines the interface between layers:
(ns boundary.product.ports)
(defprotocol IProductRepository
(create-product! [this product])
(find-product-by-id [this id])
(list-products [this opts])
(update-product! [this id changes])
(delete-product! [this id]))
(defprotocol IProductService
(create-product [this input])
(get-product [this id])
(list-products [this opts]))
The shell
shell/service.clj orchestrates validation, core logic, and persistence:
(defn create-product [this input]
(let [[valid? errors data] (validate ProductInput input)]
(if valid?
;; The clock belongs in the shell so the core stays deterministic and testable.
(let [now (java.time.Instant/now)
product (product-core/prepare-product data now)]
(persistence/create-product! (:repo this) product))
(throw (ex-info "Validation failed"
{:type :validation-error :errors errors})))))
now is supplied by the shell because time is an external dependency, not part of the business rule itself.
That split is deliberate:
-
core/decides what should happen -
shell/decides how it happens in the real world
Run the module’s tests
clojure -M:test:db/h2 :product
If you only changed pure logic, you can also run the unit-focused suite:
clojure -M:test:db/h2 --focus-meta :unit
Next steps
-
Validation - using Malli schemas and the validation framework
-
Authentication - protecting your endpoints
-
Functional Core / Imperative Shell - deeper dive into the pattern