bb scaffold generate --module-name product --entity Product \
--field name:string:required --field sku:string:required --field price:decimal
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.
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.
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.
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.
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.
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
|
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.
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.
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.
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
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 & 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 |