eva4j 1.0.16 → 1.0.18

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 (151) hide show
  1. package/AGENTS.md +220 -5
  2. package/DOMAIN_YAML_GUIDE.md +188 -3
  3. package/FUTURE_FEATURES.md +33 -52
  4. package/QUICK_REFERENCE.md +8 -4
  5. package/bin/eva4j.js +70 -2
  6. package/config/defaults.json +1 -0
  7. package/docs/CAMUNDA_DMN_GUIDE.md +1380 -0
  8. package/docs/KAFKA_PRODUCTION_CONFIG.md +441 -0
  9. package/docs/RABBITMQ_PRODUCTION_CONFIG.md +227 -0
  10. package/docs/commands/ADD_RABBITMQ_CLIENT.md +192 -0
  11. package/docs/commands/EVALUATE_SYSTEM.md +290 -10
  12. package/docs/commands/GENERATE_RABBITMQ_EVENT.md +341 -0
  13. package/docs/commands/GENERATE_RABBITMQ_LISTENER.md +595 -0
  14. package/docs/commands/GENERATE_TEMPORAL_FLOW.md +52 -12
  15. package/docs/commands/INDEX.md +27 -3
  16. package/docs/prototype/TEMPORAL_COMMUNICATION_PATTERNS.md +731 -0
  17. package/docs/prototype/TEMPORAL_DESIGN_METHODOLOGY.md +740 -0
  18. package/docs/prototype/system/RISKS.md +277 -0
  19. package/docs/prototype/system/customers.yaml +133 -0
  20. package/docs/prototype/system/inventory.yaml +109 -0
  21. package/docs/prototype/system/notifications.yaml +131 -0
  22. package/docs/prototype/system/orders.yaml +241 -0
  23. package/docs/prototype/system/payments.yaml +256 -0
  24. package/docs/prototype/system/products.yaml +168 -0
  25. package/docs/prototype/system/system.yaml +269 -0
  26. package/examples/domain-endpoints-multi-aggregate.yaml +140 -0
  27. package/examples/domain-events.yaml +26 -0
  28. package/examples/domain-read-models.yaml +113 -0
  29. package/examples/system/customer.yaml +89 -0
  30. package/examples/system/orders.yaml +119 -0
  31. package/examples/system/product.yaml +27 -0
  32. package/examples/system/system.yaml +80 -0
  33. package/package.json +1 -1
  34. package/read-model-spec.md +664 -0
  35. package/src/agents/design-gap-analyst-temporal.agent.md +452 -0
  36. package/src/agents/design-gap-analyst.agent.md +383 -0
  37. package/src/agents/design-reviewer-temporal.agent.md +412 -0
  38. package/src/agents/design-reviewer.agent.md +34 -5
  39. package/src/agents/implement-use-cases.prompt.md +179 -0
  40. package/src/agents/ux-gap-analyst.agent.md +412 -0
  41. package/src/commands/add-rabbitmq-client.js +261 -0
  42. package/src/commands/add-temporal-client.js +22 -2
  43. package/src/commands/build.js +267 -11
  44. package/src/commands/evaluate-system.js +700 -13
  45. package/src/commands/generate-entities.js +560 -24
  46. package/src/commands/generate-http-exchange.js +3 -0
  47. package/src/commands/generate-kafka-event.js +3 -0
  48. package/src/commands/generate-kafka-listener.js +3 -0
  49. package/src/commands/generate-rabbitmq-event.js +665 -0
  50. package/src/commands/generate-rabbitmq-listener.js +205 -0
  51. package/src/commands/generate-record.js +2 -2
  52. package/src/commands/generate-resource.js +4 -1
  53. package/src/commands/generate-temporal-activity.js +970 -33
  54. package/src/commands/generate-temporal-flow.js +98 -38
  55. package/src/commands/generate-temporal-system.js +708 -0
  56. package/src/commands/generate-usecase.js +4 -1
  57. package/src/skills/build-system-yaml/SKILL.md +343 -2
  58. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +253 -26
  59. package/src/skills/build-system-yaml/references/module-spec.md +90 -9
  60. package/src/skills/build-system-yaml/references/system-yaml-spec.md +36 -0
  61. package/src/skills/build-temporal-system/SKILL.md +752 -0
  62. package/src/skills/build-temporal-system/references/temporal-communication-patterns.md +167 -0
  63. package/src/skills/build-temporal-system/references/temporal-domain-yaml-spec.md +449 -0
  64. package/src/skills/build-temporal-system/references/temporal-module-spec.md +353 -0
  65. package/src/skills/build-temporal-system/references/temporal-system-yaml-spec.md +326 -0
  66. package/src/skills/implement-use-case/SKILL.md +350 -0
  67. package/src/skills/implement-use-case/references/use-case-patterns.md +980 -0
  68. package/src/skills/requirements-elicitation/SKILL.md +228 -0
  69. package/src/skills/requirements-elicitation/references/interview-framework.md +260 -0
  70. package/src/skills/requirements-elicitation/references/output-templates.md +368 -0
  71. package/src/utils/bounded-context-diagram.js +844 -0
  72. package/src/utils/config-manager.js +4 -2
  73. package/src/utils/domain-validator.js +495 -17
  74. package/src/utils/naming.js +20 -0
  75. package/src/utils/system-validator.js +169 -11
  76. package/src/utils/system-yaml-parser.js +318 -0
  77. package/src/utils/temporal-validator.js +497 -0
  78. package/src/utils/validator.js +3 -1
  79. package/src/utils/yaml-to-entity.js +281 -9
  80. package/templates/aggregate/AggregateRepository.java.ejs +4 -0
  81. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +8 -0
  82. package/templates/aggregate/AggregateRoot.java.ejs +38 -4
  83. package/templates/aggregate/DomainEventHandler.java.ejs +116 -22
  84. package/templates/aggregate/JpaAggregateRoot.java.ejs +4 -4
  85. package/templates/aggregate/JpaEntity.java.ejs +2 -2
  86. package/templates/base/docker/rabbitmq-services.yaml.ejs +12 -0
  87. package/templates/base/resources/parameters/develop/kafka.yaml.ejs +5 -0
  88. package/templates/base/resources/parameters/develop/rabbitmq.yaml.ejs +15 -0
  89. package/templates/base/resources/parameters/develop/temporal.yaml.ejs +0 -3
  90. package/templates/base/resources/parameters/local/kafka.yaml.ejs +5 -0
  91. package/templates/base/resources/parameters/local/rabbitmq.yaml.ejs +15 -0
  92. package/templates/base/resources/parameters/local/temporal.yaml.ejs +0 -3
  93. package/templates/base/resources/parameters/production/kafka.yaml.ejs +39 -8
  94. package/templates/base/resources/parameters/production/rabbitmq.yaml.ejs +32 -0
  95. package/templates/base/resources/parameters/production/temporal.yaml.ejs +0 -3
  96. package/templates/base/resources/parameters/test/kafka.yaml.ejs +12 -6
  97. package/templates/base/resources/parameters/test/rabbitmq.yaml.ejs +15 -0
  98. package/templates/base/resources/parameters/test/temporal.yaml.ejs +0 -3
  99. package/templates/base/root/AGENTS.md.ejs +1 -1
  100. package/templates/crud/DeleteCommandHandler.java.ejs +19 -1
  101. package/templates/crud/EndpointsController.java.ejs +1 -1
  102. package/templates/crud/ScaffoldCommand.java.ejs +5 -2
  103. package/templates/crud/ScaffoldCommandHandler.java.ejs +3 -1
  104. package/templates/crud/ScaffoldQuery.java.ejs +5 -2
  105. package/templates/crud/ScaffoldQueryHandler.java.ejs +3 -1
  106. package/templates/crud/SubEntityRemoveCommand.java.ejs +1 -1
  107. package/templates/crud/UpdateCommandHandler.java.ejs +53 -2
  108. package/templates/evaluate/report.html.ejs +1447 -90
  109. package/templates/kafka-event/KafkaConfigBean.java.ejs +1 -1
  110. package/templates/kafka-event/KafkaMessageBroker.java.ejs +3 -3
  111. package/templates/ports/PortAclMapper.java.ejs +35 -0
  112. package/templates/ports/PortFeignAdapter.java.ejs +7 -22
  113. package/templates/ports/PortFeignClient.java.ejs +4 -0
  114. package/templates/ports/PortResponseDto.java.ejs +1 -1
  115. package/templates/rabbitmq-event/RabbitConfigBean.java.ejs +33 -0
  116. package/templates/rabbitmq-event/RabbitConfigExchange.java.ejs +12 -0
  117. package/templates/rabbitmq-event/RabbitMessageBroker.java.ejs +35 -0
  118. package/templates/rabbitmq-event/RabbitMessageBrokerMethod.java.ejs +9 -0
  119. package/templates/rabbitmq-listener/RabbitConfigConsumerBean.java.ejs +33 -0
  120. package/templates/rabbitmq-listener/RabbitConfigConsumerExchange.java.ejs +12 -0
  121. package/templates/rabbitmq-listener/RabbitListenerClass.java.ejs +82 -0
  122. package/templates/rabbitmq-listener/RabbitListenerSimple.java.ejs +56 -0
  123. package/templates/read-model/ReadModelDomain.java.ejs +46 -0
  124. package/templates/read-model/ReadModelJpa.java.ejs +58 -0
  125. package/templates/read-model/ReadModelJpaRepository.java.ejs +13 -0
  126. package/templates/read-model/ReadModelKafkaListener.java.ejs +64 -0
  127. package/templates/read-model/ReadModelRabbitListener.java.ejs +71 -0
  128. package/templates/read-model/ReadModelRepository.java.ejs +42 -0
  129. package/templates/read-model/ReadModelRepositoryImpl.java.ejs +85 -0
  130. package/templates/read-model/ReadModelSyncHandler.java.ejs +54 -0
  131. package/templates/shared/configurations/kafkaConfig/KafkaConfig.java.ejs +18 -4
  132. package/templates/shared/configurations/rabbitmqConfig/RabbitMQConfig.java.ejs +100 -0
  133. package/templates/shared/configurations/temporalConfig/TemporalConfig.java.ejs +2 -64
  134. package/templates/shared/configurations/temporalConfig/TemporalWorkerFactoryLifecycle.java.ejs +41 -0
  135. package/templates/temporal-activity/ActivityImpl.java.ejs +68 -2
  136. package/templates/temporal-activity/ActivityInput.java.ejs +14 -0
  137. package/templates/temporal-activity/ActivityInterface.java.ejs +7 -1
  138. package/templates/temporal-activity/ActivityOutput.java.ejs +14 -0
  139. package/templates/temporal-activity/NestedType.java.ejs +12 -0
  140. package/templates/temporal-activity/SharedActivityInput.java.ejs +14 -0
  141. package/templates/temporal-activity/SharedActivityInterface.java.ejs +15 -0
  142. package/templates/temporal-activity/SharedActivityOutput.java.ejs +14 -0
  143. package/templates/temporal-activity/SharedNestedType.java.ejs +12 -0
  144. package/templates/temporal-flow/ModuleHeavyActivity.java.ejs +6 -0
  145. package/templates/temporal-flow/ModuleLightActivity.java.ejs +6 -0
  146. package/templates/temporal-flow/ModuleTemporalWorkerConfig.java.ejs +58 -0
  147. package/templates/temporal-flow/WorkFlowImpl.java.ejs +172 -12
  148. package/templates/temporal-flow/WorkFlowInput.java.ejs +11 -0
  149. package/templates/temporal-flow/WorkFlowInterface.java.ejs +5 -4
  150. package/templates/temporal-flow/WorkFlowService.java.ejs +42 -12
  151. package/COMMAND_EVALUATION.md +0 -911
@@ -49,12 +49,16 @@ async function parseDomainYaml(yamlPath, packageName = '', moduleName = '') {
49
49
  );
50
50
  }
51
51
 
52
+ const ports = parsePorts(domainData, moduleName);
53
+ const readModels = parseReadModels(domainData, moduleName, ports);
54
+
52
55
  return {
53
56
  aggregates,
54
57
  allEnums: extractAllEnums(domainData.aggregates),
55
58
  endpoints,
56
59
  listeners,
57
- ports: parsePorts(domainData, moduleName)
60
+ ports,
61
+ readModels
58
62
  };
59
63
  }
60
64
 
@@ -108,7 +112,9 @@ function parseAggregate(aggregateData) {
108
112
  name: eventName,
109
113
  fieldName: toCamelCase(eventName),
110
114
  fields: eventFields,
111
- triggers: event.triggers || []
115
+ triggers: event.triggers || [],
116
+ lifecycle: event.lifecycle || null,
117
+ notifies: event.notifies || []
112
118
  };
113
119
  });
114
120
 
@@ -122,6 +128,17 @@ function parseAggregate(aggregateData) {
122
128
  });
123
129
  });
124
130
 
131
+ // Build lifecycle map: { create → [event, ...], update → [...], delete → [...], softDelete → [...] }
132
+ // Used by templates to emit raise() calls at CRUD lifecycle points.
133
+ const VALID_LIFECYCLE_VALUES = ['create', 'update', 'delete', 'softDelete'];
134
+ const lifecycleEventsMap = {};
135
+ domainEvents.forEach(event => {
136
+ if (event.lifecycle && VALID_LIFECYCLE_VALUES.includes(event.lifecycle)) {
137
+ if (!lifecycleEventsMap[event.lifecycle]) lifecycleEventsMap[event.lifecycle] = [];
138
+ lifecycleEventsMap[event.lifecycle].push(event);
139
+ }
140
+ });
141
+
125
142
  return {
126
143
  name: toPascalCase(name),
127
144
  packageName: aggregateData.package || '',
@@ -132,6 +149,7 @@ function parseAggregate(aggregateData) {
132
149
  allEntities: parsedEntities,
133
150
  domainEvents,
134
151
  triggeredEventsMap,
152
+ lifecycleEventsMap,
135
153
  enums: aggregateEnums
136
154
  };
137
155
  }
@@ -675,14 +693,16 @@ function generateAggregateMethods(root, secondaryEntities) {
675
693
  }
676
694
 
677
695
  // remove method
696
+ const secondaryIdField = secondaryEntity ? secondaryEntity.fields.find(f => f.name === 'id') : null;
697
+ const secondaryIdType = secondaryIdField ? secondaryIdField.javaType : 'String';
678
698
  methods.push({
679
- name: `remove${toPascalCase(singularName)}`,
699
+ name: `remove${toPascalCase(singularName)}ById`,
680
700
  returnType: 'void',
681
701
  parameters: [{
682
- name: 'id',
683
- type: 'Long'
702
+ name: 'itemId',
703
+ type: secondaryIdType
684
704
  }],
685
- body: `this.${rel.fieldName}.removeIf(item -> item.getId().equals(id));`
705
+ body: `this.${rel.fieldName}.removeIf(item -> item.getId().equals(itemId));`
686
706
  });
687
707
 
688
708
  // get unmodifiable collection
@@ -1060,8 +1080,8 @@ function parseListeners(domainData) {
1060
1080
  // Normalise: strip trailing 'Event' suffix for class naming, re-add it consistently
1061
1081
  const baseName = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1062
1082
  const integrationEventClassName = `${baseName}IntegrationEvent`;
1063
- // e.g. PaymentApprovedKafkaListener
1064
- const listenerClassName = `${baseName}KafkaListener`;
1083
+ // Generic suffix — broker-specific suffix applied in generate-entities.js
1084
+ const listenerClassName = `${baseName}Listener`;
1065
1085
  const useCaseName = toPascalCase(listener.useCase);
1066
1086
  const commandClassName = `${useCaseName}Command`;
1067
1087
  const topic = listener.topic || null;
@@ -1253,6 +1273,255 @@ function parsePorts(domainData, moduleName = '') {
1253
1273
  });
1254
1274
  }
1255
1275
 
1276
+ /**
1277
+ * Derive a Kafka topic name from an event class name.
1278
+ * Strips trailing 'Event' suffix, then converts to SCREAMING_SNAKE_CASE.
1279
+ * e.g. 'ProductCreatedEvent' → 'PRODUCT_CREATED'
1280
+ * 'OrderCancelled' → 'ORDER_CANCELLED'
1281
+ * @param {string} eventName - PascalCase event name
1282
+ * @returns {string} SCREAMING_SNAKE_CASE topic name
1283
+ */
1284
+ function deriveTopicFromEventName(eventName) {
1285
+ const base = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1286
+ // PascalCase → SCREAMING_SNAKE_CASE
1287
+ return base
1288
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
1289
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
1290
+ .toUpperCase();
1291
+ }
1292
+
1293
+ /**
1294
+ * Parse the optional readModels section from domain.yaml.
1295
+ * Declares local read model projections maintained via domain events
1296
+ * from external bounded contexts.
1297
+ * @param {Object} domainData - Raw parsed YAML data
1298
+ * @param {string} moduleName - Current module name
1299
+ * @param {Array} ports - Parsed ports (for RM-009 warning)
1300
+ * @returns {Array} Parsed read models array (empty if not declared)
1301
+ */
1302
+ function parseReadModels(domainData, moduleName = '', ports = []) {
1303
+ if (!domainData.readModels || !Array.isArray(domainData.readModels)) return [];
1304
+
1305
+ const validActions = ['UPSERT', 'DELETE', 'SOFT_DELETE'];
1306
+
1307
+ return domainData.readModels.map(rm => {
1308
+ const name = toPascalCase(rm.name);
1309
+
1310
+ // RM-001: name must end with 'ReadModel'
1311
+ if (!name.endsWith('ReadModel')) {
1312
+ throw new Error(
1313
+ `[RM-001] Read model "${name}": name must end with "ReadModel" suffix. ` +
1314
+ `Rename to "${name}ReadModel".`
1315
+ );
1316
+ }
1317
+
1318
+ // RM-002: tableName must start with 'rm_'
1319
+ if (!rm.tableName || !rm.tableName.startsWith('rm_')) {
1320
+ throw new Error(
1321
+ `[RM-002] Read model "${name}": tableName must start with "rm_" prefix. ` +
1322
+ `Got: "${rm.tableName || '(empty)'}".`
1323
+ );
1324
+ }
1325
+
1326
+ // Source is required
1327
+ if (!rm.source || !rm.source.module || !rm.source.aggregate) {
1328
+ throw new Error(
1329
+ `[RM-010] Read model "${name}": source.module and source.aggregate are required.`
1330
+ );
1331
+ }
1332
+
1333
+ // RM-010: source.module must differ from current module
1334
+ if (rm.source.module === moduleName || toKebabCase(rm.source.module) === toKebabCase(moduleName)) {
1335
+ throw new Error(
1336
+ `[RM-010] Read model "${name}": source.module ("${rm.source.module}") is the same as the current module. ` +
1337
+ `Read models are for cross-module projections only.`
1338
+ );
1339
+ }
1340
+
1341
+ // Parse fields
1342
+ const fields = (rm.fields || []).map(f => ({
1343
+ name: toCamelCase(f.name),
1344
+ javaType: f.type
1345
+ }));
1346
+
1347
+ // RM-004: fields must include an 'id' field
1348
+ if (!fields.some(f => f.name === 'id')) {
1349
+ throw new Error(
1350
+ `[RM-004] Read model "${name}": fields must include an "id" field.`
1351
+ );
1352
+ }
1353
+
1354
+ // Parse syncedBy
1355
+ const syncedBy = rm.syncedBy || [];
1356
+
1357
+ // RM-005: syncedBy must have at least one entry
1358
+ if (syncedBy.length === 0) {
1359
+ throw new Error(
1360
+ `[RM-005] Read model "${name}": syncedBy must have at least one entry.`
1361
+ );
1362
+ }
1363
+
1364
+ // Derive the source aggregate's id key for payload extraction
1365
+ // e.g., source.aggregate = 'Product' → sourceIdKey = 'productId'
1366
+ const sourceBase = rm.source.aggregate.charAt(0).toLowerCase() + rm.source.aggregate.slice(1);
1367
+ const sourceIdKey = sourceBase + 'Id';
1368
+
1369
+ const parsedSyncedBy = syncedBy.map(sync => {
1370
+ const eventName = toPascalCase(sync.event);
1371
+ const action = (sync.action || '').toUpperCase();
1372
+
1373
+ // RM-006: action must be valid
1374
+ if (!validActions.includes(action)) {
1375
+ throw new Error(
1376
+ `[RM-006] Read model "${name}", event "${eventName}": ` +
1377
+ `syncedBy action must be one of ${validActions.join(', ')}. Got: "${action}".`
1378
+ );
1379
+ }
1380
+
1381
+ const baseName = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1382
+ const topic = sync.topic || deriveTopicFromEventName(eventName);
1383
+ const topicKey = topic.toLowerCase().replace(/_/g, '-');
1384
+
1385
+ return {
1386
+ event: eventName,
1387
+ eventBaseName: baseName,
1388
+ action,
1389
+ integrationEventClassName: `${baseName}IntegrationEvent`,
1390
+ listenerClassName: `${baseName}ReadModelListener`,
1391
+ topicConstant: topic,
1392
+ topicKey,
1393
+ topicSpringProperty: `\${topics.${topicKey}}`,
1394
+ topicVariableName: toCamelCase(topicKey.replace(/-/g, '_')),
1395
+ // UPSERT needs all readModel fields; DELETE/SOFT_DELETE only need the id
1396
+ // payloadKey maps readModel field names to producer payload keys
1397
+ // e.g., readModel 'id' → payload 'productId' (convention {entityName}Id)
1398
+ fields: action === 'UPSERT'
1399
+ ? fields.map(f => ({ ...f, payloadKey: f.name === 'id' ? sourceIdKey : f.name }))
1400
+ : [{ name: 'id', javaType: 'String', payloadKey: sourceIdKey }]
1401
+ };
1402
+ });
1403
+
1404
+ const hasSoftDelete = parsedSyncedBy.some(s => s.action === 'SOFT_DELETE');
1405
+ const sourceName = toPascalCase(rm.source.aggregate);
1406
+ const sourceModule = rm.source.module;
1407
+
1408
+ // RM-009: warn if ports still has sync calls to the same source module
1409
+ const conflictingPorts = ports.filter(p =>
1410
+ p.target && (p.target === sourceModule || toKebabCase(p.target) === toKebabCase(sourceModule))
1411
+ );
1412
+ if (conflictingPorts.length > 0) {
1413
+ const serviceNames = conflictingPorts.map(p => p.serviceName).join(', ');
1414
+ console.warn(
1415
+ `⚠️ [RM-009] Read model "${name}": ports: section still contains sync calls ` +
1416
+ `to module "${sourceModule}" (services: ${serviceNames}). Consider removing them ` +
1417
+ `since the read model provides local access to that data.`
1418
+ );
1419
+ }
1420
+
1421
+ return {
1422
+ name,
1423
+ sourceName,
1424
+ sourceModule,
1425
+ tableName: rm.tableName,
1426
+ fields,
1427
+ syncedBy: parsedSyncedBy,
1428
+ hasSoftDelete,
1429
+ // Derived class names
1430
+ domainClassName: name,
1431
+ jpaEntityName: `${name}Jpa`,
1432
+ jpaRepositoryName: `${name}JpaRepository`,
1433
+ repositoryName: `${name}Repository`,
1434
+ repositoryImplName: `${name}RepositoryImpl`,
1435
+ syncHandlerName: `Sync${sourceName}ReadModelHandler`
1436
+ };
1437
+ });
1438
+ }
1439
+
1440
+ /**
1441
+ * Resolve event constructor arguments for a domain event given entity context.
1442
+ * Replicates the same resolution logic used in AggregateRoot.java.ejs for transitions.
1443
+ *
1444
+ * @param {Object} event - Parsed domain event { name, fields }
1445
+ * @param {string} entityName - PascalCase aggregate root name (e.g. 'Product')
1446
+ * @param {Array} entityFields - Parsed fields of the root entity
1447
+ * @param {Array} valueObjects - Parsed value objects of the aggregate
1448
+ * @param {string} prefix - Expression prefix for getters (e.g. 'this' or 'updated' or 'entity')
1449
+ * @returns {Object} { args: string[], needsLocalDateTime: boolean, needsUUID: boolean }
1450
+ */
1451
+ function resolveEventArgs(event, entityName, entityFields, valueObjects = [], prefix = 'this') {
1452
+ const entityBase = entityName.charAt(0).toLowerCase() + entityName.slice(1);
1453
+ const args = [`${prefix}.getId()`];
1454
+ let needsLocalDateTime = false;
1455
+ let needsUUID = false;
1456
+
1457
+ (event.fields || []).forEach(ef => {
1458
+ // Skip {entityName}Id — already provided as aggregateId in the DomainEvent constructor
1459
+ if (ef.name === entityBase + 'Id') return;
1460
+
1461
+ const matched = entityFields.find(f => f.name === ef.name);
1462
+ if (matched) {
1463
+ if (matched.javaType === ef.javaType) {
1464
+ const cap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1465
+ args.push(`${prefix}.get${cap}()`);
1466
+ return;
1467
+ }
1468
+ // Type mismatch: entity field may be a VO wrapping the expected primitive/type
1469
+ const vo = (valueObjects || []).find(v => v.name === matched.javaType);
1470
+ if (vo) {
1471
+ const voSub = vo.fields.find(voF => voF.name === ef.name && voF.javaType === ef.javaType)
1472
+ || vo.fields.find(voF => voF.javaType === ef.javaType);
1473
+ if (voSub) {
1474
+ const oCap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1475
+ const sCap = voSub.name.charAt(0).toUpperCase() + voSub.name.slice(1);
1476
+ args.push(`${prefix}.get${oCap}().get${sCap}()`);
1477
+ return;
1478
+ }
1479
+ }
1480
+ // Enum → String: convert via .name()
1481
+ if (matched.isEnum && ef.javaType === 'String') {
1482
+ const cap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1483
+ args.push(`${prefix}.get${cap}().name()`);
1484
+ return;
1485
+ }
1486
+ args.push(`null /* TODO: provide ${ef.name} (entity returns ${matched.javaType}, expected ${ef.javaType}) */`);
1487
+ return;
1488
+ }
1489
+ if (ef.name.endsWith('At') && ef.javaType === 'LocalDateTime') {
1490
+ args.push('LocalDateTime.now()');
1491
+ needsLocalDateTime = true;
1492
+ return;
1493
+ }
1494
+ args.push(`null /* TODO: provide ${ef.name} */`);
1495
+ });
1496
+
1497
+ return { args, needsLocalDateTime, needsUUID };
1498
+ }
1499
+
1500
+ /**
1501
+ * Pre-compute resolved arguments for all lifecycle events.
1502
+ * Returns an enriched lifecycleEventsMap where each event has a `resolvedArgs` array.
1503
+ *
1504
+ * @param {Object} lifecycleEventsMap - { create: [event, ...], update: [...], ... }
1505
+ * @param {string} entityName - PascalCase aggregate root name
1506
+ * @param {Array} entityFields - Parsed fields of the root entity
1507
+ * @param {Array} valueObjects - Parsed value objects of the aggregate
1508
+ * @returns {Object} enriched map: { create: [{ ...event, resolvedArgs, needsLocalDateTime }], ... }
1509
+ */
1510
+ function resolveLifecycleEventArgs(lifecycleEventsMap, entityName, entityFields, valueObjects = []) {
1511
+ const resolved = {};
1512
+ // Each lifecycle type uses a different variable name in the generated code
1513
+ // update uses 'this' because raise() is now inside the entity's update() method
1514
+ const prefixMap = { create: 'this', update: 'this', delete: 'entity', softDelete: 'this' };
1515
+ for (const [lifecycle, events] of Object.entries(lifecycleEventsMap)) {
1516
+ const prefix = prefixMap[lifecycle] || 'this';
1517
+ resolved[lifecycle] = events.map(event => {
1518
+ const { args, needsLocalDateTime } = resolveEventArgs(event, entityName, entityFields, valueObjects, prefix);
1519
+ return { ...event, resolvedArgs: args, needsLocalDateTime };
1520
+ });
1521
+ }
1522
+ return resolved;
1523
+ }
1524
+
1256
1525
  module.exports = {
1257
1526
  parseDomainYaml,
1258
1527
  parseAggregate,
@@ -1263,5 +1532,8 @@ module.exports = {
1263
1532
  generateValidationImports,
1264
1533
  generateAggregateMethodImports,
1265
1534
  parseListeners,
1266
- parsePorts
1535
+ parsePorts,
1536
+ parseReadModels,
1537
+ resolveEventArgs,
1538
+ resolveLifecycleEventArgs
1267
1539
  };
@@ -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() {
@@ -3,67 +3,161 @@ package <%= packageName %>.<%= moduleName %>.application.usecases;
3
3
  import <%= packageName %>.shared.domain.annotations.ApplicationComponent;
4
4
  import org.springframework.transaction.event.TransactionPhase;
5
5
  import org.springframework.transaction.event.TransactionalEventListener;
6
- <% domainEvents.forEach(event => { %>
6
+ <% domainEvents.forEach(event => { -%>
7
7
  import <%= packageName %>.<%= moduleName %>.domain.models.events.<%= event.name %>;
8
- <% }); %>
9
- <% if (broker) { %>
10
- <% domainEvents.forEach(event => { %>
8
+ <% }); -%>
9
+ <% if (broker) { -%>
10
+ <% domainEvents.forEach(event => { -%>
11
11
  import <%= packageName %>.<%= moduleName %>.application.events.<%= event.integrationEventClassName %>;
12
- <% }); %>
12
+ <% }); -%>
13
13
  import <%= packageName %>.<%= moduleName %>.application.ports.MessageBroker;
14
- <% } %>
14
+ <% } -%>
15
+ <%
16
+ // Collect unique workflow flowPascal names from events with notifies (Temporal bridge)
17
+ // flowPascal = workflow name with "Workflow" suffix stripped (e.g. PlaceOrderWorkflow → PlaceOrder)
18
+ // The generated service class is {flowPascal}WorkFlowService (e.g. PlaceOrderWorkFlowService)
19
+ const _flowPascalSet = new Set();
20
+ const _inputClassSet = new Set();
21
+ let _needsLocalDateTimeImport = false;
22
+ if (typeof temporal !== 'undefined' && temporal) {
23
+ domainEvents.forEach(event => {
24
+ const _evFieldNames = new Set((event.fields || []).map(f => f.name));
25
+ const _aggrIdWf = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1) + 'Id';
26
+ (event.notifies || []).forEach(n => {
27
+ if (n.workflow && n.flowPascal) _flowPascalSet.add(n.flowPascal);
28
+ if (n.inputFields && n.inputFields.length > 0 && n.flowPascal) {
29
+ _inputClassSet.add(n.flowPascal + 'Input');
30
+ // Check if any unresolvable field uses LocalDateTime.now()
31
+ n.inputFields.forEach(f => {
32
+ if (f.name !== _aggrIdWf && !_evFieldNames.has(f.name)
33
+ && f.name.endsWith('At') && f.javaType === 'LocalDateTime') {
34
+ _needsLocalDateTimeImport = true;
35
+ }
36
+ });
37
+ }
38
+ });
39
+ });
40
+ }
41
+ const _flowPascals = Array.from(_flowPascalSet);
42
+ const _hasTemporalBridge = _flowPascals.length > 0;
43
+ -%>
44
+ <% if (_hasTemporalBridge) { -%>
45
+ <% if (_needsLocalDateTimeImport) { -%>
46
+ import java.time.LocalDateTime;
47
+ <% } -%>
48
+ <% _flowPascals.forEach(fp => { -%>
49
+ import <%= packageName %>.<%= moduleName %>.application.usecases.<%= fp %>WorkFlowService;
50
+ <% }); -%>
51
+ <% _inputClassSet.forEach(cls => { -%>
52
+ import <%= packageName %>.<%= moduleName %>.application.usecases.<%= cls %>;
53
+ <% }); -%>
54
+ <% } -%>
15
55
 
16
56
  /**
17
57
  * <%= aggregateName %>DomainEventHandler — Domain Event Bridge
18
58
  *
19
- * Connects the internal Spring event bus (ApplicationEventPublisher) with the
20
- * external messaging port (MessageBroker).
59
+ * Connects the internal Spring event bus (ApplicationEventPublisher) with<% if (broker && _hasTemporalBridge) { %>
60
+ * the external messaging port (MessageBroker) and Temporal workflow orchestration.<% } else if (broker) { %>
61
+ * the external messaging port (MessageBroker).<% } else if (_hasTemporalBridge) { %>
62
+ * the Temporal workflow orchestration layer.<% } else { %>
63
+ * external side effects.<% } %>
21
64
  *
22
65
  * Architecture:
23
66
  * AggregateRepositoryImpl.save()
24
67
  * → eventPublisher.publishEvent(domainEvent) [internal Spring bus]
25
68
  * → @TransactionalEventListener(AFTER_COMMIT) [this class]
69
+ <% if (broker) { -%>
26
70
  * → messageBroker.publish*(integrationEvent) [port — broker-agnostic]
71
+ <% } -%>
72
+ <% if (_hasTemporalBridge) { -%>
73
+ * → workFlowService.startAsync(workflowId) [Temporal workflow launch]
74
+ <% } -%>
27
75
  *
28
76
  * AFTER_COMMIT guarantees that external events are published only when the
29
77
  * database transaction committed successfully, preventing ghost events from
30
78
  * rolled-back operations.
31
79
  *
32
80
  * Domain Events (domain/models/events/) — internal signals scoped to this bounded context.
81
+ <% if (broker) { -%>
33
82
  * Integration Events (application/events/) — broker-facing projections; changing broker
34
83
  * technology (Kafka → RabbitMQ → SNS) only requires changing the MessageBroker adapter.
35
84
  * Domain Events — and therefore this class — never need modification.
85
+ <% } -%>
36
86
  */
37
87
  @ApplicationComponent
38
88
  public class <%= aggregateName %>DomainEventHandler {
39
- <% if (broker) { %>
40
-
41
- private final MessageBroker messageBroker;
42
-
43
- public <%= aggregateName %>DomainEventHandler(MessageBroker messageBroker) {
44
- this.messageBroker = messageBroker;
89
+ <%
90
+ // Determine constructor dependencies
91
+ const _deps = [];
92
+ if (broker) _deps.push({ type: 'MessageBroker', name: 'messageBroker' });
93
+ _flowPascals.forEach(fp => {
94
+ const svcType = fp + 'WorkFlowService';
95
+ const svcName = fp.charAt(0).toLowerCase() + fp.slice(1) + 'WorkFlowService';
96
+ _deps.push({ type: svcType, name: svcName });
97
+ });
98
+ -%>
99
+ <% if (_deps.length > 0) { %>
100
+ <% _deps.forEach(dep => { -%>
101
+ private final <%= dep.type %> <%= dep.name %>;
102
+ <% }); %>
103
+ public <%= aggregateName %>DomainEventHandler(<%= _deps.map(d => d.type + ' ' + d.name).join(', ') %>) {
104
+ <% _deps.forEach(dep => { -%>
105
+ this.<%= dep.name %> = <%= dep.name %>;
106
+ <% }); -%>
45
107
  }
46
- <% } %>
47
- <% domainEvents.forEach(event => { %>
108
+ <% } -%>
109
+ <% domainEvents.forEach(event => {
110
+ const _eventNotifies = (typeof temporal !== 'undefined' && temporal)
111
+ ? (event.notifies || []).filter(n => n.workflow)
112
+ : [];
113
+ const _hasBrokerAction = !!broker;
114
+ const _hasWorkflowAction = _eventNotifies.length > 0;
115
+ -%>
48
116
 
49
117
  /**
50
- * Handles {@link <%= event.name %>} after the wrapping transaction commits.<% if (!broker) { %>
118
+ * Handles {@link <%= event.name %>} after the wrapping transaction commits.<% if (!_hasBrokerAction && !_hasWorkflowAction) { %>
51
119
  * <p>
52
120
  * TODO: Implement the side effect for this event (e.g., send notification,
53
121
  * update a read model, trigger a saga step, etc.).<% } %>
54
122
  */
55
123
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
56
124
  public void on<%= event.name %>(<%= event.name %> event) {
57
- <% if (broker) { %>
58
- <%
125
+ <% if (_hasBrokerAction) {
59
126
  const _aggrIdField = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1) + 'Id';
60
- %>
127
+ -%>
61
128
  messageBroker.publish<%= event.integrationEventClassName %>(new <%= event.integrationEventClassName %>(<% if (event.fields && event.fields.length > 0) { event.fields.forEach(function(field, idx) { %><%= field.name === _aggrIdField ? 'event.getAggregateId()' : 'event.get' + field.name.charAt(0).toUpperCase() + field.name.slice(1) + '()' %><%= idx < event.fields.length - 1 ? ', ' : '' %><% }); } %>));
62
- <% } else { %>
129
+ <% } -%>
130
+ <% if (_hasWorkflowAction) { _eventNotifies.forEach(n => {
131
+ const _svcName = n.flowPascal.charAt(0).toLowerCase() + n.flowPascal.slice(1) + 'WorkFlowService';
132
+ const _aggrIdFieldWf = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1) + 'Id';
133
+ const _eventFieldNames = new Set((event.fields || []).map(f => f.name));
134
+ const _hasTypedInput = n.inputFields && n.inputFields.length > 0;
135
+ -%>
136
+ <% if (_hasTypedInput) { -%>
137
+ <%= _svcName %>.startAsync(
138
+ event.getAggregateId(),
139
+ new <%= n.flowPascal %>Input(<% n.inputFields.forEach(function(inputF, idx) {
140
+ let _arg;
141
+ if (inputF.name === _aggrIdFieldWf) {
142
+ _arg = 'event.getAggregateId()';
143
+ } else if (_eventFieldNames.has(inputF.name)) {
144
+ _arg = 'event.get' + inputF.name.charAt(0).toUpperCase() + inputF.name.slice(1) + '()';
145
+ } else if (inputF.name.endsWith('At') && inputF.javaType === 'LocalDateTime') {
146
+ _arg = 'LocalDateTime.now()';
147
+ } else {
148
+ _arg = 'null /* TODO: provide ' + inputF.name + ' */';
149
+ }
150
+ %><%= idx > 0 ? ', ' : '' %><%= _arg %><% }); %>)
151
+ );
152
+ <% } else { -%>
153
+ <%= _svcName %>.startAsync(event.getAggregateId());
154
+ <% } -%>
155
+ <% }); } -%>
156
+ <% if (!_hasBrokerAction && !_hasWorkflowAction) { -%>
63
157
  // TODO: handle <%= event.name %> — add your side-effect logic here
64
158
  // e.g.: notificationService.notify(event);
65
159
  // readModelUpdater.on(event);
66
- <% } %>
160
+ <% } -%>
67
161
  }
68
162
  <% }); %>
69
163
  }