eva4j 1.0.13 → 1.0.15

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 (106) hide show
  1. package/AGENTS.md +314 -10
  2. package/COMMAND_EVALUATION.md +15 -16
  3. package/DOMAIN_YAML_GUIDE.md +576 -10
  4. package/FUTURE_FEATURES.md +1627 -1168
  5. package/README.md +318 -13
  6. package/bin/eva4j.js +34 -0
  7. package/config/defaults.json +1 -0
  8. package/design-system.md +797 -0
  9. package/docs/commands/EVALUATE_SYSTEM.md +994 -0
  10. package/docs/commands/GENERATE_ENTITIES.md +795 -6
  11. package/docs/commands/INDEX.md +10 -1
  12. package/examples/domain-endpoints-relations.yaml +353 -0
  13. package/examples/domain-endpoints-versioned.yaml +144 -0
  14. package/examples/domain-endpoints.yaml +135 -0
  15. package/examples/domain-events.yaml +166 -20
  16. package/examples/domain-listeners.yaml +212 -0
  17. package/examples/domain-one-to-many.yaml +1 -0
  18. package/examples/domain-one-to-one.yaml +1 -0
  19. package/examples/domain-ports.yaml +414 -0
  20. package/examples/domain-soft-delete.yaml +47 -44
  21. package/examples/system/notification.yaml +147 -0
  22. package/examples/system/product.yaml +185 -0
  23. package/examples/system/system.yaml +112 -0
  24. package/examples/system-report.html +971 -0
  25. package/examples/system.yaml +332 -0
  26. package/package.json +2 -1
  27. package/src/commands/build.js +714 -0
  28. package/src/commands/create.js +7 -3
  29. package/src/commands/detach.js +1 -0
  30. package/src/commands/evaluate-system.js +610 -0
  31. package/src/commands/generate-entities.js +1331 -49
  32. package/src/commands/generate-http-exchange.js +2 -0
  33. package/src/commands/generate-kafka-event.js +98 -11
  34. package/src/generators/base-generator.js +8 -1
  35. package/src/generators/postman-generator.js +188 -0
  36. package/src/generators/shared-generator.js +10 -0
  37. package/src/utils/config-manager.js +54 -0
  38. package/src/utils/context-builder.js +1 -0
  39. package/src/utils/domain-diagram.js +192 -0
  40. package/src/utils/domain-validator.js +970 -0
  41. package/src/utils/fake-data.js +376 -0
  42. package/src/utils/naming.js +3 -2
  43. package/src/utils/system-validator.js +434 -0
  44. package/src/utils/yaml-to-entity.js +302 -8
  45. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  46. package/templates/aggregate/AggregateRepository.java.ejs +8 -2
  47. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
  48. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  49. package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
  50. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  51. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  52. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  53. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  54. package/templates/base/gradle/build.gradle.ejs +3 -2
  55. package/templates/base/root/AGENTS.md.ejs +306 -45
  56. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
  57. package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
  58. package/templates/base/root/system.yaml.ejs +97 -0
  59. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  60. package/templates/crud/Controller.java.ejs +4 -4
  61. package/templates/crud/CreateCommand.java.ejs +4 -0
  62. package/templates/crud/CreateItemDto.java.ejs +4 -0
  63. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  64. package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
  65. package/templates/crud/EndpointsController.java.ejs +178 -0
  66. package/templates/crud/FindByQuery.java.ejs +17 -0
  67. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  68. package/templates/crud/ListQuery.java.ejs +1 -1
  69. package/templates/crud/ListQueryHandler.java.ejs +8 -8
  70. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  71. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  72. package/templates/crud/ScaffoldQuery.java.ejs +13 -0
  73. package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
  74. package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
  75. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  76. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  77. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  78. package/templates/crud/TransitionCommand.java.ejs +9 -0
  79. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  80. package/templates/crud/UpdateCommand.java.ejs +4 -0
  81. package/templates/evaluate/report.html.ejs +1363 -0
  82. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  83. package/templates/kafka-event/Event.java.ejs +16 -0
  84. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  85. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  86. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  87. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  88. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
  89. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  90. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  91. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  92. package/templates/mock/MockEvent.java.ejs +10 -0
  93. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  94. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  95. package/templates/mock/SpringEventListener.java.ejs +61 -0
  96. package/templates/ports/PortDomainModel.java.ejs +35 -0
  97. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  98. package/templates/ports/PortFeignClient.java.ejs +45 -0
  99. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  100. package/templates/ports/PortInterface.java.ejs +45 -0
  101. package/templates/ports/PortNestedType.java.ejs +28 -0
  102. package/templates/ports/PortRequestDto.java.ejs +30 -0
  103. package/templates/ports/PortResponseDto.java.ejs +28 -0
  104. package/templates/postman/Collection.json.ejs +1 -1
  105. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  106. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
@@ -1,7 +1,7 @@
1
1
  const yaml = require('js-yaml');
2
2
  const fs = require('fs-extra');
3
3
  const pluralize = require('pluralize');
4
- const { toPascalCase, toCamelCase, toSnakeCase } = require('./naming');
4
+ const { toPascalCase, toCamelCase, toSnakeCase, toKebabCase } = require('./naming');
5
5
 
6
6
  /**
7
7
  * Parse domain.yaml and extract aggregates with entities and value objects
@@ -23,10 +23,38 @@ async function parseDomainYaml(yamlPath, packageName = '', moduleName = '') {
23
23
  packageName,
24
24
  moduleName
25
25
  }));
26
-
26
+
27
+ const endpoints = parseEndpoints(domainData);
28
+ const listeners = parseListeners(domainData);
29
+
30
+ // ── C2-006: useCase name collision between endpoints and listeners ─────────
31
+ // Both sections generate a "{UseCase}Command.java". The generator processes
32
+ // listeners first, then endpoints — the endpoint run silently overwrites the
33
+ // listener command, leaving the KafkaListener dispatching a constructor that
34
+ // no longer exists → compile error.
35
+ const endpointUseCases = new Set(
36
+ endpoints ? endpoints.versions.flatMap(v => v.operations.map(op => op.useCase)) : []
37
+ );
38
+ const collisions = listeners
39
+ .map(l => l.useCase)
40
+ .filter(uc => endpointUseCases.has(uc));
41
+ if (collisions.length > 0) {
42
+ throw new Error(
43
+ `[C2-006] useCase name collision in domain.yaml:\n` +
44
+ collisions.map(uc =>
45
+ ` - "${uc}" appears in both endpoints: (operation) and listeners: (useCase).\n` +
46
+ ` Both would generate "${uc}Command.java" — the endpoint version overwrites the listener version.\n` +
47
+ ` Fix: rename the listener useCase, e.g. "${uc.replace(/^Create/, 'Initialize')}".`
48
+ ).join('\n')
49
+ );
50
+ }
51
+
27
52
  return {
28
53
  aggregates,
29
- allEnums: extractAllEnums(domainData.aggregates)
54
+ allEnums: extractAllEnums(domainData.aggregates),
55
+ endpoints,
56
+ listeners,
57
+ ports: parsePorts(domainData, moduleName)
30
58
  };
31
59
  }
32
60
 
@@ -79,10 +107,21 @@ function parseAggregate(aggregateData) {
79
107
  return {
80
108
  name: eventName,
81
109
  fieldName: toCamelCase(eventName),
82
- fields: eventFields
110
+ fields: eventFields,
111
+ triggers: event.triggers || []
83
112
  };
84
113
  });
85
114
 
115
+ // Build inverse map: { methodName → [event, ...] }
116
+ // Used by the AggregateRoot template to emit raise() calls inside transition methods.
117
+ const triggeredEventsMap = {};
118
+ domainEvents.forEach(event => {
119
+ (event.triggers || []).forEach(method => {
120
+ if (!triggeredEventsMap[method]) triggeredEventsMap[method] = [];
121
+ triggeredEventsMap[method].push(event);
122
+ });
123
+ });
124
+
86
125
  return {
87
126
  name: toPascalCase(name),
88
127
  packageName: aggregateData.package || '',
@@ -91,7 +130,9 @@ function parseAggregate(aggregateData) {
91
130
  valueObjects: parsedValueObjects,
92
131
  aggregateMethods,
93
132
  allEntities: parsedEntities,
94
- domainEvents
133
+ domainEvents,
134
+ triggeredEventsMap,
135
+ enums: aggregateEnums
95
136
  };
96
137
  }
97
138
 
@@ -107,8 +148,16 @@ function parseAggregate(aggregateData) {
107
148
  * @returns {Object} Parsed entity
108
149
  */
109
150
  function parseEntity(entityData, aggregateName, packageName = '', moduleName = '', aggregateEnums = [], valueObjectNames = [], inverseRelationships = {}) {
110
- const { name, isRoot = false, tableName, properties, fields: fieldsYaml, relationships = [], auditable = false, audit } = entityData;
151
+ const { name, isRoot = false, tableName, properties, fields: fieldsYaml, relationships = [], auditable = false, audit, hasSoftDelete = false } = entityData;
111
152
 
153
+ // Validate hasSoftDelete
154
+ if (hasSoftDelete !== undefined && typeof hasSoftDelete !== 'boolean') {
155
+ throw new Error(`Entity "${name}": hasSoftDelete must be a boolean (true/false)`);
156
+ }
157
+ if (hasSoftDelete === true && isRoot === false) {
158
+ console.warn(`⚠️ Entity "${name}": hasSoftDelete is only supported on the aggregate root (isRoot: true). It will be ignored for secondary entities.`);
159
+ }
160
+
112
161
  // Accept both 'properties' and 'fields' field names
113
162
  let entityFields = properties || fieldsYaml || [];
114
163
 
@@ -172,6 +221,15 @@ function parseEntity(entityData, aggregateName, packageName = '', moduleName = '
172
221
  ];
173
222
  }
174
223
  }
224
+
225
+ // Inject deletedAt field for soft-delete root entities
226
+ const effectiveSoftDelete = hasSoftDelete === true && isRoot === true;
227
+ if (effectiveSoftDelete) {
228
+ entityFields = [
229
+ ...entityFields,
230
+ { name: 'deletedAt', type: 'LocalDateTime' }
231
+ ];
232
+ }
175
233
 
176
234
  const className = toPascalCase(name);
177
235
  const fieldName = toCamelCase(name);
@@ -204,6 +262,7 @@ function parseEntity(entityData, aggregateName, packageName = '', moduleName = '
204
262
  fieldName,
205
263
  tableName: table,
206
264
  isRoot,
265
+ hasSoftDelete: effectiveSoftDelete,
207
266
  auditable: auditable === true, // Legacy support
208
267
  audit: auditConfig, // New audit configuration
209
268
  fields,
@@ -233,7 +292,7 @@ function buildAnnotationString(validation) {
233
292
  if (value !== undefined) params.push(`value = ${value}`);
234
293
  if (min !== undefined) params.push(`min = ${min}`);
235
294
  if (max !== undefined) params.push(`max = ${max}`);
236
- if (regexp !== undefined) params.push(`regexp = "${regexp}"`);
295
+ if (regexp !== undefined) params.push(`regexp = "${regexp.replace(/\\/g, '\\\\')}"`);
237
296
  if (integer !== undefined) params.push(`integer = ${integer}`);
238
297
  if (fraction !== undefined) params.push(`fraction = ${fraction}`);
239
298
  if (inclusive !== undefined) params.push(`inclusive = ${inclusive}`);
@@ -961,6 +1020,239 @@ function extractAllEnums(aggregates) {
961
1020
  return Array.from(enumsMap.values());
962
1021
  }
963
1022
 
1023
+ /**
1024
+ * Parse the optional endpoints section from domain.yaml.
1025
+ * When present, controls which use cases and versioned controllers are generated.
1026
+ * @param {Object} domainData - Raw parsed YAML data
1027
+ * @returns {Object|null} Parsed endpoints structure or null if not declared
1028
+ */
1029
+ function parseEndpoints(domainData) {
1030
+ if (!domainData.endpoints) return null;
1031
+ const { basePath = '/', versions = [] } = domainData.endpoints;
1032
+ return {
1033
+ basePath,
1034
+ versions: versions.map(v => ({
1035
+ version: v.version,
1036
+ operations: (v.operations || []).map(op => {
1037
+ const method = (op.method || 'GET').toUpperCase();
1038
+ return {
1039
+ method,
1040
+ path: op.path || '/',
1041
+ description: op.description || '',
1042
+ useCase: toPascalCase(op.useCase),
1043
+ type: method === 'GET' ? 'query' : 'command'
1044
+ };
1045
+ })
1046
+ }))
1047
+ };
1048
+ }
1049
+
1050
+ /**
1051
+ * Parse the optional listeners section from domain.yaml.
1052
+ * Declares integration events this module CONSUMES from external producers.
1053
+ * @param {Object} domainData - Raw parsed YAML data
1054
+ * @returns {Array} Parsed listeners array (empty if not declared)
1055
+ */
1056
+ function parseListeners(domainData) {
1057
+ if (!domainData.listeners || !Array.isArray(domainData.listeners)) return [];
1058
+ return domainData.listeners.map(listener => {
1059
+ const eventName = toPascalCase(listener.event);
1060
+ // Normalise: strip trailing 'Event' suffix for class naming, re-add it consistently
1061
+ const baseName = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1062
+ const integrationEventClassName = `${baseName}IntegrationEvent`;
1063
+ // e.g. PaymentApprovedKafkaListener
1064
+ const listenerClassName = `${baseName}KafkaListener`;
1065
+ const useCaseName = toPascalCase(listener.useCase);
1066
+ const commandClassName = `${useCaseName}Command`;
1067
+ const topic = listener.topic || null;
1068
+ const fields = (listener.fields || []).map(f => ({
1069
+ name: toCamelCase(f.name),
1070
+ javaType: f.type
1071
+ }));
1072
+ const nestedTypes = (listener.nestedTypes || []).map(nt => ({
1073
+ name: toPascalCase(nt.name),
1074
+ fields: (nt.fields || []).map(f => ({
1075
+ name: toCamelCase(f.name),
1076
+ javaType: f.type
1077
+ }))
1078
+ }));
1079
+ return {
1080
+ event: eventName,
1081
+ baseName,
1082
+ producer: listener.producer || null,
1083
+ topic,
1084
+ useCase: useCaseName,
1085
+ commandClassName,
1086
+ integrationEventClassName,
1087
+ listenerClassName,
1088
+ fields,
1089
+ nestedTypes
1090
+ };
1091
+ });
1092
+ }
1093
+
1094
+ /**
1095
+ * Derive a domain model type name from a method name.
1096
+ * Strips common verb prefixes and 'ById/ByName/...' suffixes so that
1097
+ * 'findCustomerById' → 'Customer', 'processPayment' → 'Payment'.
1098
+ * @param {string} methodName camelCase method name
1099
+ * @returns {string} PascalCase domain model name
1100
+ */
1101
+ function deriveDomainType(methodName) {
1102
+ let name = toPascalCase(methodName);
1103
+
1104
+ // Strip verb prefix
1105
+ name = name.replace(
1106
+ /^(Find|Get|Fetch|Search|Retrieve|List|Check|Process|Create|Update|Delete|Cancel|Submit|Execute)/,
1107
+ ''
1108
+ );
1109
+
1110
+ // Strip trailing 'By{Something}' (e.g. ById, ByName, ByCode)
1111
+ name = name.replace(/By[A-Z][a-zA-Z0-9]*$/, '');
1112
+
1113
+ // Strip common informational suffixes
1114
+ name = name.replace(/(?:Status|Availability|All)$/, '');
1115
+
1116
+ // Fall back to full PascalCase method name if we stripped everything
1117
+ return name || toPascalCase(methodName);
1118
+ }
1119
+
1120
+ /**
1121
+ * Parse the optional ports section from domain.yaml.
1122
+ * Declares HTTP services this module CALLS synchronously (Feign clients).
1123
+ * Entries sharing the same service: are grouped into a single FeignClient.
1124
+ * @param {Object} domainData - Raw parsed YAML data
1125
+ * @param {string} moduleName - Module name (used for property key naming)
1126
+ * @returns {Array} Parsed port service groups (empty if not declared)
1127
+ */
1128
+ function parsePorts(domainData, moduleName = '') {
1129
+ if (!domainData.ports || !Array.isArray(domainData.ports)) return [];
1130
+
1131
+ const serviceMap = new Map();
1132
+
1133
+ for (const entry of domainData.ports) {
1134
+ if (!entry.service || !entry.name) continue;
1135
+
1136
+ const serviceName = toPascalCase(entry.service);
1137
+ const methodName = toCamelCase(entry.name);
1138
+ const methodPascal = toPascalCase(entry.name);
1139
+
1140
+ // Parse HTTP verb + path
1141
+ const httpParts = (entry.http || 'GET /').trim().split(/\s+/);
1142
+ const httpVerb = (httpParts[0] || 'GET').toUpperCase();
1143
+ const httpPath = httpParts[1] || '/';
1144
+
1145
+ // Extract path variables: /screenings/{id}/seats → ['id']
1146
+ const pathVarMatches = httpPath.match(/\{(\w+)\}/g) || [];
1147
+ const pathVariables = pathVarMatches.map(pv => pv.slice(1, -1));
1148
+
1149
+ // Response fields
1150
+ const responseFields = (entry.fields || []).map(f => ({
1151
+ name: toCamelCase(f.name),
1152
+ javaType: f.type
1153
+ }));
1154
+
1155
+ // Body fields (POST/PUT/PATCH only)
1156
+ const bodyAllowed = httpVerb !== 'GET' && httpVerb !== 'DELETE';
1157
+ const bodyFields = bodyAllowed
1158
+ ? (entry.body || []).map(f => ({ name: toCamelCase(f.name), javaType: f.type }))
1159
+ : [];
1160
+
1161
+ // nestedTypes per method
1162
+ const nestedTypes = (entry.nestedTypes || []).map(nt => ({
1163
+ name: toPascalCase(nt.name),
1164
+ fields: (nt.fields || []).map(f => ({
1165
+ name: toCamelCase(f.name),
1166
+ javaType: f.type
1167
+ }))
1168
+ }));
1169
+
1170
+ const returnList = entry.returnList === true;
1171
+ const hasResponse = responseFields.length > 0;
1172
+ const hasBody = bodyFields.length > 0;
1173
+ // ACL: infra DTO name (lives in infrastructure/adapters/{service}/)
1174
+ const infraDtoName = hasResponse ? `${methodPascal}Dto` : null;
1175
+ // Domain model type (lives in domain/models/)
1176
+ const domainType = hasResponse
1177
+ ? (entry.domainType ? toPascalCase(entry.domainType) : deriveDomainType(entry.name))
1178
+ : null;
1179
+ // Keep responseDtoName as alias to infraDtoName for backward-compat
1180
+ const responseDtoName = infraDtoName;
1181
+ const requestDtoName = hasBody ? `${methodPascal}RequestDto` : null;
1182
+
1183
+ const method = {
1184
+ name: methodName,
1185
+ namePascal: methodPascal,
1186
+ httpVerb,
1187
+ httpPath,
1188
+ pathVariables,
1189
+ fields: responseFields,
1190
+ bodyFields,
1191
+ nestedTypes,
1192
+ returnList,
1193
+ hasResponse,
1194
+ hasBody,
1195
+ infraDtoName,
1196
+ domainType,
1197
+ responseDtoName,
1198
+ requestDtoName
1199
+ };
1200
+
1201
+ if (!serviceMap.has(serviceName)) {
1202
+ serviceMap.set(serviceName, {
1203
+ serviceName,
1204
+ serviceNameCamelCase: toCamelCase(serviceName),
1205
+ target: entry.target || null,
1206
+ baseUrl: entry.baseUrl || null,
1207
+ methods: [],
1208
+ nestedTypes: [],
1209
+ domainModels: [] // ACL: unique domain models per service group
1210
+ });
1211
+ }
1212
+
1213
+ const group = serviceMap.get(serviceName);
1214
+ group.methods.push(method);
1215
+
1216
+ // Keep baseUrl from the first entry that declares it
1217
+ if (entry.baseUrl && !group.baseUrl) {
1218
+ group.baseUrl = entry.baseUrl;
1219
+ }
1220
+
1221
+ // Deduplicate nestedTypes within the service group
1222
+ for (const nt of nestedTypes) {
1223
+ if (!group.nestedTypes.some(existing => existing.name === nt.name)) {
1224
+ group.nestedTypes.push(nt);
1225
+ }
1226
+ }
1227
+
1228
+ // ACL: collect unique domain models (by name) across all methods in this service
1229
+ if (method.domainType && method.hasResponse) {
1230
+ if (!group.domainModels.some(dm => dm.name === method.domainType)) {
1231
+ group.domainModels.push({
1232
+ name: method.domainType,
1233
+ fields: method.fields
1234
+ });
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ const moduleKebab = toKebabCase(moduleName);
1240
+
1241
+ return Array.from(serviceMap.values()).map(group => {
1242
+ const serviceKebab = toKebabCase(group.serviceName);
1243
+ return {
1244
+ ...group,
1245
+ baseUrl: group.baseUrl || 'http://localhost:8080',
1246
+ baseUrlProperty: `${moduleKebab}.${serviceKebab}.base-url`,
1247
+ feignClientName: `${moduleKebab}-${serviceKebab}`,
1248
+ feignClientClassName: `${group.serviceName}FeignClient`,
1249
+ feignAdapterClassName: `${group.serviceName}FeignAdapter`,
1250
+ feignConfigClassName: `${group.serviceName}FeignConfig`,
1251
+ adapterPackage: group.serviceNameCamelCase
1252
+ };
1253
+ });
1254
+ }
1255
+
964
1256
  module.exports = {
965
1257
  parseDomainYaml,
966
1258
  parseAggregate,
@@ -969,5 +1261,7 @@ module.exports = {
969
1261
  generateAggregateMethods,
970
1262
  generateEntityImports,
971
1263
  generateValidationImports,
972
- generateAggregateMethodImports
1264
+ generateAggregateMethodImports,
1265
+ parseListeners,
1266
+ parsePorts
973
1267
  };
@@ -82,8 +82,9 @@ public class <%= aggregateName %>Mapper {
82
82
  <% } else if (!rel.isCollection && rel.type === 'OneToOne') { %>
83
83
  // Map OneToOne relationship <%= rel.fieldName %>
84
84
  if (domain.get<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>() != null) {
85
- <%= rel.target %>Jpa <%= rel.fieldName %>Jpa = toJpa<%= rel.target %>(domain.get<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>(), jpa);
86
- jpa.set<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>(<%= rel.fieldName %>Jpa);
85
+ <%= rel.target %>Jpa <%= rel.fieldName %>Jpa = toJpa<%= rel.target %>(domain.get<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>());
86
+ <% if (rel.mappedBy) { %> <%= rel.fieldName %>Jpa.set<%= rel.mappedBy.charAt(0).toUpperCase() + rel.mappedBy.slice(1) %>(jpa);
87
+ <% } %> jpa.set<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>(<%= rel.fieldName %>Jpa);
87
88
  }
88
89
  <% } else if (!rel.isCollection) { %>
89
90
  jpa.set<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>(toJpa<%= rel.target %>(domain.get<%= rel.fieldName.charAt(0).toUpperCase() + rel.fieldName.slice(1) %>()));
@@ -17,7 +17,13 @@ public interface <%= rootEntity.name %>Repository {
17
17
 
18
18
  Page<<%= rootEntity.name %>> findAll(Pageable pageable);
19
19
 
20
- void deleteById(<%= rootEntity.fields[0].javaType %> id);
21
-
22
20
  boolean existsById(<%= rootEntity.fields[0].javaType %> id);
21
+ <% if (!hasSoftDelete) { %>
22
+ void deleteById(<%= rootEntity.fields[0].javaType %> id);
23
+ <% } %>
24
+ <% if (findByOps && findByOps.length > 0) { %>
25
+ <% findByOps.forEach(function(op) { %>
26
+ Page<<%= rootEntity.name %>> <%= op.jpaMethodName %>(<%= op.fieldJavaType %> <%= op.fieldName %>, Pageable pageable);
27
+ <% }); %>
28
+ <% } %>
23
29
  }
@@ -49,13 +49,23 @@ public class <%= rootEntity.name %>RepositoryImpl implements <%= rootEntity.name
49
49
  .map(mapper::toDomain);
50
50
  }
51
51
 
52
+ @Override
53
+ public boolean existsById(<%= rootEntity.fields[0].javaType %> id) {
54
+ return jpaRepository.existsById(id);
55
+ }
56
+ <% if (!hasSoftDelete) { %>
52
57
  @Override
53
58
  public void deleteById(<%= rootEntity.fields[0].javaType %> id) {
54
59
  jpaRepository.deleteById(id);
55
60
  }
56
-
61
+ <% } %>
62
+ <% if (findByOps && findByOps.length > 0) { %>
63
+ <% findByOps.forEach(function(op) { %>
57
64
  @Override
58
- public boolean existsById(<%= rootEntity.fields[0].javaType %> id) {
59
- return jpaRepository.existsById(id);
65
+ public Page<<%= rootEntity.name %>> <%= op.jpaMethodName %>(<%= op.fieldJavaType %> <%= op.fieldName %>, Pageable pageable) {
66
+ return jpaRepository.<%= op.jpaMethodName %>(<%= op.fieldName %>, pageable)
67
+ .map(mapper::toDomain);
60
68
  }
69
+ <% }); %>
70
+ <% } %>
61
71
  }
@@ -18,6 +18,14 @@ import <%= packageName %>.shared.domain.DomainEvent;
18
18
  import <%= packageName %>.<%= moduleName %>.domain.models.events.<%= event.name %>;
19
19
  <% }); %>
20
20
  <% } %>
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'); });
25
+ %>
26
+ <% if (_needsLocalDateTimeImport) { %>
27
+ import java.time.LocalDateTime;
28
+ <% } %>
21
29
  import java.util.ArrayList;
22
30
  import java.util.Collections;
23
31
  import java.util.List;
@@ -82,7 +90,7 @@ public class <%= name %> {
82
90
  <% }); %>
83
91
  }
84
92
 
85
- <% const creationFields = fields.filter(f => f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' && f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly && !f.autoInit); %>
93
+ <% 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); %>
86
94
  <% const autoInitFields = fields.filter(f => f.autoInit); %>
87
95
  <% const defaultValueFields = fields.filter(f => f.readOnly && !f.autoInit && f.javaDefaultValue); %>
88
96
  <% if (creationFields.length > 0 || autoInitFields.length > 0 || defaultValueFields.length > 0) { %>
@@ -184,8 +192,40 @@ public class <%= name %> {
184
192
  }
185
193
  <% } %>
186
194
  this.<%= field.name %> = this.<%= field.name %>.transitionTo(<%- field.javaType %>.<%= transition.to %>);
195
+ <%
196
+ const _triggeredEvents = (triggeredEventsMap || {})[methodName] || [];
197
+ _triggeredEvents.forEach(function(evt) {
198
+ const _entityBase = name.charAt(0).toLowerCase() + name.slice(1);
199
+ const _args = ['this.getId()'];
200
+ (evt.fields || []).forEach(function(ef) {
201
+ // Skip {entityName}Id — already provided as aggregateId in the DomainEvent constructor
202
+ if (ef.name === _entityBase + 'Id') { return; }
203
+ const _matched = fields.find(function(f) { return f.name === ef.name; });
204
+ if (_matched) {
205
+ if (_matched.javaType === ef.javaType) {
206
+ _args.push('this.get' + ef.name.charAt(0).toUpperCase() + ef.name.slice(1) + '()'); return;
207
+ }
208
+ // Type mismatch: entity field may be a VO wrapping the expected primitive/type
209
+ var _vo = (valueObjects || []).find(function(vo) { return vo.name === _matched.javaType; });
210
+ if (_vo) {
211
+ var _voSub = _vo.fields.find(function(voF) { return voF.name === ef.name && voF.javaType === ef.javaType; });
212
+ if (!_voSub) { _voSub = _vo.fields.find(function(voF) { return voF.javaType === ef.javaType; }); }
213
+ if (_voSub) {
214
+ var _oCap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
215
+ var _sCap = _voSub.name.charAt(0).toUpperCase() + _voSub.name.slice(1);
216
+ _args.push('this.get' + _oCap + '().get' + _sCap + '()'); return;
217
+ }
218
+ }
219
+ _args.push('null /* TODO: provide ' + ef.name + ' (entity returns ' + _matched.javaType + ', expected ' + ef.javaType + ') */'); return;
220
+ }
221
+ if (ef.name.endsWith('At') && ef.javaType === 'LocalDateTime') { _args.push('LocalDateTime.now()'); return; }
222
+ _args.push('null /* TODO: provide ' + ef.name + ' */');
223
+ });
224
+ %>
225
+ raise(new <%= evt.name %>(<%= _args.join(', ') %>));
226
+ <% }); %>
187
227
  }
188
- <% }); %>
228
+ <% });%>
189
229
 
190
230
  // ─── State Query Helpers ─────────────────────────────────────────────────
191
231
  <% meta.enumValues.forEach(value => { %>
@@ -202,4 +242,22 @@ public class <%= name %> {
202
242
  <% }); %>
203
243
  <% }); %>
204
244
  <% } %>
245
+ <% if (hasSoftDelete) { %>
246
+
247
+ // ─── Soft Delete ─────────────────────────────────────────────────────────
248
+ /**
249
+ * Marks this entity as deleted by setting deletedAt timestamp.
250
+ * Use this instead of physical deletion to preserve records.
251
+ */
252
+ public void softDelete() {
253
+ if (this.deletedAt != null) {
254
+ throw new IllegalStateException("<%= name %> is already deleted");
255
+ }
256
+ this.deletedAt = java.time.LocalDateTime.now();
257
+ }
258
+
259
+ public boolean isDeleted() {
260
+ return this.deletedAt != null;
261
+ }
262
+ <% } %>
205
263
  }
@@ -6,32 +6,37 @@ import org.springframework.transaction.event.TransactionalEventListener;
6
6
  <% domainEvents.forEach(event => { %>
7
7
  import <%= packageName %>.<%= moduleName %>.domain.models.events.<%= event.name %>;
8
8
  <% }); %>
9
- <% if (hasKafkaEvents) { %>
10
- import <%= packageName %>.<%= moduleName %>.domain.repositories.MessageBroker;
9
+ <% if (broker) { %>
10
+ <% domainEvents.forEach(event => { %>
11
+ import <%= packageName %>.<%= moduleName %>.application.events.<%= event.integrationEventClassName %>;
12
+ <% }); %>
13
+ import <%= packageName %>.<%= moduleName %>.application.ports.MessageBroker;
11
14
  <% } %>
12
15
 
13
16
  /**
14
17
  * <%= aggregateName %>DomainEventHandler — Domain Event Bridge
15
18
  *
16
- * This handler connects the internal Spring event bus (ApplicationEventPublisher)
17
- * with the external messaging port (MessageBroker).
19
+ * Connects the internal Spring event bus (ApplicationEventPublisher) with the
20
+ * external messaging port (MessageBroker).
18
21
  *
19
22
  * Architecture:
20
23
  * AggregateRepositoryImpl.save()
21
- * → eventPublisher.publishEvent(domainEvent) [internal Spring bus]
22
- * → @TransactionalEventListener(AFTER_COMMIT) [this class]
23
- * → messageBroker.send*(event) [port — broker-agnostic]
24
+ * → eventPublisher.publishEvent(domainEvent) [internal Spring bus]
25
+ * → @TransactionalEventListener(AFTER_COMMIT) [this class]
26
+ * → messageBroker.publish*(integrationEvent) [port — broker-agnostic]
24
27
  *
25
- * AFTER_COMMIT ensures the external event is only published if the
26
- * database transaction committed successfully, preventing ghost events
27
- * from rolled-back operations.
28
+ * AFTER_COMMIT guarantees that external events are published only when the
29
+ * database transaction committed successfully, preventing ghost events from
30
+ * rolled-back operations.
28
31
  *
29
- * To switch brokers (Kafka RabbitMQ SNS): change only the MessageBroker
30
- * adapter in infrastructure/. This class never needs modification.
32
+ * Domain Events (domain/models/events/) — internal signals scoped to this bounded context.
33
+ * Integration Events (application/events/) broker-facing projections; changing broker
34
+ * technology (Kafka → RabbitMQ → SNS) only requires changing the MessageBroker adapter.
35
+ * Domain Events — and therefore this class — never need modification.
31
36
  */
32
37
  @ApplicationComponent
33
38
  public class <%= aggregateName %>DomainEventHandler {
34
- <% if (hasKafkaEvents) { %>
39
+ <% if (broker) { %>
35
40
 
36
41
  private final MessageBroker messageBroker;
37
42
 
@@ -42,16 +47,18 @@ public class <%= aggregateName %>DomainEventHandler {
42
47
  <% domainEvents.forEach(event => { %>
43
48
 
44
49
  /**
45
- * Handles {@link <%= event.name %>} after the wrapping transaction commits.
46
- * <% if (!event.kafka) { %>
50
+ * Handles {@link <%= event.name %>} after the wrapping transaction commits.<% if (!broker) { %>
51
+ * <p>
47
52
  * TODO: Implement the side effect for this event (e.g., send notification,
48
- * update a read model, trigger a saga step, etc.).
49
- * <% } %>
53
+ * update a read model, trigger a saga step, etc.).<% } %>
50
54
  */
51
55
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
52
- public void on<%= event.name %>(<%= event.name %> event) {
53
- <% if (event.kafka) { %>
54
- messageBroker.send<%= event.name %>(event);
56
+ public void on<%= event.name %>(<%= event.name %> event) {
57
+ <% if (broker) { %>
58
+ <%
59
+ const _aggrIdField = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1) + 'Id';
60
+ %>
61
+ 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 ? ', ' : '' %><% }); } %>));
55
62
  <% } else { %>
56
63
  // TODO: handle <%= event.name %> — add your side-effect logic here
57
64
  // e.g.: notificationService.notify(event);
@@ -1,10 +1,16 @@
1
1
  package <%= packageName %>.<%= moduleName %>.domain.models.events;
2
2
 
3
3
  import <%= packageName %>.shared.domain.DomainEvent;
4
- <% const needsBigDecimal = fields.some(f => f.javaType === 'BigDecimal'); %>
5
- <% const needsLocalDate = fields.some(f => f.javaType === 'LocalDate' || f.javaType === 'LocalDateTime' || f.javaType === 'LocalTime'); %>
6
- <% const needsUUID = fields.some(f => f.javaType === 'UUID'); %>
7
- <% const needsList = fields.some(f => f.isCollection); %>
4
+ <%
5
+ // Fields named {aggregateName}Id are excluded the aggregate id is already
6
+ // available via getAggregateId() inherited from DomainEvent.
7
+ const _aggregateBase = aggregateName.charAt(0).toLowerCase() + aggregateName.slice(1);
8
+ const _ownFields = fields.filter(f => f.name !== _aggregateBase + 'Id');
9
+ %>
10
+ <% const needsBigDecimal = _ownFields.some(f => f.javaType === 'BigDecimal'); %>
11
+ <% const needsLocalDate = _ownFields.some(f => f.javaType === 'LocalDate' || f.javaType === 'LocalDateTime' || f.javaType === 'LocalTime'); %>
12
+ <% const needsUUID = _ownFields.some(f => f.javaType === 'UUID'); %>
13
+ <% const needsList = _ownFields.some(f => f.isCollection); %>
8
14
  <% if (needsBigDecimal) { %>
9
15
  import java.math.BigDecimal;
10
16
  <% } %>
@@ -18,6 +24,15 @@ import java.util.UUID;
18
24
  <% if (needsList) { %>
19
25
  import java.util.List;
20
26
  <% } %>
27
+ <%
28
+ const _STANDARD_DOMAIN_TYPES = new Set(['String','Integer','Long','Double','Float','Boolean','BigDecimal','LocalDate','LocalDateTime','LocalTime','Instant','UUID']);
29
+ const _customDomainElementTypes = _ownFields
30
+ ? [...new Set(_ownFields.filter(f => f.isCollection && f.collectionElementType && !_STANDARD_DOMAIN_TYPES.has(f.collectionElementType)).map(f => f.collectionElementType))]
31
+ : [];
32
+ %>
33
+ <% _customDomainElementTypes.forEach(typeName => { %>
34
+ import <%= packageName %>.<%= moduleName %>.domain.models.events.<%- typeName %>;
35
+ <% }); %>
21
36
 
22
37
  /**
23
38
  * <%= name %> - Domain Event
@@ -25,23 +40,24 @@ import java.util.List;
25
40
  * Raised when: TODO — describe the business fact this event represents.
26
41
  * Aggregate: <%= aggregateName %>
27
42
  *
43
+ * The aggregate id is available via {@link #getAggregateId()} (inherited from DomainEvent).
28
44
  * This is a pure domain class. It carries no Spring or infrastructure dependencies.
29
45
  * Publish it externally via <%= aggregateName %>DomainEventHandler (application layer).
30
46
  */
31
47
  public final class <%= name %> extends DomainEvent {
32
48
 
33
- <% fields.forEach(field => { %>
49
+ <% _ownFields.forEach(field => { %>
34
50
  private final <%- field.javaType %> <%= field.name %>;
35
51
  <% }); %>
36
52
 
37
- public <%= name %>(String aggregateId<% fields.forEach(field => { %>, <%- field.javaType %> <%= field.name %><% }); %>) {
53
+ public <%= name %>(String aggregateId<% _ownFields.forEach(field => { %>, <%- field.javaType %> <%= field.name %><% }); %>) {
38
54
  super(aggregateId);
39
- <% fields.forEach(field => { %>
55
+ <% _ownFields.forEach(field => { %>
40
56
  this.<%= field.name %> = <%= field.name %>;
41
57
  <% }); %>
42
58
  }
43
59
 
44
- <% fields.forEach(field => { %>
60
+ <% _ownFields.forEach(field => { %>
45
61
  public <%- field.javaType %> get<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>() {
46
62
  return <%= field.name %>;
47
63
  }