@eventmodelers/cli 1.0.44 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/README.md +67 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-state-change
|
|
3
|
+
authors:
|
|
4
|
+
- Martin Dilger
|
|
5
|
+
description: >
|
|
6
|
+
Implement Event Sourcing write slices using OpenCQRS 2.0.0 (Spring Boot, EventSourcingDB) in this
|
|
7
|
+
project's one established pattern: Command record → immutable write-model record → @CommandHandling
|
|
8
|
+
method (in a @CommandHandlerConfiguration class) that checks state inline → CommandHandlingTestFixture
|
|
9
|
+
unit test (no Spring context, no event store). Use when implementing a new write slice / command
|
|
10
|
+
handler from a slice.json event model in this project. Plain Java only. There is exactly one
|
|
11
|
+
supported style — do not offer alternatives.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# OpenCQRS — Write Slice (Java)
|
|
15
|
+
|
|
16
|
+
Directory layout is flat — `src/main/java/.../slices/{context}/{slicename}/`, no `write`/`read`/
|
|
17
|
+
`automation` folder layer in between (only the shared `slices/{context}/api/` folder, holding Command
|
|
18
|
+
and Event records shared by every slice in that context, sits alongside slice folders).
|
|
19
|
+
|
|
20
|
+
## Step 0: Read the slice definition
|
|
21
|
+
|
|
22
|
+
Read `.build-kit/.slices/{context}/{slicename}/slice.json`. Extract, and use **only** what's there:
|
|
23
|
+
|
|
24
|
+
- `commands[].fields[]` → Command record fields, in order
|
|
25
|
+
- `events[].fields[]` → Event record fields, in order
|
|
26
|
+
- `specifications[]` (GWT scenarios) → one test method per scenario
|
|
27
|
+
- Which command field(s) have `idAttribute: true` — these compose the command's `getSubject()` (see
|
|
28
|
+
Step 1)
|
|
29
|
+
- `storylines[]` (optional, may be absent) → narrated walkthroughs with ordered `elements[]` "beats";
|
|
30
|
+
see Step 7b for how a COMMAND beat in one of these can add a supplementary test
|
|
31
|
+
|
|
32
|
+
Never invent a field, business rule, or event that isn't in slice.json.
|
|
33
|
+
|
|
34
|
+
## Step 0a: Determine `{basePackage}`
|
|
35
|
+
|
|
36
|
+
Every code example below is rooted at `{basePackage}.slices.{context}.{slicename}`. Resolve
|
|
37
|
+
`{basePackage}` as documented in `.build-kit/CLAUDE.md`'s Structure section — never hardcode
|
|
38
|
+
`com.example.quickstart` (the shipped quickstart scaffold's package) or any other specific package.
|
|
39
|
+
|
|
40
|
+
## Step 1: Command
|
|
41
|
+
|
|
42
|
+
Check `src/main/java/.../{context}/api/` first for an existing `{Context}Command` marker interface —
|
|
43
|
+
add to it rather than creating a duplicate if other commands in this context already share one:
|
|
44
|
+
|
|
45
|
+
```java
|
|
46
|
+
package {basePackage}.slices.{context}.api;
|
|
47
|
+
|
|
48
|
+
import com.opencqrs.framework.command.Command;
|
|
49
|
+
|
|
50
|
+
public interface {Context}Command extends Command {
|
|
51
|
+
|
|
52
|
+
String idField();
|
|
53
|
+
|
|
54
|
+
@Override
|
|
55
|
+
default String getSubject() {
|
|
56
|
+
return "/{context-lower}/" + idField();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The command itself:
|
|
62
|
+
|
|
63
|
+
```java
|
|
64
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
65
|
+
|
|
66
|
+
import {basePackage}.slices.{context}.api.{Context}Command;
|
|
67
|
+
import com.opencqrs.framework.command.Command;
|
|
68
|
+
|
|
69
|
+
public record {SliceName}Command(String field1, String idField) implements {Context}Command {
|
|
70
|
+
|
|
71
|
+
@Override
|
|
72
|
+
public SubjectCondition getSubjectCondition() {
|
|
73
|
+
return SubjectCondition.PRISTINE; // or EXISTS — see below
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**`getSubjectCondition()`** — checked by the `CommandRouter` before your handler runs, independent of
|
|
79
|
+
whatever your own business-rule code checks:
|
|
80
|
+
|
|
81
|
+
- `SubjectCondition.PRISTINE` — the subject must **not** already have any events (creation commands).
|
|
82
|
+
Violated → `CommandSubjectAlreadyExistsException`.
|
|
83
|
+
- `SubjectCondition.EXISTS` — the subject **must** already have at least one event (commands that act
|
|
84
|
+
on something that must already exist). Violated → `CommandSubjectDoesNotExistException`.
|
|
85
|
+
- `SubjectCondition.NONE` (the interface default — only omit the override for this) — no check. Rare;
|
|
86
|
+
most commands are one of the two above.
|
|
87
|
+
|
|
88
|
+
**Two co-equal id fields, no natural parent/child relationship** (e.g. `email` + `courseId` for a
|
|
89
|
+
subscription) — there is no compound-id class or tagging system to build, unlike frameworks that source
|
|
90
|
+
strictly by aggregate id. A subject is just a path string: concatenate both fields directly, in a fixed,
|
|
91
|
+
documented order:
|
|
92
|
+
|
|
93
|
+
```java
|
|
94
|
+
@Override
|
|
95
|
+
public String getSubject() {
|
|
96
|
+
return "/subscription/" + email() + "/" + courseId();
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Parent/child relationship** (e.g. a "page" belonging to a "book") — nest the child's subject under
|
|
101
|
+
the parent's, as its own separate slice with its own command/write-model, rather than cramming child
|
|
102
|
+
data into the parent's write model:
|
|
103
|
+
|
|
104
|
+
```java
|
|
105
|
+
// {basePackage}.slices.{context}.api.{Context}PageCommand
|
|
106
|
+
@Override
|
|
107
|
+
default String getSubject() {
|
|
108
|
+
return "/{context-lower}/" + isbn() + "/page/" + page();
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This hierarchical-subject relationship is also exactly what Step 3's `sourcingMode` choice hinges on —
|
|
113
|
+
see below.
|
|
114
|
+
|
|
115
|
+
## Step 2: Event — only if it doesn't already exist
|
|
116
|
+
|
|
117
|
+
Check `src/main/java/.../{context}/api/` first; add to it rather than creating a duplicate.
|
|
118
|
+
|
|
119
|
+
```java
|
|
120
|
+
package {basePackage}.slices.{context}.api;
|
|
121
|
+
|
|
122
|
+
public record {EventName}(String field1, String idField) {}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Plain record — no annotation needed on the event class itself. **Then register its type explicitly** in
|
|
126
|
+
`src/main/java/{basePackage}/config/CqrsConfiguration.java`'s `eventTypeResolver()` bean — this is a
|
|
127
|
+
single project-wide map, not per-slice, and every event used anywhere in the project must be listed:
|
|
128
|
+
|
|
129
|
+
```java
|
|
130
|
+
"quickstart.{context-lower}.{eventname-lower}.v1", {EventName}.class
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**This step is easy to forget and doesn't fail at compile time.** The default fallback
|
|
134
|
+
(`ClassNameEventTypeResolver`) silently uses the Java classname as the stored type instead, which then
|
|
135
|
+
breaks the moment the class is renamed or moved — always add the explicit registration line as part of
|
|
136
|
+
this slice, never skip it.
|
|
137
|
+
|
|
138
|
+
## Step 3: Write-model record
|
|
139
|
+
|
|
140
|
+
Immutable record — **not** a mutable entity with setters. Use a nested sealed interface for a field
|
|
141
|
+
that has a small closed set of states (see `Lending` below), rather than a loose boolean/enum pair that
|
|
142
|
+
can go out of sync.
|
|
143
|
+
|
|
144
|
+
**Derive the field(s) from this slice's `specifications[]` (Step 0), not from the event's shape.** Each
|
|
145
|
+
GWT scenario's `given`/`then` pair states the one decision the command handler must make and the prior
|
|
146
|
+
fact that decision depends on — that fact is the field. Re-read the scenarios before naming fields: a
|
|
147
|
+
"given no prior activity" / "given already {X}" pair means one boolean flag or sealed-interface state
|
|
148
|
+
for {X}; a scenario that discriminates on a value (not just presence/absence) means a value field
|
|
149
|
+
holding that value, not a boolean. Add exactly one field per fact a scenario actually branches on — an
|
|
150
|
+
event can carry several fields, but the write model only needs the ones a `specifications[]` scenario
|
|
151
|
+
checks.
|
|
152
|
+
|
|
153
|
+
```java
|
|
154
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
155
|
+
|
|
156
|
+
import java.util.Set;
|
|
157
|
+
|
|
158
|
+
public record {SliceName}(String idField, <otherFields>, <ruleField>) {
|
|
159
|
+
|
|
160
|
+
public {SliceName} with(<ruleFieldType> updated) {
|
|
161
|
+
return new {SliceName}(idField(), <otherFields>, updated);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`@StateRebuilding` methods (Step 4) reconstruct this by applying one event at a time — write the record
|
|
167
|
+
so each transition is a cheap `with(...)`-style copy, not a full manual reconstruction.
|
|
168
|
+
|
|
169
|
+
## Step 4: Command handler + state rebuilding
|
|
170
|
+
|
|
171
|
+
**State for a command handler always comes from `@StateRebuilding` methods reconstructing the write
|
|
172
|
+
model from sourced events — never from an external database, JPA repository, or any other read
|
|
173
|
+
model.** See [OpenCQRS's `StateRebuildingHandler` extension
|
|
174
|
+
point](https://docs.opencqrs.com/reference/extension_points/state_rebuilding_handler/): the command
|
|
175
|
+
router executes all matching `@StateRebuilding` methods against the sourced events (in-memory, no
|
|
176
|
+
external query) before the `@CommandHandling` method runs — that reconstructed instance is the *only*
|
|
177
|
+
source of prior state a business rule may check. An `@Autowired` collaborator in a handler method is for
|
|
178
|
+
side effects or lookups unrelated to *this subject's own state* (e.g. an external ID generator); it must
|
|
179
|
+
never stand in for a `@StateRebuilding` method to answer "what has already happened to this subject." If
|
|
180
|
+
a rule needs data from another subject's events, add a `@StateRebuilding` method for that event type and
|
|
181
|
+
use `sourcingMode = SourcingMode.RECURSIVE` (see below) — do not reach for a database read model instead.
|
|
182
|
+
|
|
183
|
+
Both live in one `@CommandHandlerConfiguration` class per context (or per slice, if the project already
|
|
184
|
+
splits it that way — check existing slices first):
|
|
185
|
+
|
|
186
|
+
```java
|
|
187
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
188
|
+
|
|
189
|
+
import {basePackage}.slices.{context}.api.*;
|
|
190
|
+
import com.opencqrs.framework.command.*;
|
|
191
|
+
|
|
192
|
+
@CommandHandlerConfiguration
|
|
193
|
+
public class {SliceName}Handling {
|
|
194
|
+
|
|
195
|
+
@CommandHandling
|
|
196
|
+
public String handle({SliceName}Command command, CommandEventPublisher<{SliceName}> publisher) {
|
|
197
|
+
publisher.publish(new {EventName}(command.field1(), command.idField()));
|
|
198
|
+
return command.idField();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@StateRebuilding
|
|
202
|
+
public {SliceName} on({EventName} event) {
|
|
203
|
+
return new {SliceName}(event.idField(), /* initial field values */);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Creation commands** (subject condition `PRISTINE`) use the `(command, publisher)` signature above —
|
|
209
|
+
there is no prior write-model instance to pass, since none exists yet.
|
|
210
|
+
|
|
211
|
+
**Commands that depend on prior state** take the write-model instance as the first parameter, and the
|
|
212
|
+
matching `@StateRebuilding` method takes both the previous instance and the event:
|
|
213
|
+
|
|
214
|
+
```java
|
|
215
|
+
@CommandHandling
|
|
216
|
+
public void handle({SliceName} state, {SliceName}Command command, CommandEventPublisher<{SliceName}> publisher) {
|
|
217
|
+
if (state.<ruleField>()) {
|
|
218
|
+
throw new IllegalStateException("...");
|
|
219
|
+
}
|
|
220
|
+
publisher.publish(new {EventName}(command.field1(), command.idField()));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@StateRebuilding
|
|
224
|
+
public {SliceName} on({SliceName} state, {EventName} event) {
|
|
225
|
+
return state.with(/* updated field */);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Parameters may be given in any order; add `@Autowired SomeDependency dep` for injected collaborators
|
|
230
|
+
(resolved from the `ApplicationContext` — no separate mocking wiring needed beyond `@MockitoBean` in the
|
|
231
|
+
test, see Step 7). Add `Map<String, ?> metadata` as a parameter to read metadata the caller sent — see
|
|
232
|
+
Step 5's `opencqrs.metadata.propagation.keys`.
|
|
233
|
+
|
|
234
|
+
**`sourcingMode` — how far back state is rebuilt from:**
|
|
235
|
+
|
|
236
|
+
- **Omit it** (default: `RECURSIVE`) — fetches this subject's own events **and** any events published
|
|
237
|
+
under a nested subject (e.g. a `/book/{isbn}/page/{page}` slice's events are visible to a
|
|
238
|
+
`/book/{isbn}` handler). Use this whenever the business rule genuinely needs to see child-subject
|
|
239
|
+
data — add a `@StateRebuilding` method for that child event type directly onto this write-model
|
|
240
|
+
record.
|
|
241
|
+
- **`@CommandHandling(sourcingMode = SourcingMode.LOCAL)`** — fetches only this exact subject's own
|
|
242
|
+
events, ignoring any nested subjects. Use this for creation commands and any command whose rule
|
|
243
|
+
genuinely has nothing to do with child-subject data — it's the cheaper, more precise choice whenever
|
|
244
|
+
RECURSIVE's extra reads aren't needed.
|
|
245
|
+
|
|
246
|
+
Getting this wrong doesn't fail loudly: too-broad (RECURSIVE when unnecessary) usually still works, just
|
|
247
|
+
wastefully; too-narrow (LOCAL when a rule actually depends on a child subject's events) silently drops
|
|
248
|
+
data the rule needed, and the bug only shows up as a business rule that never fires.
|
|
249
|
+
|
|
250
|
+
## Step 5: REST endpoint — only if slice.json shows an inbound `SCREEN` dependency on the command
|
|
251
|
+
|
|
252
|
+
```java
|
|
253
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
254
|
+
|
|
255
|
+
import com.opencqrs.framework.command.CommandRouter;
|
|
256
|
+
import jakarta.servlet.http.HttpServletRequest;
|
|
257
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
258
|
+
import org.springframework.http.ResponseEntity;
|
|
259
|
+
import org.springframework.web.bind.annotation.*;
|
|
260
|
+
import java.util.Map;
|
|
261
|
+
|
|
262
|
+
@RestController
|
|
263
|
+
@RequestMapping("/api/{context}")
|
|
264
|
+
public class {SliceName}RestController {
|
|
265
|
+
|
|
266
|
+
@Autowired
|
|
267
|
+
private CommandRouter commandRouter;
|
|
268
|
+
|
|
269
|
+
@PostMapping("/{resource}")
|
|
270
|
+
public ResponseEntity<Void> handle(@RequestBody {SliceName}RequestBody body, HttpServletRequest request) {
|
|
271
|
+
var command = new {SliceName}Command(body.field1(), body.idField());
|
|
272
|
+
commandRouter.send(command, Map.of("request-uri", request.getRequestURI()));
|
|
273
|
+
return ResponseEntity.ok().build();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
public record {SliceName}RequestBody(String field1, String idField) {}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
This project uses plain Spring **WebMVC** (`ResponseEntity`, blocking) — **not** WebFlux/`Mono`.
|
|
281
|
+
|
|
282
|
+
If the only inbound dependency is another slice's `AUTOMATION`, skip this step — it calls the
|
|
283
|
+
`CommandRouter` in-process, it doesn't need HTTP.
|
|
284
|
+
|
|
285
|
+
**Metadata propagation**: `commandRouter.send(command, Map.of("key", value))` attaches metadata to the
|
|
286
|
+
command execution; whether that metadata also gets copied onto the *published events'* own metadata is
|
|
287
|
+
controlled project-wide by `opencqrs.metadata.propagation.keys` in `application.properties` — add a key
|
|
288
|
+
there only if a slice actually needs to read it back later via `@StateRebuilding`'s/`@EventHandling`'s
|
|
289
|
+
`Map<String, ?>` parameter.
|
|
290
|
+
|
|
291
|
+
**Exception mapping**: business rule violations (`IllegalStateException` or a dedicated exception type)
|
|
292
|
+
and the framework's own `CommandSubjectAlreadyExistsException` / `CommandSubjectDoesNotExistException` /
|
|
293
|
+
`CqrsFrameworkException.TransientException` / `CqrsFrameworkException.NonTransientException` should map
|
|
294
|
+
to HTTP statuses via a shared `@ControllerAdvice` — check whether the project already has one
|
|
295
|
+
(`{basePackage}.rest.ExceptionControllerAdvice` or similar) before adding a new one. See
|
|
296
|
+
[references/rest-api-patterns.md](references/rest-api-patterns.md) for the full pattern plus a
|
|
297
|
+
`@WebMvcTest`/`MockMvc`-based test shape.
|
|
298
|
+
|
|
299
|
+
## Step 6: Feature flag
|
|
300
|
+
|
|
301
|
+
Every slice component (handler, REST controller) gets `@ConditionalOnProperty(prefix =
|
|
302
|
+
"slices.{context}.write", name = "{slicename}.enabled")` — the write-model record does not need it. Wire
|
|
303
|
+
the flag in:
|
|
304
|
+
|
|
305
|
+
- `src/main/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=true`
|
|
306
|
+
- `src/test/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=false`
|
|
307
|
+
|
|
308
|
+
See [references/feature-flag-patterns.md](references/feature-flag-patterns.md) for the full pattern.
|
|
309
|
+
This flag is irrelevant to the Step 7 test below — that test never boots Spring, so
|
|
310
|
+
`@ConditionalOnProperty` never runs.
|
|
311
|
+
|
|
312
|
+
## Step 7: Test — `CommandHandlingTestFixture`, no Spring-booted event store
|
|
313
|
+
|
|
314
|
+
`@CommandHandlingTest` boots a narrow Spring test slice that auto-discovers this project's
|
|
315
|
+
`@CommandHandlerConfiguration` classes and wires a `CommandHandlingTestFixture<YourCommand>` per test
|
|
316
|
+
method — no event store, no HTTP, no real database.
|
|
317
|
+
|
|
318
|
+
```java
|
|
319
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
320
|
+
|
|
321
|
+
import {basePackage}.slices.{context}.api.*;
|
|
322
|
+
import com.opencqrs.framework.command.CommandHandlingTest;
|
|
323
|
+
import com.opencqrs.framework.command.CommandHandlingTestFixture;
|
|
324
|
+
import org.junit.jupiter.api.Test;
|
|
325
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
326
|
+
|
|
327
|
+
@CommandHandlingTest
|
|
328
|
+
class {SliceName}HandlingTest {
|
|
329
|
+
|
|
330
|
+
@Test
|
|
331
|
+
void happyPath(@Autowired CommandHandlingTestFixture<{SliceName}Command> fixture) {
|
|
332
|
+
fixture.given()
|
|
333
|
+
.nothing()
|
|
334
|
+
.when(new {SliceName}Command("value1", "id-1"))
|
|
335
|
+
.succeeds()
|
|
336
|
+
.allEvents()
|
|
337
|
+
.exactly(new {EventName}("value1", "id-1"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@Test
|
|
341
|
+
void ruleViolation(@Autowired CommandHandlingTestFixture<{SliceName}Command> fixture) {
|
|
342
|
+
fixture.given()
|
|
343
|
+
.events(new {EventName}("value1", "id-1"))
|
|
344
|
+
.when(new {SliceName}Command("value2", "id-1"))
|
|
345
|
+
.fails()
|
|
346
|
+
.throwing(IllegalStateException.class);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
One test method per GWT scenario in slice.json's `specifications[]`.
|
|
352
|
+
|
|
353
|
+
**Given-phase options**: `.nothing()` (no prior events/state — creation case) · `.events(e1, e2, ...)`
|
|
354
|
+
(replays these through your `@StateRebuilding` methods to build prior state — prefer this, it exercises
|
|
355
|
+
the same code path production uses) · `.state(new {SliceName}(...))` (injects prior state directly,
|
|
356
|
+
bypassing `@StateRebuilding` — only reach for this if replaying events is impractical for the scenario).
|
|
357
|
+
|
|
358
|
+
**Then-phase assertions**: `.succeeds()` / `.fails().throwing(ExceptionClass.class)` · on success,
|
|
359
|
+
`.allEvents().exactly(new Event(...), ...)` (equality match, in order) or `.allEvents().single(e ->
|
|
360
|
+
e.ofType(EventClass.class))` / `.single(e -> e.asserting(a -> a.commandSubject().payloadType(...)))` for
|
|
361
|
+
a looser one-event check · `.havingResult(expectedValue)` to assert the command handler's return value
|
|
362
|
+
(check this *before* `.nextEvents()`/`.allEvents()` in the chain — see the reference file). Mock
|
|
363
|
+
`@Autowired` collaborators with `@MockitoBean` at the test class level, exactly like a normal
|
|
364
|
+
`@SpringBootTest`.
|
|
365
|
+
|
|
366
|
+
Full fluent-API cheat sheet: see
|
|
367
|
+
[references/test-fixture-patterns.md](references/test-fixture-patterns.md).
|
|
368
|
+
|
|
369
|
+
## Step 7b: Storyline-Derived Tests (Optional)
|
|
370
|
+
|
|
371
|
+
`slice.json` may carry a `storylines[]` array alongside `specifications[]` — narrated walkthroughs
|
|
372
|
+
where an ordered sequence of `elements[]` "beats" (EVENT/COMMAND/READMODEL/...) shows one use case
|
|
373
|
+
end to end. This is a secondary, supplementary source — `specifications[]` (Step 7) stays the
|
|
374
|
+
primary and default source of test methods. Most slices have no `storylines[]`; skip this step
|
|
375
|
+
silently when there's nothing relevant.
|
|
376
|
+
|
|
377
|
+
A storyline embedded in this slice's slice.json already belongs entirely to this slice — no need
|
|
378
|
+
to match beats against `commands[]` by id/title. Find beats whose `type` is `COMMAND`. For each such
|
|
379
|
+
beat, the storyline gives you a ready-made test: `given` = the cumulative ordered `EVENT` beats
|
|
380
|
+
preceding it in the storyline, `when` = the command built from the beat's `fields`, `then` = the
|
|
381
|
+
`EVENT` beat(s) immediately following it in the storyline.
|
|
382
|
+
|
|
383
|
+
```java
|
|
384
|
+
@Test
|
|
385
|
+
void storylineBeat(@Autowired CommandHandlingTestFixture<{SliceName}Command> fixture) {
|
|
386
|
+
fixture.given()
|
|
387
|
+
.events(new {PrecedingEventName}(/* fields from earlier beats */))
|
|
388
|
+
.when(new {SliceName}Command(/* fields from the command beat */))
|
|
389
|
+
.succeeds()
|
|
390
|
+
.allEvents()
|
|
391
|
+
.exactly(new {EventName}(/* fields from the following event beat */));
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Do **not** try to also assert read-model state in this same test — that half (the following
|
|
396
|
+
EVENT→READMODEL beats) belongs to `build-state-view`'s own storyline step, since this fixture never
|
|
397
|
+
touches a projector. If the beat immediately after the command isn't an EVENT, don't force a test —
|
|
398
|
+
leave it undocumented rather than fabricating an assertion.
|
|
399
|
+
|
|
400
|
+
## Final Verification
|
|
401
|
+
|
|
402
|
+
Before considering the slice done:
|
|
403
|
+
|
|
404
|
+
- [ ] Every field in slice.json's `commands[]` is in the Command record — no invented fields, none missing
|
|
405
|
+
- [ ] Every field in slice.json's `events[]` is in the Event record — no invented fields, none missing
|
|
406
|
+
- [ ] The new event's type string is registered in `CqrsConfiguration`'s `eventTypeResolver()`
|
|
407
|
+
- [ ] `getSubjectCondition()` matches whether this is a creation (`PRISTINE`) or must-already-exist (`EXISTS`) command
|
|
408
|
+
- [ ] `sourcingMode` reflects whether this handler's rule needs nested/child-subject events (`RECURSIVE`, the default) or not (`LOCAL`)
|
|
409
|
+
- [ ] Every `specifications[]` scenario has a corresponding test method
|
|
410
|
+
- [ ] If `storylines[]` is present: every COMMAND beat for this slice's command has a storyline test — or was deliberately skipped as untraceable
|
|
411
|
+
- [ ] No business rule exists in the handler that isn't traceable to slice.json's `description`/`comments`
|
|
412
|
+
- [ ] `./mvnw compile -q`, then run the slice's own tests only
|
|
413
|
+
- [ ] If checks pass, commit with `feat: {Slice Name}` and set slice status to `Done`
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Feature Flag Patterns
|
|
2
|
+
|
|
3
|
+
House convention (not an OpenCQRS requirement) for letting a slice's components be toggled on/off
|
|
4
|
+
independently — useful for merging a slice's code before it's ready to go live on the board.
|
|
5
|
+
|
|
6
|
+
## Command handler + REST controller
|
|
7
|
+
|
|
8
|
+
```java
|
|
9
|
+
@CommandHandlerConfiguration
|
|
10
|
+
@ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
|
|
11
|
+
public class {SliceName}Handling {
|
|
12
|
+
// ...
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`@ConditionalOnProperty` works the same way on a `@CommandHandlerConfiguration`-annotated class as on any
|
|
17
|
+
other Spring `@Configuration` — the whole class (and every `@CommandHandling`/`@StateRebuilding` method
|
|
18
|
+
in it) is skipped when the flag is off.
|
|
19
|
+
|
|
20
|
+
```java
|
|
21
|
+
@RestController
|
|
22
|
+
@ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
|
|
23
|
+
public class {SliceName}RestController {
|
|
24
|
+
// ...
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration files
|
|
29
|
+
|
|
30
|
+
`src/main/resources/application.properties`:
|
|
31
|
+
|
|
32
|
+
```properties
|
|
33
|
+
slices.{context}.write.{slicename}.enabled=true
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`src/test/resources/application.properties` (default off in plain `@SpringBootTest`s that boot the
|
|
37
|
+
whole context — irrelevant to `@CommandHandlingTest`, which never boots the full context):
|
|
38
|
+
|
|
39
|
+
```properties
|
|
40
|
+
slices.{context}.write.{slicename}.enabled=false
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Automations that depend on this slice's command
|
|
44
|
+
|
|
45
|
+
If another slice's automation dispatches this slice's command, **enable both** the automation and this
|
|
46
|
+
write slice in any test that exercises the automation end-to-end.
|
package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# REST API Patterns (WebMVC)
|
|
2
|
+
|
|
3
|
+
This project uses plain Spring **WebMVC** — blocking `ResponseEntity`, not WebFlux/`Mono`.
|
|
4
|
+
`spring-boot-starter-webmvc` (not `-web`, not `-webflux`) is on the classpath.
|
|
5
|
+
|
|
6
|
+
## Controller
|
|
7
|
+
|
|
8
|
+
```java
|
|
9
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
10
|
+
|
|
11
|
+
import com.opencqrs.framework.command.CommandRouter;
|
|
12
|
+
import jakarta.servlet.http.HttpServletRequest;
|
|
13
|
+
import java.net.URI;
|
|
14
|
+
import java.util.Map;
|
|
15
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
16
|
+
import org.springframework.http.ResponseEntity;
|
|
17
|
+
import org.springframework.validation.annotation.Validated;
|
|
18
|
+
import org.springframework.web.bind.annotation.*;
|
|
19
|
+
|
|
20
|
+
@RestController
|
|
21
|
+
@RequestMapping("/api/{context}")
|
|
22
|
+
public class {SliceName}RestController {
|
|
23
|
+
|
|
24
|
+
@Autowired
|
|
25
|
+
private CommandRouter commandRouter;
|
|
26
|
+
|
|
27
|
+
@PostMapping("/{resource}")
|
|
28
|
+
public ResponseEntity<Void> handle(@RequestBody @Validated {SliceName}RequestBody body, HttpServletRequest request) {
|
|
29
|
+
var command = new {SliceName}Command(body.field1(), body.idField());
|
|
30
|
+
String id = commandRouter.send(command, Map.of("request-uri", request.getRequestURI()));
|
|
31
|
+
return ResponseEntity.created(URI.create("/api/{context}/" + id)).build();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public record {SliceName}RequestBody(String field1, String idField) {}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For a command with no useful return value, drop the `String id = ...` capture and just call
|
|
39
|
+
`commandRouter.send(command, ...)`, returning `ResponseEntity.ok().build()`.
|
|
40
|
+
|
|
41
|
+
## Exception mapping
|
|
42
|
+
|
|
43
|
+
Check first whether the project already has a shared `@ControllerAdvice` (e.g.
|
|
44
|
+
`{basePackage}.rest.ExceptionControllerAdvice`) before adding a new one — most projects need exactly
|
|
45
|
+
one, covering the framework's own exceptions project-wide:
|
|
46
|
+
|
|
47
|
+
```java
|
|
48
|
+
package {basePackage}.rest;
|
|
49
|
+
|
|
50
|
+
import com.opencqrs.framework.CqrsFrameworkException;
|
|
51
|
+
import com.opencqrs.framework.command.CommandSubjectAlreadyExistsException;
|
|
52
|
+
import com.opencqrs.framework.command.CommandSubjectDoesNotExistException;
|
|
53
|
+
import java.util.Map;
|
|
54
|
+
import org.springframework.http.HttpStatus;
|
|
55
|
+
import org.springframework.web.bind.annotation.*;
|
|
56
|
+
|
|
57
|
+
@ControllerAdvice
|
|
58
|
+
public class ExceptionControllerAdvice {
|
|
59
|
+
|
|
60
|
+
private Map<String, Object> jsonError(Exception e) {
|
|
61
|
+
return Map.of("message", e.getMessage());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@ExceptionHandler(CommandSubjectDoesNotExistException.class)
|
|
65
|
+
@ResponseStatus(HttpStatus.NOT_FOUND)
|
|
66
|
+
@ResponseBody
|
|
67
|
+
public Map<String, Object> subjectNotFound(CommandSubjectDoesNotExistException e) {
|
|
68
|
+
return jsonError(e);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@ExceptionHandler(CommandSubjectAlreadyExistsException.class)
|
|
72
|
+
@ResponseStatus(HttpStatus.CONFLICT)
|
|
73
|
+
@ResponseBody
|
|
74
|
+
public Map<String, Object> subjectAlreadyExists(CommandSubjectAlreadyExistsException e) {
|
|
75
|
+
return jsonError(e);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@ExceptionHandler(CqrsFrameworkException.TransientException.class)
|
|
79
|
+
@ResponseStatus(HttpStatus.CONFLICT)
|
|
80
|
+
@ResponseBody
|
|
81
|
+
public Map<String, Object> transientErrors(CqrsFrameworkException.TransientException e) {
|
|
82
|
+
return jsonError(e);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@ExceptionHandler(CqrsFrameworkException.NonTransientException.class)
|
|
86
|
+
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
87
|
+
@ResponseBody
|
|
88
|
+
public Map<String, Object> nonTransientErrors(CqrsFrameworkException.NonTransientException e) {
|
|
89
|
+
return jsonError(e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A slice's own business-rule exception (e.g. `BookAlreadyLentException`) maps to its own status with a
|
|
95
|
+
plain `@ResponseStatus` on the exception class itself — no `@ExceptionHandler` needed for those:
|
|
96
|
+
|
|
97
|
+
```java
|
|
98
|
+
@ResponseStatus(HttpStatus.CONFLICT)
|
|
99
|
+
public class {SliceName}RuleViolationException extends RuntimeException {}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Controller test (`@WebMvcTest` + `MockMvc`)
|
|
103
|
+
|
|
104
|
+
Standard Spring Boot Test — not OpenCQRS-specific. Mock the `CommandRouter` bean itself; this is a thin
|
|
105
|
+
HTTP-mapping test, not a business-rule test (that's Step 7's `CommandHandlingTestFixture` test).
|
|
106
|
+
|
|
107
|
+
**This project is on Spring Boot 4** — `@WebMvcTest` moved to the `spring-boot-webmvc-test` artifact
|
|
108
|
+
under package `org.springframework.boot.webmvc.test.autoconfigure` (not the Boot 3
|
|
109
|
+
`org.springframework.boot.test.autoconfigure.web.servlet` package). Verified by compiling and running
|
|
110
|
+
against the real dependency.
|
|
111
|
+
|
|
112
|
+
```java
|
|
113
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
114
|
+
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|
115
|
+
import org.springframework.http.MediaType;
|
|
116
|
+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
117
|
+
import org.springframework.test.web.servlet.MockMvc;
|
|
118
|
+
|
|
119
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
120
|
+
import static org.mockito.BDDMockito.given;
|
|
121
|
+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
122
|
+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
123
|
+
|
|
124
|
+
@WebMvcTest({SliceName}RestController.class)
|
|
125
|
+
class {SliceName}RestControllerTest {
|
|
126
|
+
|
|
127
|
+
@Autowired
|
|
128
|
+
private MockMvc mockMvc;
|
|
129
|
+
|
|
130
|
+
@MockitoBean
|
|
131
|
+
private CommandRouter commandRouter;
|
|
132
|
+
|
|
133
|
+
@Test
|
|
134
|
+
void postsCommandAndReturnsCreated() throws Exception {
|
|
135
|
+
given(commandRouter.send(any(), any())).willReturn("id-1");
|
|
136
|
+
|
|
137
|
+
mockMvc.perform(post("/api/{context}/{resource}")
|
|
138
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
139
|
+
.content("""
|
|
140
|
+
{"field1": "value1", "idField": "id-1"}
|
|
141
|
+
"""))
|
|
142
|
+
.andExpect(status().isCreated());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|