@eventmodelers/cli 1.0.45 → 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.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/cli.js +53 -1
  3. package/package.json +2 -2
  4. package/shared/build-kit/lib/checks/README.md +59 -0
  5. package/shared/build-kit/lib/ralph.js +110 -28
  6. package/shared/build-kit/lib/util/find-slice.cjs +59 -0
  7. package/shared/build-kit/ralph-claude.js +6 -2
  8. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  9. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  10. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  11. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  12. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  13. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  14. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  17. package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
  18. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  19. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  20. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  21. package/stacks/kurrent/templates/root/README.md +46 -0
  22. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  23. package/stacks/kurrent/templates/root/mvnw +259 -0
  24. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  25. package/stacks/kurrent/templates/root/pom.xml +152 -0
  26. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  27. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  28. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  29. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  30. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  31. package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
  32. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  33. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  34. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  35. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  36. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  37. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  38. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  39. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  40. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  41. package/stacks/node/templates/root/package.json +2 -1
  42. package/stacks/node/templates/root/setup-env.sh +7 -1
  43. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  44. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  45. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  46. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  47. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  48. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  49. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  50. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  51. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
  52. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  53. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  54. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  55. package/stacks/opencqrs/templates/root/README.md +42 -0
  56. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  57. package/stacks/opencqrs/templates/root/mvnw +259 -0
  58. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  59. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  60. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  61. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  62. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  63. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  64. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  65. package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
  66. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  67. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  68. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  69. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  70. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  71. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  72. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  73. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  74. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  75. package/stacks/supabase/templates/root/package.json +2 -1
  76. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  77. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  78. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  79. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  80. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  81. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  82. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  83. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  84. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  85. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  86. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  87. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  88. package/stacks/umadb/templates/root/README.md +48 -0
  89. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  90. package/stacks/umadb/templates/root/mvnw +259 -0
  91. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  92. package/stacks/umadb/templates/root/pom.xml +151 -0
  93. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  94. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  95. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  96. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  97. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  98. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  99. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  100. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  101. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  102. 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.
@@ -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
+ ```