@eventmodelers/cli 1.0.45 → 1.0.47

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 (123) hide show
  1. package/README.md +5 -2
  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/shared/skills/learn-eventmodelers-api/SKILL.md +52 -46
  9. package/shared/skills/request-feedback/SKILL.md +7 -5
  10. package/stacks/axon/templates/build-kit/CLAUDE.md +1 -1
  11. package/stacks/blank/templates/build-kit/CLAUDE.md +1 -1
  12. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  13. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  14. package/stacks/cratis-csharp/templates/build-kit/CLAUDE.md +9 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  17. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  18. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  19. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  20. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  21. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  22. package/stacks/kurrent/templates/build-kit/CLAUDE.md +124 -0
  23. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  24. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  25. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  26. package/stacks/kurrent/templates/root/README.md +46 -0
  27. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  28. package/stacks/kurrent/templates/root/mvnw +259 -0
  29. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  30. package/stacks/kurrent/templates/root/pom.xml +152 -0
  31. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  32. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  33. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  34. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  35. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  36. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +2 -2
  37. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +0 -1
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +1 -1
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/examples.md +2 -2
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -2
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-interview-protocol/SKILL.md +1 -1
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +4 -4
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +1 -1
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +1 -1
  46. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +1 -1
  47. package/stacks/modeling-kit/templates/.claude/skills/handle-comment/SKILL.md +13 -4
  48. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +2 -2
  49. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +5 -5
  50. package/stacks/modeling-kit/templates/kit/AGENTS.md +1 -1
  51. package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -2
  52. package/stacks/node/templates/build-kit/CLAUDE.md +23 -1
  53. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  54. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  55. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  56. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  57. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  58. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  59. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  60. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  61. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  62. package/stacks/node/templates/root/package.json +2 -1
  63. package/stacks/node/templates/root/setup-env.sh +7 -1
  64. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  65. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  66. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  67. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  68. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  69. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  70. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  71. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  72. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +103 -0
  73. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  74. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  75. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  76. package/stacks/opencqrs/templates/root/README.md +42 -0
  77. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  78. package/stacks/opencqrs/templates/root/mvnw +259 -0
  79. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  80. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  81. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  82. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  83. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  84. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  85. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  86. package/stacks/supabase/templates/build-kit/CLAUDE.md +26 -1
  87. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  88. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  89. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  90. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  91. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  92. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  93. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  94. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  95. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  96. package/stacks/supabase/templates/root/package.json +2 -1
  97. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  98. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  99. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  100. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  101. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  102. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  103. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  104. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  105. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  106. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  107. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  108. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  109. package/stacks/umadb/templates/root/README.md +48 -0
  110. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  111. package/stacks/umadb/templates/root/mvnw +259 -0
  112. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  113. package/stacks/umadb/templates/root/pom.xml +151 -0
  114. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  115. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  116. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  117. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  118. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  119. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  120. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  121. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  122. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  123. package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
@@ -0,0 +1,418 @@
1
+ ---
2
+ name: build-state-change
3
+ authors:
4
+ - Martin Dilger
5
+ description: >
6
+ Implement Event Sourcing write slices directly against KurrentDB 1.2.x (Java client) in this
7
+ project's one established pattern: Command record → pure evolve/decide functions → a small
8
+ @Component wrapper using the shared EventStore helper to read/append with optimistic concurrency →
9
+ plain JUnit unit test (zero I/O — no KurrentDB, no Spring context). There is no CQRS framework here;
10
+ this convention IS the framework. Use when implementing a new write slice / command handler from a
11
+ slice.json event model in this project. Plain Java only. There is exactly one supported style — do
12
+ not offer alternatives.
13
+ ---
14
+
15
+ # KurrentDB — Write Slice (Java)
16
+
17
+ Directory layout is flat — `src/main/java/.../slices/{context}/{slicename}/`, no `write`/`read`/
18
+ `automation` folder layer in between (only the shared `slices/{context}/api/` folder, holding this
19
+ context's sealed event interface + event records + `{Context}EventTypes` registry, sits alongside slice
20
+ folders).
21
+
22
+ ## Step 0: Read the slice definition
23
+
24
+ Read `.build-kit/.slices/{context}/{slicename}/slice.json`. Extract, and use **only** what's there:
25
+
26
+ - `commands[].fields[]` → Command record fields, in order
27
+ - `events[].fields[]` → Event record fields, in order
28
+ - `specifications[]` (GWT scenarios) → one test method per scenario
29
+ - Which command field(s) have `idAttribute: true` — these compose the stream id (see Step 1)
30
+ - `storylines[]` (optional, may be absent) → narrated walkthroughs with ordered `elements[]` "beats";
31
+ see Step 6b for how a COMMAND beat in one of these can add a supplementary test
32
+
33
+ Never invent a field, business rule, or event that isn't in slice.json.
34
+
35
+ ## Step 0a: Determine `{basePackage}`
36
+
37
+ Every code example below is rooted at `{basePackage}.slices.{context}.{slicename}`. Resolve
38
+ `{basePackage}` as documented in `.build-kit/CLAUDE.md`'s Structure section — never hardcode
39
+ `com.example.quickstart` (the shipped quickstart scaffold's package) or any other specific package.
40
+
41
+ ## Step 1: Command
42
+
43
+ Plain record — no interface to implement, there is no `Command` type in this stack:
44
+
45
+ ```java
46
+ package {basePackage}.slices.{context}.{slicename};
47
+
48
+ public record {SliceName}Command(String idField, String field1) {}
49
+ ```
50
+
51
+ **Stream id** — one KurrentDB stream per aggregate instance, named `"{context-lower}-" + idField`.
52
+ Define this as a small static method on the slice's `*Handling` class (Step 4), not duplicated across
53
+ files:
54
+
55
+ ```java
56
+ static String streamId(String idField) {
57
+ return "{context-lower}-" + idField;
58
+ }
59
+ ```
60
+
61
+ **Two co-equal id fields, no natural parent/child relationship** — a stream id is just a string;
62
+ concatenate both fields directly, in a fixed, documented order: `"{context-lower}-" + field1 + "-" +
63
+ field2`.
64
+
65
+ **Parent/child relationship** (e.g. a "page" belonging to a "book") — this is its own separate slice
66
+ with its own stream (e.g. `"{context-lower}-" + isbn + "-page-" + page`), not nested data inside the
67
+ parent's stream. Unlike frameworks with hierarchical subjects, there is no automatic
68
+ "parent sees child's events" mechanism here — if a decision genuinely needs another stream's data, read
69
+ it explicitly (`eventStore.read(otherStreamId)`) inside `decide`'s caller (Step 5), never inside `decide`
70
+ itself (which must stay pure).
71
+
72
+ ## Step 2: Event — only if it doesn't already exist
73
+
74
+ Check `src/main/java/.../{context}/api/` first; add to the existing sealed interface rather than
75
+ creating a duplicate.
76
+
77
+ ```java
78
+ package {basePackage}.slices.{context}.api;
79
+
80
+ public record {EventName}(String idField, String field1) implements {Context}Event {}
81
+ ```
82
+
83
+ If `{Context}Event` (the sealed marker interface) doesn't exist yet for this context, create it:
84
+
85
+ ```java
86
+ package {basePackage}.slices.{context}.api;
87
+
88
+ public sealed interface {Context}Event permits {EventName}, /* ...every other event in this context */ {}
89
+ ```
90
+
91
+ Every `permits` clause must be updated whenever a new event is added — the compiler enforces this
92
+ (a missing entry is a compile error), which is exactly the safety net a sealed interface buys you here.
93
+
94
+ **Then register the event's type name** in this context's `{Context}EventTypes` class — a single
95
+ project-wide-per-context map, not per-slice:
96
+
97
+ ```java
98
+ package {basePackage}.slices.{context}.api;
99
+
100
+ import com.fasterxml.jackson.databind.ObjectMapper;
101
+ import io.kurrent.dbclient.RecordedEvent;
102
+
103
+ import java.io.IOException;
104
+ import java.io.UncheckedIOException;
105
+ import java.util.Map;
106
+ import java.util.Optional;
107
+
108
+ public final class {Context}EventTypes {
109
+ private static final ObjectMapper MAPPER = new ObjectMapper();
110
+
111
+ private static final Map<String, Class<? extends {Context}Event>> TYPES = Map.of(
112
+ "{EventName}", {EventName}.class
113
+ // , "{OtherEventName}", {OtherEventName}.class
114
+ );
115
+
116
+ private {Context}EventTypes() {}
117
+
118
+ public static String typeNameOf({Context}Event event) {
119
+ return switch (event) {
120
+ case {EventName} e -> "{EventName}";
121
+ // add one arm per event type — the compiler enforces exhaustiveness
122
+ };
123
+ }
124
+
125
+ public static byte[] serialize({Context}Event event) {
126
+ try {
127
+ return MAPPER.writeValueAsBytes(event);
128
+ } catch (IOException e) {
129
+ throw new UncheckedIOException(e);
130
+ }
131
+ }
132
+
133
+ /** Empty for an event type this context doesn't recognize — a foreign event on a shared stream. */
134
+ public static Optional<{Context}Event> deserialize(RecordedEvent recordedEvent) {
135
+ Class<? extends {Context}Event> type = TYPES.get(recordedEvent.getEventType());
136
+ if (type == null) {
137
+ return Optional.empty();
138
+ }
139
+ try {
140
+ return Optional.of(MAPPER.readValue(recordedEvent.getEventData(), type));
141
+ } catch (IOException e) {
142
+ throw new UncheckedIOException(e);
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ **This step is easy to forget and doesn't fail at compile time.** An unregistered event type is simply
149
+ invisible to every reader that scans a stream (`evolve`, a projection, an automation) — `deserialize`
150
+ returns empty for it and it's silently skipped. Add the map entry and the `typeNameOf` switch arm as
151
+ part of the same commit that introduces the event, never as an afterthought.
152
+
153
+ Use a short, stable string for the type name (e.g. `"BookPurchased"`) — **never** the Java classname.
154
+ The classname will get refactored eventually; the stored event's type string must not change when it
155
+ does.
156
+
157
+ ## Step 3: Write-model record
158
+
159
+ Immutable record — **not** a mutable entity with setters. Use a nested sealed interface for a field
160
+ that has a small closed set of states, rather than a loose boolean/enum pair that can go out of sync.
161
+
162
+ **Derive the field(s) from this slice's `specifications[]` (Step 0), not from the event's shape.** Each
163
+ GWT scenario's `given`/`then` pair states the one decision the command handler must make and the prior
164
+ fact that decision depends on — that fact is the field. Re-read the scenarios before naming fields: a
165
+ "given no prior activity" / "given already {X}" pair means one boolean flag or sealed-interface state
166
+ for {X}; a scenario that discriminates on a value (not just presence/absence) means a value field
167
+ holding that value, not a boolean. Add exactly one field per fact a scenario actually branches on.
168
+
169
+ ```java
170
+ package {basePackage}.slices.{context}.{slicename};
171
+
172
+ public record {SliceName}(String idField, <otherFields>, <ruleField>) {
173
+
174
+ public {SliceName} with(<ruleFieldType> updated) {
175
+ return new {SliceName}(idField(), <otherFields>, updated);
176
+ }
177
+ }
178
+ ```
179
+
180
+ ## Step 4: evolve / decide — pure functions, zero I/O
181
+
182
+ Both are `static` methods on the slice's `*Handling` class. **Neither touches KurrentDB, Spring, or any
183
+ collaborator that does I/O** — that's what makes Step 7's test free of any container/context.
184
+
185
+ ```java
186
+ package {basePackage}.slices.{context}.{slicename};
187
+
188
+ import {basePackage}.slices.{context}.api.{EventName};
189
+ import {basePackage}.slices.{context}.api.{Context}Event;
190
+
191
+ import java.util.List;
192
+
193
+ class {SliceName}Handling {
194
+ // ... (Step 5 continues this same class)
195
+
196
+ static String streamId(String idField) {
197
+ return "{context-lower}-" + idField;
198
+ }
199
+
200
+ // ---- evolve: pure, no I/O. Exhaustive switch over the sealed event interface — the compiler
201
+ // errors if a new permitted event type isn't handled here. ----
202
+ static {SliceName} evolve({SliceName} state, {Context}Event event) {
203
+ return switch (event) {
204
+ case {EventName} e -> new {SliceName}(e.idField(), /* initial field values */);
205
+ // one arm per event this write-model reacts to; other permitted events in this
206
+ // context that this slice doesn't care about still need a `case ... -> state;` arm
207
+ // (exhaustiveness applies to every type the sealed interface permits, not just this
208
+ // slice's own events)
209
+ };
210
+ }
211
+
212
+ // ---- decide: pure, no I/O. Throws a plain business exception on rule violation. ----
213
+ static List<{Context}Event> decide({SliceName}Command command, {SliceName} state) {
214
+ if (state != null && /* rule from specifications[] */) {
215
+ throw new IllegalStateException("...");
216
+ }
217
+ return List.of(new {EventName}(command.idField(), command.field1()));
218
+ }
219
+ }
220
+ ```
221
+
222
+ **Creation commands** (no prior stream) — `decide` receives `state == null`; check for that explicitly
223
+ rather than assuming a non-null default. **Commands that depend on prior state** — `decide` receives the
224
+ folded `{SliceName}` state built by replaying every recognized event through `evolve` (Step 5 does this
225
+ folding, not `decide` itself).
226
+
227
+ ## Step 5: The `*Handling` component — the only part that touches the event store
228
+
229
+ ```java
230
+ package {basePackage}.slices.{context}.{slicename};
231
+
232
+ import {basePackage}.common.EventStore;
233
+ import {basePackage}.slices.{context}.api.{Context}Event;
234
+ import {basePackage}.slices.{context}.api.{Context}EventTypes;
235
+ import io.kurrent.dbclient.EventData;
236
+ import org.springframework.stereotype.Component;
237
+
238
+ import java.util.List;
239
+ import java.util.UUID;
240
+
241
+ @Component
242
+ public class {SliceName}Handling {
243
+
244
+ private final EventStore eventStore;
245
+
246
+ public {SliceName}Handling(EventStore eventStore) {
247
+ this.eventStore = eventStore;
248
+ }
249
+
250
+ // ... streamId/evolve/decide from Step 4 ...
251
+
252
+ private {SliceName} replay(EventStore.StreamEvents streamEvents) {
253
+ {SliceName} state = null;
254
+ for (var resolved : streamEvents.events()) {
255
+ var domainEvent = {Context}EventTypes.deserialize(resolved.getOriginalEvent());
256
+ if (domainEvent.isPresent()) {
257
+ state = evolve(state, domainEvent.get());
258
+ }
259
+ }
260
+ return state;
261
+ }
262
+
263
+ private void append(String idField, EventStore.StreamEvents streamEvents, List<{Context}Event> events) {
264
+ List<EventData> eventData = events.stream()
265
+ .map(e -> EventData.builderAsJson(UUID.randomUUID(), {Context}EventTypes.typeNameOf(e),
266
+ {Context}EventTypes.serialize(e)).build())
267
+ .toList();
268
+ eventStore.append(streamId(idField), streamEvents.expectedState(), eventData);
269
+ }
270
+
271
+ public void handle({SliceName}Command command) {
272
+ var streamEvents = eventStore.read(streamId(command.idField()));
273
+ var state = replay(streamEvents);
274
+ var events = decide(command, state);
275
+ append(command.idField(), streamEvents, events);
276
+ }
277
+ }
278
+ ```
279
+
280
+ `EventStore.StreamEvents.expectedState()` (see `{basePackage}.common.EventStore`) resolves to
281
+ `StreamState.noStream()` when the stream doesn't exist yet, or `StreamState.streamRevision(...)`
282
+ pinned to exactly what was just read — this is the optimistic-concurrency check: if another writer
283
+ appended to the same stream between this read and this append, KurrentDB rejects the append with
284
+ `WrongExpectedVersionException` rather than silently interleaving. That exception propagates unwrapped
285
+ out of `eventStore.append(...)` (see `EventStore`'s own Javadoc) — Step 6 maps it to an HTTP 409; an
286
+ automation dispatching a command instead has its own idempotency handling, see `build-automation`.
287
+
288
+ For a command per slice.json with more than one command mapped to this same write model (e.g. purchase
289
+ + lend on the same `Book`), add one more public `handle...`/`decide` pair to this same class — see the
290
+ example above where `decide` is overloaded per command type, mirroring how a real hand-rolled Java ES
291
+ codebase reads.
292
+
293
+ ## Step 6: REST endpoint — only if slice.json shows an inbound `SCREEN` dependency on the command
294
+
295
+ ```java
296
+ package {basePackage}.slices.{context}.{slicename};
297
+
298
+ import io.kurrent.dbclient.WrongExpectedVersionException;
299
+ import org.springframework.http.HttpStatus;
300
+ import org.springframework.http.ResponseEntity;
301
+ import org.springframework.web.bind.annotation.*;
302
+
303
+ @RestController
304
+ @RequestMapping("/api/{context}")
305
+ public class {SliceName}RestController {
306
+
307
+ private final {SliceName}Handling handling;
308
+
309
+ public {SliceName}RestController({SliceName}Handling handling) {
310
+ this.handling = handling;
311
+ }
312
+
313
+ @PostMapping("/{resource}")
314
+ public ResponseEntity<Void> handle(@RequestBody {SliceName}RequestBody body) {
315
+ try {
316
+ handling.handle(new {SliceName}Command(body.idField(), body.field1()));
317
+ return ResponseEntity.ok().build();
318
+ } catch (WrongExpectedVersionException e) {
319
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
320
+ }
321
+ }
322
+
323
+ public record {SliceName}RequestBody(String idField, String field1) {}
324
+ }
325
+ ```
326
+
327
+ This project uses plain Spring **WebMVC** (`ResponseEntity`, blocking) — not WebFlux/`Mono`.
328
+
329
+ If the only inbound dependency is another slice's `AUTOMATION`, skip this step — the automation calls
330
+ `{SliceName}Handling` in-process, it doesn't need HTTP.
331
+
332
+ ## Step 7: Test — plain JUnit, zero I/O
333
+
334
+ `evolve`/`decide` are `static` and package-private — call them directly from a test in the same
335
+ package. **No KurrentDB, no Spring context, no container.** This is the primary and default test for a
336
+ state-change slice; only reach for an integration-style test (a real, disposable KurrentDB via
337
+ Testcontainers — see [references/integration-test-patterns.md](references/integration-test-patterns.md))
338
+ when you specifically need to verify the `EventStore`/`*Handling` wiring itself, not the business rule.
339
+
340
+ ```java
341
+ package {basePackage}.slices.{context}.{slicename};
342
+
343
+ import {basePackage}.slices.{context}.api.{EventName};
344
+ import org.junit.jupiter.api.Test;
345
+
346
+ import java.util.List;
347
+
348
+ import static org.assertj.core.api.Assertions.assertThat;
349
+ import static org.assertj.core.api.Assertions.assertThatThrownBy;
350
+ import static {basePackage}.slices.{context}.{slicename}.{SliceName}Handling.decide;
351
+ import static {basePackage}.slices.{context}.{slicename}.{SliceName}Handling.evolve;
352
+
353
+ class {SliceName}HandlingTest {
354
+
355
+ @Test
356
+ void givenNothing_whenCommand_thenEventEmitted() {
357
+ var events = decide(new {SliceName}Command("id-1", "value1"), null);
358
+
359
+ assertThat(events).containsExactly(new {EventName}("id-1", "value1"));
360
+ }
361
+
362
+ @Test
363
+ void givenRuleAlreadyTrue_whenCommand_thenThrows() {
364
+ var state = evolve(null, new {EventName}("id-1", "value1"));
365
+
366
+ assertThatThrownBy(() -> decide(new {SliceName}Command("id-1", "value2"), state))
367
+ .isInstanceOf(IllegalStateException.class);
368
+ }
369
+ }
370
+ ```
371
+
372
+ One test method per GWT scenario in slice.json's `specifications[]`. Build the "given" state by folding
373
+ `evolve` over the scenario's given events (`evolve(evolve(null, e1), e2)`, etc.) — exactly what `replay`
374
+ does in production — rather than hand-constructing a `{SliceName}` record literal, so the test exercises
375
+ the same reconstruction path production uses.
376
+
377
+ ## Step 6b: Storyline-Derived Tests (Optional)
378
+
379
+ `slice.json` may carry a `storylines[]` array alongside `specifications[]` — narrated walkthroughs
380
+ where an ordered sequence of `elements[]` "beats" (EVENT/COMMAND/READMODEL/...) shows one use case
381
+ end to end. This is a secondary, supplementary source — `specifications[]` (Step 7) stays the
382
+ primary and default source of test methods. Most slices have no `storylines[]`; skip this step
383
+ silently when there's nothing relevant.
384
+
385
+ Find beats whose `type` is `COMMAND`. For each such beat: fold `evolve` over the cumulative ordered
386
+ `EVENT` beats preceding it to build `state`, call `decide` with the command built from the beat's
387
+ `fields`, and assert the result against the `EVENT` beat(s) immediately following it in the storyline.
388
+
389
+ ```java
390
+ @Test
391
+ void storylineBeat() {
392
+ var state = evolve(null, new {PrecedingEventName}(/* fields from earlier beats */));
393
+
394
+ var events = decide(new {SliceName}Command(/* fields from the command beat */), state);
395
+
396
+ assertThat(events).containsExactly(new {EventName}(/* fields from the following event beat */));
397
+ }
398
+ ```
399
+
400
+ Do **not** try to also assert read-model state in this same test — that half (the following
401
+ EVENT→READMODEL beats) belongs to `build-state-view`'s own storyline step. If the beat immediately
402
+ after the command isn't an EVENT, don't force a test — leave it undocumented rather than fabricating an
403
+ assertion.
404
+
405
+ ## Final Verification
406
+
407
+ Before considering the slice done:
408
+
409
+ - [ ] Every field in slice.json's `commands[]` is in the Command record — no invented fields, none missing
410
+ - [ ] Every field in slice.json's `events[]` is in the Event record — no invented fields, none missing
411
+ - [ ] The new event is added to the context's sealed `{Context}Event` interface's `permits` clause
412
+ - [ ] The new event's type name is registered in `{Context}EventTypes` (both the `TYPES` map and the `typeNameOf` switch)
413
+ - [ ] `evolve` is an exhaustive switch (compiler-enforced) and never performs I/O
414
+ - [ ] `decide` never performs I/O and only throws for rules traceable to slice.json's `description`/`comments`
415
+ - [ ] Every `specifications[]` scenario has a corresponding test method, built by folding `evolve` over given events
416
+ - [ ] If `storylines[]` is present: every COMMAND beat for this slice's command has a storyline test — or was deliberately skipped as untraceable
417
+ - [ ] `./mvnw compile -q`, then run the slice's own tests only
418
+ - [ ] If checks pass, commit with `feat: {Slice Name}` and set slice status to `Done`
@@ -0,0 +1,41 @@
1
+ # Feature Flag Patterns
2
+
3
+ House convention (not a KurrentDB 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
+ ## Handling component + REST controller
7
+
8
+ ```java
9
+ @Component
10
+ @ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
11
+ public class {SliceName}Handling {
12
+ // ...
13
+ }
14
+
15
+ @RestController
16
+ @ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
17
+ public class {SliceName}RestController {
18
+ // ...
19
+ }
20
+ ```
21
+
22
+ ## Configuration files
23
+
24
+ `src/main/resources/application.properties`:
25
+
26
+ ```properties
27
+ slices.{context}.write.{slicename}.enabled=true
28
+ ```
29
+
30
+ `src/test/resources/application.properties` (only relevant to a `@SpringBootTest` that boots the whole
31
+ context — irrelevant to the plain JUnit tests from Step 7 of the main skill, which never boot Spring at
32
+ all):
33
+
34
+ ```properties
35
+ slices.{context}.write.{slicename}.enabled=false
36
+ ```
37
+
38
+ ## Automations that depend on this slice's command
39
+
40
+ If another slice's automation dispatches this slice's command, **enable both** the automation and this
41
+ write slice in any test that exercises the automation end-to-end.
@@ -0,0 +1,64 @@
1
+ # Integration Test Patterns (Real KurrentDB via Testcontainers)
2
+
3
+ The pure `evolve`/`decide` unit test (Step 7 of the main skill) is the primary and default test for a
4
+ write slice — it's the one to write for every slice. Reach for this pattern only when you specifically
5
+ need to verify the `EventStore`/`*Handling` wiring itself (the read-replay-decide-append round trip,
6
+ optimistic concurrency) rather than a business rule, which the pure test already covers.
7
+
8
+ There is no official Testcontainers module for KurrentDB — use the generic container support directly.
9
+
10
+ ## Container setup
11
+
12
+ ```java
13
+ import org.testcontainers.containers.GenericContainer;
14
+ import org.testcontainers.containers.wait.strategy.Wait;
15
+ import org.testcontainers.junit.jupiter.Testcontainers;
16
+ import org.testcontainers.utility.DockerImageName;
17
+
18
+ import java.time.Duration;
19
+
20
+ @Testcontainers
21
+ class {SliceName}HandlingIntegrationTest {
22
+
23
+ static GenericContainer<?> kurrentdb = new GenericContainer<>(
24
+ DockerImageName.parse("docker.kurrent.io/kurrent-latest/kurrentdb:latest"))
25
+ .withExposedPorts(2113)
26
+ .withCommand("--insecure", "--run-projections=All", "--enable-atom-pub-over-http")
27
+ .waitingFor(Wait.forHttp("/health/live").forPort(2113).forStatusCode(204)
28
+ .withStartupTimeout(Duration.ofSeconds(90)));
29
+
30
+ static {SliceName}Handling handling;
31
+
32
+ @BeforeAll
33
+ static void setUp() {
34
+ kurrentdb.start();
35
+ var settings = KurrentDBConnectionString.parseOrThrow(
36
+ "kurrentdb://" + kurrentdb.getHost() + ":" + kurrentdb.getMappedPort(2113) + "?tls=false");
37
+ var client = KurrentDBClient.create(settings);
38
+ handling = new {SliceName}Handling(new EventStore(client));
39
+ }
40
+
41
+ @AfterAll
42
+ static void tearDown() {
43
+ kurrentdb.stop();
44
+ }
45
+
46
+ // ... @Test methods calling handling.handle(...) directly, no Spring context needed —
47
+ // {SliceName}Handling's only dependency is EventStore/KurrentDBClient, both wired by hand above.
48
+ }
49
+ ```
50
+
51
+ ## Key points
52
+
53
+ - **No Spring context needed** — construct `KurrentDBClient` and the slice's `*Handling` class
54
+ directly. A `@SpringBootTest` here would also try to build the JPA/Postgres datasource (since the
55
+ full application context wires that up too), which this test has no need of and no container for.
56
+ - **No Postgres needed either** — that's the read-model side's concern (`build-state-view`), not this
57
+ one's.
58
+ - A 90-second startup timeout is intentionally generous: under ARM/amd64 emulation (Apple Silicon
59
+ Docker Desktop), this image's startup is slow and occasionally needs a retry — this is a known
60
+ characteristic of running an amd64-only image under emulation, not a bug in the client or this
61
+ pattern. If a run fails with a container-startup timeout or an early connection error, retry once
62
+ before investigating further.
63
+ - Use a fresh, unique id (e.g. `"id-" + System.nanoTime()`) per test method so tests don't collide on
64
+ the same stream when run in the same container instance.