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