Liking cljdoc? Tell your friends :D

Your First Module

bb scaffold generate writes a working CRUD module in the shape the framework expects. This page walks through what lands on disk, so that when you edit it you know which file you are in and why.

Every snippet below is the generator’s real output — a test regenerates this module and checks that the names on this page still exist.

Generate one

bb scaffold generate --module-name product --entity Product \
  --field name:string:required --field sku:string:required --field price:decimal
src/<your-project>/product/
├── core/
│   ├── product.clj      ← pure functions: no I/O, no clock, no database
│   └── ui.clj           ← Hiccup templates, also pure
├── shell/
│   ├── persistence.clj  ← DatabaseProductRepository — SQL and row mapping
│   ├── service.clj      ← ProductService — orchestration
│   ├── http.clj         ← route definitions
│   ├── web_handlers.clj ← HTML handlers
│   └── module_wiring.clj ← the four Integrant components
├── ports.clj            ← the two protocols
└── schema.clj           ← Malli schemas

migrations/            ← up and down SQL for the products table
test/<your-project>/product/   ← a test namespace per layer

The module lives under your project’s namespace, not under wagoe — it is your code. The key that switches it on is :wagoe/product, beside :wagoe/http and :wagoe/h2: the namespace says whose code it is, the key names the module to the framework.

Then activate it:

bb scaffold integrate product   # --dry-run to see the config first
clojure -M:migrate up

integrate writes one key into resources/conf/{dev,test}/config.edn. Nothing else needs changing: src/ and test/ are already on the project’s paths, so the module’s tests run under plain clojure -M:test.

The generated migration creates the table. For editing it — and for the patterns that avoid locking a live table — see Database Migrations.

schema.clj

Two shapes: the entity as it is stored, and what an API request may contain.

(def Product
  "Schema for Product entity."
  [:map {:title "Product"}
   [:id :uuid]
   [:name :string]
   [:sku :string]
   [:price {:optional true} :double]
   [:created-at inst?]
   [:updated-at {:optional true} [:maybe inst?]]
   [:deleted-at {:optional true} [:maybe inst?]]])

(def CreateProductRequest
  "Schema for create product API requests."
  [:map {:title "Create Product Request"}
   [:name :string]
   [:sku :string]
   [:price {:optional true} :double]])

The generator infers types from your --field flags and stops there. Bounds and formats are yours to add — [:string {:min 1 :max 200}] for a name, a regex for a SKU. See Validation.

Keys are kebab-case everywhere inside the application. The conversion to snake_case for the database and camelCase for JSON happens at the boundary, in persistence.clj and the HTTP layer. See Conventions.

core/product.clj

Three pure functions. No database, no clock, no UUID generation:

(defn prepare-new-product
  [data entity-id current-time]
  (merge data
         {:id entity-id
          :created-at current-time
          :updated-at current-time}))

(defn apply-product-update
  [existing updates current-time]
  (merge existing updates {:updated-at current-time}))

(defn validate-product
  [data]
  (if (schema/validate-product data)
    [true nil data]
    [false (schema/explain-product data) nil]))

entity-id and current-time are arguments rather than calls. A function that reads the clock returns something different every time you run it, which is a poor thing to write a test against — so the shell reads the clock and the core receives the result. The generated core test passes a fixed instant and a fixed UUID and asserts on exact values, with no mocking anywhere.

ports.clj

Two protocols: what persistence must provide, and what the module offers the rest of the application.

(defprotocol IProductRepository
  (find-by-id [this id])
  (find-all [this options])
  (create [this entity])
  (update-entity [this entity])
  (delete [this id]))

(defprotocol IProductService
  (get-product [this id])
  (list-products [this options])
  (create-product [this data])
  (update-product [this id data])
  (delete-product [this id]))

Repository methods are generic (create, delete); service methods carry the entity name (create-product). That is not decoration. defprotocol interns each method as a var in the namespace, so two protocols in one file may not share a method name — when both declared update-product, the second silently replaced the first and the repository ended up with the service’s arity. update-entity also avoids shadowing clojure.core/update.

shell/service.clj

Where the impure things happen:

(defn- current-time [] (Instant/now))
(defn- generate-product-id [] (UUID/randomUUID))

(defrecord ProductService [repository]
  ports/IProductService
  (create-product [_this data]
    (let [prepared (core/prepare-new-product data (generate-product-id) (current-time))]
      (.create repository prepared)))
  ...)

The clock and the ID generator live here; the core gets their results.

The generated create-product does not validate. core/validate-product is written for you and nothing calls it yet — wiring it in is the intended first edit:

(create-product [_this data]
  (let [[valid? errors] (core/validate-product data)]
    (if valid?
      (.create repository (core/prepare-new-product data (generate-product-id) (current-time)))
      (throw (ex-info "Validation failed" {:type :validation-error :errors errors})))))

:type :validation-error is what turns this into a 400 rather than a 500 — see Conventions.

shell/http.clj

Reitit route data — [path data & children] — and stub handlers returning canned responses:

(defn api-routes
  [_service]
  [["/products"
    {:get  {:handler (fn [_req] {:status 200 :body []})}
     :post {:handler (fn [_req] {:status 201 :body {}})}}]
   ["/products/:id"
    {:get    {:handler (fn [_req] {:status 200 :body {}})}
     :put    {:handler (fn [_req] {:status 200 :body {}})}
     :delete {:handler (fn [_req] {:status 204})}}]])

(defn product-routes
  [service config]
  {:api    (api-routes service)
   :web    (web-routes service config)
   :static []})

The paths are relative. product-routes returns a contribution, not a route table: the application mounts :api under /api/v1 and :web under /web, so writing /api/products here would serve it at /api/v1/api/products.

Wiring the handlers to the service is the second edit, after validation. The service is already available — it is the service argument.

The split, in one line

core/ decides what should happen. shell/ does it. If a function needs the current time, a database, or the network, it belongs in the shell — and bb check:fcis will tell you when it has drifted.

Run the tests

clojure -M:test                        # everything
clojure -M:test --focus-meta :unit     # just the pure ones

The generator writes three test namespaces: one for the core, one for the repository, one for the service.

The same thing, already built

examples/shop in the framework repository is this page’s output, committed: wagoe new shop plus exactly the bb scaffold command above. CI boots it and asserts that /api/v1/products answers, so what you read there is what the current generators produce — not what they produced when someone last wrote it down.

cd examples/shop
WAG_ENV=test JWT_SECRET=$(openssl rand -hex 32) clojure -M:run

Next steps

  • Validation — Malli schemas and the validation framework

  • Authentication — protecting your endpoints

  • Functional Core / Imperative Shell — the pattern in depth

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