@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.
- package/README.md +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
|
@@ -0,0 +1,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
|
+
```
|