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.
Files changed (43) hide show
  1. package/AGENTS.md +218 -5
  2. package/DOMAIN_YAML_GUIDE.md +185 -2
  3. package/FUTURE_FEATURES.md +33 -52
  4. package/docs/commands/EVALUATE_SYSTEM.md +18 -2
  5. package/examples/domain-events.yaml +26 -0
  6. package/examples/domain-read-models.yaml +113 -0
  7. package/package.json +1 -1
  8. package/read-model-spec.md +664 -0
  9. package/src/agents/design-reviewer.agent.md +3 -0
  10. package/src/commands/generate-entities.js +254 -10
  11. package/src/commands/generate-http-exchange.js +3 -0
  12. package/src/commands/generate-kafka-event.js +3 -0
  13. package/src/commands/generate-kafka-listener.js +3 -0
  14. package/src/commands/generate-record.js +2 -2
  15. package/src/commands/generate-resource.js +4 -1
  16. package/src/commands/generate-temporal-activity.js +4 -1
  17. package/src/commands/generate-temporal-flow.js +4 -1
  18. package/src/commands/generate-usecase.js +4 -1
  19. package/src/skills/build-system-yaml/SKILL.md +122 -1
  20. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +205 -24
  21. package/src/skills/build-system-yaml/references/module-spec.md +33 -1
  22. package/src/skills/build-system-yaml/references/system-yaml-spec.md +36 -0
  23. package/src/utils/config-manager.js +4 -2
  24. package/src/utils/domain-validator.js +230 -14
  25. package/src/utils/naming.js +10 -0
  26. package/src/utils/validator.js +3 -1
  27. package/src/utils/yaml-to-entity.js +272 -3
  28. package/templates/aggregate/AggregateRepository.java.ejs +4 -0
  29. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +8 -0
  30. package/templates/aggregate/AggregateRoot.java.ejs +38 -4
  31. package/templates/aggregate/JpaAggregateRoot.java.ejs +2 -2
  32. package/templates/crud/DeleteCommandHandler.java.ejs +19 -1
  33. package/templates/crud/UpdateCommandHandler.java.ejs +53 -2
  34. package/templates/read-model/ReadModelDomain.java.ejs +46 -0
  35. package/templates/read-model/ReadModelJpa.java.ejs +58 -0
  36. package/templates/read-model/ReadModelJpaRepository.java.ejs +11 -0
  37. package/templates/read-model/ReadModelKafkaListener.java.ejs +64 -0
  38. package/templates/read-model/ReadModelRepository.java.ejs +42 -0
  39. package/templates/read-model/ReadModelRepositoryImpl.java.ejs +81 -0
  40. package/templates/read-model/ReadModelSyncHandler.java.ejs +52 -0
  41. package/test-c2010.js +49 -0
  42. package/test-update-compat.js +109 -0
  43. 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