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
@@ -20,6 +20,10 @@ public interface <%= rootEntity.name %>Repository {
20
20
  boolean existsById(<%= rootEntity.fields[0].javaType %> id);
21
21
  <% if (!hasSoftDelete) { %>
22
22
  void deleteById(<%= rootEntity.fields[0].javaType %> id);
23
+ <% if (hasDeleteLifecycle) { %>
24
+
25
+ void delete(<%= rootEntity.name %> <%= rootEntity.fieldName %>);
26
+ <% } %>
23
27
  <% } %>
24
28
  <% if (findByOps && findByOps.length > 0) { %>
25
29
  <% findByOps.forEach(function(op) { %>
@@ -58,6 +58,14 @@ public class <%= rootEntity.name %>RepositoryImpl implements <%= rootEntity.name
58
58
  public void deleteById(<%= rootEntity.fields[0].javaType %> id) {
59
59
  jpaRepository.deleteById(id);
60
60
  }
61
+ <% if (hasDeleteLifecycle) { %>
62
+
63
+ @Override
64
+ public void delete(<%= rootEntity.name %> <%= rootEntity.fieldName %>) {
65
+ <%= rootEntity.fieldName %>.pullDomainEvents().forEach(eventPublisher::publishEvent);
66
+ jpaRepository.deleteById(<%= rootEntity.fieldName %>.getId());
67
+ }
68
+ <% } %>
61
69
  <% } %>
62
70
  <% if (findByOps && findByOps.length > 0) { %>
63
71
  <% findByOps.forEach(function(op) { %>
@@ -19,9 +19,14 @@ import <%= packageName %>.<%= moduleName %>.domain.models.events.<%= event.name
19
19
  <% }); %>
20
20
  <% } %>
21
21
  <%
22
- const _needsLocalDateTimeImport = Object.values(triggeredEventsMap || {}).flat().some(function(ev) {
23
- return (ev.fields || []).some(function(ef) { return ef.name.endsWith('At') && ef.javaType === 'LocalDateTime'; });
24
- }) && !imports.some(function(imp) { return imp.includes('LocalDateTime'); });
22
+ const _allLifecycleEvents = Object.values(lifecycleEventsMap || {}).flat();
23
+ const _needsLocalDateTimeImport = (
24
+ Object.values(triggeredEventsMap || {}).flat().some(function(ev) {
25
+ return (ev.fields || []).some(function(ef) { return ef.name.endsWith('At') && ef.javaType === 'LocalDateTime'; });
26
+ }) ||
27
+ _allLifecycleEvents.some(function(ev) { return ev.needsLocalDateTime; })
28
+ ) && !imports.some(function(imp) { return imp.includes('LocalDateTime'); });
29
+ const _hasCreateLifecycle = ((lifecycleEventsMap || {}).create || []).length > 0;
25
30
  %>
26
31
  <% if (_needsLocalDateTimeImport) { %>
27
32
  import java.time.LocalDateTime;
@@ -36,6 +41,7 @@ import java.util.List;
36
41
  */
37
42
  public class <%= name %> {
38
43
  <% if (domainEvents && domainEvents.length > 0) { %>
44
+ <% const _needsPublicRaise = ((lifecycleEventsMap || {}).delete || []).length > 0; %>
39
45
 
40
46
  // ─── Domain Events ──────────────────────────────────────────────────────────
41
47
  private final List<DomainEvent> _domainEvents = new ArrayList<>();
@@ -44,7 +50,7 @@ public class <%= name %> {
44
50
  * Registers a domain event to be published after successful persistence.
45
51
  * Call this inside business methods when something significant happens.
46
52
  */
47
- protected void raise(DomainEvent event) {
53
+ <%= _needsPublicRaise ? 'public' : 'protected' %> void raise(DomainEvent event) {
48
54
  _domainEvents.add(event);
49
55
  }
50
56
 
@@ -93,9 +99,13 @@ public class <%= name %> {
93
99
  <% const creationFields = fields.filter(f => f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' && f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly && !f.autoInit); %>
94
100
  <% const autoInitFields = fields.filter(f => f.autoInit); %>
95
101
  <% const defaultValueFields = fields.filter(f => f.readOnly && !f.autoInit && f.javaDefaultValue); %>
102
+ <% const _createLifecycleEvents = (lifecycleEventsMap || {}).create || []; %>
96
103
  <% if (creationFields.length > 0 || autoInitFields.length > 0 || defaultValueFields.length > 0) { %>
97
104
  // Constructor for new entity creation (without id, audit fields, readOnly and auto-initialized fields)
98
105
  public <%= name %>(<% let paramIdx = 0; %><% creationFields.forEach((field, idx) => { %><% if (paramIdx > 0) { %>, <% } %><%- field.javaType %> <%= field.name %><% paramIdx++; %><% }); %>) {
106
+ <% if (_createLifecycleEvents.length > 0) { %>
107
+ this.id = java.util.UUID.randomUUID().toString();
108
+ <% } %>
99
109
  <% creationFields.forEach(field => { %>
100
110
  this.<%= field.name %> = <%= field.name %>;
101
111
  <% }); %>
@@ -104,6 +114,26 @@ public class <%= name %> {
104
114
  <% }); %>
105
115
  <% defaultValueFields.forEach(field => { %>
106
116
  this.<%= field.name %> = <%- field.javaDefaultValue %>;
117
+ <% }); %>
118
+ <% _createLifecycleEvents.forEach(function(evt) { %>
119
+ raise(new <%= evt.name %>(<%= evt.resolvedArgs.join(', ') %>));
120
+ <% }); %>
121
+ }
122
+ <% } %>
123
+ <% const _updateLifecycleEvents = (lifecycleEventsMap || {}).update || []; %>
124
+ <% if (_updateLifecycleEvents.length > 0 && creationFields.length > 0) { %>
125
+
126
+ // ─── Update Method (lifecycle: update) ───────────────────────────────────
127
+ /**
128
+ * Updates the entity fields and raises domain event(s).
129
+ * Receives final (already-merged) values — PATCH semantics are resolved by the caller.
130
+ */
131
+ public void update(<% creationFields.forEach((field, idx) => { %><%- field.javaType %> <%= field.name %><%= idx < creationFields.length - 1 ? ', ' : '' %><% }); %>) {
132
+ <% creationFields.forEach(field => { %>
133
+ this.<%= field.name %> = <%= field.name %>;
134
+ <% }); %>
135
+ <% _updateLifecycleEvents.forEach(function(evt) { %>
136
+ raise(new <%= evt.name %>(<%= evt.resolvedArgs.join(', ') %>));
107
137
  <% }); %>
108
138
  }
109
139
  <% } %>
@@ -243,6 +273,7 @@ public class <%= name %> {
243
273
  <% }); %>
244
274
  <% } %>
245
275
  <% if (hasSoftDelete) { %>
276
+ <% const _softDeleteLifecycleEvents = (lifecycleEventsMap || {}).softDelete || []; %>
246
277
 
247
278
  // ─── Soft Delete ─────────────────────────────────────────────────────────
248
279
  /**
@@ -254,6 +285,9 @@ public class <%= name %> {
254
285
  throw new IllegalStateException("<%= name %> is already deleted");
255
286
  }
256
287
  this.deletedAt = java.time.LocalDateTime.now();
288
+ <% _softDeleteLifecycleEvents.forEach(function(evt) { %>
289
+ raise(new <%= evt.name %>(<%= evt.resolvedArgs.join(', ') %>));
290
+ <% }); %>
257
291
  }
258
292
 
259
293
  public boolean isDeleted() {
@@ -68,9 +68,9 @@ if (audit && audit.trackUser) {
68
68
  private <%- field.javaTypeJpa %> <%= field.name %> = new ArrayList<>();
69
69
  <% } else { %>
70
70
  <% if (index === 0) { %>@Id
71
- <% if (field.javaType === 'String') { %>@GeneratedValue(strategy = GenerationType.UUID)
71
+ <% if (!hasCreateLifecycle) { %><% if (field.javaType === 'String') { %>@GeneratedValue(strategy = GenerationType.UUID)
72
72
  <% } else if (field.javaType === 'Long' || field.javaType === 'Integer') { %>@GeneratedValue(strategy = GenerationType.IDENTITY)
73
- <% } %><% } %><% if (field.annotations.length > 0) { %><% field.annotations.forEach(annotation => { %><%= annotation %>
73
+ <% } %><% } %><% } %><% if (field.annotations.length > 0) { %><% field.annotations.forEach(annotation => { %><%= annotation %>
74
74
  <% }); %><% } %><% if (field.isEmbedded || field.isValueObject) { %><%
75
75
  // Find the Value Object definition to get its fields
76
76
  const vo = valueObjects && valueObjects.find(v => v.name === field.javaType);
@@ -2,9 +2,20 @@ package <%= packageName %>.<%= moduleName %>.application.usecases;
2
2
 
3
3
  import <%= packageName %>.<%= moduleName %>.application.commands.Delete<%= aggregateName %>Command;
4
4
  import <%= packageName %>.<%= moduleName %>.domain.repositories.<%= aggregateName %>Repository;
5
- <% if (hasSoftDelete) { %>
5
+ <% const _deleteLifecycleEvents = (lifecycleEventsMap || {}).delete || []; %>
6
+ <% const _softDeleteLifecycleEvents2 = (lifecycleEventsMap || {}).softDelete || []; %>
7
+ <% if (hasSoftDelete || _deleteLifecycleEvents.length > 0) { %>
6
8
  import <%= packageName %>.<%= moduleName %>.domain.models.entities.<%= aggregateName %>;
7
9
  <% } %>
10
+ <% if (_deleteLifecycleEvents.length > 0) { %>
11
+ import <%= packageName %>.shared.domain.DomainEvent;
12
+ <% _deleteLifecycleEvents.forEach(function(evt) { %>
13
+ import <%= packageName %>.<%= moduleName %>.domain.models.events.<%= evt.name %>;
14
+ <% }); %>
15
+ <% if (_deleteLifecycleEvents.some(function(evt) { return evt.needsLocalDateTime; })) { %>
16
+ import java.time.LocalDateTime;
17
+ <% } %>
18
+ <% } %>
8
19
  import <%= packageName %>.shared.domain.annotations.ApplicationComponent;
9
20
  import <%= packageName %>.shared.domain.annotations.LogExceptions;
10
21
  import <%= packageName %>.shared.domain.customExceptions.NotFoundException;
@@ -33,6 +44,13 @@ public class Delete<%= aggregateName %>CommandHandler implements CommandHandler<
33
44
  .orElseThrow(() -> new NotFoundException("<%= aggregateName %> not found with id: " + command.id()));
34
45
  entity.softDelete();
35
46
  repository.save(entity);
47
+ <% } else if (_deleteLifecycleEvents.length > 0) { %>
48
+ <%= aggregateName %> entity = repository.findById(command.id())
49
+ .orElseThrow(() -> new NotFoundException("<%= aggregateName %> not found with id: " + command.id()));
50
+ <% _deleteLifecycleEvents.forEach(function(evt) { %>
51
+ entity.raise(new <%= evt.name %>(<%= evt.resolvedArgs.join(', ') %>));
52
+ <% }); %>
53
+ repository.delete(entity);
36
54
  <% } else { %>
37
55
  if (!repository.existsById(command.id())) {
38
56
  throw new NotFoundException("<%= aggregateName %> not found with id: " + command.id());
@@ -6,6 +6,7 @@ import <%= packageName %>.<%= moduleName %>.domain.repositories.<%= aggregateNam
6
6
  <% if ((commandFields || []).some(f => f.originalVoType)) { %>
7
7
  import <%= packageName %>.<%= moduleName %>.application.mappers.<%= aggregateName %>ApplicationMapper;
8
8
  <% } %>
9
+ <% const _updateLifecycleEvents = (lifecycleEventsMap || {}).update || []; %>
9
10
  import <%= packageName %>.shared.domain.annotations.ApplicationComponent;
10
11
  import <%= packageName %>.shared.domain.annotations.LogExceptions;
11
12
  import <%= packageName %>.shared.domain.customExceptions.NotFoundException;
@@ -15,8 +16,13 @@ import org.springframework.transaction.annotation.Transactional;
15
16
  /**
16
17
  * Update<%= aggregateName %>CommandHandler — PATCH
17
18
  *
19
+ <% if (_updateLifecycleEvents.length > 0) { %>
20
+ * Delegates field updates to the aggregate root's update() method,
21
+ * which raises domain events internally.
22
+ <% } else { %>
18
23
  * Reconstructs the <%= aggregateName %> aggregate using the full constructor,
19
24
  * merging each command field with the existing entity value.
25
+ <% } %>
20
26
  * Non-null command fields override the current value; null fields are preserved
21
27
  * from the loaded entity. No setters are required on the domain entity.
22
28
  */
@@ -52,12 +58,56 @@ public class Update<%= aggregateName %>CommandHandler implements CommandHandler<
52
58
  const cmdFieldMap = {};
53
59
  (commandFields || []).forEach(f => { cmdFieldMap[f.name] = f; });
54
60
 
61
+ function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
62
+
63
+ // Updatable fields: same filter as creationFields (exclude id, audit, deletedAt, readOnly, autoInit)
64
+ const updatableFields = (rootEntity.fields || []).filter(f =>
65
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
66
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly && !f.autoInit
67
+ );
68
+ %>
69
+ <% if (_updateLifecycleEvents.length > 0) { %>
70
+ // Merge: use command value when non-null, otherwise preserve existing value.
71
+ // The aggregate root's update() method raises domain events internally.
72
+ existing.update(
73
+ <% updatableFields.forEach((field, idx) => { %>
74
+ <% const cmdField = cmdFieldMap[field.name]; %>
75
+ <% const getter = `existing.get${capitalize(field.name)}()`; %>
76
+ <% const comma = idx < updatableFields.length - 1 ? ',' : ''; %>
77
+ <% if (cmdField && cmdField.originalVoType) { %>
78
+ command.<%= field.name %>() != null
79
+ ? mapper.to<%= cmdField.originalVoType %>(command.<%= field.name %>())
80
+ : <%= getter %><%= comma %>
81
+ <% } else if (cmdField) { %>
82
+ command.<%= field.name %>() != null ? command.<%= field.name %>() : <%= getter %><%= comma %>
83
+ <% } else { %>
84
+ <%= getter %><%= comma %> // preserved: <%= field.name %>
85
+ <% } %>
86
+ <% }); %>
87
+ );
88
+ <% if ((oneToManyRelationships || []).length > 0) { %>
89
+
90
+ // TODO: Collection relationships require an explicit replacement strategy.
91
+ // Choose the right approach for your domain and uncomment:
92
+ //
93
+ // Option A — Clear-and-replace (simple, loses existing entity ids):
94
+ // existing.get<collection>().clear();
95
+ // command.<collection>().forEach(dto -> entity.add<Item>(...));
96
+ //
97
+ // Option B — Merge by id (preserves audit trail, more complex):
98
+ // reconcile existing items against command items by id
99
+ <% (oneToManyRelationships || []).forEach(rel => { %>
100
+ // if (command.<%= rel.fieldName %>() != null) { /* replace <%= rel.fieldName %> */ }
101
+ <% }); %>
102
+ <% } %>
103
+
104
+ repository.save(existing);
105
+ <% } else { %>
106
+ <%
55
107
  // oneToOneRelationships are non-collection non-inverse rels → appear in the full constructor
56
108
  const ctorRels = oneToOneRelationships || [];
57
109
  const totalArgs = (rootEntity.fields || []).length + ctorRels.length;
58
110
  let argIdx = 0;
59
-
60
- function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
61
111
  %>
62
112
  // Merge: use command value when non-null, otherwise preserve existing value.
63
113
  // The full constructor is called so no setters are needed on the domain entity.
@@ -98,5 +148,6 @@ public class Update<%= aggregateName %>CommandHandler implements CommandHandler<
98
148
  <% } %>
99
149
 
100
150
  repository.save(updated);
151
+ <% } %>
101
152
  }
102
153
  }
@@ -0,0 +1,46 @@
1
+ package <%= packageName %>.<%= moduleName %>.domain.models.readmodels;
2
+ <% const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal'); %>
3
+ <% const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType)); %>
4
+ <% const needsInstant = fields && fields.some(f => f.javaType === 'Instant'); %>
5
+ <% const needsUUID = fields && fields.some(f => f.javaType === 'UUID'); %>
6
+ <% if (needsBigDecimal) { %>
7
+ import java.math.BigDecimal;
8
+ <% } %>
9
+ <% if (needsLocalDate) { %>
10
+ import java.time.LocalDate;
11
+ import java.time.LocalDateTime;
12
+ import java.time.LocalTime;
13
+ <% } %>
14
+ <% if (needsInstant) { %>
15
+ import java.time.Instant;
16
+ <% } %>
17
+ <% if (needsUUID) { %>
18
+ import java.util.UUID;
19
+ <% } %>
20
+
21
+ /**
22
+ * Local Read Model — projection of data from <%= sourceModule %>/<%= sourceName %>.
23
+ * <p>
24
+ * This class is read-only from this module's perspective.
25
+ * The source module owns mutations; this projection is maintained
26
+ * via domain events (see syncedBy configuration in domain.yaml).
27
+ * <p>
28
+ * No business logic, no setters, no empty constructor.
29
+ */
30
+ public class <%= domainClassName %> {
31
+ <% fields.forEach(field => { %>
32
+ private final <%- field.javaType %> <%= field.name %>;
33
+ <% }); %>
34
+
35
+ public <%= domainClassName %>(<%= fields.map(f => f.javaType + ' ' + f.name).join(', ') %>) {
36
+ <% fields.forEach(field => { %>
37
+ this.<%= field.name %> = <%= field.name %>;
38
+ <% }); %>
39
+ }
40
+ <% fields.forEach(field => { %>
41
+
42
+ public <%- field.javaType %> get<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>() {
43
+ return this.<%= field.name %>;
44
+ }
45
+ <% }); %>
46
+ }
@@ -0,0 +1,58 @@
1
+ package <%= packageName %>.<%= moduleName %>.infrastructure.database.entities;
2
+ <% const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal'); %>
3
+ <% const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType)); %>
4
+ <% const needsInstant = fields && fields.some(f => f.javaType === 'Instant'); %>
5
+ <% const needsUUID = fields && fields.some(f => f.javaType === 'UUID'); %>
6
+ <% if (needsBigDecimal) { %>
7
+ import java.math.BigDecimal;
8
+ <% } %>
9
+ <% if (needsLocalDate || hasSoftDelete) { %>
10
+ import java.time.LocalDate;
11
+ import java.time.LocalDateTime;
12
+ import java.time.LocalTime;
13
+ <% } %>
14
+ <% if (needsInstant) { %>
15
+ import java.time.Instant;
16
+ <% } %>
17
+ <% if (needsUUID) { %>
18
+ import java.util.UUID;
19
+ <% } %>
20
+
21
+ import jakarta.persistence.Column;
22
+ import jakarta.persistence.Entity;
23
+ import jakarta.persistence.Id;
24
+ import jakarta.persistence.Table;
25
+ import lombok.AllArgsConstructor;
26
+ import lombok.Builder;
27
+ import lombok.Getter;
28
+ import lombok.NoArgsConstructor;
29
+ import lombok.Setter;
30
+
31
+ /**
32
+ * JPA entity for the local read model — table: <%= tableName %>.
33
+ * Projection of data from <%= sourceModule %>/<%= sourceName %>.
34
+ * <p>
35
+ * No audit fields. The @Id mirrors the source entity's ID (not auto-generated).
36
+ */
37
+ @Entity
38
+ @Table(name = "<%= tableName %>")
39
+ @Getter
40
+ @Setter
41
+ @NoArgsConstructor
42
+ @AllArgsConstructor
43
+ @Builder
44
+ public class <%= jpaEntityName %> {
45
+
46
+ @Id
47
+ private String id;
48
+ <% fields.filter(f => f.name !== 'id').forEach(field => { %>
49
+
50
+ @Column(name = "<%= field.name.replace(/([A-Z])/g, '_$1').toLowerCase() %>")
51
+ private <%- field.javaType %> <%= field.name %>;
52
+ <% }); %>
53
+ <% if (hasSoftDelete) { %>
54
+
55
+ @Column(name = "deleted_at")
56
+ private LocalDateTime deletedAt;
57
+ <% } %>
58
+ }
@@ -0,0 +1,11 @@
1
+ package <%= packageName %>.<%= moduleName %>.infrastructure.database.repositories;
2
+
3
+ import <%= packageName %>.<%= moduleName %>.infrastructure.database.entities.<%= jpaEntityName %>;
4
+ import org.springframework.data.jpa.repository.JpaRepository;
5
+
6
+ /**
7
+ * Spring Data JPA repository for the <%= domainClassName %> read model.
8
+ * Table: <%= tableName %>.
9
+ */
10
+ public interface <%= jpaRepositoryName %> extends JpaRepository<<%= jpaEntityName %>, String> {
11
+ }
@@ -0,0 +1,64 @@
1
+ package <%= packageName %>.<%= moduleName %>.infrastructure.kafkaListener;
2
+ <% const isUpsert = action === 'UPSERT'; %>
3
+ <% if (isUpsert) { %>
4
+
5
+ import <%= packageName %>.<%= moduleName %>.application.events.<%= integrationEventClassName %>;
6
+ <% } %>
7
+ import <%= packageName %>.<%= moduleName %>.application.usecases.<%= syncHandlerName %>;
8
+ import <%= packageName %>.shared.infrastructure.eventEnvelope.EventEnvelope;
9
+
10
+ import com.fasterxml.jackson.databind.ObjectMapper;
11
+ import org.springframework.kafka.annotation.KafkaListener;
12
+ import org.springframework.kafka.support.Acknowledgment;
13
+ import org.springframework.stereotype.Component;
14
+
15
+ import java.util.Map;
16
+ <% if (isUpsert) { %>
17
+ <% const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal'); %>
18
+ <% const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType)); %>
19
+ <% const needsInstant = fields && fields.some(f => f.javaType === 'Instant'); %>
20
+ <% const needsUUID = fields && fields.some(f => f.javaType === 'UUID'); %>
21
+ <% if (needsBigDecimal) { %>import java.math.BigDecimal;
22
+ <% } %><% if (needsLocalDate) { %>import java.time.LocalDate;
23
+ import java.time.LocalDateTime;
24
+ import java.time.LocalTime;
25
+ <% } %><% if (needsInstant) { %>import java.time.Instant;
26
+ <% } %><% if (needsUUID) { %>import java.util.UUID;
27
+ <% } %>
28
+ <% } %>
29
+
30
+ /**
31
+ * Kafka listener for read model sync — topic: <%= topicConstant %>.
32
+ * Delegates to <%= syncHandlerName %>.on<%= eventBaseName %>().
33
+ */
34
+ @Component("<%= moduleName %>.<%= listenerClassName %>")
35
+ public class <%= listenerClassName %> {
36
+
37
+ private final <%= syncHandlerName %> syncHandler;
38
+ private final ObjectMapper objectMapper;
39
+
40
+ public <%= listenerClassName %>(<%= syncHandlerName %> syncHandler, ObjectMapper objectMapper) {
41
+ this.syncHandler = syncHandler;
42
+ this.objectMapper = objectMapper;
43
+ }
44
+
45
+ @KafkaListener(topics = "<%= topicSpringProperty %>", groupId = "${spring.application.name}-<%= moduleName %>-group")
46
+ public void handle(EventEnvelope<Map<String, Object>> event, Acknowledgment ack) {
47
+ <% if (isUpsert) { %>
48
+ <% (fields || []).forEach(f => { %>
49
+ <%- f.javaType %> <%= f.name %> = objectMapper.convertValue(event.data().get("<%= f.payloadKey || f.name %>"), <%- f.javaType %>.class);
50
+ <% }); %>
51
+
52
+ syncHandler.on<%= eventBaseName %>(new <%= integrationEventClassName %>(
53
+ <% (fields || []).forEach((f, i) => { %>
54
+ <%= f.name %><%= i < fields.length - 1 ? ',' : '' %>
55
+ <% }); %>
56
+ ));
57
+ <% } else { %>
58
+ String id = objectMapper.convertValue(event.data().get("<%= (fields[0] && fields[0].payloadKey) || 'id' %>"), String.class);
59
+ syncHandler.on<%= eventBaseName %>(id);
60
+ <% } %>
61
+
62
+ ack.acknowledge();
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ package <%= packageName %>.<%= moduleName %>.domain.repositories;
2
+ <% const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal'); %>
3
+ <% const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType)); %>
4
+ <% const needsInstant = fields && fields.some(f => f.javaType === 'Instant'); %>
5
+ <% const needsUUID = fields && fields.some(f => f.javaType === 'UUID'); %>
6
+ <% if (needsBigDecimal) { %>
7
+ import java.math.BigDecimal;
8
+ <% } %>
9
+ <% if (needsLocalDate) { %>
10
+ import java.time.LocalDate;
11
+ import java.time.LocalDateTime;
12
+ import java.time.LocalTime;
13
+ <% } %>
14
+ <% if (needsInstant) { %>
15
+ import java.time.Instant;
16
+ <% } %>
17
+ <% if (needsUUID) { %>
18
+ import java.util.UUID;
19
+ <% } %>
20
+
21
+ import <%= packageName %>.<%= moduleName %>.domain.models.readmodels.<%= domainClassName %>;
22
+
23
+ import java.util.Optional;
24
+
25
+ /**
26
+ * Repository interface for the <%= domainClassName %> read model.
27
+ * Provides read-only access and upsert/delete for event-driven sync.
28
+ */
29
+ public interface <%= repositoryName %> {
30
+
31
+ void upsert(<%= domainClassName %> readModel);
32
+
33
+ void deleteById(String id);
34
+ <% if (hasSoftDelete) { %>
35
+
36
+ void softDeleteById(String id);
37
+ <% } %>
38
+
39
+ Optional<<%= domainClassName %>> findById(String id);
40
+
41
+ boolean existsById(String id);
42
+ }
@@ -0,0 +1,81 @@
1
+ package <%= packageName %>.<%= moduleName %>.infrastructure.database.repositories;
2
+ <% const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal'); %>
3
+ <% const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType)); %>
4
+ <% const needsInstant = fields && fields.some(f => f.javaType === 'Instant'); %>
5
+ <% const needsUUID = fields && fields.some(f => f.javaType === 'UUID'); %>
6
+ <% if (needsBigDecimal) { %>
7
+ import java.math.BigDecimal;
8
+ <% } %>
9
+ <% if (needsLocalDate || hasSoftDelete) { %>
10
+ import java.time.LocalDateTime;
11
+ <% } %>
12
+ <% if (needsInstant) { %>
13
+ import java.time.Instant;
14
+ <% } %>
15
+ <% if (needsUUID) { %>
16
+ import java.util.UUID;
17
+ <% } %>
18
+
19
+ import <%= packageName %>.<%= moduleName %>.domain.models.readmodels.<%= domainClassName %>;
20
+ import <%= packageName %>.<%= moduleName %>.domain.repositories.<%= repositoryName %>;
21
+ import <%= packageName %>.<%= moduleName %>.infrastructure.database.entities.<%= jpaEntityName %>;
22
+ import org.springframework.stereotype.Repository;
23
+
24
+ import java.util.Optional;
25
+
26
+ /**
27
+ * Repository implementation for the <%= domainClassName %> read model.
28
+ * Maps between domain read model and JPA entity.
29
+ */
30
+ @Repository
31
+ public class <%= repositoryImplName %> implements <%= repositoryName %> {
32
+
33
+ private final <%= jpaRepositoryName %> jpaRepository;
34
+
35
+ public <%= repositoryImplName %>(<%= jpaRepositoryName %> jpaRepository) {
36
+ this.jpaRepository = jpaRepository;
37
+ }
38
+
39
+ @Override
40
+ public void upsert(<%= domainClassName %> readModel) {
41
+ <%= jpaEntityName %> entity = <%= jpaEntityName %>.builder()
42
+ <% fields.forEach(field => { %>
43
+ .<%= field.name %>(readModel.get<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>())
44
+ <% }); %>
45
+ .build();
46
+ jpaRepository.save(entity);
47
+ }
48
+
49
+ @Override
50
+ public void deleteById(String id) {
51
+ jpaRepository.deleteById(id);
52
+ }
53
+
54
+ @Override
55
+ public Optional<<%= domainClassName %>> findById(String id) {
56
+ return jpaRepository.findById(id).map(this::toDomain);
57
+ }
58
+
59
+ @Override
60
+ public boolean existsById(String id) {
61
+ return jpaRepository.existsById(id);
62
+ }
63
+ <% if (hasSoftDelete) { %>
64
+
65
+ @Override
66
+ public void softDeleteById(String id) {
67
+ jpaRepository.findById(id).ifPresent(entity -> {
68
+ entity.setDeletedAt(LocalDateTime.now());
69
+ jpaRepository.save(entity);
70
+ });
71
+ }
72
+ <% } %>
73
+
74
+ private <%= domainClassName %> toDomain(<%= jpaEntityName %> entity) {
75
+ return new <%= domainClassName %>(
76
+ <% fields.forEach((field, idx) => { %>
77
+ entity.get<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>()<%= idx < fields.length - 1 ? ',' : '' %>
78
+ <% }); %>
79
+ );
80
+ }
81
+ }
@@ -0,0 +1,52 @@
1
+ package <%= packageName %>.<%= moduleName %>.application.usecases;
2
+ <% const upsertEntries = syncedBy.filter(s => s.action === 'UPSERT'); %>
3
+ <% const upsertImports = [...new Set(upsertEntries.map(s => s.integrationEventClassName))]; %>
4
+ <% upsertImports.forEach(cls => { %>
5
+ import <%= packageName %>.<%= moduleName %>.application.events.<%= cls %>;
6
+ <% }); %>
7
+ import <%= packageName %>.<%= moduleName %>.domain.models.readmodels.<%= domainClassName %>;
8
+ import <%= packageName %>.<%= moduleName %>.domain.repositories.<%= repositoryName %>;
9
+ import <%= packageName %>.shared.domain.annotations.ApplicationComponent;
10
+ import <%= packageName %>.shared.domain.annotations.LogExceptions;
11
+
12
+ /**
13
+ * Sync handler for the <%= domainClassName %> read model.
14
+ * Maintains the local projection by processing events from <%= sourceModule %>/<%= sourceName %>.
15
+ * <p>
16
+ * One method per syncedBy event — Kafka listeners delegate directly to these methods.
17
+ */
18
+ @ApplicationComponent
19
+ public class <%= syncHandlerName %> {
20
+
21
+ private final <%= repositoryName %> repository;
22
+
23
+ public <%= syncHandlerName %>(<%= repositoryName %> repository) {
24
+ this.repository = repository;
25
+ }
26
+ <% syncedBy.forEach(sync => { %>
27
+ <% if (sync.action === 'UPSERT') { %>
28
+
29
+ @LogExceptions
30
+ public void on<%= sync.eventBaseName %>(<%= sync.integrationEventClassName %> event) {
31
+ <%= domainClassName %> model = new <%= domainClassName %>(
32
+ <% fields.forEach((field, idx) => { %>
33
+ event.<%= field.name %>()<%= idx < fields.length - 1 ? ',' : '' %>
34
+ <% }); %>
35
+ );
36
+ repository.upsert(model);
37
+ }
38
+ <% } else if (sync.action === 'DELETE') { %>
39
+
40
+ @LogExceptions
41
+ public void on<%= sync.eventBaseName %>(String id) {
42
+ repository.deleteById(id);
43
+ }
44
+ <% } else if (sync.action === 'SOFT_DELETE') { %>
45
+
46
+ @LogExceptions
47
+ public void on<%= sync.eventBaseName %>(String id) {
48
+ repository.softDeleteById(id);
49
+ }
50
+ <% } %>
51
+ <% }); %>
52
+ }