@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,376 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-state-change
|
|
3
|
+
authors:
|
|
4
|
+
- Martin Dilger
|
|
5
|
+
description: >
|
|
6
|
+
Implement DCB-style write slices against the raw UmaDB Java client in this project's one
|
|
7
|
+
established pattern: Command record → mutable Decision class (a Query + an apply(Event) fold) →
|
|
8
|
+
@Component CommandHandler using DecisionModelLoader → InMemoryUmaDbClient-based unit test (no
|
|
9
|
+
Spring context, no Docker). Use when implementing a new write slice / command handler from a
|
|
10
|
+
slice.json event model in this project. There is exactly one supported style — do not offer
|
|
11
|
+
alternatives.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# UmaDB — Write Slice
|
|
15
|
+
|
|
16
|
+
One pattern only. Directory layout is flat — `src/main/java/.../slices/{context}/{slicename}/`, no
|
|
17
|
+
`write`/`read`/`automation` folder layer in between (only the shared `slices/{context}/events/`
|
|
18
|
+
folder sits alongside slice folders). Every step below is grounded in the `RegisterCustomer` slice
|
|
19
|
+
(single id field, test in `RegisterCustomerCommandHandlerTest`) and, for the compound-identifier
|
|
20
|
+
case in Step 1, the `SubscribeToCourse` slice (test in `SubscribeToCourseCommandHandlerTest`) —
|
|
21
|
+
both verified, compiled and passing under `mvn test` against
|
|
22
|
+
`io.github.domenicdev:umadb-java-client:0.7` and a real `umadb/umadb:0.7.5` server.
|
|
23
|
+
|
|
24
|
+
UmaDB itself has no annotation-driven modelling layer at all (no `@Command`/`@Event`/`@Aggregate`
|
|
25
|
+
the way Axon Framework has) — every convention below (the Decision class shape, the tag-string
|
|
26
|
+
format, the `DecisionModelLoader` read-decide-append loop) is this project's own, hand-rolled once
|
|
27
|
+
in the shared `eventstore` package and reused by every slice, not something UmaDB enforces itself.
|
|
28
|
+
|
|
29
|
+
## Step 0: Read the slice definition
|
|
30
|
+
|
|
31
|
+
Read `.build-kit/.slices/{context}/{slicename}/slice.json`. Extract, and use **only** what's there:
|
|
32
|
+
|
|
33
|
+
- `commands[].fields[]` → Command record fields, in order
|
|
34
|
+
- `events[].fields[]` → Event record fields, in order
|
|
35
|
+
- `specifications[]` (GWT scenarios) → one test method per scenario
|
|
36
|
+
- Which command field(s) have `idAttribute: true` — these become the tag(s) the command's Query and
|
|
37
|
+
the resulting event are both scoped to (see Step 1/Step 3)
|
|
38
|
+
- `storylines[]` (optional, may be absent) → narrated walkthroughs with ordered `elements[]`
|
|
39
|
+
"beats"; see Step 7b for how a COMMAND beat in one of these can add a supplementary test
|
|
40
|
+
|
|
41
|
+
Never invent a field, business rule, or event that isn't in slice.json.
|
|
42
|
+
|
|
43
|
+
## Step 0a: Determine `{basePackage}`
|
|
44
|
+
|
|
45
|
+
Every code example below is rooted at `{basePackage}.slices.{context}.{slicename}`. Resolve
|
|
46
|
+
`{basePackage}` as documented in `.build-kit/CLAUDE.md` — never hardcode `io.umadb.quickstart` (the
|
|
47
|
+
shipped quickstart scaffold's package) or any other specific package.
|
|
48
|
+
|
|
49
|
+
## Step 1: Command
|
|
50
|
+
|
|
51
|
+
**Exactly one field has `idAttribute: true`** — no annotation needed (UmaDB has nothing like
|
|
52
|
+
`@TargetEntityId`); the field is just referenced directly wherever the id is needed:
|
|
53
|
+
|
|
54
|
+
```java
|
|
55
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
56
|
+
|
|
57
|
+
public record {SliceName}Command(String field1, String idField) {}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Two or more fields have `idAttribute: true`** — combine them into a compound id record, with a
|
|
61
|
+
convenience method building it from the command. Verified against `SubscribeToCourseCommand`
|
|
62
|
+
(`email` + `courseId` both `idAttribute: true`):
|
|
63
|
+
|
|
64
|
+
```java
|
|
65
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
66
|
+
|
|
67
|
+
public record {SliceName}Id(String field1, String field2) {}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```java
|
|
71
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
72
|
+
|
|
73
|
+
public record {SliceName}Command(String field1, String field2) {
|
|
74
|
+
|
|
75
|
+
public {SliceName}Id identifier() {
|
|
76
|
+
return new {SliceName}Id(field1, field2);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The Decision's `relevantEvents(...)` (Step 3) and the CommandHandler (Step 4) both take this
|
|
82
|
+
`{SliceName}Id` wherever a single id string would otherwise appear.
|
|
83
|
+
|
|
84
|
+
## Step 2: Event — only if it doesn't already exist
|
|
85
|
+
|
|
86
|
+
Check `src/main/java/.../{context}/events/` first; add to the existing sealed interface rather than
|
|
87
|
+
creating a duplicate.
|
|
88
|
+
|
|
89
|
+
```java
|
|
90
|
+
package {basePackage}.slices.{context}.events;
|
|
91
|
+
|
|
92
|
+
public record {EventName}(String field1, String idField) implements {Context}Event {
|
|
93
|
+
|
|
94
|
+
public static final String TYPE = "{Context}.{EventName}";
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`TYPE` is this project's own convention for what goes into UmaDB's `Event.type()` — UmaDB has no
|
|
99
|
+
`@Event(namespace, name, version)` annotation of its own. Add the tag constant to the context's
|
|
100
|
+
`EventTags` class if it isn't already there:
|
|
101
|
+
|
|
102
|
+
```java
|
|
103
|
+
public static final String {TAG_CONSTANT} = "idField";
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Step 3: Decision class
|
|
107
|
+
|
|
108
|
+
Package-private, mutable field(s) per fact a `specifications[]` scenario actually branches on —
|
|
109
|
+
**not** an immutable state record with free-standing decide/evolve functions.
|
|
110
|
+
|
|
111
|
+
**Derive the field(s) from this slice's `specifications[]` (Step 0), not from the event's shape.**
|
|
112
|
+
Each GWT scenario's `given`/`then` pair states the one decision the command handler must make and
|
|
113
|
+
the prior fact that decision depends on — that fact is the field. A "given no prior activity" /
|
|
114
|
+
"given already {X}" pair means one boolean flag for {X}; a scenario that discriminates on a value
|
|
115
|
+
(not just presence/absence) means a value field holding that value, not a boolean.
|
|
116
|
+
|
|
117
|
+
```java
|
|
118
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
119
|
+
|
|
120
|
+
import io.umadb.client.Event;
|
|
121
|
+
import io.umadb.client.Query;
|
|
122
|
+
import io.umadb.client.QueryItem;
|
|
123
|
+
import {basePackage}.slices.{context}.events.{EventName};
|
|
124
|
+
import {basePackage}.slices.{context}.events.EventTags;
|
|
125
|
+
|
|
126
|
+
import java.util.List;
|
|
127
|
+
|
|
128
|
+
class {SliceName}Decision {
|
|
129
|
+
|
|
130
|
+
boolean <ruleFlag>;
|
|
131
|
+
|
|
132
|
+
static Query relevantEvents(String idField) {
|
|
133
|
+
return Query.of(QueryItem.of(
|
|
134
|
+
List.of({EventName}.TYPE),
|
|
135
|
+
List.of(EventTags.tag(EventTags.{TAG_CONSTANT}, idField))
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
void apply(Event event) {
|
|
140
|
+
if (event.type().equals({EventName}.TYPE)) {
|
|
141
|
+
this.<ruleFlag> = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`apply` takes the raw `io.umadb.client.Event`, not a decoded domain object — most decisions only
|
|
148
|
+
need to know a matching event of a given `type()` existed (a boolean flag). Only decode the payload
|
|
149
|
+
(`EventCodec.fromEvent(event, {EventName}.class)`) when a scenario needs an actual field value, not
|
|
150
|
+
just presence/absence.
|
|
151
|
+
|
|
152
|
+
**Tag each event type by what THIS decision actually needs checked for it — not uniformly.** When
|
|
153
|
+
multiple event types feed one decision (a `Query` with several `QueryItem`s, OR'd together), each
|
|
154
|
+
item gets its own tag set, chosen per the specific invariant that event type is being loaded to
|
|
155
|
+
verify — this is context-dependent, not a fixed property of the event type itself. Verified worked
|
|
156
|
+
example — `SubscribeToCourseDecision`, id is `SubscriptionId(email, courseId)`, two rules, two
|
|
157
|
+
different tag scopes on two different event types:
|
|
158
|
+
|
|
159
|
+
```java
|
|
160
|
+
static Query relevantEvents(SubscriptionId id) {
|
|
161
|
+
return Query.of(List.of(
|
|
162
|
+
// "is this customer registered at all" — scoped to email only
|
|
163
|
+
QueryItem.of(List.of(CustomerRegistered.TYPE), List.of(EventTags.tag(EventTags.EMAIL, id.email()))),
|
|
164
|
+
// "did this customer already subscribe to THIS course" — scoped to email + courseId
|
|
165
|
+
QueryItem.of(List.of(SubscribedToCourse.TYPE), List.of(
|
|
166
|
+
EventTags.tag(EventTags.EMAIL, id.email()),
|
|
167
|
+
EventTags.tag(EventTags.COURSE_ID, id.courseId())))
|
|
168
|
+
));
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`CustomerRegistered` only needs the `email` tag — "is this customer registered" doesn't involve a
|
|
173
|
+
course. `SubscribedToCourse` needs **both** `email` and `courseId` together — the rule is "already
|
|
174
|
+
subscribed to *this* course", not "subscribed to any course". Getting the tag scope wrong doesn't
|
|
175
|
+
fail loudly: too wide silently pulls in unrelated events, too narrow silently drops events the rule
|
|
176
|
+
needed. See [references/umadb-query-patterns.md](references/umadb-query-patterns.md) for the full
|
|
177
|
+
`Query`/`QueryItem` matching rules this relies on.
|
|
178
|
+
|
|
179
|
+
## Step 4: Command handler
|
|
180
|
+
|
|
181
|
+
Uses the shared `DecisionModelLoader` (in `{basePackage}.eventstore`) — never call
|
|
182
|
+
`UmaDbClient.handle(ReadRequest...)`/`.handle(AppendRequest...)` directly from a command handler;
|
|
183
|
+
the loader is this project's one shared read-decide-append loop (see its Javadoc for why).
|
|
184
|
+
|
|
185
|
+
```java
|
|
186
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
187
|
+
|
|
188
|
+
import {basePackage}.eventstore.DecisionModelLoader;
|
|
189
|
+
import {basePackage}.slices.{context}.events.{EventName};
|
|
190
|
+
import {basePackage}.slices.{context}.events.EventTags;
|
|
191
|
+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
192
|
+
import org.springframework.stereotype.Component;
|
|
193
|
+
|
|
194
|
+
import java.util.List;
|
|
195
|
+
|
|
196
|
+
@Component
|
|
197
|
+
@ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
|
|
198
|
+
public class {SliceName}CommandHandler {
|
|
199
|
+
|
|
200
|
+
private final DecisionModelLoader loader;
|
|
201
|
+
|
|
202
|
+
public {SliceName}CommandHandler(DecisionModelLoader loader) {
|
|
203
|
+
this.loader = loader;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public void handle({SliceName}Command command) {
|
|
207
|
+
var query = {SliceName}Decision.relevantEvents(command.idField());
|
|
208
|
+
var loaded = loader.load(query, {SliceName}Decision::new, {SliceName}Decision::apply);
|
|
209
|
+
|
|
210
|
+
if (loaded.decision().<ruleFlag>) {
|
|
211
|
+
throw new IllegalStateException("...");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
loader.append(
|
|
215
|
+
new {EventName}(command.field1(), command.idField()),
|
|
216
|
+
{EventName}.TYPE,
|
|
217
|
+
List.of(EventTags.tag(EventTags.{TAG_CONSTANT}, command.idField())),
|
|
218
|
+
query,
|
|
219
|
+
loaded.lastPosition()
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`loader.append` throws `OptimisticConcurrencyException` (from `{basePackage}.eventstore`) if a
|
|
226
|
+
conflicting event was appended concurrently between this handler's `load` and `append` calls — this
|
|
227
|
+
IS the consistency boundary, not just a convenience; see
|
|
228
|
+
[references/umadb-query-patterns.md](references/umadb-query-patterns.md).
|
|
229
|
+
|
|
230
|
+
## Step 5: REST endpoint — only if slice.json shows an inbound `SCREEN` dependency on the command
|
|
231
|
+
|
|
232
|
+
```java
|
|
233
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
234
|
+
|
|
235
|
+
import {basePackage}.eventstore.OptimisticConcurrencyException;
|
|
236
|
+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
237
|
+
import org.springframework.http.ResponseEntity;
|
|
238
|
+
import org.springframework.web.bind.annotation.PostMapping;
|
|
239
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
240
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
241
|
+
|
|
242
|
+
@RestController
|
|
243
|
+
@ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
|
|
244
|
+
public class {SliceName}RestController {
|
|
245
|
+
|
|
246
|
+
private final {SliceName}CommandHandler commandHandler;
|
|
247
|
+
|
|
248
|
+
public {SliceName}RestController({SliceName}CommandHandler commandHandler) {
|
|
249
|
+
this.commandHandler = commandHandler;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@PostMapping("/api/{context}/{resource}")
|
|
253
|
+
public ResponseEntity<Void> handle(@RequestBody {SliceName}RequestBody body) {
|
|
254
|
+
try {
|
|
255
|
+
commandHandler.handle(new {SliceName}Command(body.field1(), body.idField()));
|
|
256
|
+
return ResponseEntity.ok().build();
|
|
257
|
+
} catch (IllegalStateException | OptimisticConcurrencyException e) {
|
|
258
|
+
return ResponseEntity.badRequest().build();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
public record {SliceName}RequestBody(String field1, String idField) {}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
If the only inbound dependency is another slice's `AUTOMATION`, skip this step — an automation
|
|
267
|
+
calls the command handler directly (see `build-automation`), it doesn't need HTTP.
|
|
268
|
+
|
|
269
|
+
This project uses plain Spring MVC (`spring-boot-starter-web`), **not** WebFlux — `UmaDbClient`'s
|
|
270
|
+
core API is blocking (returns `Iterator`, not a reactive `Publisher`), so a reactive controller
|
|
271
|
+
would only hide the blocking calls behind a `Mono`, not remove them. Plain `ResponseEntity<...>`,
|
|
272
|
+
not `Mono<ResponseEntity<...>>`.
|
|
273
|
+
|
|
274
|
+
## Step 6: Feature flag
|
|
275
|
+
|
|
276
|
+
Every slice component (command handler, REST controller) gets `@ConditionalOnProperty(prefix =
|
|
277
|
+
"slices.{context}.write", name = "{slicename}.enabled")` — the Decision class does not need it.
|
|
278
|
+
Wire the flag in both places:
|
|
279
|
+
|
|
280
|
+
- `src/main/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=true`
|
|
281
|
+
- `src/test/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=false`
|
|
282
|
+
|
|
283
|
+
See [references/feature-flag-patterns.md](references/feature-flag-patterns.md) for the full
|
|
284
|
+
pattern. This flag is irrelevant to the Step 7 test below — that test never boots Spring, so
|
|
285
|
+
`@ConditionalOnProperty` never runs.
|
|
286
|
+
|
|
287
|
+
## Step 7: Test — `InMemoryUmaDbClient`, no Spring context, no Docker
|
|
288
|
+
|
|
289
|
+
UmaDB ships no test-fixture library (unlike Axon Framework's `axon-test`) — this project's
|
|
290
|
+
`InMemoryUmaDbClient` (`src/test/java/.../testsupport/`, already in the root scaffold) is a
|
|
291
|
+
from-scratch fake `UmaDbClient` implementation for exactly this. Write given/when/then as plain
|
|
292
|
+
JUnit + AssertJ, one test method per GWT scenario in slice.json's `specifications[]`:
|
|
293
|
+
|
|
294
|
+
```java
|
|
295
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
296
|
+
|
|
297
|
+
import {basePackage}.eventstore.DecisionModelLoader;
|
|
298
|
+
import {basePackage}.testsupport.InMemoryUmaDbClient;
|
|
299
|
+
import org.junit.jupiter.api.BeforeEach;
|
|
300
|
+
import org.junit.jupiter.api.DisplayName;
|
|
301
|
+
import org.junit.jupiter.api.Test;
|
|
302
|
+
|
|
303
|
+
import static org.assertj.core.api.Assertions.assertThatNoException;
|
|
304
|
+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
305
|
+
|
|
306
|
+
class {SliceName}CommandHandlerTest {
|
|
307
|
+
|
|
308
|
+
private {SliceName}CommandHandler commandHandler;
|
|
309
|
+
|
|
310
|
+
@BeforeEach
|
|
311
|
+
void setUp() {
|
|
312
|
+
var loader = new DecisionModelLoader(new InMemoryUmaDbClient());
|
|
313
|
+
commandHandler = new {SliceName}CommandHandler(loader);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@Test
|
|
317
|
+
@DisplayName("given no prior activity, when {sliceName}, then succeeds")
|
|
318
|
+
void happyPath() {
|
|
319
|
+
assertThatNoException().isThrownBy(
|
|
320
|
+
() -> commandHandler.handle(new {SliceName}Command("value1", "id-1")));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@Test
|
|
324
|
+
@DisplayName("given <rule already true>, when {sliceName}, then rejected")
|
|
325
|
+
void ruleViolation() {
|
|
326
|
+
commandHandler.handle(new {SliceName}Command("value1", "id-1"));
|
|
327
|
+
|
|
328
|
+
assertThatThrownBy(() -> commandHandler.handle(new {SliceName}Command("value2", "id-1")))
|
|
329
|
+
.isInstanceOf(IllegalStateException.class);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
When a slice's decision needs prior events from ANOTHER slice's command handler first (like
|
|
335
|
+
`SubscribeToCourse` needing a `RegisterCustomer` to have happened), construct both command handlers
|
|
336
|
+
from the SAME `DecisionModelLoader`/`InMemoryUmaDbClient` instance and call the prerequisite handler
|
|
337
|
+
directly in the test — see `SubscribeToCourseCommandHandlerTest`. Don't hand-craft raw `Event`
|
|
338
|
+
objects as a shortcut; go through the real command handler so the test also exercises that
|
|
339
|
+
handler's own tagging.
|
|
340
|
+
|
|
341
|
+
Full cheat sheet — `Query`/`QueryItem` matching, `AppendCondition` semantics, idempotent-append
|
|
342
|
+
behaviour, why `subscribe()` isn't faked: see
|
|
343
|
+
[references/umadb-query-patterns.md](references/umadb-query-patterns.md).
|
|
344
|
+
|
|
345
|
+
## Step 7b: Storyline-Derived Tests (Optional)
|
|
346
|
+
|
|
347
|
+
`slice.json` may carry a `storylines[]` array alongside `specifications[]` — narrated walkthroughs
|
|
348
|
+
where an ordered sequence of `elements[]` "beats" (EVENT/COMMAND/READMODEL/...) shows one use case
|
|
349
|
+
end to end. This is a secondary, supplementary source — `specifications[]` (Step 7) stays the
|
|
350
|
+
primary and default source of test methods. Most slices have no `storylines[]`; skip this step
|
|
351
|
+
silently when there's nothing relevant.
|
|
352
|
+
|
|
353
|
+
Find beats whose `type` is `COMMAND`. For each such beat: `given` = the cumulative ordered `EVENT`
|
|
354
|
+
beats preceding it in the storyline (dispatched through their own real command handlers, per Step
|
|
355
|
+
7's guidance), `when` = the command built from the beat's `fields`, `then` = the `EVENT` beat(s)
|
|
356
|
+
immediately following it — asserted by reading the event back via a second `client.handle(ReadRequest...)`
|
|
357
|
+
call, or more simply by asserting the command handler didn't throw and trusting Step 7's own
|
|
358
|
+
per-field tests to have already covered the event's shape.
|
|
359
|
+
|
|
360
|
+
Do **not** try to also assert read-model state in this same test — that half belongs to
|
|
361
|
+
`build-state-view`'s own storyline step, since this test never touches a projector. If the beat
|
|
362
|
+
immediately after the command isn't an EVENT, don't force a test — leave it undocumented rather than
|
|
363
|
+
fabricating an assertion.
|
|
364
|
+
|
|
365
|
+
## Final Verification
|
|
366
|
+
|
|
367
|
+
Before considering the slice done:
|
|
368
|
+
|
|
369
|
+
- [ ] Every field in slice.json's `commands[]` is in the Command record — no invented fields, none missing
|
|
370
|
+
- [ ] Every field in slice.json's `events[]` is in the Event record — no invented fields, none missing
|
|
371
|
+
- [ ] Every `specifications[]` scenario has a corresponding test method
|
|
372
|
+
- [ ] If `storylines[]` is present: every COMMAND beat for this slice's command has a storyline test — or was deliberately skipped as untraceable
|
|
373
|
+
- [ ] No business rule exists in the handler that isn't traceable to slice.json's `description`/`comments`
|
|
374
|
+
- [ ] The Decision's `relevantEvents` query and the append's consistency-boundary query are the SAME query
|
|
375
|
+
- [ ] `mvn compile -q`, then run the slice's own tests only
|
|
376
|
+
- [ ] If checks pass, commit with `feat: {Slice Name}` and set slice status to `Done`
|
package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Feature Flag Patterns
|
|
2
|
+
|
|
3
|
+
This project's one supported approach: `@ConditionalOnProperty` on the command handler and REST
|
|
4
|
+
controller (**not** on the Decision class - verified against the `RegisterCustomer` and
|
|
5
|
+
`SubscribeToCourse` slices, neither of which puts the annotation on its decision-model class).
|
|
6
|
+
Read slices (`build-state-view`) are never feature-flagged - only write and automation slices are.
|
|
7
|
+
Examples use a generic `Ordering` bounded context.
|
|
8
|
+
|
|
9
|
+
## Annotation on slice components
|
|
10
|
+
|
|
11
|
+
```java
|
|
12
|
+
// Command handler
|
|
13
|
+
@ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
|
|
14
|
+
@Component
|
|
15
|
+
public class PlaceOrderCommandHandler { ... }
|
|
16
|
+
|
|
17
|
+
// REST controller (if applicable)
|
|
18
|
+
@ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
|
|
19
|
+
@RestController
|
|
20
|
+
public class PlaceOrderRestController { ... }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## `application.properties` (main — enable by default)
|
|
24
|
+
|
|
25
|
+
```properties
|
|
26
|
+
slices.ordering.write.placeorder.enabled=true
|
|
27
|
+
slices.ordering.automation.notifycustomeronorder.enabled=true
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## `application.properties` (test — disable by default)
|
|
31
|
+
|
|
32
|
+
```properties
|
|
33
|
+
slices.ordering.write.placeorder.enabled=false
|
|
34
|
+
slices.ordering.automation.notifycustomeronorder.enabled=false
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This only matters for tests that boot a Spring context (`@SpringBootTest`). The
|
|
38
|
+
`InMemoryUmaDbClient` unit-test pattern in
|
|
39
|
+
[umadb-query-patterns.md](umadb-query-patterns.md) never boots Spring, so these properties don't
|
|
40
|
+
affect it either way - a Testcontainers-based end-to-end test (see `build-state-view`'s reference)
|
|
41
|
+
overrides them back to `true` for itself via `@DynamicPropertySource`, since it's the one test that
|
|
42
|
+
does need the real beans wired.
|
package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# UmaDB Query, AppendCondition & Test Fake — Cheat Sheet
|
|
2
|
+
|
|
3
|
+
Verified against `io.github.domenicdev:umadb-java-client:0.7` (package `io.umadb.client`) and a real
|
|
4
|
+
`umadb/umadb:0.7.5` server (Testcontainers), and against the `RegisterCustomer`/`SubscribeToCourse`
|
|
5
|
+
slices this pattern was built from.
|
|
6
|
+
|
|
7
|
+
## `Query` / `QueryItem` matching semantics
|
|
8
|
+
|
|
9
|
+
A `Query` is a list of `QueryItem`s. **Items are OR'd together** - an event matches the query if it
|
|
10
|
+
matches ANY item. Within one item:
|
|
11
|
+
|
|
12
|
+
- `types` (OR): event matches if `types` is empty, or the event's `type()` is one of them
|
|
13
|
+
- `tags` (AND): event matches if `tags` is empty, or the event's `tags()` contains ALL of them
|
|
14
|
+
- an item matches only if BOTH the type condition and the tag condition match
|
|
15
|
+
|
|
16
|
+
An **empty `Query`** (`Query.empty()`, or `Query.of(List.of())`) matches every event - never pass
|
|
17
|
+
`null`/empty expecting "match nothing".
|
|
18
|
+
|
|
19
|
+
```java
|
|
20
|
+
// "CustomerRegistered for this email" OR "SubscribedToCourse for this email+courseId"
|
|
21
|
+
Query.of(List.of(
|
|
22
|
+
QueryItem.of(List.of(CustomerRegistered.TYPE), List.of(EventTags.tag(EventTags.EMAIL, email))),
|
|
23
|
+
QueryItem.of(List.of(SubscribedToCourse.TYPE), List.of(
|
|
24
|
+
EventTags.tag(EventTags.EMAIL, email), EventTags.tag(EventTags.COURSE_ID, courseId)))
|
|
25
|
+
));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Tag scope is a property of the rule, not the event type.** The same event type can legitimately
|
|
29
|
+
need a wider or narrower tag set depending on which decision is consuming it - see
|
|
30
|
+
`SubscribeToCourseDecision.relevantEvents` (`build-state-change` Step 3): `CustomerRegistered` is
|
|
31
|
+
scoped to `email` alone (the rule is "registered at all"), but `SubscribedToCourse` is scoped to
|
|
32
|
+
`email` AND `courseId` together (the rule is "subscribed to THIS course", not "subscribed to any
|
|
33
|
+
course"). Getting this wrong doesn't fail loudly - too wide silently pulls in unrelated events, too
|
|
34
|
+
narrow silently drops events the rule needed.
|
|
35
|
+
|
|
36
|
+
## `AppendCondition` - the actual consistency boundary
|
|
37
|
+
|
|
38
|
+
```java
|
|
39
|
+
AppendCondition.failIfExistsAfter(query, lastPosition)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Fails the append (throws `UmaDbException.IntegrityException`) if any event matching `query` exists
|
|
43
|
+
at a position **strictly greater than** `lastPosition` - i.e. appeared after the position this
|
|
44
|
+
command's `DecisionModelLoader.load` call observed via `getHeadPosition()`. Always pass the SAME
|
|
45
|
+
query used to load the decision - a narrower/different query here silently weakens the consistency
|
|
46
|
+
guarantee (a conflicting write could slip through undetected).
|
|
47
|
+
|
|
48
|
+
`DecisionModelLoader.append` already wraps this and translates the exception into
|
|
49
|
+
`OptimisticConcurrencyException` - don't call `AppendCondition`/`client.handle(AppendRequest...)`
|
|
50
|
+
directly from a command handler; use the loader.
|
|
51
|
+
|
|
52
|
+
**Idempotency**: appending an `Event` whose `id` (a `UUID`) already exists in the store is a no-op
|
|
53
|
+
that returns the existing position, regardless of any condition - verified against the client's own
|
|
54
|
+
`UmaDbClientTest#testIdempotentAppendReturnsSamePosition`. `Event.of(...)` generates a random id per
|
|
55
|
+
call, so this only matters if you're deliberately re-sending the exact same `Event` instance/id for
|
|
56
|
+
retry safety - not something a normal command handler needs to think about.
|
|
57
|
+
|
|
58
|
+
## Testing: `InMemoryUmaDbClient`, not a fixture DSL
|
|
59
|
+
|
|
60
|
+
UmaDB has no test-fixture library (unlike `axon-test`'s `AxonTestFixture`). This project's
|
|
61
|
+
`src/test/java/.../testsupport/InMemoryUmaDbClient` (shipped in the root scaffold) is a from-scratch
|
|
62
|
+
`UmaDbClient` implementation replicating the matching/condition/idempotency rules above, fast and
|
|
63
|
+
container-free. Write given/when/then as plain JUnit, not through a fixture DSL:
|
|
64
|
+
|
|
65
|
+
```java
|
|
66
|
+
var loader = new DecisionModelLoader(new InMemoryUmaDbClient());
|
|
67
|
+
var commandHandler = new RegisterCustomerCommandHandler(loader);
|
|
68
|
+
|
|
69
|
+
// given no prior activity, when registerCustomer, then succeeds
|
|
70
|
+
assertThatNoException().isThrownBy(() -> commandHandler.handle(new RegisterCustomerCommand(...)));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`InMemoryUmaDbClient.subscribe(...)` deliberately throws `UnsupportedOperationException` - no
|
|
74
|
+
command-handler test needs it (that's `build-state-view`/`build-automation`'s territory, tested via
|
|
75
|
+
a direct `on(event)` call instead - see those skills). The one thing that genuinely needs a live
|
|
76
|
+
subscription (`EventDispatcher` itself) is proven against a REAL `umadb/umadb` server via
|
|
77
|
+
Testcontainers instead - see `build-state-view`'s reference integration test. Don't try to make the
|
|
78
|
+
fake support subscription; use the real container for that one case.
|