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
|
@@ -62,11 +62,32 @@ Si el usuario no proveyó todos los datos, **pregunta** antes de generar:
|
|
|
62
62
|
3. **Endpoints REST** por módulo (método + path + caso de uso)
|
|
63
63
|
4. **Flujos async**: evento → productor → consumidores + `useCase` de cada consumidor
|
|
64
64
|
5. **Llamadas sync**: caller → destino → endpoints usados
|
|
65
|
+
6. **Dependencias de datos cross-module**: ¿algún módulo necesita datos de otro para validar o enriquecer? → candidato a **Read Model** (proyección local mantenida por eventos) en vez de llamada sync
|
|
65
66
|
|
|
66
67
|
> Si `system/system.yaml` ya existe, léelo y pregunta solo por cambios.
|
|
67
68
|
|
|
68
69
|
Aplica el rol funcional: sugiere módulos necesarios no mencionados, propone flujos async coherentes, anticipa invariantes. Confirma antes de agregar elementos no solicitados.
|
|
69
70
|
|
|
71
|
+
### Read Models — decisión de diseño
|
|
72
|
+
|
|
73
|
+
Cuando un módulo necesita datos de otro módulo, evalúa antes de decidir entre `ports:` (sync HTTP) y `readModels:` (async, proyección local):
|
|
74
|
+
|
|
75
|
+
| Pregunta | Si la respuesta es SÍ → |
|
|
76
|
+
|---|---|
|
|
77
|
+
| ¿El dato se consulta en cada request o en operaciones frecuentes? | `readModels:` |
|
|
78
|
+
| ¿Se tolera consistencia eventual (ms de delay)? | `readModels:` |
|
|
79
|
+
| ¿Se prepara el sistema para microservicios (`eva detach`)? | `readModels:` |
|
|
80
|
+
| ¿Se necesita consistencia fuerte (ej: saldo financiero)? | `ports:` |
|
|
81
|
+
| ¿Es una llamada infrecuente y simple (ej: lookup puntual)? | `ports:` |
|
|
82
|
+
|
|
83
|
+
**Patrón típico:** Si un módulo ya consume eventos de otro módulo (`listeners:`) y además llama sync para obtener datos del mismo módulo (`ports:`), es candidato fuerte a reemplazar el port por un readModel.
|
|
84
|
+
|
|
85
|
+
Si decides usar readModel:
|
|
86
|
+
1. En `system.yaml`: declarar eventos con `consumers[].readModel:` en vez de `useCase:`
|
|
87
|
+
2. En `{module}.yaml`: declarar `readModels:` con `source`, `tableName`, `fields`, `syncedBy`
|
|
88
|
+
3. Eliminar la entrada `integrations.sync[]` que reemplaza
|
|
89
|
+
4. Asegurar que el módulo fuente emita los eventos necesarios en sus `events:` — usar `lifecycle:` para eventos CRUD (`create`/`update`/`delete`/`softDelete`) en vez de `triggers:`
|
|
90
|
+
|
|
70
91
|
---
|
|
71
92
|
|
|
72
93
|
## Paso 2 — Estructura del system.yaml
|
|
@@ -108,6 +129,13 @@ integrations:
|
|
|
108
129
|
consumers:
|
|
109
130
|
- module: payments
|
|
110
131
|
useCase: HandleOrderPlaced
|
|
132
|
+
# Read Model sync — usa readModel: en vez de useCase:
|
|
133
|
+
- event: ProductCreatedEvent
|
|
134
|
+
producer: products
|
|
135
|
+
topic: PRODUCT_CREATED
|
|
136
|
+
consumers:
|
|
137
|
+
- module: orders
|
|
138
|
+
readModel: ProductReadModel # proyección local de datos cross-module
|
|
111
139
|
sync:
|
|
112
140
|
- caller: orders
|
|
113
141
|
calls: customers
|
|
@@ -134,6 +162,8 @@ integrations:
|
|
|
134
162
|
- Sin port names genéricos compartidos entre módulos
|
|
135
163
|
- `consumers[].useCase` siempre presente y en PascalCase
|
|
136
164
|
- `calls.using:` solo referencia endpoints de `exposes:` del destino
|
|
165
|
+
- Cada consumer declara exactamente `useCase:` (lógica de negocio) o `readModel:` (proyección local), nunca ambos
|
|
166
|
+
- `readModel:` PascalCase + sufijo `ReadModel` — su módulo consumidor declara `readModels:` en domain.yaml
|
|
137
167
|
|
|
138
168
|
---
|
|
139
169
|
|
|
@@ -146,6 +176,8 @@ Antes de proponer el `system.yaml`, verifica:
|
|
|
146
176
|
- [ ] Sin dependencias circulares síncronas
|
|
147
177
|
- [ ] Todos los `consumers[].module` existen en `modules:`
|
|
148
178
|
- [ ] Todos los `consumers[].useCase` presentes y en PascalCase
|
|
179
|
+
- [ ] `consumers[]` con `readModel:` en PascalCase + sufijo `ReadModel`
|
|
180
|
+
- [ ] Cada consumer tiene exactamente `useCase:` o `readModel:`, nunca ambos
|
|
149
181
|
- [ ] Todos los `calls.using:` existen en `exposes:` del destino
|
|
150
182
|
- [ ] Módulos pasivos no son `caller`
|
|
151
183
|
- [ ] Todo en inglés
|
|
@@ -257,6 +289,7 @@ C4Container
|
|
|
257
289
|
- `ContainerQueue()` para el broker — solo si `messaging.enabled: true`; usar el tipo de `messaging.broker`
|
|
258
290
|
- `System_Ext()` fuera del boundary para servicios externos
|
|
259
291
|
- **Flechas async siempre pasan por el broker**: `producer → broker` y `broker → consumer`, nunca directo
|
|
292
|
+
- **Read Model sync también pasa por el broker**: `Rel(source, broker, "ProductCreatedEvent", "Kafka")` + `Rel(broker, consumer, "Sync ProductReadModel", "Kafka")` — no usar flecha directa
|
|
260
293
|
- **Flechas sync** directas entre containers: de caller a callee con label del port name
|
|
261
294
|
- Los `Rel()` de eventos incluyen el nombre del evento en el campo `technology`/label
|
|
262
295
|
- Derivar actores `Person()` de quienes consumen los `exposes[]`
|
|
@@ -302,7 +335,95 @@ Antes de guardar, verifica:
|
|
|
302
335
|
|
|
303
336
|
Lee `references/domain-yaml-spec.md` para la especificación completa de estructura, reglas, restricciones y checklist del `system/{module}.yaml`.
|
|
304
337
|
|
|
305
|
-
Para cada módulo en `modules:`, genera `system/{nombre-del-modulo}.yaml` con: aggregates, entities, valueObjects, enums (con transitions si aplica), events, endpoints, listeners y
|
|
338
|
+
Para cada módulo en `modules:`, genera `system/{nombre-del-modulo}.yaml` con: aggregates, entities, valueObjects, enums (con transitions si aplica), events, endpoints, listeners, ports y **readModels** — todo inferido del `system.yaml`.
|
|
339
|
+
|
|
340
|
+
### Inferencia de readModels desde system.yaml
|
|
341
|
+
|
|
342
|
+
Cuando `integrations.async[].consumers[]` tiene `readModel:` y el `module` es el módulo actual:
|
|
343
|
+
1. Agrupar todos los eventos del mismo `readModel:` → una entrada `readModels:` con múltiples `syncedBy`
|
|
344
|
+
2. `source.module` = el `producer` de esas integraciones async
|
|
345
|
+
3. `source.aggregate` = derivar del nombre del readModel (ej: `ProductReadModel` → `Product`)
|
|
346
|
+
4. `tableName` = `rm_` + snake_case del source module (ej: `rm_products`)
|
|
347
|
+
5. `fields` = inferir del payload del evento fuente (incluir siempre `id`)
|
|
348
|
+
6. `syncedBy[].action` = `UPSERT` para Created/Updated, `SOFT_DELETE` para Deactivated, `DELETE` para Deleted
|
|
349
|
+
7. Si había una entrada `integrations.sync[]` al mismo módulo fuente → **no generar `ports:`** para esa llamada (el readModel la reemplaza)
|
|
350
|
+
|
|
351
|
+
### Lifecycle events en módulos fuente
|
|
352
|
+
|
|
353
|
+
Cuando el módulo actual es `producer` en `integrations.async[]` y algún consumer tiene `readModel:`, los eventos de este módulo deben usar `lifecycle:` (operación CRUD) en vez de `triggers:` (transición de estado).
|
|
354
|
+
|
|
355
|
+
Para cada evento de este módulo consumido por un readModel:
|
|
356
|
+
|
|
357
|
+
1. Agregar `lifecycle:` al evento — derivar el valor del nombre del evento:
|
|
358
|
+
| Patrón del nombre | `lifecycle:` |
|
|
359
|
+
|---|---|
|
|
360
|
+
| `*CreatedEvent`, `*RegisteredEvent` | `create` |
|
|
361
|
+
| `*UpdatedEvent` | `update` |
|
|
362
|
+
| `*DeletedEvent` | `delete` |
|
|
363
|
+
| `*DeactivatedEvent` | `softDelete` |
|
|
364
|
+
|
|
365
|
+
2. **NO** agregar `triggers:` — estos eventos son CRUD, no transiciones de estado
|
|
366
|
+
3. Si `lifecycle: softDelete` → la entidad raíz **debe** tener `hasSoftDelete: true`
|
|
367
|
+
4. Si `lifecycle: delete` → la entidad raíz **NO debe** tener `hasSoftDelete: true`
|
|
368
|
+
5. `fields:` del evento debe incluir **todos** los campos declarados en el `readModels[].fields` del módulo consumidor (el payload es la fuente de verdad de la proyección)
|
|
369
|
+
6. Siempre incluir `{entityName}Id` como campo (se mapea a `aggregateId` del DomainEvent base)
|
|
370
|
+
7. `fields:` del lifecycle event solo puede contener: (a) `{entityName}Id` (aggregateId), (b) campos que existen en la entidad raíz, (c) campos temporales `*At` + `LocalDateTime`. No incluir campos que no existan en la entidad — genera error `C2-010`
|
|
371
|
+
8. Los campos de los readModels consumidores deben ser subconjunto de los campos de la entidad raíz del productor. Si el readModel necesita un campo, ese campo debe existir en la entidad fuente — de lo contrario los lifecycle events no podrán emitirlo (C2-010) y el campo siempre será null (C1-007)
|
|
372
|
+
|
|
373
|
+
**Ejemplo — módulo `products` como fuente de `ProductReadModel`:**
|
|
374
|
+
|
|
375
|
+
```yaml
|
|
376
|
+
aggregates:
|
|
377
|
+
- name: Product
|
|
378
|
+
entities:
|
|
379
|
+
- name: product
|
|
380
|
+
isRoot: true
|
|
381
|
+
tableName: products
|
|
382
|
+
hasSoftDelete: true
|
|
383
|
+
audit:
|
|
384
|
+
enabled: true
|
|
385
|
+
fields:
|
|
386
|
+
- name: id
|
|
387
|
+
type: String
|
|
388
|
+
- name: name
|
|
389
|
+
type: String
|
|
390
|
+
- name: price
|
|
391
|
+
type: BigDecimal
|
|
392
|
+
- name: status
|
|
393
|
+
type: String
|
|
394
|
+
readOnly: true
|
|
395
|
+
defaultValue: "ACTIVE"
|
|
396
|
+
events:
|
|
397
|
+
- name: ProductCreatedEvent
|
|
398
|
+
lifecycle: create
|
|
399
|
+
fields:
|
|
400
|
+
- name: productId
|
|
401
|
+
type: String
|
|
402
|
+
- name: name
|
|
403
|
+
type: String
|
|
404
|
+
- name: price
|
|
405
|
+
type: BigDecimal
|
|
406
|
+
- name: status
|
|
407
|
+
type: String
|
|
408
|
+
- name: ProductUpdatedEvent
|
|
409
|
+
lifecycle: update
|
|
410
|
+
fields:
|
|
411
|
+
- name: productId
|
|
412
|
+
type: String
|
|
413
|
+
- name: name
|
|
414
|
+
type: String
|
|
415
|
+
- name: price
|
|
416
|
+
type: BigDecimal
|
|
417
|
+
- name: status
|
|
418
|
+
type: String
|
|
419
|
+
- name: ProductDeactivatedEvent
|
|
420
|
+
lifecycle: softDelete
|
|
421
|
+
fields:
|
|
422
|
+
- name: productId
|
|
423
|
+
type: String
|
|
424
|
+
- name: deactivatedAt
|
|
425
|
+
type: LocalDateTime
|
|
426
|
+
```
|
|
306
427
|
|
|
307
428
|
---
|
|
308
429
|
|
|
@@ -32,6 +32,14 @@ Propón campos necesarios no mencionados, Value Objects expresivos, invariantes
|
|
|
32
32
|
11. ❌ **Todo en inglés**
|
|
33
33
|
12. ❌ **No transiciones sin evidencia de activación** — toda transición activada por `ports:` o scheduler necesita un domain event con `triggers`
|
|
34
34
|
13. ❌ **No reutilizar `service:` en `ports[]` entre módulos** — nombres propios del bounded context
|
|
35
|
+
14. ❌ **No `readModels[].source.module` igual al módulo actual** — readModels son cross-module exclusivamente
|
|
36
|
+
15. ❌ **No auditoría, endpoints REST ni lógica de negocio en `readModels:`** — son proyecciones inmutables
|
|
37
|
+
16. ❌ **No `readModels[].name` sin sufijo `ReadModel`** — siempre `PascalCase + ReadModel`
|
|
38
|
+
17. ❌ **No `readModels[].tableName` sin prefijo `rm_`** — identificación visual obligatoria
|
|
39
|
+
18. ❌ **No `lifecycle:` y `triggers:` en el mismo evento** — son mutuamente excluyentes
|
|
40
|
+
19. ❌ **No `lifecycle: softDelete` sin `hasSoftDelete: true`** en la entidad raíz
|
|
41
|
+
20. ❌ **No `lifecycle: delete` con `hasSoftDelete: true`** — delete es hard delete
|
|
42
|
+
21. ❌ **No declarar campos en lifecycle events que no existan en la entidad raíz** — solo `{entityName}Id`, campos de la entidad y campos temporales auto-resolubles (`*At` + `LocalDateTime`). Genera error `C2-010`
|
|
35
43
|
|
|
36
44
|
---
|
|
37
45
|
|
|
@@ -41,7 +49,8 @@ Propón campos necesarios no mencionados, Value Objects expresivos, invariantes
|
|
|
41
49
|
|---|---|
|
|
42
50
|
| `modules[x].exposes[]` | `endpoints:` con `basePath` + `versions[].operations[]` |
|
|
43
51
|
| `integrations.async[]` donde `producer = módulo` | `events:` |
|
|
44
|
-
| `integrations.async[].consumers[]` donde `module = módulo` | `listeners:` (con `useCase`) |
|
|
52
|
+
| `integrations.async[].consumers[]` donde `module = módulo` y tiene `useCase` | `listeners:` (con `useCase`) |
|
|
53
|
+
| `integrations.async[].consumers[]` donde `module = módulo` y tiene `readModel` | `readModels:` (con `syncedBy`) |
|
|
45
54
|
| `integrations.sync[]` donde `caller = módulo` | `ports:` (con `service`, `http`) |
|
|
46
55
|
|
|
47
56
|
### Formato correcto de endpoints
|
|
@@ -176,6 +185,35 @@ aggregates:
|
|
|
176
185
|
- name: reason
|
|
177
186
|
type: String
|
|
178
187
|
|
|
188
|
+
# ── Lifecycle events (CRUD-based, no transitions) ──
|
|
189
|
+
# Use lifecycle: instead of triggers: when the event is emitted
|
|
190
|
+
# at a CRUD operation (create/update/delete/softDelete).
|
|
191
|
+
# Typical use: source modules whose events feed readModels.
|
|
192
|
+
#
|
|
193
|
+
# - name: ProductCreatedEvent
|
|
194
|
+
# lifecycle: create # raise() in creation constructor
|
|
195
|
+
# fields:
|
|
196
|
+
# - name: productId
|
|
197
|
+
# type: String
|
|
198
|
+
# - name: name
|
|
199
|
+
# type: String
|
|
200
|
+
#
|
|
201
|
+
# - name: ProductUpdatedEvent
|
|
202
|
+
# lifecycle: update # raise() in UpdateCommandHandler
|
|
203
|
+
# fields: [...]
|
|
204
|
+
#
|
|
205
|
+
# - name: ProductDeletedEvent
|
|
206
|
+
# lifecycle: delete # raise() in DeleteCommandHandler
|
|
207
|
+
# fields:
|
|
208
|
+
# - name: productId
|
|
209
|
+
# type: String
|
|
210
|
+
#
|
|
211
|
+
# - name: ProductDeactivatedEvent
|
|
212
|
+
# lifecycle: softDelete # raise() in softDelete() method
|
|
213
|
+
# fields:
|
|
214
|
+
# - name: productId
|
|
215
|
+
# type: String
|
|
216
|
+
|
|
179
217
|
endpoints:
|
|
180
218
|
basePath: /orders
|
|
181
219
|
versions:
|
|
@@ -289,34 +327,164 @@ Anotaciones: `NotNull`, `NotBlank`, `NotEmpty`, `Email`, `Size`, `Min`, `Max`, `
|
|
|
289
327
|
|
|
290
328
|
---
|
|
291
329
|
|
|
292
|
-
## Proyecciones locales (
|
|
330
|
+
## Proyecciones locales (`readModels:`)
|
|
331
|
+
|
|
332
|
+
Cuando un módulo necesita datos de otro bounded context para validar precondiciones o enriquecer entidades, usar `readModels:` en vez de `ports:` (sync HTTP). Esto elimina dependencias síncronas y mejora autonomía, resiliencia y rendimiento.
|
|
293
333
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
334
|
+
### Cuándo usar readModels vs ports
|
|
335
|
+
|
|
336
|
+
| Criterio | `readModels:` (async) | `ports:` (sync) |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| Consistencia eventual aceptable | ✅ | — |
|
|
339
|
+
| Se necesita consistencia fuerte | — | ✅ |
|
|
340
|
+
| Datos se consultan frecuentemente | ✅ | — |
|
|
341
|
+
| Llamada infrecuente y simple | — | ✅ |
|
|
342
|
+
| Preparación para microservicios | ✅ | — |
|
|
299
343
|
|
|
300
|
-
|
|
344
|
+
### Estructura
|
|
301
345
|
|
|
302
346
|
```yaml
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
347
|
+
# Nivel raíz, sibling de aggregates:, listeners:, ports:
|
|
348
|
+
readModels:
|
|
349
|
+
- name: ProductReadModel # PascalCase + sufijo "ReadModel" (OBLIGATORIO)
|
|
350
|
+
source: # Trazabilidad al módulo fuente (OBLIGATORIO)
|
|
351
|
+
module: products # Módulo fuente (kebab-case)
|
|
352
|
+
aggregate: Product # Agregado fuente (PascalCase)
|
|
353
|
+
tableName: rm_products # Tabla en BD (OBLIGATORIO, prefijo rm_)
|
|
354
|
+
fields: # Campos proyectados — subconjunto del fuente
|
|
355
|
+
- name: id
|
|
356
|
+
type: String
|
|
357
|
+
- name: name
|
|
358
|
+
type: String
|
|
359
|
+
- name: price
|
|
360
|
+
type: BigDecimal
|
|
361
|
+
- name: status
|
|
362
|
+
type: String
|
|
363
|
+
syncedBy: # Eventos que mantienen esta tabla (min 1)
|
|
364
|
+
- event: ProductCreatedEvent # Nombre del evento (PascalCase + sufijo Event)
|
|
365
|
+
action: UPSERT # Acción: UPSERT | DELETE | SOFT_DELETE
|
|
366
|
+
- event: ProductUpdatedEvent
|
|
367
|
+
action: UPSERT
|
|
368
|
+
- event: ProductDeactivatedEvent
|
|
369
|
+
action: SOFT_DELETE
|
|
318
370
|
```
|
|
319
371
|
|
|
372
|
+
### Reglas
|
|
373
|
+
|
|
374
|
+
- **`name:`** — PascalCase, **DEBE** terminar con `ReadModel`
|
|
375
|
+
- **`tableName:`** — **DEBE** empezar con `rm_` (identificación visual en BD)
|
|
376
|
+
- **`fields:`** — **DEBE** incluir un campo `id`
|
|
377
|
+
- **`syncedBy:`** — **DEBE** tener al menos una entrada
|
|
378
|
+
- **`source.module:`** — **NO PUEDE** ser el mismo módulo actual (cross-module exclusivamente)
|
|
379
|
+
- **Sin auditoría** — Los readModels **nunca** tienen campos de auditoría
|
|
380
|
+
- **Sin endpoints REST** — Los readModels **nunca** exponen endpoints
|
|
381
|
+
- **Sin lógica de negocio** — La clase de dominio es inmutable (solo getters)
|
|
382
|
+
|
|
383
|
+
### Acciones de sincronización
|
|
384
|
+
|
|
385
|
+
| Acción | Significado | Uso |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| `UPSERT` | Insertar si es nuevo, actualizar si existe | Creaciones, actualizaciones, cambios de estado |
|
|
388
|
+
| `DELETE` | Eliminar el registro permanentemente | Hard deletes en el módulo fuente |
|
|
389
|
+
| `SOFT_DELETE` | Marcar como inactivo con timestamp | Cuando el fuente usa soft delete |
|
|
390
|
+
|
|
391
|
+
### Inferencia desde system.yaml
|
|
392
|
+
|
|
393
|
+
| Fuente en system.yaml | Destino en domain.yaml |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `consumers[].readModel: ProductReadModel` | `readModels:` entry con ese nombre |
|
|
396
|
+
| `integrations.async[].producer` | `readModels[].source.module` |
|
|
397
|
+
| `integrations.async[].topic` | Topic derivado automáticamente del nombre del evento |
|
|
398
|
+
|
|
399
|
+
### Impacto en el módulo fuente
|
|
400
|
+
|
|
401
|
+
El módulo fuente **debe** emitir los eventos referenciados en `syncedBy`. Asegurar que los `events:` del fuente incluyan todos los campos declarados en `readModels[].fields` (el payload es la fuente de verdad para la proyección).
|
|
402
|
+
|
|
403
|
+
**Restricción de cobertura:** Los campos del readModel (excepto `id`) deben estar cubiertos por al menos un evento UPSERT en `syncedBy[]`. Si un campo no aparece en ningún evento UPSERT, siempre será null — genera warning `C1-007`. Además, los campos del readModel deben ser subconjunto de los campos de la entidad raíz del módulo fuente (por C2-010, los lifecycle events no pueden emitir campos ajenos a la entidad).
|
|
404
|
+
|
|
405
|
+
### Propiedad `lifecycle:` en eventos del módulo fuente
|
|
406
|
+
|
|
407
|
+
Cuando un evento existe para alimentar un readModel (operación CRUD pura, sin transición de estado), usar `lifecycle:` en vez de `triggers:`.
|
|
408
|
+
|
|
409
|
+
| Valor | Punto de emisión | Descripción |
|
|
410
|
+
|---|---|---|
|
|
411
|
+
| `create` | Constructor de creación de la entidad | UUID auto-generado como id antes de raise() |
|
|
412
|
+
| `update` | UpdateCommandHandler, antes de `repository.save()` | raise() sobre la entidad reconstruida |
|
|
413
|
+
| `delete` | DeleteCommandHandler, antes de `repository.delete()` | Hard delete — requiere `hasSoftDelete` ausente o false |
|
|
414
|
+
| `softDelete` | Método `softDelete()` de la entidad | Requiere `hasSoftDelete: true` en la entidad raíz |
|
|
415
|
+
|
|
416
|
+
**Derivación desde el nombre del evento:**
|
|
417
|
+
|
|
418
|
+
| Patrón del nombre del evento | `lifecycle:` |
|
|
419
|
+
|---|---|
|
|
420
|
+
| `*CreatedEvent`, `*RegisteredEvent` | `create` |
|
|
421
|
+
| `*UpdatedEvent` | `update` |
|
|
422
|
+
| `*DeletedEvent` | `delete` |
|
|
423
|
+
| `*DeactivatedEvent` | `softDelete` |
|
|
424
|
+
|
|
425
|
+
**Ejemplo — módulo fuente `products`:**
|
|
426
|
+
|
|
427
|
+
```yaml
|
|
428
|
+
aggregates:
|
|
429
|
+
- name: Product
|
|
430
|
+
entities:
|
|
431
|
+
- name: product
|
|
432
|
+
isRoot: true
|
|
433
|
+
tableName: products
|
|
434
|
+
hasSoftDelete: true # requerido por lifecycle: softDelete
|
|
435
|
+
audit:
|
|
436
|
+
enabled: true
|
|
437
|
+
fields:
|
|
438
|
+
- name: id
|
|
439
|
+
type: String
|
|
440
|
+
- name: name
|
|
441
|
+
type: String
|
|
442
|
+
- name: price
|
|
443
|
+
type: BigDecimal
|
|
444
|
+
- name: status
|
|
445
|
+
type: String
|
|
446
|
+
readOnly: true
|
|
447
|
+
defaultValue: "ACTIVE"
|
|
448
|
+
events:
|
|
449
|
+
- name: ProductCreatedEvent
|
|
450
|
+
lifecycle: create
|
|
451
|
+
fields:
|
|
452
|
+
- name: productId
|
|
453
|
+
type: String
|
|
454
|
+
- name: name
|
|
455
|
+
type: String
|
|
456
|
+
- name: price
|
|
457
|
+
type: BigDecimal
|
|
458
|
+
- name: status
|
|
459
|
+
type: String
|
|
460
|
+
- name: ProductUpdatedEvent
|
|
461
|
+
lifecycle: update
|
|
462
|
+
fields:
|
|
463
|
+
- name: productId
|
|
464
|
+
type: String
|
|
465
|
+
- name: name
|
|
466
|
+
type: String
|
|
467
|
+
- name: price
|
|
468
|
+
type: BigDecimal
|
|
469
|
+
- name: status
|
|
470
|
+
type: String
|
|
471
|
+
- name: ProductDeactivatedEvent
|
|
472
|
+
lifecycle: softDelete
|
|
473
|
+
fields:
|
|
474
|
+
- name: productId
|
|
475
|
+
type: String
|
|
476
|
+
- name: deactivatedAt
|
|
477
|
+
type: LocalDateTime
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**Reglas:**
|
|
481
|
+
- `lifecycle:` y `triggers:` son mutuamente excluyentes — un evento usa uno u otro, nunca ambos
|
|
482
|
+
- `lifecycle: softDelete` requiere `hasSoftDelete: true` en la entidad raíz
|
|
483
|
+
- `lifecycle: delete` requiere que `hasSoftDelete` sea `false` o esté ausente
|
|
484
|
+
- `fields:` del evento debe incluir **todos** los campos del readModel que lo consume (el payload es la fuente de verdad para la proyección)
|
|
485
|
+
- Siempre incluir `{entityName}Id` como campo (se mapea a `aggregateId` del DomainEvent base)
|
|
486
|
+
- `fields:` del lifecycle event solo puede contener: (a) `{entityName}Id`, (b) campos que existen en la entidad raíz del agregado, (c) campos temporales auto-resolubles (nombre termina en `At` + tipo `LocalDateTime`). Cualquier otro campo genera error `C2-010`
|
|
487
|
+
|
|
320
488
|
---
|
|
321
489
|
|
|
322
490
|
## Clasificación de campos readOnly
|
|
@@ -406,5 +574,18 @@ Si un valor no es trazable → falta un endpoint o listener en el diseño.
|
|
|
406
574
|
- [ ] `nestedTypes[]` para campos de tipo objeto en listeners/ports
|
|
407
575
|
- [ ] Transiciones por `ports:` tienen domain event con `triggers`
|
|
408
576
|
- [ ] Cada enum `*Type` valor trazable a listener/endpoint/event
|
|
409
|
-
- [ ] Proyecciones: `
|
|
577
|
+
- [ ] Proyecciones cross-module: usar `readModels:` con `source`, `tableName` (prefijo `rm_`), `fields` (incluye `id`), `syncedBy`
|
|
578
|
+
- [ ] `readModels[].name` termina con `ReadModel` (PascalCase)
|
|
579
|
+
- [ ] `readModels[].source.module` ≠ módulo actual
|
|
580
|
+
- [ ] `readModels[].syncedBy` → al menos una entrada con `action` válida (UPSERT, DELETE, SOFT_DELETE)
|
|
581
|
+
- [ ] Eventos en `syncedBy` deben existir como `events:` en el módulo fuente
|
|
582
|
+
- [ ] Eventos del módulo fuente consumidos por readModels → deben tener `lifecycle:` (no `triggers:`)
|
|
583
|
+
- [ ] `lifecycle:` derivado del nombre del evento: `*CreatedEvent`→`create`, `*UpdatedEvent`→`update`, `*DeletedEvent`→`delete`, `*DeactivatedEvent`→`softDelete`
|
|
584
|
+
- [ ] `lifecycle: softDelete` → entidad raíz tiene `hasSoftDelete: true`
|
|
585
|
+
- [ ] `lifecycle: delete` → entidad raíz NO tiene `hasSoftDelete: true`
|
|
586
|
+
- [ ] `lifecycle:` y `triggers:` nunca en el mismo evento
|
|
587
|
+
- [ ] Lifecycle event fields son campos de la entidad raíz (excluyendo `{entityName}Id` y `*At` temporal) — `C2-010`
|
|
588
|
+
- [ ] ReadModel fields cubiertos por eventos UPSERT del productor — `C1-007`
|
|
589
|
+
- [ ] ReadModel fields son subconjunto de los campos de la entidad raíz fuente
|
|
590
|
+
- [ ] Si `readModels:` reemplaza un `ports:`, eliminar la entrada sync correspondiente
|
|
410
591
|
- [ ] Todo en inglés
|
|
@@ -61,6 +61,17 @@ Especificación técnica narrativa del sistema completo. Una sección `##` por c
|
|
|
61
61
|
**When called:** [in which use case and under what condition]
|
|
62
62
|
**Endpoints used:** [METHOD /path list]
|
|
63
63
|
**Data obtained and how it's used:** [detailed description]
|
|
64
|
+
|
|
65
|
+
### Read Models (local projections)
|
|
66
|
+
[Only if module has readModels: in its domain.yaml]
|
|
67
|
+
|
|
68
|
+
#### {ReadModelName} ← {source-module}
|
|
69
|
+
**Purpose:** [why this projection exists — what data it provides and why sync HTTP was replaced]
|
|
70
|
+
**Source aggregate:** [{Aggregate} in {source-module}]
|
|
71
|
+
**Table:** [{tableName}]
|
|
72
|
+
**Projected fields:** [field list with types]
|
|
73
|
+
**Synced by:** [event names and actions (UPSERT/DELETE/SOFT_DELETE)]
|
|
74
|
+
**Consistency model:** Eventual — acceptable delay: [specify, e.g. "milliseconds"]
|
|
64
75
|
```
|
|
65
76
|
|
|
66
77
|
### Reglas del system.md
|
|
@@ -245,6 +256,27 @@ flowchart TD
|
|
|
245
256
|
**When called:** [in which use case and condition]
|
|
246
257
|
**Endpoints used:** [METHOD /path list]
|
|
247
258
|
**Data obtained and how it's used:** [detailed description]
|
|
259
|
+
|
|
260
|
+
## Read Models (local projections)
|
|
261
|
+
[Only if module has readModels: in its domain.yaml]
|
|
262
|
+
|
|
263
|
+
### {ReadModelName} ← {source-module}
|
|
264
|
+
**Purpose:** [why this projection exists — what data it provides and why sync HTTP was not used]
|
|
265
|
+
**Source aggregate:** [{Aggregate} in {source-module}]
|
|
266
|
+
**Table:** [{tableName}]
|
|
267
|
+
**Projected fields:** [field list with types and business meaning]
|
|
268
|
+
**Synced by:** [event name → action, for each syncedBy entry]
|
|
269
|
+
**Consistency model:** Eventual — acceptable delay: [specify]
|
|
270
|
+
**Replaces:** [sync port name, if applicable — e.g., "Replaces OrderProductService sync call"]
|
|
271
|
+
|
|
272
|
+
### Architectural Decision — {ReadModelName}
|
|
273
|
+
|
|
274
|
+
**Context:** [{module} needs {data description} from {source-module} to {business reason}]
|
|
275
|
+
**Decision:** Use event-driven Local Read Model instead of sync HTTP call.
|
|
276
|
+
**Consequences:**
|
|
277
|
+
- (+) Module is fully autonomous — no runtime dependency on {source-module}
|
|
278
|
+
- (+) Lower latency — local DB query vs HTTP roundtrip
|
|
279
|
+
- (-) Eventual consistency — milliseconds delay on updates
|
|
248
280
|
```
|
|
249
281
|
|
|
250
282
|
---
|
|
@@ -253,7 +285,7 @@ flowchart TD
|
|
|
253
285
|
|
|
254
286
|
- **Todo en inglés**: títulos, secciones, descripciones, invariantes, use cases.
|
|
255
287
|
- **INVARIANTES obligatorias**: al menos 2–3 por módulo. Analizar unicidad, estados válidos, rangos, precondiciones de transición.
|
|
256
|
-
- **Diagrama de interacciones obligatorio**: todos los endpoints y
|
|
288
|
+
- **Diagrama de interacciones obligatorio**: todos los endpoints, eventos entrantes y read models. Sin entradas → nodo `[[passive module]]`. Read models se muestran como subgraphs separados con label `Read Models` y nodos `📦 {ReadModelName}` conectados desde eventos de sincronización.
|
|
257
289
|
- **Diagrama de secuencia obligatorio**: al menos un `sequenceDiagram` cubriendo el happy path. Diagramas adicionales para bifurcaciones (error, compensación). Modelar todos los actores reales.
|
|
258
290
|
- **Diagrama de flujo por caso de uso**: `flowchart TD` dentro de cada `### {UseCase}` con trigger, invariantes, lógica y eventos.
|
|
259
291
|
- **Máquina de estados condicional**: solo si hay entidades con ciclo de vida. Restricciones de transición son invariantes implícitas.
|
|
@@ -58,6 +58,14 @@ integrations:
|
|
|
58
58
|
- module: notifications
|
|
59
59
|
useCase: NotifyOrderPlaced # acción que notifications ejecuta
|
|
60
60
|
|
|
61
|
+
# Read Model sync — consumer usa readModel: en vez de useCase:
|
|
62
|
+
- event: ProductCreatedEvent
|
|
63
|
+
producer: products
|
|
64
|
+
topic: PRODUCT_CREATED
|
|
65
|
+
consumers:
|
|
66
|
+
- module: orders
|
|
67
|
+
readModel: ProductReadModel # ← indica sync de read model, no lógica de negocio
|
|
68
|
+
|
|
61
69
|
sync:
|
|
62
70
|
- caller: orders # módulo que hace la llamada
|
|
63
71
|
calls: customers # módulo destino
|
|
@@ -78,6 +86,7 @@ integrations:
|
|
|
78
86
|
| Port names | PascalCase + sufijo `Service` — **único por módulo** | `OrderCustomerService` | `CustomerService` (compartido) |
|
|
79
87
|
| useCases | PascalCase, verbo + sustantivo | `CreateOrder`, `FindAllOrders` | `createOrder`, `orders` |
|
|
80
88
|
| `consumers[].useCase` | PascalCase, verbo + sustantivo | `HandleOrderPlaced` | `orderPlaced` |
|
|
89
|
+
| `consumers[].readModel` | PascalCase + `ReadModel` | `ProductReadModel` | `productReadModel` |
|
|
81
90
|
|
|
82
91
|
---
|
|
83
92
|
|
|
@@ -91,6 +100,31 @@ integrations:
|
|
|
91
100
|
- ✅ `consumers[].module` debe existir en `modules:`
|
|
92
101
|
- ✅ Módulos pasivos (notificaciones, auditoría) son **consumidores**, nunca `caller`
|
|
93
102
|
- ℹ️ Varios módulos pueden consumir el mismo evento sin riesgo de colisión de beans
|
|
103
|
+
- ℹ️ `consumers[]` puede usar `readModel:` en vez de `useCase:` para indicar sync de Read Model local (proyección de datos cross-module mantenida por eventos)
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Consumers: useCase vs readModel
|
|
108
|
+
|
|
109
|
+
Cada entrada en `consumers[]` debe declarar **exactamente uno** de:
|
|
110
|
+
|
|
111
|
+
| Campo | Cuándo usar | Qué genera en domain.yaml |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `useCase:` | Lógica de negocio (handler + command) | `listeners:` entry |
|
|
114
|
+
| `readModel:` | Proyección local de datos (sync handler) | `readModels:` entry con `syncedBy` |
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
consumers:
|
|
118
|
+
- module: payments
|
|
119
|
+
useCase: HandleOrderPlaced # → listeners: en payments.yaml
|
|
120
|
+
- module: orders
|
|
121
|
+
readModel: ProductReadModel # → readModels: en orders.yaml
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Reglas de `readModel:`:**
|
|
125
|
+
- PascalCase, sufijo `ReadModel`
|
|
126
|
+
- El module consumidor declara `readModels:` en su `domain.yaml` con `source.module` apuntando al producer
|
|
127
|
+
- Al reemplazar un port sync por un readModel, eliminar la entrada de `integrations.sync[]`
|
|
94
128
|
|
|
95
129
|
---
|
|
96
130
|
|
|
@@ -170,6 +204,8 @@ consumers:
|
|
|
170
204
|
- [ ] Sin dependencias circulares síncronas
|
|
171
205
|
- [ ] Todos los `consumers[].module` existen en `modules:`
|
|
172
206
|
- [ ] Todos los `consumers[].useCase` presentes y en PascalCase
|
|
207
|
+
- [ ] `consumers[]` con `readModel:` usan PascalCase + sufijo `ReadModel`
|
|
208
|
+
- [ ] Cada consumer tiene exactamente uno de `useCase:` o `readModel:` (nunca ambos)
|
|
173
209
|
- [ ] Todos los `calls.using:` existen en `exposes:` del destino
|
|
174
210
|
- [ ] Módulos pasivos no son `caller`
|
|
175
211
|
- [ ] `useCases` en PascalCase
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs-extra');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const { toCamelCase } = require('./naming');
|
|
3
4
|
|
|
4
5
|
class ConfigManager {
|
|
5
6
|
constructor(projectPath = process.cwd()) {
|
|
@@ -88,8 +89,9 @@ class ConfigManager {
|
|
|
88
89
|
async moduleExists(moduleName) {
|
|
89
90
|
const config = await this.loadProjectConfig();
|
|
90
91
|
if (!config) return false;
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
const normalized = toCamelCase(moduleName);
|
|
94
|
+
return config.modules.some(module => module.name === normalized);
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
/**
|