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
package/AGENTS.md CHANGED
@@ -616,12 +616,15 @@ public void cancel() {
616
616
  }
617
617
  ```
618
618
 
619
- Si un evento **no declara `triggers`**, el desarrollador debe llamar a `raise()` manualmente dentro del método de negocio.
619
+ Si un evento **no declara `triggers`** ni `lifecycle`, el desarrollador debe llamar a `raise()` manualmente dentro del método de negocio.
620
620
 
621
621
  **Validaciones generadas:**
622
- - **C2-004** (error): trigger referencia un método que no existe en ninguna transición del módulo
622
+ - **C2-004** (error): trigger referencia un método que no existe en ninguna transición del módulo (se omite para eventos con `lifecycle`)
623
623
  - **C2-005** (info): transición sin ningún evento asociado — considera declarar `triggers`
624
624
  - **C2-001** se silencia automáticamente para transiciones que ya tienen `triggers`
625
+ - **C2-008** (error): valor de `lifecycle` inválido (no es `create`, `update`, `delete` ni `softDelete`)
626
+ - **C2-009** (warning): `lifecycle: softDelete` sin `hasSoftDelete: true` en la entidad raíz, o `lifecycle: delete` con `hasSoftDelete: true`
627
+ - **C2-010** (error): campo de lifecycle event no existe en la entidad raíz del agregado (excluyendo `{entityName}Id` y campos `*At` + `LocalDateTime`)
625
628
 
626
629
  **Auto-wiring de broker:** Si el proyecto tiene un broker de mensajería instalado (`eva add kafka-client`), `eva g entities` genera automáticamente la capa de Integration Events para **todos** los eventos declarados — sin necesidad de ejecutar `eva g kafka-event` por separado:
627
630
 
@@ -658,6 +661,114 @@ public void confirm() {
658
661
 
659
662
  **Nota:** el flag `kafka: true` por evento ya no es necesario — todos los eventos se cablearán automáticamente cuando haya un broker instalado.
660
663
 
664
+ #### Propiedad `lifecycle`
665
+
666
+ Conecta un evento a una operación CRUD del ciclo de vida del agregado. A diferencia de `triggers` (que se conecta a métodos de transición de estado), `lifecycle` emite `raise()` automáticamente en el punto CRUD correspondiente.
667
+
668
+ **Valores válidos:**
669
+
670
+ | Valor | Punto de emisión | Descripción |
671
+ |---|---|---|
672
+ | `create` | Constructor de creación de la entidad | UUID auto-generado como id antes de raise() |
673
+ | `update` | Método `update()` de la entidad raíz | Handler llama `existing.update(...)`; `raise()` interno |
674
+ | `delete` | DeleteCommandHandler, antes de `repository.delete()` | Requiere `hasSoftDelete: false`; genera `repository.delete(entity)` |
675
+ | `softDelete` | Método `softDelete()` de la entidad | Requiere `hasSoftDelete: true`; raise() después de `this.deletedAt = ...` |
676
+
677
+ **Ejemplo en domain.yaml:**
678
+
679
+ ```yaml
680
+ events:
681
+ - name: ProductCreatedEvent
682
+ lifecycle: create
683
+ fields:
684
+ - name: productId
685
+ type: String
686
+ - name: name
687
+ type: String
688
+ - name: price
689
+ type: BigDecimal
690
+
691
+ - name: ProductUpdatedEvent
692
+ lifecycle: update
693
+ fields:
694
+ - name: productId
695
+ type: String
696
+ - name: name
697
+ type: String
698
+
699
+ - name: ProductDeletedEvent
700
+ lifecycle: delete
701
+ fields:
702
+ - name: productId
703
+ type: String
704
+ - name: deletedAt
705
+ type: LocalDateTime
706
+ ```
707
+
708
+ **Resultado generado para `lifecycle: create`:**
709
+
710
+ ```java
711
+ // En Product.java — constructor de creación
712
+ public Product(String name, String description, BigDecimal price) {
713
+ this.id = java.util.UUID.randomUUID().toString();
714
+ this.name = name;
715
+ this.description = description;
716
+ this.price = price;
717
+ raise(new ProductCreatedEvent(this.getId(), this.getName(), this.getPrice()));
718
+ }
719
+ ```
720
+
721
+ **Resultado generado para `lifecycle: update`:**
722
+
723
+ ```java
724
+ // En Product.java — método update()
725
+ public void update(String name, String description, BigDecimal price) {
726
+ this.name = name;
727
+ this.description = description;
728
+ this.price = price;
729
+ raise(new ProductUpdatedEvent(this.getId(), this.getName(), this.getPrice()));
730
+ }
731
+ ```
732
+
733
+ ```java
734
+ // En UpdateProductCommandHandler.java
735
+ Product existing = repository.findById(command.id())...;
736
+ existing.update(
737
+ command.name() != null ? command.name() : existing.getName(),
738
+ command.description() != null ? command.description() : existing.getDescription(),
739
+ command.price() != null ? command.price() : existing.getPrice()
740
+ );
741
+ repository.save(existing);
742
+ ```
743
+
744
+ **Resultado generado para `lifecycle: delete`:**
745
+
746
+ ```java
747
+ // En DeleteProductCommandHandler.java
748
+ Product entity = repository.findById(command.id())...;
749
+ entity.raise(new ProductDeletedEvent(entity.getId(), LocalDateTime.now()));
750
+ repository.delete(entity);
751
+ ```
752
+
753
+ **Resultado generado para `lifecycle: softDelete`:**
754
+
755
+ ```java
756
+ // En Product.java — método softDelete()
757
+ public void softDelete() {
758
+ if (this.deletedAt != null) { throw new IllegalStateException("..."); }
759
+ this.deletedAt = java.time.LocalDateTime.now();
760
+ raise(new ProductDeactivatedEvent(this.getId(), LocalDateTime.now()));
761
+ }
762
+ ```
763
+
764
+ **Resolución de argumentos:** usa las mismas reglas que `triggers` (match por nombre de campo, VO unwrapping, `LocalDateTime.now()` para campos *At, `null /* TODO */` para no resueltos).
765
+
766
+ **Visibilidad de `raise()`:** cuando un evento usa `lifecycle: delete`, el método `raise()` en la entidad se genera como `public` (en vez de `protected`) para permitir que los handlers de aplicación lo invoquen. Con `lifecycle: update`, `raise()` permanece `protected` porque se invoca internamente desde el método `update()` de la entidad.
767
+
768
+ **Infraestructura de repositorio:** cuando un evento usa `lifecycle: delete`, el generador agrega automáticamente `void delete(Entity entity)` al repositorio (interface + implementación). La implementación publica los eventos pendientes antes de la eliminación física.
769
+
770
+ **Restricción:** un evento puede declarar `triggers` O `lifecycle`, no ambos. `lifecycle` aplica solo a eventos de la entidad raíz del agregado.
771
+
661
772
  ### Consumo de Eventos Externos (`listeners[]`)
662
773
 
663
774
  ```yaml
@@ -821,8 +932,94 @@ aggregates:
821
932
  └── events: → Domain Events que PRODUCE (async, broker)
822
933
  listeners: → Integration Events que CONSUME (async, broker)
823
934
  ports: → Servicios HTTP que LLAMA (sync, Feign)
935
+ readModels: → Proyecciones locales mantenidas por eventos (async, broker)
824
936
  ```
825
937
 
938
+ ### Proyecciones Locales (`readModels[]`)
939
+
940
+ Un Read Model es una **proyección local de datos de otro bounded context**, mantenida mediante eventos de dominio. Elimina dependencias síncronas (HTTP) entre módulos, mejorando autonomía, resiliencia y rendimiento.
941
+
942
+ ```yaml
943
+ # Nivel raíz, sibling de aggregates:, listeners:, ports:
944
+ readModels:
945
+ - name: ProductReadModel # PascalCase + sufijo "ReadModel" (OBLIGATORIO)
946
+ source: # Trazabilidad al módulo fuente (OBLIGATORIO)
947
+ module: products # Módulo fuente (kebab-case)
948
+ aggregate: Product # Agregado fuente (PascalCase)
949
+ tableName: rm_products # Tabla en BD (OBLIGATORIO, prefijo rm_)
950
+ fields: # Campos proyectados — subconjunto del fuente
951
+ - name: id
952
+ type: String
953
+ - name: name
954
+ type: String
955
+ - name: price
956
+ type: BigDecimal
957
+ - name: status
958
+ type: String
959
+ syncedBy: # Eventos que mantienen esta tabla (min 1)
960
+ - event: ProductCreatedEvent # Nombre del evento (PascalCase + sufijo Event)
961
+ action: UPSERT # Acción: UPSERT | DELETE | SOFT_DELETE
962
+ - event: ProductUpdatedEvent
963
+ action: UPSERT
964
+ - event: ProductDeactivatedEvent
965
+ action: SOFT_DELETE
966
+ ```
967
+
968
+ ### Artefactos generados por `readModels[]`
969
+
970
+ Por cada read model:
971
+
972
+ | Archivo generado | Descripción |
973
+ |---|---|
974
+ | `domain/models/readmodels/{Name}.java` | Clase de dominio (inmutable, sin setters, sin auditoría) |
975
+ | `infrastructure/database/entities/{Name}Jpa.java` | Entidad JPA (Lombok, `@Id` NO auto-generado) |
976
+ | `infrastructure/database/repositories/{Name}JpaRepository.java` | Spring Data JPA interface |
977
+ | `domain/repositories/{Name}Repository.java` | Interfaz de repositorio (puerto) |
978
+ | `infrastructure/database/repositories/{Name}RepositoryImpl.java` | Implementación del repositorio |
979
+ | `application/usecases/Sync{Source}ReadModelHandler.java` | Handler de sincronización (un método por evento) |
980
+
981
+ Por cada entrada en `syncedBy`:
982
+
983
+ | Archivo generado | Descripción |
984
+ |---|---|
985
+ | `application/events/{EventBase}IntegrationEvent.java` | Integration Event (reutilizado si ya existe) |
986
+ | `infrastructure/kafkaListener/{EventBase}ReadModelListener.java` | Kafka listener que delega al sync handler |
987
+ | `parameters/*/kafka.yaml` | Registro del topic (actualizado) |
988
+
989
+ ### Acciones de sincronización
990
+
991
+ | Acción | Significado | Uso |
992
+ |---|---|---|
993
+ | `UPSERT` | Insertar si es nuevo, actualizar si existe | Creaciones, actualizaciones, cambios de estado |
994
+ | `DELETE` | Eliminar el registro permanentemente | Hard deletes en el módulo fuente |
995
+ | `SOFT_DELETE` | Marcar como inactivo con timestamp | Cuando el fuente usa soft delete |
996
+
997
+ > **Nota:** Para acciones `DELETE` y `SOFT_DELETE`, el listener **no usa IntegrationEvent** — extrae directamente el `id` del payload y lo pasa como `String` al `SyncHandler`. El `SyncHandler` llama a `repository.deleteById(id)` o `repository.softDeleteById(id)` respectivamente. El generador tampoco produce el archivo IntegrationEvent para estas acciones.
998
+
999
+ ### Reglas de `readModels[]`
1000
+
1001
+ - **`name:`** — PascalCase, **DEBE** terminar con `ReadModel`
1002
+ - **`tableName:`** — **DEBE** empezar con `rm_` (identificación visual en BD)
1003
+ - **`fields:`** — **DEBE** incluir un campo `id`
1004
+ - **`syncedBy:`** — **DEBE** tener al menos una entrada
1005
+ - **`source.module:`** — **NO PUEDE** ser el mismo módulo actual (readModels son para proyecciones cross-module)
1006
+ - **Topic derivado automáticamente** — Se deriva del nombre del evento: strip sufijo `Event` → SCREAMING_SNAKE_CASE. Override opcional con `topic:` explícito
1007
+ - **Sin auditoría** — Los readModels **nunca** tienen campos de auditoría
1008
+ - **Sin endpoints REST** — Los readModels **nunca** exponen endpoints REST
1009
+ - **Sin lógica de negocio** — La clase de dominio es inmutable (solo getters)
1010
+
1011
+ ### Validaciones
1012
+
1013
+ | Código | Severidad | Regla |
1014
+ |---|---|---|
1015
+ | RM-001 | ERROR | `name` debe terminar con `ReadModel` |
1016
+ | RM-002 | ERROR | `tableName` debe empezar con `rm_` |
1017
+ | RM-004 | ERROR | `fields` debe incluir un campo `id` |
1018
+ | RM-005 | ERROR | `syncedBy` debe tener al menos una entrada |
1019
+ | RM-006 | ERROR | `syncedBy[].action` debe ser `UPSERT`, `DELETE` o `SOFT_DELETE` |
1020
+ | RM-009 | WARNING | `ports:` todavía tiene llamadas sync al mismo `source.module` — considerar removerlas |
1021
+ | RM-010 | ERROR | `source.module` es el mismo módulo actual |
1022
+
826
1023
  Cuando una entidad tiene `hasSoftDelete: true`, eva4j genera eliminación lógica en lugar de física.
827
1024
 
828
1025
  ### Configuración en domain.yaml
@@ -1478,7 +1675,13 @@ Al generar o modificar código, verificar:
1478
1675
  - [ ] Value Object con comportamiento → declarar `methods` en lugar de lógica en entidad
1479
1676
  - [ ] Evento de dominio → declarar en `events[]`, publicar con `raise()` en método de negocio
1480
1677
  - [ ] Evento con `triggers: [methodName]` → el generador emite `raise()` automáticamente; args no resolubles quedan como `null /* TODO */`
1481
- - [ ] Sin `triggers` en el evento el dev llama a `raise()` manualmente
1678
+ - [ ] Evento con `lifecycle: create` el generador emite UUID auto-generado + `raise()` en el constructor de creación
1679
+ - [ ] Evento con `lifecycle: update` → el generador emite método `update()` en la entidad raíz con `raise()` interno; handler llama `existing.update(...)`; `raise()` permanece `protected`
1680
+ - [ ] Evento con `lifecycle: delete` → el generador emite `raise()` en DeleteCommandHandler + genera `repository.delete(entity)` con publicación de eventos; `raise()` se genera como `public`
1681
+ - [ ] Evento con `lifecycle: softDelete` → el generador emite `raise()` dentro del método `softDelete()` de la entidad; requiere `hasSoftDelete: true`
1682
+ - [ ] Un evento puede declarar `triggers` O `lifecycle`, no ambos
1683
+ - [ ] Campos de lifecycle events son campos de la entidad raíz (excluyendo `{entityName}Id` y `*At` temporal) — `C2-010`
1684
+ - [ ] Sin `triggers` ni `lifecycle` en el evento → el dev llama a `raise()` manualmente
1482
1685
  - [ ] Evento con broker → **no** usar `kafka: true`; si `eva add kafka-client` está instalado, `eva g entities` auto-cablea todos los eventos
1483
1686
  - [ ] Distinguir entre Domain Event (`domain/models/events/X.java`) e Integration Event (`application/events/XIntegrationEvent.java`) — cambios de broker solo afectan al adaptador `MessageBroker`
1484
1687
  - [ ] Consumo de eventos externos → declarar en `listeners[]` (nivel raíz); `topic:` obligatorio en módulos standalone
@@ -1493,9 +1696,19 @@ Al generar o modificar código, verificar:
1493
1696
  - [ ] Métodos con cuerpo (POST/PUT/PATCH) → incluir `body:`; campos de tipo objeto en `nestedTypes:`
1494
1697
  - [ ] Tipo de dominio auto-derivado del nombre del método — usar `domainType:` para sobrescribir si es necesario
1495
1698
  - [ ] Cada `service:` en `ports[]` genera: interfaz (devuelve modelos de dominio), FeignClient (devuelve DTOs infra), FeignAdapter (mapea ACL), FeignConfig + `urls.yaml`
1699
+ - [ ] Read models → declarar en `readModels[]` (nivel raíz); `name` debe terminar con `ReadModel`, `tableName` debe empezar con `rm_`
1700
+ - [ ] Read models nunca tienen auditoría, endpoints REST, ni lógica de negocio
1701
+ - [ ] Cada read model genera: clase de dominio inmutable, JPA entity (sin audit), repositorio (interface + impl), sync handler
1702
+ - [ ] Cada `syncedBy` entry genera: IntegrationEvent (reutilizado si ya existe), KafkaListener, registro de topic
1703
+ - [ ] Para `DELETE`/`SOFT_DELETE`, el listener bypasses IntegrationEvent — extrae `id` directamente y pasa `String id` al SyncHandler
1704
+ - [ ] `SOFT_DELETE` → `repository.softDeleteById(id)`; `DELETE` → `repository.deleteById(id)`; `UPSERT` → reconstruye modelo completo con IntegrationEvent + `repository.upsert()`
1705
+ - [ ] `source.module` nunca puede ser el mismo módulo (RM-010) — readModels son exclusivamente cross-module
1706
+ - [ ] ReadModel fields cubiertos por eventos UPSERT del productor — `C1-007`
1707
+ - [ ] ReadModel fields son subconjunto de los campos de la entidad raíz fuente (por C2-010, los lifecycle events no pueden emitir campos ajenos)
1708
+ - [ ] Topics de readModels se derivan automáticamente del nombre del evento (strip `Event` → SCREAMING_SNAKE_CASE)
1496
1709
 
1497
1710
  ---
1498
1711
 
1499
- **Última actualización:** 2026-03-12
1500
- **Versión de eva4j:** 1.0.14
1712
+ **Última actualización:** 2026-03-24
1713
+ **Versión de eva4j:** 1.0.15
1501
1714
  **Estado:** Documento de referencia para agentes IA
@@ -139,6 +139,9 @@ aggregates:
139
139
  # Eventos de dominio que emite este agregado (dentro del agregado)
140
140
  - name: NombreEventoOcurrido
141
141
  fields: []
142
+ # Opciones mutuamente excluyentes para emisión automática:
143
+ # triggers: [methodName] # Emite raise() dentro de un método de transición
144
+ # lifecycle: create # Emite raise() en operación CRUD (create|update|delete|softDelete)
142
145
 
143
146
  # listeners: — eventos externos que CONSUME este módulo (nivel raíz)
144
147
  listeners:
@@ -147,6 +150,18 @@ listeners:
147
150
  topic: TOPIC_NAME # Topic Kafka (obligatorio en módulos standalone)
148
151
  useCase: HandleExternalEvent # Caso de uso que maneja el evento
149
152
  fields: [] # Payload del Integration Event recibido
153
+
154
+ # readModels: — proyecciones locales de datos de otro módulo (nivel raíz)
155
+ readModels:
156
+ - name: ProductReadModel # PascalCase + sufijo ReadModel
157
+ source:
158
+ module: products # Módulo fuente
159
+ aggregate: Product # Agregado fuente
160
+ tableName: rm_products # Tabla SQL (prefijo rm_)
161
+ fields: [] # Campos proyectados
162
+ syncedBy: # Eventos que sincronizan la tabla
163
+ - event: ProductCreatedEvent
164
+ action: UPSERT # UPSERT | DELETE | SOFT_DELETE
150
165
  ```
151
166
 
152
167
  ### Ubicación del archivo
@@ -1711,13 +1726,62 @@ aggregates:
1711
1726
  |-----------|------|-------------|-------------|
1712
1727
  | `name` | String | ✅ | Nombre de la clase del evento (PascalCase) |
1713
1728
  | `fields` | Array | ✅ | Campos que transporta el evento |
1714
- | `triggers` | Array\<String\> | ➖ | Nombres de métodos de transición que publican este evento. El generador emite `raise(new XEvent(...))` automáticamente. |
1729
+ | `triggers` | Array\<String\> | ➖ | Nombres de métodos de transición que publican este evento. El generador emite `raise(new XEvent(...))` automáticamente. Mutuamente excluyente con `lifecycle`. |
1730
+ | `lifecycle` | String | ➖ | Operación CRUD que publica este evento: `create`, `update`, `delete`, `softDelete`. Mutuamente excluyente con `triggers`. |
1715
1731
  | `topic` | String | ➖ | **Override del topic Kafka.** Por defecto el generador quita el sufijo `Event` del nombre de la clase: `ProductPublishedEvent` → `PRODUCT_PUBLISHED`. Úsalo cuando el consumer en `listeners[]` de otro módulo declara un topic diferente al auto-derivado. |
1716
1732
 
1717
1733
  > **Regla de derivación de topic:** `ProductPublishedEvent` → quita `Event` → `ProductPublished` → SCREAMING_SNAKE → `PRODUCT_PUBLISHED`. Esto garantiza que el producer y el consumer coincidan cuando el consumer declara `topic: PRODUCT_PUBLISHED` en `listeners[]`.
1718
1734
 
1719
1735
  > **`kafka: true`** ya no es necesario — si el proyecto tiene `kafka-client` instalado, todos los eventos se cablearán automáticamente al ejecutar `eva g entities`.
1720
1736
 
1737
+ ### Ejemplo con `lifecycle:`
1738
+
1739
+ ```yaml
1740
+ events:
1741
+ - name: ProductCreatedEvent
1742
+ lifecycle: create # raise() en el constructor de creación
1743
+ fields:
1744
+ - name: productId
1745
+ type: String
1746
+ - name: name
1747
+ type: String
1748
+
1749
+ - name: ProductUpdatedEvent
1750
+ lifecycle: update # raise() en UpdateCommandHandler antes de save()
1751
+ fields:
1752
+ - name: productId
1753
+ type: String
1754
+ - name: name
1755
+ type: String
1756
+
1757
+ - name: ProductDeletedEvent
1758
+ lifecycle: delete # raise() en DeleteCommandHandler; requiere !hasSoftDelete
1759
+ fields:
1760
+ - name: productId
1761
+ type: String
1762
+ - name: deletedAt
1763
+ type: LocalDateTime
1764
+
1765
+ - name: ProductDeactivatedEvent
1766
+ lifecycle: softDelete # raise() en softDelete(); requiere hasSoftDelete: true
1767
+ fields:
1768
+ - name: productId
1769
+ type: String
1770
+ - name: deletedAt
1771
+ type: LocalDateTime
1772
+ ```
1773
+
1774
+ **Puntos de emisión:**
1775
+
1776
+ | `lifecycle` | Punto de emisión | UUID auto-gen | `raise()` visibility |
1777
+ |---|---|---|---|
1778
+ | `create` | Constructor de creación | ✅ `this.id = UUID.randomUUID().toString()` | `protected` |
1779
+ | `update` | `UpdateCommandHandler`, antes de `repository.save()` | — | `public` |
1780
+ | `delete` | `DeleteCommandHandler`, antes de `repository.delete()` | — | `public` |
1781
+ | `softDelete` | Método `softDelete()` de la entidad | — | `protected` |
1782
+
1783
+ > **Validaciones:** `C2-008` (error) si el valor de lifecycle no es válido. `C2-009` (warning) si `lifecycle: softDelete` sin `hasSoftDelete: true`, o `lifecycle: delete` con `hasSoftDelete: true`. `C2-010` (error) si un campo del lifecycle event no existe en la entidad raíz del agregado.
1784
+
1721
1785
  ### Archivos generados
1722
1786
 
1723
1787
  Para cada evento, eva4j genera dos archivos:
@@ -1946,7 +2010,9 @@ domain.yaml
1946
2010
 
1947
2011
  ├── listeners: → Integration Events que CONSUME (infrastructure/kafkaListener/)
1948
2012
 
1949
- └── ports: → Servicios HTTP que LLAMA (infrastructure/adapters/{service}/)
2013
+ ├── ports: → Servicios HTTP que LLAMA (infrastructure/adapters/{service}/)
2014
+
2015
+ └── readModels: → Proyecciones locales mantenidas por eventos (infrastructure/kafkaListener/)
1950
2016
  ```
1951
2017
 
1952
2018
  ---
@@ -2082,6 +2148,123 @@ Ver ejemplo completo en [`examples/domain-ports.yaml`](examples/domain-ports.yam
2082
2148
 
2083
2149
  ---
2084
2150
 
2151
+ ## Sección readModels
2152
+
2153
+ `readModels:` es una sección de nivel raíz (sibling de `aggregates:`, `listeners:` y `ports:`) que declara **proyecciones locales de datos de otro bounded context**, mantenidas mediante eventos de dominio. Elimina dependencias síncronas (HTTP) entre módulos.
2154
+
2155
+ ### Estructura completa
2156
+
2157
+ ```yaml
2158
+ readModels:
2159
+ - name: ProductReadModel # PascalCase + sufijo "ReadModel" (OBLIGATORIO)
2160
+ source: # Trazabilidad al módulo fuente (OBLIGATORIO)
2161
+ module: products # Módulo fuente (kebab-case)
2162
+ aggregate: Product # Agregado fuente (PascalCase)
2163
+ tableName: rm_products # Tabla en BD (OBLIGATORIO, prefijo rm_)
2164
+ fields: # Campos proyectados (OBLIGATORIO, debe incluir id)
2165
+ - name: id
2166
+ type: String
2167
+ - name: name
2168
+ type: String
2169
+ - name: price
2170
+ type: BigDecimal
2171
+ - name: status
2172
+ type: String
2173
+ syncedBy: # Eventos que mantienen la tabla (OBLIGATORIO, min 1)
2174
+ - event: ProductCreatedEvent # Nombre del evento (PascalCase)
2175
+ action: UPSERT # UPSERT | DELETE | SOFT_DELETE
2176
+ - event: ProductUpdatedEvent
2177
+ action: UPSERT
2178
+ - event: ProductDeactivatedEvent
2179
+ action: SOFT_DELETE
2180
+ ```
2181
+
2182
+ ### Propiedades
2183
+
2184
+ | Propiedad | Tipo | Obligatorio | Descripción |
2185
+ |---|---|---|---|
2186
+ | `name` | String | SÍ | Nombre del read model. **DEBE** terminar con `ReadModel`. PascalCase. |
2187
+ | `source` | Object | SÍ | Trazabilidad al módulo y agregado fuente. |
2188
+ | `source.module` | String | SÍ | Módulo que posee los datos. Kebab-case. |
2189
+ | `source.aggregate` | String | SÍ | Agregado fuente. PascalCase. |
2190
+ | `tableName` | String | SÍ | Nombre de tabla SQL. **DEBE** empezar con `rm_`. |
2191
+ | `fields` | Array | SÍ | Campos a proyectar. Debe incluir `id`. |
2192
+ | `syncedBy` | Array | SÍ | Eventos que sincronizan esta tabla. Mínimo 1. |
2193
+ | `syncedBy[].event` | String | SÍ | Nombre del evento. PascalCase. |
2194
+ | `syncedBy[].action` | String | SÍ | Uno de: `UPSERT`, `DELETE`, `SOFT_DELETE`. |
2195
+ | `syncedBy[].topic` | String | NO | Override del topic Kafka. Por defecto se auto-deriva del nombre del evento. |
2196
+
2197
+ ### Acciones de sincronización
2198
+
2199
+ | Acción | Significado | Cuándo usarla |
2200
+ |---|---|---|
2201
+ | `UPSERT` | Insertar si no existe, actualizar si existe | Creaciones, actualizaciones, cambios de estado |
2202
+ | `DELETE` | Eliminar permanentemente | Hard deletes en el módulo fuente |
2203
+ | `SOFT_DELETE` | Marcar como inactivo con timestamp | Cuando el fuente usa soft delete |
2204
+
2205
+ ### Derivación automática de topics
2206
+
2207
+ El topic se deriva del nombre del evento: se elimina el sufijo `Event` y se convierte a SCREAMING_SNAKE_CASE:
2208
+
2209
+ - `ProductCreatedEvent` → `PRODUCT_CREATED`
2210
+ - `CustomerUpdatedEvent` → `CUSTOMER_UPDATED`
2211
+ - `OrderCancelled` → `ORDER_CANCELLED`
2212
+
2213
+ Para sobrescribir, usar `topic:` explícito en la entrada de `syncedBy`.
2214
+
2215
+ ### Artefactos generados
2216
+
2217
+ **Por cada read model (6 archivos):**
2218
+
2219
+ | Archivo | Descripción |
2220
+ |---|---|
2221
+ | `domain/models/readmodels/{Name}.java` | Clase de dominio inmutable (sin setters, sin auditoría) |
2222
+ | `infrastructure/database/entities/{Name}Jpa.java` | Entidad JPA con Lombok. `@Id` no auto-generado |
2223
+ | `infrastructure/database/repositories/{Name}JpaRepository.java` | Spring Data JPA interface |
2224
+ | `domain/repositories/{Name}Repository.java` | Interfaz de repositorio (puerto) |
2225
+ | `infrastructure/database/repositories/{Name}RepositoryImpl.java` | Implementación del repositorio |
2226
+ | `application/usecases/Sync{Source}ReadModelHandler.java` | Handler con un método por evento |
2227
+
2228
+ **Por cada entrada en `syncedBy` (hasta 3 archivos):**
2229
+
2230
+ | Archivo | Descripción |
2231
+ |---|---|
2232
+ | `application/events/{EventBase}IntegrationEvent.java` | Integration Event (reutilizado si ya existe) |
2233
+ | `infrastructure/kafkaListener/{EventBase}ReadModelListener.java` | Kafka listener que delega al sync handler |
2234
+ | `parameters/*/kafka.yaml` | Registro del topic |
2235
+
2236
+ ### Validaciones
2237
+
2238
+ | Código | Severidad | Regla |
2239
+ |---|---|---|
2240
+ | RM-001 | ERROR | `name` debe terminar con `ReadModel` |
2241
+ | RM-002 | ERROR | `tableName` debe empezar con `rm_` |
2242
+ | RM-004 | ERROR | `fields` debe incluir un campo `id` |
2243
+ | RM-005 | ERROR | `syncedBy` debe tener al menos una entrada |
2244
+ | RM-006 | ERROR | `syncedBy[].action` debe ser `UPSERT`, `DELETE` o `SOFT_DELETE` |
2245
+ | RM-008 / C1-007 | WARNING | Campo del readModel no cubierto por ningún evento UPSERT del productor — siempre será null |
2246
+ | RM-009 | WARNING | `ports:` tiene llamadas sync al mismo `source.module` — considerar eliminarlas |
2247
+ | RM-010 | ERROR | `source.module` es el mismo módulo actual |
2248
+
2249
+ ### Diferencias con agregados
2250
+
2251
+ | Característica | Aggregate | Read Model |
2252
+ |---|---|---|
2253
+ | Sección YAML | `aggregates:` | `readModels:` |
2254
+ | Prefijo de tabla | Ninguno | `rm_` |
2255
+ | Lógica de negocio | Sí | No |
2256
+ | Emite eventos | Sí | Nunca |
2257
+ | Endpoints REST | Opcional | Nunca |
2258
+ | Clase de dominio | Entidad rica | Clase inmutable |
2259
+ | Campos de auditoría | Opcionales | Nunca |
2260
+ | `source:` requerido | No | Sí |
2261
+
2262
+ ### Referencia
2263
+
2264
+ Ver ejemplo completo en [`examples/domain-read-models.yaml`](examples/domain-read-models.yaml).
2265
+
2266
+ ---
2267
+
2085
2268
  ## Relaciones
2086
2269
 
2087
2270
  eva4j soporta relaciones JPA bidireccionales completas con generación automática del lado inverso.
@@ -459,66 +459,47 @@ public ResponseEntity<ErrorDto> handleOptimisticLock(ObjectOptimisticLockingFail
459
459
 
460
460
  ---
461
461
 
462
- ## 6. Read Models Separados (Proyecciones)
462
+ ## 6. Read Models Separados (Proyecciones)
463
+
464
+ > **Implementado en v1.0.15** — Sección `readModels:` en `domain.yaml` con generación automática de proyecciones locales mantenidas por eventos Kafka. Ver [`DOMAIN_YAML_GUIDE.md`](DOMAIN_YAML_GUIDE.md#sección-readmodels) y [`read-model-spec.md`](read-model-spec.md).
463
465
 
464
466
  ### Descripción
465
467
 
466
- En CQRS puro, el lado de lectura puede tener su propio modelo optimizado para consultas, independiente del modelo de escritura. Los `*ResponseDto` actuales son transformaciones directas del dominio, suficiente para casos simples pero insuficientes para reportes o vistas que joinean múltiples agregados.
468
+ Proyecciones locales de datos de otro bounded context, mantenidas mediante eventos de dominio. Elimina dependencias síncronas (HTTP) entre módulos.
467
469
 
468
- ### Sintaxis Propuesta
470
+ ### Sintaxis Implementada
469
471
 
470
472
  ```yaml
471
- aggregates:
472
- - name: Order
473
- readModels:
474
- - name: OrderSummary
475
- description: "Vista desnormalizada para listados"
476
- fields:
477
- - name: id
478
- type: String
479
- - name: orderNumber
480
- type: String
481
- - name: customerName
482
- type: String
483
- - name: totalAmount
484
- type: BigDecimal
485
- - name: itemCount
486
- type: Integer
487
- - name: status
488
- type: OrderStatus
489
- source: native_query
473
+ readModels:
474
+ - name: ProductReadModel
475
+ source:
476
+ module: products
477
+ aggregate: Product
478
+ tableName: rm_products
479
+ fields:
480
+ - name: id
481
+ type: String
482
+ - name: name
483
+ type: String
484
+ - name: price
485
+ type: BigDecimal
486
+ syncedBy:
487
+ - event: ProductCreatedEvent
488
+ action: UPSERT
489
+ - event: ProductUpdatedEvent
490
+ action: UPSERT
491
+ - event: ProductDeactivatedEvent
492
+ action: SOFT_DELETE
490
493
  ```
491
494
 
492
- ### Código Generado
495
+ ### Artefactos Generados
493
496
 
494
- ```java
495
- public interface OrderSummaryProjection {
496
- String getId();
497
- String getOrderNumber();
498
- String getCustomerName();
499
- BigDecimal getTotalAmount();
500
- Integer getItemCount();
501
- OrderStatus getStatus();
502
- }
503
- ```
504
-
505
- ```java
506
- @Query(value = """
507
- SELECT
508
- o.id,
509
- o.order_number AS orderNumber,
510
- c.name AS customerName,
511
- o.total_amount AS totalAmount,
512
- COUNT(i.id) AS itemCount,
513
- o.status
514
- FROM orders o
515
- JOIN customers c ON c.id = o.customer_id
516
- LEFT JOIN order_items i ON i.order_id = o.id
517
- WHERE o.deleted = false
518
- GROUP BY o.id, c.name
519
- """, nativeQuery = true)
520
- Page<OrderSummaryProjection> findOrderSummaries(Pageable pageable);
521
- ```
497
+ - Clase de dominio inmutable (`domain/models/readmodels/`)
498
+ - Entidad JPA sin auditoría (`infrastructure/database/entities/`)
499
+ - Repositorio (interfaz + impl)
500
+ - Sync handler con un método por evento
501
+ - Kafka listeners (uno por entry en `syncedBy`)
502
+ - Integration Events (reutilizados si ya existen)
522
503
 
523
504
  ---
524
505
 
@@ -1607,7 +1588,7 @@ Sin DataFaker, el seeder genera datos con patrones simples (`"User 0"`, `"user0@
1607
1588
  | 3 | Soft Delete Completo | Alta | Baja | ✅ Implementado |
1608
1589
  | 4 | Paginación en Queries | Impl. | -- | ✅ Implementado |
1609
1590
  | 5 | Optimistic Locking | Media | Baja | Pendiente |
1610
- | 6 | Read Models / Proyecciones | Media | Alta | Pendiente |
1591
+ | 6 | Read Models / Proyecciones | Media | Alta | Implementado |
1611
1592
  | 7 | Enums con Transiciones | Impl. | -- | ✅ Implementado |
1612
1593
  | 8 | Specifications Pattern | Media | Media | Pendiente |
1613
1594
  | 9 | JSON Schema para domain.yaml | Tooling | Media | Pendiente |
@@ -472,7 +472,7 @@ Verifies that the producer → consumer graph is coherent at the code level: eve
472
472
  | C1-002 | 🔴 error | `listeners[]` references an event that no domain module produces |
473
473
  | C1-003 | 🔴 error | Field in `listener.fields` does not exist in the producer event |
474
474
  | C1-004 | 🔴 error | Field exists in both producer and consumer but with incompatible types |
475
- | C1-005 | 🔴 error | `system.yaml` registers a module as consumer but that module has no matching `listener` |
475
+ | C1-005 | 🔴 error | `system.yaml` registers a module as consumer but that module has no matching `listener` or `readModels[].syncedBy` |
476
476
  | C1-006 | 🔴 error | `listener.producer` references the wrong producer module |
477
477
 
478
478
  #### C1-001 — Produced event with no consumers in system.yaml
@@ -534,7 +534,9 @@ listeners:
534
534
 
535
535
  **Fix:** Align the field type in the listener with the type declared in the producer event.
536
536
 
537
- #### C1-005 — Registered consumer has no listener in domain.yaml
537
+ #### C1-005 — Registered consumer has no listener or readModel.syncedBy in domain.yaml
538
+
539
+ For `useCase:` consumers, the module must have a matching `listeners[]` entry:
538
540
 
539
541
  ```yaml
540
542
  # system.yaml registers notifications as consumer:
@@ -548,6 +550,20 @@ consumers:
548
550
 
549
551
  **Fix:** Add a `listeners[]` entry for `OrderCreatedEvent` in `notifications/domain.yaml`.
550
552
 
553
+ For `readModel:` consumers, the module must have a matching `readModels[].syncedBy[]` entry:
554
+
555
+ ```yaml
556
+ # system.yaml registers orders as readModel consumer:
557
+ consumers:
558
+ - module: orders
559
+ readModel: ProductReadModel
560
+ # orders/domain.yaml must have readModels[].syncedBy[].event matching the event
561
+ ```
562
+
563
+ **Message:** `[C1-005] system.yaml registra 'orders' como consumidor readModel de 'ProductCreatedEvent' pero el módulo no tiene readModels[].syncedBy con ese evento`
564
+
565
+ **Fix:** Add a `readModels[]` entry with a `syncedBy[]` entry for the event in the consumer module's `domain.yaml`.
566
+
551
567
  #### C1-006 — listener.producer references wrong module
552
568
 
553
569
  ```yaml
@@ -305,6 +305,32 @@ aggregates:
305
305
  - name: estimatedDeliveryDate # no resuelto → null /* TODO */
306
306
  type: LocalDate
307
307
 
308
+ # ─── Eventos con lifecycle: (alterntiva a triggers:) ──────────────
309
+ # Cuando el evento se produce en una operación CRUD y no en una transición de estado,
310
+ # se usa lifecycle: en lugar de triggers:
311
+ # El generador emite raise() automáticamente en el punto CRUD correspondiente.
312
+
313
+ - name: OrderCreatedEvent
314
+ lifecycle: create # raise() en el constructor de creación
315
+ fields: # UUID auto-generado como id antes de raise()
316
+ - name: orderId
317
+ type: String
318
+ - name: customerName # → this.getCustomerName() si existe en la entidad
319
+ type: String
320
+ - name: createdAt
321
+ type: LocalDateTime # → LocalDateTime.now()
322
+
323
+ - name: OrderUpdatedEvent
324
+ lifecycle: update # raise() en el método update() de la entidad raíz
325
+ fields:
326
+ - name: orderId
327
+ type: String
328
+ - name: customerName
329
+ type: String
330
+
331
+ # lifecycle: delete requiere hasSoftDelete: false en la entidad raíz
332
+ # lifecycle: softDelete requiere hasSoftDelete: true en la entidad raíz
333
+
308
334
  # ─── Eventos externos que este módulo CONSUME ────────────────────────────────
309
335
  # Nivel raíz, sibling de aggregates:
310
336
  # Requiere broker instalado (eva add kafka-client) para generación.