Liking cljdoc? Tell your friends :D

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

Run it from your project root:

bb scaffold generate --module-name product --entity Product \
  --field name:string:required --field sku:string:required --field price:decimal

For that product module the scaffolder creates the familiar FC/IS layout under src/boundary/product/:

src/boundary/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
The scaffolder emits modules under the boundary.<module>.* namespace (e.g. boundary.product.core.product) into src/boundary/. Wiring the module into deps.edn/tests.edn currently assumes the monorepo libs/ layout — see the scaffolder reference for the current integration flow.

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}]]])
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

A project generated with boundary new runs its tests through Kaocha via the :test alias:

clojure -M:test

If you only changed pure logic, run the unit suite:

clojure -M:test :unit
clojure -M:test:db/h2 :product is the monorepo form — inside this framework repository, where each library has its own Kaocha suite id and the :db/h2 alias selects the in-memory database. Generated projects use the plain :test alias shown above.

Next steps

  • Validation - using Malli schemas and the validation framework

  • Authentication - protecting your endpoints

  • Functional Core / Imperative Shell - deeper dive into the pattern

Can you improve this documentation?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