一键导入
grain-service
Create a complete Grain domain service with schemas, read models, commands, queries, views, and tests
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Create a complete Grain domain service with schemas, read models, commands, queries, views, and tests
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | grain-service |
| description | Create a complete Grain domain service with schemas, read models, commands, queries, views, and tests |
This skill walks through creating a full CRUD domain service from scratch. Follow each step
mechanically. The example builds a "Widgets" service -- replace widgets/widget with your actual
domain entity throughout.
This repo is compact (a single src/ tree under one app namespace), not Polylith — do not
introduce Polylith components/, bases/, interface namespaces, or per-service deps.edn files.
A service is just a directory of namespaces under src/cjbarre/grain_todo_list/service/, registered
into the running system by side-effect requires from the root namespace cjbarre.grain-todo-list.
Naming conventions used below:
widgets (used as the event/command/query keyword prefix)widget (singular)src/cjbarre/grain_todo_list/service/widget_service/cjbarre.grain-todo-list.service.widget-serviceCreate the service namespace tree (no deps.edn, no interface.clj, no core/ segment):
src/cjbarre/grain_todo_list/service/widget_service/
schemas.clj
read_models.clj
commands.clj
queries.clj
ui.clj
ui/components.clj
todo_processors.clj ;; only if the service has event-driven side effects
test/cjbarre/grain_todo_list/service/widget_service/
commands_test.clj
Each namespace maps directly to its file:
| File | Namespace |
|---|---|
schemas.clj | cjbarre.grain-todo-list.service.widget-service.schemas |
read_models.clj | cjbarre.grain-todo-list.service.widget-service.read-models |
commands.clj | cjbarre.grain-todo-list.service.widget-service.commands |
queries.clj | cjbarre.grain-todo-list.service.widget-service.queries |
ui.clj | cjbarre.grain-todo-list.service.widget-service.ui |
ui/components.clj | cjbarre.grain-todo-list.service.widget-service.ui.components |
todo_processors.clj | cjbarre.grain-todo-list.service.widget-service.todo-processors |
There is no interface namespace and no schema re-export indirection. Cross-service code
requires the concrete local namespace directly, e.g.
[cjbarre.grain-todo-list.service.todo-list-service.read-models :as rm].
schemas.clj)(ns cjbarre.grain-todo-list.service.widget-service.schemas
"Malli schemas for widget service commands, events, queries, and read models."
(:require [ai.obney.grain.schema-util.interface :refer [defschemas]]))
(defschemas common-schemas
{::widget [:map
[:user-id :uuid]
[:widget-id :uuid]
[:name [:string {:min 1 :error/message "Name is required"}]]
[:description {:optional true} [:maybe :string]]
[:active :boolean]]})
(defschemas event-schemas
{:widgets/widget-created [:map
[:user-id :uuid]
[:widget-id :uuid]
[:name :string]
[:description [:maybe :string]]]
:widgets/widget-updated [:map
[:user-id :uuid]
[:widget-id :uuid]
[:name :string]
[:description [:maybe :string]]]
:widgets/widget-deleted [:map
[:user-id :uuid]
[:widget-id :uuid]]})
(defschemas command-schemas
{:widgets/create-widget [:map
[:name [:string {:min 1 :error/message "Name is required"}]]
[:description {:optional true} [:maybe :string]]]
:widgets/update-widget [:map
[:widget-id :uuid]
[:name [:string {:min 1 :error/message "Name is required"}]]
[:description {:optional true} [:maybe :string]]]
:widgets/delete-widget [:map
[:widget-id :uuid]]})
(defschemas query-schemas
{:widgets/widgets-page [:map]
:widgets/widget-detail-page [:map [:widget-id :uuid]]})
(defschemas read-model-schemas
{:widgets/widgets [:map-of :uuid ::widget]})
The five group names (common-schemas, event-schemas, command-schemas, query-schemas,
read-model-schemas) match the real services exactly — see todo_list_service/schemas.clj.
read_models.clj)(ns cjbarre.grain-todo-list.service.widget-service.read-models
"Widget read models -- projections built from events."
(:require [ai.obney.grain.read-model-processor-v2.interface :as rmp :refer [defreadmodel]]))
;; Event type set
(def widget-event-types
#{:widgets/widget-created
:widgets/widget-updated
:widgets/widget-deleted})
;; Reducer multimethod
(defmulti widgets* (fn [_state event] (:event/type event)))
(defmethod widgets* :widgets/widget-created
[state {:keys [user-id widget-id name description]}]
(assoc state widget-id {:user-id user-id
:widget-id widget-id
:name name
:description description
:active true}))
(defmethod widgets* :widgets/widget-updated
[state {:keys [widget-id name description]}]
(update state widget-id merge {:name name :description description}))
(defmethod widgets* :widgets/widget-deleted
[state {:keys [widget-id]}]
(dissoc state widget-id))
(defmethod widgets* :default [state _event] state)
;; Register read model
(defreadmodel :widgets widgets
{:events widget-event-types :version 1}
[state event]
(widgets* state event))
;; Projection helpers -- take ctx, read via rmp/project.
;; Scope reads to the current user with a tag (matches todo-list-service).
(defn user-scope
[ctx]
(when-let [user-id (:current-user/id ctx)]
{:tags #{[:user user-id]}}))
(defn all-widgets
[ctx]
(if-let [scope (user-scope ctx)]
(rmp/project ctx :widgets/widgets scope)
(rmp/project ctx :widgets/widgets {:tags #{[:user nil]}})))
(defn get-widget
[ctx widget-id]
(get (rmp/project ctx :widgets/widgets {:tags #{[:widget widget-id]}})
widget-id))
(defn active-widgets
[ctx]
(->> (vals (all-widgets ctx))
(filter :active)
(sort-by :name)))
commands.clj)Authorization functions take ctx and read identity from (:current-user/id ctx). Errors return
anomaly maps ({::anom/category ... ::anom/message ...}). Do NOT return form-field-reset signals
(e.g. :name "") from commands — clearing a field is UI behavior, done with ds-ui/reset-signal in
the component effect (see Step 6).
(ns cjbarre.grain-todo-list.service.widget-service.commands
"Command handlers for widget service."
(:require [ai.obney.grain.command-processor-v2.interface :refer [defcommand]]
[ai.obney.grain.event-store-v3.interface :refer [->event]]
[cjbarre.grain-todo-list.service.widget-service.read-models :as rm]
[cognitect.anomalies :as anom]))
(defn current-user-id [ctx] (:current-user/id ctx))
(defn authenticated? [ctx] (some? (current-user-id ctx)))
(defn owns-widget?
[{{:keys [widget-id]} :command :as ctx}]
(boolean (and (authenticated? ctx)
widget-id
(rm/get-widget ctx widget-id))))
(defcommand :widgets create-widget
{:authorized? authenticated?}
"Create a new widget."
[{{:keys [name description]} :command :as ctx}]
(let [widget-id (random-uuid)
user-id (current-user-id ctx)]
{:command-result/events
[(->event {:type :widgets/widget-created
:tags #{[:widget widget-id] [:user user-id]}
:body {:user-id user-id
:widget-id widget-id
:name name
:description description}})]
:datastar/signals {:__toast "Widget created!"}}))
(defcommand :widgets update-widget
{:authorized? owns-widget?}
"Update an existing widget."
[{{:keys [widget-id name description]} :command :as ctx}]
(let [widget (rm/get-widget ctx widget-id)
user-id (current-user-id ctx)]
(cond
(not widget)
{::anom/category ::anom/not-found
::anom/message "Widget not found."}
:else
{:command-result/events
[(->event {:type :widgets/widget-updated
:tags #{[:widget widget-id] [:user user-id]}
:body {:user-id user-id
:widget-id widget-id
:name name
:description description}})]
:datastar/signals {:__toast "Widget updated!"}})))
(defcommand :widgets delete-widget
{:authorized? owns-widget?}
"Delete a widget."
[{{:keys [widget-id]} :command :as ctx}]
(let [widget (rm/get-widget ctx widget-id)
user-id (current-user-id ctx)]
(cond
(not widget)
{::anom/category ::anom/not-found
::anom/message "Widget not found."}
:else
{:command-result/events
[(->event {:type :widgets/widget-deleted
:tags #{[:widget widget-id] [:user user-id]}
:body {:user-id user-id :widget-id widget-id}})]
:datastar/signals {:__toast "Widget deleted."}})))
queries.clj)(ns cjbarre.grain-todo-list.service.widget-service.queries
"Query handlers for widget service pages."
(:require [ai.obney.grain.datastar.ui :as ds-ui]
[ai.obney.grain.query-processor.interface :refer [defquery]]
[cjbarre.grain-todo-list.service.widget-service.read-models :as rm]
[cjbarre.grain-todo-list.service.widget-service.ui :as ui]))
(defn render [page] (ds-ui/hiccup page))
(defn current-user-id [ctx] (:current-user/id ctx))
(defn authenticated? [ctx] (some? (current-user-id ctx)))
;; Event-driven page -- re-renders when widget events fire.
;; Hybrid auth: widgets are account-scoped here, so the page requires login. A PUBLIC browsing/landing
;; page would use `:authorized? (constantly true)` instead — gate only per-user/account-scoped data.
(defquery :widgets widgets-page
{:authorized? authenticated?
:datastar/path "/widgets"
:datastar/title "Widgets"
:grain/read-models {:widgets/widgets 1}}
"Widgets management page."
[ctx]
(let [widgets (vec (rm/active-widgets ctx))]
{:query/result {:widgets widgets}
:datastar/hiccup (render (ui/widgets-page {:widgets widgets}))}))
;; Detail page.
;; ROUTES USE QUERY PARAMS, NEVER PATH PARAMS. e.g. /widget?widget-id=<uuid>
;; Read it from (:query ctx). Never write "/widgets/:widget-id".
(defquery :widgets widget-detail-page
{:authorized? authenticated?
:datastar/path "/widget"
:datastar/title "Widget Detail"
:grain/read-models {:widgets/widgets 1}}
"Widget detail page."
[{{:keys [widget-id]} :query :as ctx}]
(let [widget (rm/get-widget ctx widget-id)]
{:query/result {:widget widget}
:datastar/hiccup (render (ui/widget-detail-page {:widget widget}))}))
Rendering mode — always event-driven. NEVER use :datastar/fps.
Every query you write shows domain data, so every query declares :grain/read-models {…} for the read
models it renders. The page then re-renders automatically (and only) when those events fire — no
polling, never stale.
:grain/read-models {:widgets/widgets 1} ;; <rm-name> <version> — event-driven
Do NOT use :datastar/fps. It's the polling/one-shot knob, and it's a footgun: if you omit
:grain/read-models, grain defaults to 30fps polling (wasteful + laggy). The only non-polling use
of :datastar/fps 0 is a truly static, contentless page (render-once) — your feature pages always
have data, so they always use :grain/read-models.
Path params: read query params from (:query ctx). The :widget-id here arrives via
/widget?widget-id=<uuid> and is coerced to :uuid by the query schema.
ui.clj + ui/components.clj)Pages are thin compositions of the app's component library. App-wide primitives live in
cjbarre.grain-todo-list.foundation.ui (app-shell, action-error) and
cjbarre.grain-todo-list.foundation.ui.components (surface, action-button, text-field, chip,
badge, empty-state, page-section, panel, section-heading, metadata-text, …). Domain widgets
(forms, rows, anything with ds-ui effects) live in your service's ui/components.clj. Never hand-roll
one-off layout — factor a component instead. See the grain-ui-component skill for the checked
ds-ui DSL.
ui.clj — pure page functions(ns cjbarre.grain-todo-list.service.widget-service.ui
"Pure Hiccup pages -- thin compositions of foundation + service components."
(:require [ai.obney.grain.datastar.ui :as ds-ui]
[cjbarre.grain-todo-list.foundation.ui :as app-ui]
[cjbarre.grain-todo-list.foundation.ui.components :as c]
[cjbarre.grain-todo-list.service.widget-service.ui.components :as wc]))
(defn widgets-page [{:keys [widgets]}]
(app-ui/app-shell {:title "Widgets"}
(app-ui/action-error)
(c/panel {:title "Widgets" :status "Manage your widgets."}
(wc/widget-add)
(c/page-section {:title "All Widgets" :count (count widgets)}
(wc/widget-list widgets)))))
(defn widget-detail-page [{:keys [widget]}]
(app-ui/app-shell {:title (or (:name widget) "Widget")}
(app-ui/action-error)
(if widget
(c/surface {:tag :section :variant :card}
(c/section-heading {:title (:name widget)})
(c/metadata-text (:description widget)))
[:div {:class "alert alert-warning"} "Widget not found."])))
ui/components.clj — domain widgets with the checked ds-ui DSLCommands dispatch through the single /actions route with ds-ui/dispatch. Reset form fields here
with ds-ui/reset-signal — never from the command.
(ns cjbarre.grain-todo-list.service.widget-service.ui.components
(:require [ai.obney.grain.datastar.ui :as ds-ui]
[cjbarre.grain-todo-list.foundation.ui.components
:refer [empty-state metadata-text surface text-field]]))
(defn widget-add []
(ds-ui/with-signals [name {:init ""}]
[:form {:on/submit {:effect (ds-ui/effects
(ds-ui/dispatch :widgets/create-widget
{:name (ds-ui/trimmed name)})
(ds-ui/reset-signal name))
:modifiers {:prevent true}}}
(text-field {:bind/value name
:aria-label "Widget name"
:placeholder "Add a widget"
:required true})]))
(defn widget-row [widget]
[:a {:href (ds-ui/href :widgets/widget-detail-page {:widget-id (:widget-id widget)})
:class "block"}
[:p {:class "font-medium"} (:name widget)]
(metadata-text (:description widget))])
(defn widget-list [widgets]
(if (seq widgets)
(surface {:variant :list} (map widget-row widgets))
(empty-state {:title "No widgets yet"
:description "Create your first widget to get started."})))
(Use the real component names you find in foundation/ui/components.clj — the list above is
representative, not exhaustive. Inspect that file for exact signatures.)
test/cjbarre/grain_todo_list/service/widget_service/commands_test.clj)Tests call the generated command function directly. defcommand :widgets create-widget generates a
function named widgets-create-widget.
(ns cjbarre.grain-todo-list.service.widget-service.commands-test
(:require [clojure.test :refer [deftest testing is]]
[cjbarre.grain-todo-list.service.widget-service.commands :as cmd]
[cognitect.anomalies :as anom]))
(defn find-event [result event-type]
(->> (:command-result/events result)
(filter #(= event-type (:event/type %)))
first
:event/body))
(defn event-of-type? [result event-type]
(some? (find-event result event-type)))
(deftest create-widget-test
(testing "creates a widget successfully"
(let [user-id (random-uuid)
ctx {:current-user/id user-id
:command {:name "Test Widget" :description "A test widget"}}
result (cmd/widgets-create-widget ctx)]
(is (contains? result :command-result/events))
(is (event-of-type? result :widgets/widget-created))
(let [event (find-event result :widgets/widget-created)]
(is (some? (:widget-id event)))
(is (= "Test Widget" (:name event)))))))
For commands whose authorization or behavior depends on existing read-model state (update-widget,
delete-widget), drive the full event-store round trip with the Grain test utilities — model your
setup on the real command tests in the repo (auth, append events, then re-query). The
:authorized? fn and the read-model projection are what make state-dependent commands meaningful.
cjbarre.grain-todo-list)Edit src/cjbarre/grain_todo_list.clj. The system picks up your service entirely through side-effect
requires — there is no deps.edn change and no per-command/per-query route to add.
In the :require block of cjbarre.grain-todo-list, add (matching the existing
todo-list-service / user-service entries):
[cjbarre.grain-todo-list.service.widget-service.commands]
[cjbarre.grain-todo-list.service.widget-service.queries]
[cjbarre.grain-todo-list.service.widget-service.read-models]
[cjbarre.grain-todo-list.service.widget-service.schemas]
;; only if the service has event-driven side effects:
[cjbarre.grain-todo-list.service.widget-service.todo-processors]
commands / queries registering by side-effect puts them in the global registries
(cp/global-command-registry, query-processor/global-query-registry).read-models / schemas register the read model and Malli schemas.ds/routes (already
wired in the ::routes init-key). You do not add a route per page./actions route, already wired with ds/action-handler in
::routes. You do not add a route per command.That's it. No processors map to wire (see Step 10), no Integrant key to touch.
Adding the requires is not done. Finish at the gate (see the Charter + Definition of done in CLAUDE.md) — all must pass, and report each:
(require '[cjbarre.grain-todo-list :as app])
(app/stop app)
(def app (app/start))
After editing a single namespace you can reload just it: (require 'the.ns :reload), but a clean
restart is the reliable way to pick up new registrations.clojure -T:build test green (test skill).(:missing-schemas (tools/catalog)) empty.:authorized? (mutations are never
(constantly true)).404 /css/main.css). Lint clean: clj-kondo --lint src test./widgets and verify EACH acceptance check as a user (pass/fail) — acceptance is the
contract.design-review skill on each screen until it passes the rubric.todo_processors.clj)If the service needs to react to its own (or another service's) events — send an email, fire a
follow-up command — add defprocessor forms. They register by the side-effect require you already
added in Step 8a; the root system already runs a tenant poller, so a new service usually needs
nothing else. There is no (def todo-processors {}) map and no Integrant wiring of a
processors map.
(ns cjbarre.grain-todo-list.service.widget-service.todo-processors
(:require [ai.obney.grain.todo-processor-v2.interface :refer [defprocessor]]
[cjbarre.grain-todo-list.foundation.email :as email]))
(defprocessor :widgets notify-on-create
{:topics #{:widgets/widget-created}}
[{{:keys [name]} :event
:keys [app-base email-client email-from]}]
{:result/effect
(fn []
(email/send
email-client
{:from email-from
:to ["ops@grain-todo.local"]
:subject "New widget created"
:body-html (format "<p>Widget created: %s</p>" name)}))
:result/checkpoint :after})
See user_service/todo_processors.clj for the real, working pattern (auth-domain emails).
If your service additionally needs its own lifecycle (rare), todo_processors.clj may also hold
start/stop fns wrapping tp/start-tenant-poller / tp/stop-tenant-poller — but the root system
already runs a poller, so most services only need defprocessor forms.
If no side effects are needed, do not create this file at all — skip it.
The fullest worked example is the todo-list service; the user service shows the auth domain and
defprocessor. Read these for exact idioms:
src/cjbarre/grain_todo_list/service/todo_list_service/schemas.clj — five schema groupssrc/cjbarre/grain_todo_list/service/todo_list_service/read_models.clj — read model + projection helperssrc/cjbarre/grain_todo_list/service/todo_list_service/commands.clj — command handlers + auth fnssrc/cjbarre/grain_todo_list/service/todo_list_service/queries.clj — query handlerssrc/cjbarre/grain_todo_list/service/todo_list_service/ui.clj — pure page functionssrc/cjbarre/grain_todo_list/service/todo_list_service/ui/components.clj — domain widgets (ds-ui DSL)src/cjbarre/grain_todo_list/service/todo_list_service/todo_processors.clj — defprocessor (if present)src/cjbarre/grain_todo_list/service/todo_list_service/periodic_tasks.clj — periodic triggerssrc/cjbarre/grain_todo_list/service/user_service/{commands,queries,read_models,schemas}.clj — auth domainsrc/cjbarre/grain_todo_list/service/user_service/todo_processors.clj — defprocessor examplessrc/cjbarre/grain_todo_list/foundation/ui.clj — app-shell, action-errorsrc/cjbarre/grain_todo_list/foundation/ui/components.clj — the app UI component librarysrc/cjbarre/grain_todo_list.clj — root: Integrant system, routes, side-effect requires, wiringgrain-ui-component + design-review skills — the app component library + visual quality gateGive your AI agents something more useful than a prompt. Velocity through clarity.
Visual quality gate — screenshot each primary screen, critique it against the baseline rubric, and refine the components until it passes. Use before declaring a build done.
Implement command handlers that validate state, emit events, and return signals using the Grain framework
Create scheduled background jobs that run on cron schedules
Implement query handlers that read from event-sourced read models and render hiccup
Build read models by reducing events into queryable state with defreadmodel