eva4j 1.0.16 → 1.0.17
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/AGENTS.md +218 -5
- package/DOMAIN_YAML_GUIDE.md +185 -2
- package/FUTURE_FEATURES.md +33 -52
- package/docs/commands/EVALUATE_SYSTEM.md +18 -2
- package/examples/domain-events.yaml +26 -0
- package/examples/domain-read-models.yaml +113 -0
- package/package.json +1 -1
- package/read-model-spec.md +664 -0
- package/src/agents/design-reviewer.agent.md +3 -0
- package/src/commands/generate-entities.js +254 -10
- package/src/commands/generate-http-exchange.js +3 -0
- package/src/commands/generate-kafka-event.js +3 -0
- package/src/commands/generate-kafka-listener.js +3 -0
- package/src/commands/generate-record.js +2 -2
- package/src/commands/generate-resource.js +4 -1
- package/src/commands/generate-temporal-activity.js +4 -1
- package/src/commands/generate-temporal-flow.js +4 -1
- package/src/commands/generate-usecase.js +4 -1
- package/src/skills/build-system-yaml/SKILL.md +122 -1
- package/src/skills/build-system-yaml/references/domain-yaml-spec.md +205 -24
- package/src/skills/build-system-yaml/references/module-spec.md +33 -1
- package/src/skills/build-system-yaml/references/system-yaml-spec.md +36 -0
- package/src/utils/config-manager.js +4 -2
- package/src/utils/domain-validator.js +230 -14
- package/src/utils/naming.js +10 -0
- package/src/utils/validator.js +3 -1
- package/src/utils/yaml-to-entity.js +272 -3
- package/templates/aggregate/AggregateRepository.java.ejs +4 -0
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +8 -0
- package/templates/aggregate/AggregateRoot.java.ejs +38 -4
- package/templates/aggregate/JpaAggregateRoot.java.ejs +2 -2
- package/templates/crud/DeleteCommandHandler.java.ejs +19 -1
- package/templates/crud/UpdateCommandHandler.java.ejs +53 -2
- package/templates/read-model/ReadModelDomain.java.ejs +46 -0
- package/templates/read-model/ReadModelJpa.java.ejs +58 -0
- package/templates/read-model/ReadModelJpaRepository.java.ejs +11 -0
- package/templates/read-model/ReadModelKafkaListener.java.ejs +64 -0
- package/templates/read-model/ReadModelRepository.java.ejs +42 -0
- package/templates/read-model/ReadModelRepositoryImpl.java.ejs +81 -0
- package/templates/read-model/ReadModelSyncHandler.java.ejs +52 -0
- package/test-c2010.js +49 -0
- package/test-update-compat.js +109 -0
- package/test-update-lifecycle.js +121 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
# Local Read Model Convention — Specification
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
This document defines the standard convention for implementing the **Local Read Model** pattern (also known as Materialized View or Event-Driven Cache) in eva4j projects.
|
|
6
|
+
|
|
7
|
+
A Local Read Model is a **projection of data owned by another bounded context**, maintained via domain events. It eliminates synchronous (HTTP) dependencies between modules, improving autonomy, resilience, and performance.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## When to Use
|
|
12
|
+
|
|
13
|
+
Use a Local Read Model when:
|
|
14
|
+
|
|
15
|
+
- A module needs data from another module to **validate preconditions** or **enrich entities** at creation time
|
|
16
|
+
- The data is read-only from the consumer's perspective (the source module owns mutations)
|
|
17
|
+
- Eventual consistency (milliseconds of delay) is acceptable for the business domain
|
|
18
|
+
- You want to eliminate `ports:` (sync HTTP calls) to prepare for microservice extraction (`eva detach`)
|
|
19
|
+
|
|
20
|
+
Do **NOT** use a Local Read Model when:
|
|
21
|
+
|
|
22
|
+
- You need **strong consistency** (e.g., financial transactions that require real-time balance checks)
|
|
23
|
+
- The source data changes so frequently that the event volume becomes a bottleneck
|
|
24
|
+
- A single sync call at an infrequent operation is simpler and sufficient
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## YAML Schema — `readModels` Section
|
|
29
|
+
|
|
30
|
+
The `readModels` section is declared in `{module}.yaml` as a **sibling** of `aggregates`, `listeners`, and `ports`.
|
|
31
|
+
|
|
32
|
+
### Location in File
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
# {module}.yaml
|
|
36
|
+
|
|
37
|
+
aggregates:
|
|
38
|
+
- name: ... # Domain aggregates (business entities)
|
|
39
|
+
|
|
40
|
+
readModels: # ← NEW SECTION — Local Read Models
|
|
41
|
+
- name: ...
|
|
42
|
+
|
|
43
|
+
listeners:
|
|
44
|
+
- event: ... # External event consumers (non-cache)
|
|
45
|
+
|
|
46
|
+
# ports: # ← REMOVED when replaced by readModels
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Full Schema
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
readModels:
|
|
53
|
+
- name: ProductReadModel # PascalCase + "ReadModel" suffix (REQUIRED)
|
|
54
|
+
source: # Origin traceability (REQUIRED)
|
|
55
|
+
module: products # Source module name (kebab-case)
|
|
56
|
+
aggregate: Product # Source aggregate name (PascalCase)
|
|
57
|
+
tableName: rm_products # Database table name (REQUIRED, must use rm_ prefix)
|
|
58
|
+
fields: # Projected fields — subset of source (REQUIRED, min 1)
|
|
59
|
+
- name: id
|
|
60
|
+
type: String
|
|
61
|
+
- name: name
|
|
62
|
+
type: String
|
|
63
|
+
- name: price
|
|
64
|
+
type: BigDecimal
|
|
65
|
+
- name: status
|
|
66
|
+
type: String
|
|
67
|
+
syncedBy: # Events that maintain this table (REQUIRED, min 1)
|
|
68
|
+
- event: ProductCreatedEvent # Event name (PascalCase + Event suffix)
|
|
69
|
+
action: UPSERT # Sync action: UPSERT | DELETE | SOFT_DELETE
|
|
70
|
+
- event: ProductUpdatedEvent
|
|
71
|
+
action: UPSERT
|
|
72
|
+
- event: ProductDeactivatedEvent
|
|
73
|
+
action: UPSERT
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Property Reference
|
|
77
|
+
|
|
78
|
+
| Property | Type | Required | Description |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `name` | String | YES | Read model name. MUST end with `ReadModel` suffix. PascalCase. |
|
|
81
|
+
| `source` | Object | YES | Traceability to the owning module and aggregate. |
|
|
82
|
+
| `source.module` | String | YES | Module that owns the source data. Kebab-case. |
|
|
83
|
+
| `source.aggregate` | String | YES | Aggregate in the source module. PascalCase. |
|
|
84
|
+
| `tableName` | String | YES | Database table name. MUST use `rm_` prefix. Snake_case. |
|
|
85
|
+
| `fields` | Array | YES | Fields to project. Must include `id`. Subset of source aggregate fields. |
|
|
86
|
+
| `fields[].name` | String | YES | Field name. CamelCase. |
|
|
87
|
+
| `fields[].type` | String | YES | Java type (String, BigDecimal, Long, Integer, Boolean, LocalDateTime, etc.) |
|
|
88
|
+
| `syncedBy` | Array | YES | Events that trigger sync operations on this read model. |
|
|
89
|
+
| `syncedBy[].event` | String | YES | Event name. PascalCase, must end with `Event`. |
|
|
90
|
+
| `syncedBy[].action` | String | YES | One of: `UPSERT`, `DELETE`, `SOFT_DELETE`. |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Sync Actions
|
|
95
|
+
|
|
96
|
+
Only three actions are supported — keep it simple:
|
|
97
|
+
|
|
98
|
+
| Action | Meaning | SQL Equivalent | When to Use |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `UPSERT` | Insert if new, update if exists | `INSERT ... ON CONFLICT (id) DO UPDATE` | Creation, updates, status changes |
|
|
101
|
+
| `DELETE` | Remove the record permanently | `DELETE FROM rm_x WHERE id = ?` | Hard deletes in source module |
|
|
102
|
+
| `SOFT_DELETE` | Mark as inactive with timestamp | `UPDATE rm_x SET deleted_at = NOW() WHERE id = ?` | Source uses soft delete pattern |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Naming Conventions
|
|
107
|
+
|
|
108
|
+
### Database Tables
|
|
109
|
+
|
|
110
|
+
| Element | Convention | Example |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| Table prefix | `rm_` (Read Model) | `rm_products` |
|
|
113
|
+
| Table name body | Source module name, snake_case | `rm_customers` |
|
|
114
|
+
| Uniqueness | Prefix + source module ensures no collision | `rm_products` in orders ≠ `rm_products` in deliveries (different DBs in microservice mode) |
|
|
115
|
+
|
|
116
|
+
Visual identification in database:
|
|
117
|
+
```
|
|
118
|
+
myapp_db=# \dt
|
|
119
|
+
Schema | Name | Type
|
|
120
|
+
--------+-----------------+-------
|
|
121
|
+
public | orders | table ← domain
|
|
122
|
+
public | order_items | table ← domain
|
|
123
|
+
public | rm_products | table ← read model (projection from products)
|
|
124
|
+
public | rm_customers | table ← read model (projection from customers)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Java Classes
|
|
128
|
+
|
|
129
|
+
| Artifact | Convention | Example | Package |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| JPA Entity | `{Name}Jpa` | `ProductReadModelJpa` | `infrastructure/database/entities/` |
|
|
132
|
+
| JPA Repository | `{Name}JpaRepository` | `ProductReadModelJpaRepository` | `infrastructure/database/repositories/` |
|
|
133
|
+
| Domain Interface | `{Name}Repository` | `ProductReadModelRepository` | `domain/repositories/` |
|
|
134
|
+
| Repository Impl | `{Name}RepositoryImpl` | `ProductReadModelRepositoryImpl` | `infrastructure/database/repositories/` |
|
|
135
|
+
| Sync Handler | `Sync{Source}ReadModelHandler` | `SyncProductReadModelHandler` | `application/usecases/` |
|
|
136
|
+
| Sync Command | `Sync{Source}ReadModelCommand` | `SyncProductReadModelCommand` | `application/commands/` |
|
|
137
|
+
|
|
138
|
+
### File Structure in Module
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
orders/
|
|
142
|
+
├── domain/
|
|
143
|
+
│ ├── models/
|
|
144
|
+
│ │ ├── entities/
|
|
145
|
+
│ │ │ └── Order.java ← Domain entity
|
|
146
|
+
│ │ └── readmodels/ ← NEW directory
|
|
147
|
+
│ │ ├── ProductReadModel.java ← Domain read model (record or simple class)
|
|
148
|
+
│ │ └── CustomerReadModel.java
|
|
149
|
+
│ └── repositories/
|
|
150
|
+
│ ├── OrderRepository.java ← Domain repository
|
|
151
|
+
│ ├── ProductReadModelRepository.java ← Read model repository interface
|
|
152
|
+
│ └── CustomerReadModelRepository.java
|
|
153
|
+
├── application/
|
|
154
|
+
│ ├── commands/
|
|
155
|
+
│ │ └── SyncProductReadModelCommand.java ← Sync command
|
|
156
|
+
│ └── usecases/
|
|
157
|
+
│ └── SyncProductReadModelHandler.java ← Sync handler (one per read model)
|
|
158
|
+
└── infrastructure/
|
|
159
|
+
└── database/
|
|
160
|
+
├── entities/
|
|
161
|
+
│ ├── OrderJpa.java ← Domain JPA entity
|
|
162
|
+
│ ├── ProductReadModelJpa.java ← Read model JPA entity
|
|
163
|
+
│ └── CustomerReadModelJpa.java
|
|
164
|
+
└── repositories/
|
|
165
|
+
├── OrderJpaRepository.java
|
|
166
|
+
├── ProductReadModelJpaRepository.java
|
|
167
|
+
└── CustomerReadModelJpaRepository.java
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Read Model vs Aggregate — Comparison
|
|
173
|
+
|
|
174
|
+
| Characteristic | Aggregate | Read Model |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| YAML section | `aggregates:` | `readModels:` |
|
|
177
|
+
| Table prefix | None (`orders`) | `rm_` (`rm_products`) |
|
|
178
|
+
| Has business logic | Yes (methods, invariants) | No (data only) |
|
|
179
|
+
| Emits events | Yes | Never |
|
|
180
|
+
| Has REST endpoints | Can | Never |
|
|
181
|
+
| Domain class | Rich entity | Record or anemic class |
|
|
182
|
+
| Modified by | Use cases within the module | Events from another module |
|
|
183
|
+
| Has `source:` property | No | Yes (mandatory) |
|
|
184
|
+
| Has `syncedBy:` property | No | Yes (mandatory) |
|
|
185
|
+
| Constructor pattern | Business + reconstruction | Full constructor only (for reconstruction) |
|
|
186
|
+
| Audit fields | Optional (`audit:`) | Never (not business data) |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Generated Code Expectations
|
|
191
|
+
|
|
192
|
+
### Domain Read Model Class
|
|
193
|
+
|
|
194
|
+
A simple class or record — no business logic, no setters, no empty constructor:
|
|
195
|
+
|
|
196
|
+
```java
|
|
197
|
+
// domain/models/readmodels/ProductReadModel.java
|
|
198
|
+
package com.example.myapp.orders.domain.models.readmodels;
|
|
199
|
+
|
|
200
|
+
public class ProductReadModel {
|
|
201
|
+
private final String id;
|
|
202
|
+
private final String name;
|
|
203
|
+
private final BigDecimal price;
|
|
204
|
+
private final String status;
|
|
205
|
+
|
|
206
|
+
// Full constructor (reconstruction only)
|
|
207
|
+
public ProductReadModel(String id, String name, BigDecimal price, String status) {
|
|
208
|
+
this.id = id;
|
|
209
|
+
this.name = name;
|
|
210
|
+
this.price = price;
|
|
211
|
+
this.status = status;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Getters only — NO setters, NO business methods
|
|
215
|
+
public String getId() { return id; }
|
|
216
|
+
public String getName() { return name; }
|
|
217
|
+
public BigDecimal getPrice() { return price; }
|
|
218
|
+
public String getStatus() { return status; }
|
|
219
|
+
|
|
220
|
+
// Query helpers (read-only checks, not mutations)
|
|
221
|
+
public boolean isActive() { return "ACTIVE".equals(this.status); }
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### JPA Read Model Entity
|
|
226
|
+
|
|
227
|
+
Uses Lombok. No audit fields. No soft delete on the read model itself (the `SOFT_DELETE` action is for when the **source** uses soft delete):
|
|
228
|
+
|
|
229
|
+
```java
|
|
230
|
+
// infrastructure/database/entities/ProductReadModelJpa.java
|
|
231
|
+
@Entity
|
|
232
|
+
@Table(name = "rm_products")
|
|
233
|
+
@Getter
|
|
234
|
+
@Setter
|
|
235
|
+
@NoArgsConstructor
|
|
236
|
+
@AllArgsConstructor
|
|
237
|
+
@Builder
|
|
238
|
+
public class ProductReadModelJpa {
|
|
239
|
+
|
|
240
|
+
@Id
|
|
241
|
+
private String id; // Same ID as source entity — no auto-generation
|
|
242
|
+
|
|
243
|
+
@Column(name = "name")
|
|
244
|
+
private String name;
|
|
245
|
+
|
|
246
|
+
@Column(name = "price")
|
|
247
|
+
private BigDecimal price;
|
|
248
|
+
|
|
249
|
+
@Column(name = "status")
|
|
250
|
+
private String status;
|
|
251
|
+
|
|
252
|
+
// For SOFT_DELETE action only:
|
|
253
|
+
// @Column(name = "deleted_at")
|
|
254
|
+
// private LocalDateTime deletedAt;
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
> **Important:** The `@Id` is NOT auto-generated. It mirrors the source entity's ID exactly.
|
|
259
|
+
|
|
260
|
+
### Repository Interface
|
|
261
|
+
|
|
262
|
+
```java
|
|
263
|
+
// domain/repositories/ProductReadModelRepository.java
|
|
264
|
+
public interface ProductReadModelRepository {
|
|
265
|
+
void upsert(ProductReadModel readModel); // For UPSERT action
|
|
266
|
+
void deleteById(String id); // For DELETE action
|
|
267
|
+
Optional<ProductReadModel> findById(String id); // For consumer queries
|
|
268
|
+
boolean existsById(String id); // For existence validation
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Sync Handler
|
|
273
|
+
|
|
274
|
+
One handler per read model. Handles all `syncedBy` events via separate listener methods:
|
|
275
|
+
|
|
276
|
+
```java
|
|
277
|
+
// application/usecases/SyncProductReadModelHandler.java
|
|
278
|
+
@Service
|
|
279
|
+
public class SyncProductReadModelHandler {
|
|
280
|
+
|
|
281
|
+
private final ProductReadModelRepository repository;
|
|
282
|
+
|
|
283
|
+
// One method per syncedBy entry
|
|
284
|
+
public void onProductCreated(ProductCreatedIntegrationEvent event) {
|
|
285
|
+
ProductReadModel model = new ProductReadModel(
|
|
286
|
+
event.id(), event.name(), event.price(), event.status()
|
|
287
|
+
);
|
|
288
|
+
repository.upsert(model);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public void onProductUpdated(ProductUpdatedIntegrationEvent event) {
|
|
292
|
+
ProductReadModel model = new ProductReadModel(
|
|
293
|
+
event.id(), event.name(), event.price(), event.status()
|
|
294
|
+
);
|
|
295
|
+
repository.upsert(model);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public void onProductDeactivated(String id) {
|
|
299
|
+
repository.softDeleteById(id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Kafka Listeners (one per event, delegates to sync handler)
|
|
305
|
+
|
|
306
|
+
```java
|
|
307
|
+
// infrastructure/kafkaListener/ProductCreatedReadModelListener.java
|
|
308
|
+
@Component("ordersProductCreatedReadModelListener")
|
|
309
|
+
public class ProductCreatedReadModelListener {
|
|
310
|
+
|
|
311
|
+
private final SyncProductReadModelHandler handler;
|
|
312
|
+
|
|
313
|
+
@KafkaListener(topics = "${topics.product-created}")
|
|
314
|
+
public void handle(EventEnvelope<Map<String, Object>> event, Acknowledgment ack) {
|
|
315
|
+
// Deserialize and delegate
|
|
316
|
+
handler.onProductCreated(/* mapped event */);
|
|
317
|
+
ack.acknowledge();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Interaction with `listeners:` Section
|
|
325
|
+
|
|
326
|
+
Read model sync events are declared in `syncedBy` within `readModels`, **not** in the `listeners:` section. The `listeners:` section is reserved for business event handling (events that trigger domain use cases).
|
|
327
|
+
|
|
328
|
+
```yaml
|
|
329
|
+
readModels:
|
|
330
|
+
- name: ProductReadModel
|
|
331
|
+
syncedBy: # ← Sync events live HERE
|
|
332
|
+
- event: ProductCreatedEvent
|
|
333
|
+
action: UPSERT
|
|
334
|
+
|
|
335
|
+
listeners:
|
|
336
|
+
- event: StockReservedEvent # ← Business events live HERE
|
|
337
|
+
producer: inventory
|
|
338
|
+
useCase: HandleStockReserved
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
The code generator should:
|
|
342
|
+
1. Read `syncedBy` entries and generate Kafka listeners + sync handler
|
|
343
|
+
2. Read `listeners` entries and generate Kafka listeners + business command/handler
|
|
344
|
+
3. Both produce listener classes but in conceptually different categories
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Impact on `system.yaml`
|
|
349
|
+
|
|
350
|
+
When a read model replaces a sync call, `system.yaml` changes:
|
|
351
|
+
|
|
352
|
+
### Remove from `integrations.sync`
|
|
353
|
+
|
|
354
|
+
```yaml
|
|
355
|
+
# BEFORE
|
|
356
|
+
integrations:
|
|
357
|
+
sync:
|
|
358
|
+
- caller: orders
|
|
359
|
+
calls: products
|
|
360
|
+
port: OrderProductService
|
|
361
|
+
using:
|
|
362
|
+
- GET /products/{id}
|
|
363
|
+
|
|
364
|
+
# AFTER — entry removed entirely
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Add to `integrations.async`
|
|
368
|
+
|
|
369
|
+
```yaml
|
|
370
|
+
# New events for read model sync
|
|
371
|
+
integrations:
|
|
372
|
+
async:
|
|
373
|
+
- event: ProductCreatedEvent
|
|
374
|
+
producer: products
|
|
375
|
+
topic: PRODUCT_CREATED
|
|
376
|
+
consumers:
|
|
377
|
+
- module: orders
|
|
378
|
+
readModel: ProductReadModel # ← New field: indicates read model sync, not business use case
|
|
379
|
+
|
|
380
|
+
- event: ProductUpdatedEvent
|
|
381
|
+
producer: products
|
|
382
|
+
topic: PRODUCT_UPDATED
|
|
383
|
+
consumers:
|
|
384
|
+
- module: orders
|
|
385
|
+
readModel: ProductReadModel
|
|
386
|
+
|
|
387
|
+
- event: ProductDeactivatedEvent
|
|
388
|
+
producer: products
|
|
389
|
+
topic: PRODUCT_DEACTIVATED
|
|
390
|
+
consumers:
|
|
391
|
+
- module: orders
|
|
392
|
+
readModel: ProductReadModel
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
> **Note:** Consumer entries for read model sync use `readModel:` instead of `useCase:`. This distinguishes infrastructure sync from business logic in the system diagram.
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Impact on Source Module
|
|
400
|
+
|
|
401
|
+
The source module (`products`, `customers`) must emit the events referenced in `syncedBy`. These events should be declared in the source module's `domain.yaml` under `events:`.
|
|
402
|
+
|
|
403
|
+
### Minimum Events Required
|
|
404
|
+
|
|
405
|
+
| Source Lifecycle | Required Events |
|
|
406
|
+
|---|---|
|
|
407
|
+
| Entity can be created | `{Aggregate}CreatedEvent` |
|
|
408
|
+
| Entity can be updated | `{Aggregate}UpdatedEvent` |
|
|
409
|
+
| Entity can be deleted (hard) | `{Aggregate}DeletedEvent` |
|
|
410
|
+
| Entity can be deactivated (soft) | `{Aggregate}DeactivatedEvent` |
|
|
411
|
+
| Entity has status transitions | One event per relevant transition |
|
|
412
|
+
|
|
413
|
+
### Event Field Convention
|
|
414
|
+
|
|
415
|
+
Events consumed by read models for **UPSERT** actions should include **all fields declared in the read model's `fields`** section. The event payload is the source of truth for the projection.
|
|
416
|
+
|
|
417
|
+
For **DELETE** and **SOFT_DELETE** actions, the listener only deserializes the `id` field — the event only needs to carry the entity identifier. The `SyncHandler` calls `repository.deleteById(id)` or `repository.softDeleteById(id)` respectively.
|
|
418
|
+
|
|
419
|
+
```yaml
|
|
420
|
+
# In products.yaml — event must carry all fields that rm_products needs
|
|
421
|
+
events:
|
|
422
|
+
- name: ProductCreatedEvent
|
|
423
|
+
topic: PRODUCT_CREATED
|
|
424
|
+
fields:
|
|
425
|
+
- name: productId # Maps to readModel field "id"
|
|
426
|
+
type: String
|
|
427
|
+
- name: name
|
|
428
|
+
type: String
|
|
429
|
+
- name: price
|
|
430
|
+
type: BigDecimal
|
|
431
|
+
- name: status
|
|
432
|
+
type: String
|
|
433
|
+
- name: createdAt # Extra fields are fine — consumer ignores them
|
|
434
|
+
type: LocalDateTime
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Impact on C4 Diagrams
|
|
440
|
+
|
|
441
|
+
### `c4-container.mmd`
|
|
442
|
+
|
|
443
|
+
Replace direct sync relationships with broker-mediated relationships:
|
|
444
|
+
|
|
445
|
+
```mermaid
|
|
446
|
+
%% BEFORE (sync)
|
|
447
|
+
Rel(orders, products, "GET /products/{id}", "HTTP/JSON")
|
|
448
|
+
|
|
449
|
+
%% AFTER (async via read model)
|
|
450
|
+
Rel(products, broker, "ProductCreatedEvent, ProductUpdatedEvent", "Kafka")
|
|
451
|
+
Rel(broker, orders, "Sync ProductReadModel", "Kafka")
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Design Decisions to Document
|
|
457
|
+
|
|
458
|
+
When adopting the Local Read Model pattern, document these decisions in the module's `.md` file:
|
|
459
|
+
|
|
460
|
+
### Eventual Consistency Acknowledgment
|
|
461
|
+
|
|
462
|
+
Add an "Architectural Decisions" section:
|
|
463
|
+
|
|
464
|
+
```markdown
|
|
465
|
+
## Architectural Decisions
|
|
466
|
+
|
|
467
|
+
### ADR-001: Local Read Model for Product Data
|
|
468
|
+
|
|
469
|
+
**Context:** Orders needs product name, price, and status to validate and create orders.
|
|
470
|
+
|
|
471
|
+
**Decision:** Use event-driven Local Read Model (`rm_products`) instead of sync HTTP call.
|
|
472
|
+
|
|
473
|
+
**Consequences:**
|
|
474
|
+
- (+) Orders module is fully autonomous — no runtime dependency on products
|
|
475
|
+
- (+) Lower latency — local DB query vs HTTP roundtrip
|
|
476
|
+
- (+) Resilient — orders can be created even if products service is down
|
|
477
|
+
- (-) Eventual consistency — in a window of milliseconds, a newly created/updated
|
|
478
|
+
product may not be reflected in the read model
|
|
479
|
+
- (-) Additional infrastructure — Kafka listeners, sync handlers, extra table
|
|
480
|
+
|
|
481
|
+
**Accepted risk:** A product price change or deactivation may not be reflected for
|
|
482
|
+
a few milliseconds. This is acceptable for this business domain.
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Validation Rules for Code Generator
|
|
488
|
+
|
|
489
|
+
The code generator should validate the following when processing `readModels`:
|
|
490
|
+
|
|
491
|
+
| Code | Severity | Rule |
|
|
492
|
+
|---|---|---|
|
|
493
|
+
| RM-001 | ERROR | `name` must end with `ReadModel` suffix |
|
|
494
|
+
| RM-002 | ERROR | `tableName` must start with `rm_` prefix |
|
|
495
|
+
| RM-003 | ERROR | `source.module` must reference an existing module in `system.yaml` |
|
|
496
|
+
| RM-004 | ERROR | `fields` must include an `id` field |
|
|
497
|
+
| RM-005 | ERROR | `syncedBy` must have at least one entry |
|
|
498
|
+
| RM-006 | ERROR | `syncedBy[].action` must be one of `UPSERT`, `DELETE`, `SOFT_DELETE` |
|
|
499
|
+
| RM-007 | WARNING | `syncedBy[].event` references an event not declared in the source module's `events:` |
|
|
500
|
+
| RM-008 | WARNING | Read model field not present in any `syncedBy` event's `fields` — may always be null |
|
|
501
|
+
| RM-009 | INFO | `ports:` section still contains sync calls to the same module referenced in `source:` — consider removing |
|
|
502
|
+
| RM-010 | ERROR | `source.module` and current module are the same — read models are for cross-module projections only |
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Migration Path: Sync to Read Model
|
|
507
|
+
|
|
508
|
+
When converting an existing sync dependency to a read model:
|
|
509
|
+
|
|
510
|
+
### Step 1 — Add events to source module
|
|
511
|
+
Edit `{source}.yaml`: add `events:` entries for Created/Updated/Deleted.
|
|
512
|
+
|
|
513
|
+
### Step 2 — Add read model to consumer module
|
|
514
|
+
Edit `{consumer}.yaml`: add `readModels:` section with fields and `syncedBy`.
|
|
515
|
+
|
|
516
|
+
### Step 3 — Remove sync port from consumer module
|
|
517
|
+
Edit `{consumer}.yaml`: remove the port entry from `ports:`.
|
|
518
|
+
|
|
519
|
+
### Step 4 — Update system.yaml
|
|
520
|
+
- Remove entry from `integrations.sync[]`
|
|
521
|
+
- Add entries to `integrations.async[]` with `readModel:` consumer type
|
|
522
|
+
|
|
523
|
+
### Step 5 — Update module narrative
|
|
524
|
+
Edit `{consumer}.md`: update use case flows, sequence diagrams, and interaction diagrams to reflect local queries instead of HTTP calls.
|
|
525
|
+
|
|
526
|
+
### Step 6 — Update C4 diagrams
|
|
527
|
+
Edit `c4-container.mmd`: replace direct `Rel()` with broker-mediated relationships.
|
|
528
|
+
|
|
529
|
+
### Step 7 — Regenerate code
|
|
530
|
+
```bash
|
|
531
|
+
eva g entities {source} # Generates event classes
|
|
532
|
+
eva g entities {consumer} # Generates read model entities, repos, listeners, sync handler
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## Complete Example
|
|
538
|
+
|
|
539
|
+
### Before (sync dependency)
|
|
540
|
+
|
|
541
|
+
```yaml
|
|
542
|
+
# orders.yaml
|
|
543
|
+
ports:
|
|
544
|
+
- name: findProductById
|
|
545
|
+
service: OrderProductService
|
|
546
|
+
target: products
|
|
547
|
+
baseUrl: http://localhost:8080
|
|
548
|
+
http: GET /products/{id}
|
|
549
|
+
fields:
|
|
550
|
+
- name: id
|
|
551
|
+
type: String
|
|
552
|
+
- name: name
|
|
553
|
+
type: String
|
|
554
|
+
- name: price
|
|
555
|
+
type: BigDecimal
|
|
556
|
+
- name: status
|
|
557
|
+
type: String
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### After (event-driven read model)
|
|
561
|
+
|
|
562
|
+
```yaml
|
|
563
|
+
# products.yaml — add events
|
|
564
|
+
events:
|
|
565
|
+
- name: ProductCreatedEvent
|
|
566
|
+
topic: PRODUCT_CREATED
|
|
567
|
+
fields:
|
|
568
|
+
- name: productId
|
|
569
|
+
type: String
|
|
570
|
+
- name: name
|
|
571
|
+
type: String
|
|
572
|
+
- name: price
|
|
573
|
+
type: BigDecimal
|
|
574
|
+
- name: status
|
|
575
|
+
type: String
|
|
576
|
+
- name: createdAt
|
|
577
|
+
type: LocalDateTime
|
|
578
|
+
|
|
579
|
+
- name: ProductUpdatedEvent
|
|
580
|
+
topic: PRODUCT_UPDATED
|
|
581
|
+
fields:
|
|
582
|
+
- name: productId
|
|
583
|
+
type: String
|
|
584
|
+
- name: name
|
|
585
|
+
type: String
|
|
586
|
+
- name: price
|
|
587
|
+
type: BigDecimal
|
|
588
|
+
- name: status
|
|
589
|
+
type: String
|
|
590
|
+
- name: updatedAt
|
|
591
|
+
type: LocalDateTime
|
|
592
|
+
|
|
593
|
+
- name: ProductDeactivatedEvent
|
|
594
|
+
topic: PRODUCT_DEACTIVATED
|
|
595
|
+
triggers:
|
|
596
|
+
- deactivate
|
|
597
|
+
fields:
|
|
598
|
+
- name: productId
|
|
599
|
+
type: String
|
|
600
|
+
- name: deactivatedAt
|
|
601
|
+
type: LocalDateTime
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
```yaml
|
|
605
|
+
# orders.yaml — replace port with readModel
|
|
606
|
+
readModels:
|
|
607
|
+
- name: ProductReadModel
|
|
608
|
+
source:
|
|
609
|
+
module: products
|
|
610
|
+
aggregate: Product
|
|
611
|
+
tableName: rm_products
|
|
612
|
+
fields:
|
|
613
|
+
- name: id
|
|
614
|
+
type: String
|
|
615
|
+
- name: name
|
|
616
|
+
type: String
|
|
617
|
+
- name: price
|
|
618
|
+
type: BigDecimal
|
|
619
|
+
- name: status
|
|
620
|
+
type: String
|
|
621
|
+
syncedBy:
|
|
622
|
+
- event: ProductCreatedEvent
|
|
623
|
+
action: UPSERT
|
|
624
|
+
- event: ProductUpdatedEvent
|
|
625
|
+
action: UPSERT
|
|
626
|
+
- event: ProductDeactivatedEvent
|
|
627
|
+
action: UPSERT
|
|
628
|
+
|
|
629
|
+
# ports: ← REMOVED (no more sync call to products)
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
```yaml
|
|
633
|
+
# system.yaml — update integrations
|
|
634
|
+
integrations:
|
|
635
|
+
async:
|
|
636
|
+
- event: ProductCreatedEvent
|
|
637
|
+
producer: products
|
|
638
|
+
topic: PRODUCT_CREATED
|
|
639
|
+
consumers:
|
|
640
|
+
- module: orders
|
|
641
|
+
readModel: ProductReadModel
|
|
642
|
+
|
|
643
|
+
- event: ProductUpdatedEvent
|
|
644
|
+
producer: products
|
|
645
|
+
topic: PRODUCT_UPDATED
|
|
646
|
+
consumers:
|
|
647
|
+
- module: orders
|
|
648
|
+
readModel: ProductReadModel
|
|
649
|
+
|
|
650
|
+
- event: ProductDeactivatedEvent
|
|
651
|
+
producer: products
|
|
652
|
+
topic: PRODUCT_DEACTIVATED
|
|
653
|
+
consumers:
|
|
654
|
+
- module: orders
|
|
655
|
+
readModel: ProductReadModel
|
|
656
|
+
|
|
657
|
+
# sync: ← orders→products entry REMOVED
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
---
|
|
661
|
+
|
|
662
|
+
**Convention version:** 1.0.0
|
|
663
|
+
**Created:** 2026-03-24
|
|
664
|
+
**Applicable to:** eva4j projects using `readModels` pattern
|
|
@@ -86,6 +86,7 @@ When any design file changes, you MUST propagate to all dependent files. Never m
|
|
|
86
86
|
| New/modified event with triggers | `system/{module}.md` → Emitted Events; `system/system.yaml` → `integrations.async:` if new event |
|
|
87
87
|
| New/modified listener | `system/{module}.md` → Use Cases (incoming event handlers) |
|
|
88
88
|
| New/modified value object | `system/{module}.md` → Module Role or relevant use cases |
|
|
89
|
+
| New/modified readModel | `system/{source-module}.yaml` → verify source events have `lifecycle:` matching syncedBy entries; `system/system.yaml` → verify `integrations.async[]` exists for each syncedBy event; `system/{module}.md` → Read Models section |
|
|
89
90
|
|
|
90
91
|
### Change in `system/{module}.md`
|
|
91
92
|
|
|
@@ -139,6 +140,8 @@ When modifying design files, always follow these conventions. Read the reference
|
|
|
139
140
|
- Enum transitions require `initialValue`
|
|
140
141
|
- `hasSoftDelete: true` only on root entities (`isRoot: true`)
|
|
141
142
|
- Cross-aggregate references use `reference:` on ID fields, never `relationships:`
|
|
143
|
+
- Events consumed by readModels in other modules must declare `lifecycle:` — derive from event name convention: `*CreatedEvent`/`*RegisteredEvent`→`create`, `*UpdatedEvent`→`update`, `*DeletedEvent`→`delete`, `*DeactivatedEvent`→`softDelete`. `lifecycle` and `triggers` are mutually exclusive
|
|
144
|
+
- `lifecycle: softDelete` requires `hasSoftDelete: true` on root entity; `lifecycle: delete` requires `hasSoftDelete` absent or false
|
|
142
145
|
|
|
143
146
|
### Language Rule
|
|
144
147
|
|