@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,434 @@
1
+ ---
2
+ name: build-automation
3
+ authors:
4
+ - Martin Dilger
5
+ description: >
6
+ Implement automation slices (Event → Command) using OpenCQRS 2.0.0 (Spring Boot, EventSourcingDB) in
7
+ this project's one established pattern: an @EventHandling method that reacts to a trigger event by
8
+ dispatching a command via CommandRouter, guarded against at-least-once redelivery by catching the
9
+ target command's own subject-condition exception locally. Automations can be stateless (direct
10
+ event-to-command mapping) or backed by a private JPA read model (to look up data needed for command
11
+ construction). Use when implementing a new automation / event-to-command reactor from a slice.json
12
+ event model in this project. Plain Java only. There is exactly one supported style — do not offer
13
+ alternatives.
14
+ ---
15
+
16
+ # OpenCQRS — Automation Slice (Java)
17
+
18
+ An automation reacts to an event by dispatching a command. In Event Modeling: the **orange** stripe.
19
+
20
+ **There is no dedicated "processor"/"reactor" abstraction in OpenCQRS distinct from a projection** — an
21
+ automation is exactly the same `@EventHandling` mechanism `build-state-view` uses, just reacting by
22
+ calling `CommandRouter.send(...)` instead of (or in addition to) writing to a repository. Everything
23
+ `build-state-view` documents about processing-group naming, per-method `@Autowired` parameters, and the
24
+ `@DataJpaTest` test pattern applies here unchanged — this skill only adds what's specific to
25
+ **dispatching a command safely under at-least-once delivery**, which is the one thing that's easy to get
26
+ wrong.
27
+
28
+ There are two kinds:
29
+
30
+ - **Stateless**: the trigger event alone carries everything the target command needs.
31
+ - **With a private read model**: needs data that isn't in the trigger event itself (e.g. "look up this
32
+ book's page count to pick a random damaged page"), built by one `@EventHandling` method from earlier
33
+ events and read by another.
34
+
35
+ ## Step 0: Discover target project conventions
36
+
37
+ > **Comments & description**: Each element in the slice carries a `comments: string[]` array and a
38
+ > `description` field. Use these as implementation hints. When done, resolve each used comment:
39
+ > `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get
40
+ > IDs first via GET on same path).
41
+
42
+ Read the target project's `.build-kit/CLAUDE.md` and explore existing slices for the processing-group
43
+ naming convention already in use.
44
+
45
+ **Determine `{basePackage}`** — every path below is rooted at
46
+ `{basePackage}.slices.{context}.automation.{slicename}`. Resolve `{basePackage}` as documented in
47
+ `.build-kit/CLAUDE.md`'s Structure section.
48
+
49
+ ## Step 1: Understand the input
50
+
51
+ Extract these elements regardless of input format:
52
+
53
+ | Element | What to extract |
54
+ |---|---|
55
+ | **Trigger event** | Which event triggers the automation, and which condition filters it |
56
+ | **Target command** | Which command to dispatch, with what properties |
57
+ | **Mapping logic** | How event properties map to command properties |
58
+ | **Read model needed?** | Does the automation need data NOT in the trigger event itself? |
59
+
60
+ If the Event Modeling artifact includes slice details with `## Scenarios (GWTs)`, use them to derive
61
+ test cases. GWT format for automations: `Given (events) → Then (command | NOTHING)`. Events in Given
62
+ include read-model-building events first, trigger event last.
63
+
64
+ `slice.json` may also carry an optional `storylines[]` array — see the "Storyline-Derived Tests"
65
+ section under Step 5 for how a trigger-event beat in one of these can add a supplementary test.
66
+
67
+ If the slice details contain `## Implementation Guidelines`, **follow them**.
68
+
69
+ ### Stateless vs. with-read-model decision
70
+
71
+ Choose **with a private read model** when:
72
+
73
+ - The automation needs data that is NOT in the trigger event (e.g. "find this book's page count")
74
+ - The automation must iterate over a collection to dispatch multiple commands
75
+ - Two different events are involved: one builds the read model, another triggers the dispatch
76
+
77
+ Choose **stateless** when all command fields can be derived directly from the trigger event (plus a
78
+ pure calculation).
79
+
80
+ ## Step 2: Ensure events exist
81
+
82
+ All events the automation handles must exist in `src/main/java/.../{context}/api/`. If they don't,
83
+ create them **first** (see `build-state-change` Step 2), including registering each new event's type
84
+ string in `CqrsConfiguration`'s `eventTypeResolver()` bean.
85
+
86
+ ## Step 3: Dispatching the target command — the part that's easy to get wrong
87
+
88
+ `CommandRouter` is a normal Spring bean — inject it into an `@EventHandling` method exactly like any
89
+ other collaborator, via a method-parameter `@Autowired` (see `build-state-view`'s Step 2 on why
90
+ `@EventHandling` methods take collaborators this way, not via the constructor):
91
+
92
+ ```java
93
+ @{SliceName}Handling
94
+ public void react({TriggerEvent} event, @Autowired CommandRouter commandRouter) {
95
+ commandRouter.send(new {TargetCommand}Command(/* mapped fields */));
96
+ }
97
+ ```
98
+
99
+ **This alone is not safe.** `EventHandlingProcessor` guarantees only **at-least-once** delivery — the
100
+ same event can, and eventually will, be redelivered to this method after a crash/restart/fail-over that
101
+ happens between the command actually succeeding and the processor's checkpoint advancing past it. The
102
+ target command handler will then run a second time for the same logical trigger.
103
+
104
+ **The fix is exactly the same idempotency mechanism `build-state-change` already gives you for free: the
105
+ target command's own `SubjectCondition`.** If the target command is a creation command
106
+ (`SubjectCondition.PRISTINE`, the normal case for "mark this thing as done"), redelivery makes
107
+ `CommandRouter.send(...)` throw `CommandSubjectAlreadyExistsException` the second time — which is the
108
+ framework telling you "this was already handled", not a real failure. **Catch it locally, right at the
109
+ dispatch call**, and do nothing:
110
+
111
+ ```java
112
+ @{SliceName}Handling
113
+ public void react({TriggerEvent} event, @Autowired CommandRouter commandRouter) {
114
+ try {
115
+ commandRouter.send(new {TargetCommand}Command(/* mapped fields */));
116
+ } catch (CommandSubjectAlreadyExistsException e) {
117
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
118
+ }
119
+ }
120
+ ```
121
+
122
+ If the target command instead requires `SubjectCondition.EXISTS`, catch
123
+ `CommandSubjectDoesNotExistException` the same way where a redelivery-caused re-check is expected to
124
+ fail harmlessly. If the target command has `SubjectCondition.NONE` (no built-in idempotency check
125
+ available), you must give the dispatched command a stable, derivable subject/business key so a genuine
126
+ duplicate is rejected by that slice's own business rule instead — an automation with no way to detect
127
+ "already done" on redelivery will double-execute silently.
128
+
129
+ **Why this matters more here than it looks**: letting any other `NonTransientException` — or any
130
+ uncaught `Throwable` that isn't retried away — escape an `@EventHandling` method does not just fail
131
+ *this one event*. Per `EventHandlingProcessor`'s documented error handling, an escaping
132
+ `NonTransientException` **terminates the entire processing loop for that group**, stopping every other
133
+ event this group would otherwise still be handling, not just the one that failed. Swallowing the
134
+ expected conflict exception at the dispatch call site — never letting it propagate out of the
135
+ `@EventHandling` method — is what keeps the automation's processing group alive across redeliveries.
136
+
137
+ ## Step 4: Implement the automation
138
+
139
+ ### Stateless automation
140
+
141
+ ```java
142
+ package {basePackage}.slices.{context}.automation.{slicename};
143
+
144
+ import {basePackage}.slices.{context}.api.*;
145
+ import com.opencqrs.framework.command.CommandRouter;
146
+ import com.opencqrs.framework.command.CommandSubjectAlreadyExistsException;
147
+ import com.opencqrs.framework.eventhandler.EventHandling;
148
+ import org.springframework.beans.factory.annotation.Autowired;
149
+ import org.springframework.stereotype.Component;
150
+
151
+ @Component
152
+ public class {AutomationName}Processor {
153
+
154
+ @EventHandling("{context}-{slicename}")
155
+ public void react({TriggerEvent} event, @Autowired CommandRouter commandRouter) {
156
+ if (!shouldReact(event)) {
157
+ return;
158
+ }
159
+ try {
160
+ commandRouter.send(new {TargetCommand}Command(event.{idField()} /*, mapped fields */));
161
+ } catch (CommandSubjectAlreadyExistsException e) {
162
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
163
+ }
164
+ }
165
+
166
+ private boolean shouldReact({TriggerEvent} event) {
167
+ return true; // replace with the actual condition from the slice definition
168
+ }
169
+ }
170
+ ```
171
+
172
+ A pure mapping/calculation dependency (e.g. picking which page to mark) can be `@Autowired` in
173
+ separately, exactly like any other collaborator — no special interface required, a plain injected
174
+ Spring bean is enough.
175
+
176
+ ### Automation with a private read model
177
+
178
+ Verified, real pattern — two `@EventHandling` methods under the **same processing-group name**, so the
179
+ per-group in-order guarantee ensures the read model is built before the trigger event that consumes it
180
+ is ever handled. **Never reuse another slice's entity/repository** — build a private one, exactly like
181
+ `build-state-view`'s own private-entity rule.
182
+
183
+ ```java
184
+ package {basePackage}.slices.{context}.automation.{slicename};
185
+
186
+ import jakarta.persistence.Entity;
187
+ import jakarta.persistence.Id;
188
+
189
+ @Entity
190
+ class {AutomationName}Entity {
191
+
192
+ @Id
193
+ public String id;
194
+ public Long someLookupValue;
195
+
196
+ public {AutomationName}Entity() {}
197
+
198
+ public {AutomationName}Entity(String id, Long someLookupValue) {
199
+ this.id = id;
200
+ this.someLookupValue = someLookupValue;
201
+ }
202
+ }
203
+ ```
204
+
205
+ ```java
206
+ package {basePackage}.slices.{context}.automation.{slicename};
207
+
208
+ import org.springframework.data.repository.CrudRepository;
209
+
210
+ interface {AutomationName}Repository extends CrudRepository<{AutomationName}Entity, String> {}
211
+ ```
212
+
213
+ ```java
214
+ package {basePackage}.slices.{context}.automation.{slicename};
215
+
216
+ import {basePackage}.slices.{context}.api.*;
217
+ import com.opencqrs.framework.command.CommandRouter;
218
+ import com.opencqrs.framework.command.CommandSubjectAlreadyExistsException;
219
+ import org.springframework.beans.factory.annotation.Autowired;
220
+ import org.springframework.stereotype.Component;
221
+
222
+ @Component
223
+ public class {AutomationName}Processor {
224
+
225
+ // Phase 1 — build the private read model from a setup event
226
+ @{AutomationName}Handling
227
+ public void on({SetupEvent} event, @Autowired {AutomationName}Repository repository) {
228
+ repository.save(new {AutomationName}Entity(event.idField(), event.someLookupValue()));
229
+ }
230
+
231
+ // Phase 2 — trigger: look up what's needed, then dispatch
232
+ @{AutomationName}Handling
233
+ public void react(
234
+ {TriggerEvent} event,
235
+ @Autowired {AutomationName}Repository repository,
236
+ @Autowired CommandRouter commandRouter) {
237
+ var entry = repository.findById(event.idField()).orElseThrow();
238
+ try {
239
+ commandRouter.send(new {TargetCommand}Command(entry.id, /* derived fields */));
240
+ } catch (CommandSubjectAlreadyExistsException e) {
241
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
242
+ }
243
+ }
244
+ }
245
+ ```
246
+
247
+ Define the shared `@{AutomationName}Handling` meta-annotation (`@EventHandling("{context}-{slicename}")`)
248
+ exactly as shown in `build-state-view`'s Step 2 — required here since this pattern always has 2+ handler
249
+ methods sharing one group.
250
+
251
+ **Iterating over multiple matches** (dispatch a command per matching entry, not just one): use
252
+ `repository.findAllBy...(...)` instead of `findById`, and loop, catching the conflict exception around
253
+ each individual `commandRouter.send(...)` call rather than around the whole loop — one entry's already-
254
+ handled conflict must not stop the others from being dispatched.
255
+
256
+ ### Alternative to a private read model: reading events directly
257
+
258
+ For a stateless, read-only lookup that doesn't need its own durable table, `EventRepository`/
259
+ `EventReader` can be `@Autowired` directly into any Spring bean to read events on demand (e.g.
260
+ `eventReader.readAsObject("/{context}", Set.of(new Option.Recursive()))`) instead of maintaining a
261
+ private projection. Reach for this only for genuinely occasional, non-hot-path lookups — a private JPA
262
+ read model built incrementally (above) is the default, well-trodden choice for anything on the
263
+ automation's main dispatch path.
264
+
265
+ ## Step 5: Feature flags (optional)
266
+
267
+ Same house convention as `build-state-change`/`build-state-view` — see
268
+ [references/feature-flag-patterns.md](references/feature-flag-patterns.md). **Enable both the
269
+ automation and its target command's write slice** in any test that exercises the automation end-to-end.
270
+
271
+ ## Step 6: Implement tests
272
+
273
+ There is no built-in test fixture for `@EventHandling` methods — call them directly, exactly as
274
+ `build-state-view` does, mocking `CommandRouter` since this is the collaborator under test here.
275
+
276
+ **Stateless automation:**
277
+
278
+ ```java
279
+ package {basePackage}.slices.{context}.automation.{slicename};
280
+
281
+ import {basePackage}.slices.{context}.api.*;
282
+ import com.opencqrs.framework.command.CommandRouter;
283
+ import org.junit.jupiter.api.Test;
284
+ import org.mockito.Mock;
285
+ import org.mockito.MockitoAnnotations;
286
+
287
+ import static org.mockito.ArgumentMatchers.eq;
288
+ import static org.mockito.Mockito.*;
289
+
290
+ class {AutomationName}ProcessorTest {
291
+
292
+ private {AutomationName}Processor processor;
293
+
294
+ @Mock
295
+ private CommandRouter commandRouter;
296
+
297
+ @BeforeEach
298
+ void setUp() {
299
+ MockitoAnnotations.openMocks(this);
300
+ processor = new {AutomationName}Processor();
301
+ }
302
+
303
+ @Test
304
+ void conditionMet_dispatchesCommand() {
305
+ var event = new {TriggerEvent}("entity-1" /*, fields that meet the condition */);
306
+
307
+ processor.react(event, commandRouter);
308
+
309
+ verify(commandRouter).send(eq(new {TargetCommand}Command("entity-1" /*, expected fields */)));
310
+ }
311
+
312
+ @Test
313
+ void conditionNotMet_dispatchesNothing() {
314
+ var event = new {TriggerEvent}("entity-1" /*, fields that do NOT meet the condition */);
315
+
316
+ processor.react(event, commandRouter);
317
+
318
+ verifyNoInteractions(commandRouter);
319
+ }
320
+
321
+ @Test
322
+ void redeliveredEvent_conflictIsSwallowed() {
323
+ var event = new {TriggerEvent}("entity-1" /*, fields that meet the condition */);
324
+ doThrow(new CommandSubjectAlreadyExistsException("...", "..."))
325
+ .when(commandRouter).send(any());
326
+
327
+ processor.react(event, commandRouter); // must not throw
328
+ }
329
+ }
330
+ ```
331
+
332
+ **Automation with a private read model** — use `@DataJpaTest` + `@Import` + Testcontainers Postgres
333
+ (see `build-state-view`'s Step 4 for why this project needs the real container, not an embedded
334
+ database), mocking only `CommandRouter`:
335
+
336
+ ```java
337
+ @DataJpaTest
338
+ @Testcontainers
339
+ @Import({AutomationName}Processor.class)
340
+ class {AutomationName}ProcessorTest {
341
+
342
+ @Container
343
+ @ServiceConnection
344
+ static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
345
+
346
+ @Autowired
347
+ private {AutomationName}Processor processor;
348
+
349
+ @Autowired
350
+ private {AutomationName}Repository repository;
351
+
352
+ @MockitoBean
353
+ private CommandRouter commandRouter;
354
+
355
+ @Test
356
+ void setupThenTrigger_dispatchesForMatchingEntry() {
357
+ processor.on(new {SetupEvent}("entity-1", 42L), repository);
358
+
359
+ processor.react(new {TriggerEvent}("entity-1"), repository, commandRouter);
360
+
361
+ verify(commandRouter).send(eq(new {TargetCommand}Command("entity-1", 42L)));
362
+ }
363
+
364
+ @Test
365
+ void noSetupEvent_throwsOrSkips() {
366
+ // reflects however Step 4's lookup handles a missing entry — orElseThrow(), or a
367
+ // findAllBy(...).isEmpty() no-op loop; assert whichever this slice actually implements
368
+ }
369
+ }
370
+ ```
371
+
372
+ ### Test cases to cover
373
+
374
+ **Stateless automations:**
375
+ 1. Condition met → expected command dispatched
376
+ 2. Condition not met → no command dispatched
377
+ 3. Redelivered trigger event (target already handled) → the conflict exception is swallowed, method
378
+ does not throw
379
+
380
+ **Automations with a read model:**
381
+ 1. Setup + trigger with a matching entry → command dispatched for that entry
382
+ 2. Setup + trigger with no matching entry → no command dispatched (or however the slice defines that
383
+ case)
384
+ 3. Temporal ordering: only entries set up *before* the trigger are found — reflects that both methods
385
+ share one processing group and are therefore applied in event-store order
386
+
387
+ ### Mapping GWT scenarios to tests
388
+
389
+ | GWT Element | Test code |
390
+ |---|---|
391
+ | Event in Given | `processor.on(new Event(...), repository)` (read-model phase) or direct setup |
392
+ | Command in Then | `verify(commandRouter).send(eq(expectedCommand))` |
393
+ | NOTHING in Then | `verifyNoInteractions(commandRouter)` |
394
+
395
+ ### Storyline-Derived Tests (Optional)
396
+
397
+ `slice.json` may also carry a `storylines[]` array — narrated walkthroughs with an ordered
398
+ `elements[]` "beats" sequence (EVENT/COMMAND/READMODEL/...). This is a secondary, supplementary
399
+ source; `specifications[]` above stays the primary and default source of test cases. Most slices
400
+ have no `storylines[]` — skip silently when there's nothing relevant.
401
+
402
+ A storyline embedded in this slice's slice.json already belongs entirely to this slice — no need
403
+ to match beats against `events[]`/`commands[]` by id/title. Find a beat whose `type` is `EVENT`
404
+ immediately followed by a `COMMAND` beat. That pair is a ready-made test: `given` = the cumulative
405
+ preceding `EVENT` beats (setup events) through the trigger beat, `then` =
406
+ `verify(commandRouter).send(eq(...))` built from the command beat's fields — same shape
407
+ as the "Mapping GWT Scenarios to Tests" row above, just sourced from the storyline instead of
408
+ `specifications[]`.
409
+
410
+ If the beat following the trigger event isn't a COMMAND this automation dispatches (e.g. it's a
411
+ READMODEL or SCREEN beat), don't force a test — leave it undocumented rather than fabricating an
412
+ assertion.
413
+
414
+ ## References
415
+
416
+ - [Feature Flag Patterns](references/feature-flag-patterns.md) — `@ConditionalOnProperty`
417
+ - [Idempotent Command Dispatch](references/idempotent-dispatch-patterns.md) — the full exception-per-`SubjectCondition` table, and why swallowing it locally matters
418
+
419
+ ---
420
+
421
+ ## Final Verification: Does the Implementation Match slice.json?
422
+
423
+ Before marking this slice as `Done`, verify the implementation against slice.json:
424
+
425
+ - [ ] The trigger event in the processor matches the trigger event in slice.json exactly
426
+ - [ ] The command dispatched matches the target command defined in slice.json
427
+ - [ ] All fields mapped from trigger event (or private read model) to command come from slice.json — no invented mappings
428
+ - [ ] The dispatch call is wrapped to swallow the target command's expected `SubjectCondition` conflict exception — never left to propagate
429
+ - [ ] If a private read model is used: it is private to this slice, and both `@EventHandling` methods share one stable processing-group name
430
+ - [ ] Every new event's type string is registered in `CqrsConfiguration`'s `eventTypeResolver()`
431
+ - [ ] Every GWT scenario in `specifications[]` maps to a test case in the test class, including a redelivery/conflict-swallowed case
432
+ - [ ] If `storylines[]` is present: every trigger-EVENT→target-COMMAND beat pair for this automation has a storyline test — or was deliberately skipped as untraceable
433
+ - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
434
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
@@ -0,0 +1,19 @@
1
+ # Feature Flag Patterns
2
+
3
+ House convention (not an OpenCQRS requirement), identical to `build-state-change`'s.
4
+
5
+ ```java
6
+ @Component
7
+ @ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
8
+ public class {AutomationName}Processor {
9
+ // ...
10
+ }
11
+ ```
12
+
13
+ Add to all config files when using `@ConditionalOnProperty`:
14
+
15
+ - `src/main/resources/application.properties` — `slices.{context}.automation.{slicename}.enabled=true`
16
+ - `src/test/resources/application.properties` — `slices.{context}.automation.{slicename}.enabled=false`
17
+
18
+ **Enable BOTH the automation and its target command's write slice** in any test exercising the
19
+ automation end-to-end — the target command handler won't run if its own slice's flag is off.
@@ -0,0 +1,62 @@
1
+ # Idempotent Command Dispatch — Why And How
2
+
3
+ ## The delivery guarantee you're actually working with
4
+
5
+ `EventHandlingProcessor` (the machinery behind every `@EventHandling` method, automations included)
6
+ guarantees **at-least-once** delivery, never exactly-once. Quoting its own documented contract: event
7
+ handler code "is assumed to be idempotent, in order to be repeatable for a specific event, in case of
8
+ errors." A crash, restart, or fail-over between a handler's side effect succeeding and its checkpoint
9
+ advancing means the same event is redelivered and the handler runs again. An automation's whole job is
10
+ a side effect with an external consequence (dispatching a command) — it cannot opt out of this
11
+ contract, so it must be written to tolerate running twice for the same trigger.
12
+
13
+ ## The mechanism that makes redelivery safe: the target command's own `SubjectCondition`
14
+
15
+ You do not need a separate idempotency table or a "have I seen this event id before" check. The target
16
+ command already has a `SubjectCondition` (see `build-state-change` Step 1), checked by `CommandRouter`
17
+ before the handler runs — reuse it:
18
+
19
+ | Target command's `SubjectCondition` | What a redelivered dispatch throws | What to do |
20
+ |---|---|---|
21
+ | `PRISTINE` (creation) | `CommandSubjectAlreadyExistsException` | Catch at the `commandRouter.send(...)` call site; do nothing — this is the expected "already handled" case |
22
+ | `EXISTS` | `CommandSubjectDoesNotExistException` | Catch the same way if a redelivery-caused re-check is expected to no-op harmlessly |
23
+ | `NONE` | nothing — no built-in check | Give the command a stable, derivable subject/business key so the *slice's own business rule* rejects a genuine duplicate instead; otherwise the automation double-executes silently |
24
+
25
+ ## Why "catch it, don't just log and continue elsewhere" matters
26
+
27
+ The failure mode if you get this wrong is **not** "this one event silently fails and gets skipped" —
28
+ it's much worse. Per `EventHandlingProcessor`'s own documented error-handling table, an escaping
29
+ `CqrsFrameworkException.NonTransientException` (which `CommandSubjectAlreadyExistsException` is)
30
+ **terminates that processing group's entire loop**. Every other event this group would otherwise still
31
+ be handling — including completely unrelated ones — stops being processed until something restarts the
32
+ processor. Swallowing the exception locally, right where `commandRouter.send(...)` is called, is what
33
+ keeps the rest of the group's event stream flowing.
34
+
35
+ Do **not**:
36
+ - catch `Exception` or `Throwable` broadly around the whole handler method — that would also hide
37
+ genuine bugs (a real mapping error, a real business-rule violation on the target command) as if they
38
+ were harmless redeliveries
39
+ - rely on logging the exception without catching it — an exception that's logged via, e.g., a
40
+ `@Transactional` rollback listener but still propagates out of the method still terminates the loop
41
+
42
+ Do:
43
+ - catch the *specific* exception type your target command's `SubjectCondition` implies, at the
44
+ `commandRouter.send(...)` call site only
45
+ - let every other exception type propagate normally — those represent real problems this project's
46
+ retry/backoff policy (`opencqrs.event-handling.groups.<name>.retry.*`) or an operator should see
47
+
48
+ ## Iterating over multiple dispatches
49
+
50
+ When one trigger event fans out to several `commandRouter.send(...)` calls (one per matching entry in a
51
+ private read model), wrap **each individual call**, not the loop as a whole — one entry's already-
52
+ handled conflict must not prevent the others from being attempted:
53
+
54
+ ```java
55
+ for (var entry : matchingEntries) {
56
+ try {
57
+ commandRouter.send(new {TargetCommand}Command(entry.id() /*, ... */));
58
+ } catch (CommandSubjectAlreadyExistsException e) {
59
+ // this entry was already dispatched on a prior attempt — continue with the rest
60
+ }
61
+ }
62
+ ```