eva4j 1.0.14 → 1.0.16

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 (139) hide show
  1. package/.agents/skills/skill-creator/LICENSE.txt +202 -0
  2. package/.agents/skills/skill-creator/SKILL.md +485 -0
  3. package/.agents/skills/skill-creator/agents/analyzer.md +274 -0
  4. package/.agents/skills/skill-creator/agents/comparator.md +202 -0
  5. package/.agents/skills/skill-creator/agents/grader.md +223 -0
  6. package/.agents/skills/skill-creator/assets/eval_review.html +146 -0
  7. package/.agents/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  8. package/.agents/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  9. package/.agents/skills/skill-creator/references/schemas.md +430 -0
  10. package/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  11. package/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  12. package/.agents/skills/skill-creator/scripts/generate_report.py +326 -0
  13. package/.agents/skills/skill-creator/scripts/improve_description.py +247 -0
  14. package/.agents/skills/skill-creator/scripts/package_skill.py +136 -0
  15. package/.agents/skills/skill-creator/scripts/quick_validate.py +103 -0
  16. package/.agents/skills/skill-creator/scripts/run_eval.py +310 -0
  17. package/.agents/skills/skill-creator/scripts/run_loop.py +328 -0
  18. package/.agents/skills/skill-creator/scripts/utils.py +47 -0
  19. package/AGENTS.md +268 -6
  20. package/COMMAND_EVALUATION.md +15 -16
  21. package/DOMAIN_YAML_GUIDE.md +430 -14
  22. package/FUTURE_FEATURES.md +1627 -1168
  23. package/README.md +461 -13
  24. package/bin/eva4j.js +32 -14
  25. package/config/defaults.json +1 -0
  26. package/docs/commands/EVALUATE_SYSTEM.md +746 -261
  27. package/docs/commands/EXPORT_DIAGRAM.md +153 -0
  28. package/docs/commands/GENERATE_ENTITIES.md +599 -6
  29. package/docs/commands/INDEX.md +7 -0
  30. package/examples/domain-events.yaml +166 -20
  31. package/examples/domain-listeners.yaml +212 -0
  32. package/examples/domain-one-to-many.yaml +1 -0
  33. package/examples/domain-one-to-one.yaml +1 -0
  34. package/examples/domain-ports.yaml +414 -0
  35. package/examples/domain-soft-delete.yaml +47 -44
  36. package/examples/system/notification.yaml +147 -0
  37. package/examples/system/product.yaml +185 -0
  38. package/examples/system/system.yaml +112 -0
  39. package/examples/system-report.html +971 -0
  40. package/examples/system.yaml +46 -3
  41. package/package.json +2 -1
  42. package/src/agents/design-reviewer.agent.md +163 -0
  43. package/src/commands/build.js +714 -0
  44. package/src/commands/create.js +1 -0
  45. package/src/commands/detach.js +149 -108
  46. package/src/commands/evaluate-system.js +234 -8
  47. package/src/commands/export-diagram.js +522 -0
  48. package/src/commands/generate-entities.js +685 -66
  49. package/src/commands/generate-http-exchange.js +2 -0
  50. package/src/commands/generate-kafka-event.js +43 -10
  51. package/src/generators/base-generator.js +18 -6
  52. package/src/generators/postman-generator.js +188 -0
  53. package/src/generators/shared-generator.js +12 -2
  54. package/src/skills/build-system-yaml/SKILL.md +323 -0
  55. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +410 -0
  56. package/src/skills/build-system-yaml/references/module-spec.md +367 -0
  57. package/src/skills/build-system-yaml/references/system-yaml-spec.md +178 -0
  58. package/src/utils/config-manager.js +54 -0
  59. package/src/utils/context-builder.js +1 -0
  60. package/src/utils/domain-diagram.js +192 -0
  61. package/src/utils/domain-validator.js +1020 -0
  62. package/src/utils/fake-data.js +376 -0
  63. package/src/utils/system-validator.js +319 -199
  64. package/src/utils/yaml-to-entity.js +272 -7
  65. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  66. package/templates/aggregate/AggregateRepository.java.ejs +3 -2
  67. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +6 -5
  68. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  69. package/templates/aggregate/DomainEventHandler.java.ejs +4 -1
  70. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  71. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  72. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  73. package/templates/base/docker/Dockerfile.ejs +21 -0
  74. package/templates/base/gradle/build.gradle.ejs +3 -2
  75. package/templates/base/root/AGENTS.md.ejs +306 -45
  76. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  77. package/templates/crud/Controller.java.ejs +4 -4
  78. package/templates/crud/CreateCommand.java.ejs +4 -0
  79. package/templates/crud/CreateCommandHandler.java.ejs +3 -6
  80. package/templates/crud/CreateItemDto.java.ejs +4 -0
  81. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  82. package/templates/crud/DeleteCommandHandler.java.ejs +12 -6
  83. package/templates/crud/EndpointsController.java.ejs +6 -6
  84. package/templates/crud/FindByQuery.java.ejs +1 -1
  85. package/templates/crud/FindByQueryHandler.java.ejs +3 -9
  86. package/templates/crud/GetQueryHandler.java.ejs +2 -5
  87. package/templates/crud/ListQuery.java.ejs +1 -1
  88. package/templates/crud/ListQueryHandler.java.ejs +8 -14
  89. package/templates/crud/ScaffoldCommandHandler.java.ejs +2 -4
  90. package/templates/crud/ScaffoldQuery.java.ejs +3 -2
  91. package/templates/crud/ScaffoldQueryHandler.java.ejs +5 -6
  92. package/templates/crud/SubEntityAddCommand.java.ejs +4 -0
  93. package/templates/crud/SubEntityAddCommandHandler.java.ejs +2 -6
  94. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +2 -7
  95. package/templates/crud/TransitionCommandHandler.java.ejs +2 -6
  96. package/templates/crud/UpdateCommand.java.ejs +4 -0
  97. package/templates/crud/UpdateCommandHandler.java.ejs +2 -5
  98. package/templates/evaluate/report.html.ejs +394 -2
  99. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  100. package/templates/kafka-event/Event.java.ejs +9 -0
  101. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  102. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  103. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  104. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  105. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +25 -0
  106. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  107. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  108. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  109. package/templates/mock/MockEvent.java.ejs +10 -0
  110. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  111. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  112. package/templates/mock/SpringEventListener.java.ejs +61 -0
  113. package/templates/ports/PortDomainModel.java.ejs +35 -0
  114. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  115. package/templates/ports/PortFeignClient.java.ejs +45 -0
  116. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  117. package/templates/ports/PortInterface.java.ejs +45 -0
  118. package/templates/ports/PortNestedType.java.ejs +28 -0
  119. package/templates/ports/PortRequestDto.java.ejs +30 -0
  120. package/templates/ports/PortResponseDto.java.ejs +28 -0
  121. package/templates/postman/Collection.json.ejs +1 -1
  122. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  123. package/templates/shared/annotations/LogAfter.java.ejs +2 -0
  124. package/templates/shared/annotations/LogBefore.java.ejs +2 -0
  125. package/templates/shared/annotations/LogExceptions.java.ejs +2 -0
  126. package/templates/shared/annotations/LogLevel.java.ejs +8 -0
  127. package/templates/shared/annotations/LogTimer.java.ejs +1 -0
  128. package/templates/shared/annotations/Loggable.java.ejs +17 -0
  129. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
  130. package/templates/shared/configurations/loggerConfig/HandlerLogs.java.ejs +120 -32
  131. package/templates/shared/errorMessage/ErrorResponse.java.ejs +23 -0
  132. package/templates/shared/handlerException/HandlerExceptions.java.ejs +99 -79
  133. package/src/commands/generate-system.js +0 -243
  134. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +0 -1103
  135. package/templates/base/root/skill-build-domain-yaml.ejs +0 -292
  136. package/templates/base/root/skill-build-system-yaml.ejs +0 -252
  137. package/templates/shared/errorMessage/ErrorMessage.java.ejs +0 -5
  138. package/templates/shared/errorMessage/FullErrorMessage.java.ejs +0 -9
  139. package/templates/shared/errorMessage/ShortErrorMessage.java.ejs +0 -6
@@ -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,11 +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
54
  allEnums: extractAllEnums(domainData.aggregates),
30
- endpoints: parseEndpoints(domainData)
55
+ endpoints,
56
+ listeners,
57
+ ports: parsePorts(domainData, moduleName)
31
58
  };
32
59
  }
33
60
 
@@ -80,10 +107,21 @@ function parseAggregate(aggregateData) {
80
107
  return {
81
108
  name: eventName,
82
109
  fieldName: toCamelCase(eventName),
83
- fields: eventFields
110
+ fields: eventFields,
111
+ triggers: event.triggers || []
84
112
  };
85
113
  });
86
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
+
87
125
  return {
88
126
  name: toPascalCase(name),
89
127
  packageName: aggregateData.package || '',
@@ -93,6 +131,7 @@ function parseAggregate(aggregateData) {
93
131
  aggregateMethods,
94
132
  allEntities: parsedEntities,
95
133
  domainEvents,
134
+ triggeredEventsMap,
96
135
  enums: aggregateEnums
97
136
  };
98
137
  }
@@ -109,8 +148,16 @@ function parseAggregate(aggregateData) {
109
148
  * @returns {Object} Parsed entity
110
149
  */
111
150
  function parseEntity(entityData, aggregateName, packageName = '', moduleName = '', aggregateEnums = [], valueObjectNames = [], inverseRelationships = {}) {
112
- 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;
113
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
+
114
161
  // Accept both 'properties' and 'fields' field names
115
162
  let entityFields = properties || fieldsYaml || [];
116
163
 
@@ -174,6 +221,15 @@ function parseEntity(entityData, aggregateName, packageName = '', moduleName = '
174
221
  ];
175
222
  }
176
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
+ }
177
233
 
178
234
  const className = toPascalCase(name);
179
235
  const fieldName = toCamelCase(name);
@@ -206,6 +262,7 @@ function parseEntity(entityData, aggregateName, packageName = '', moduleName = '
206
262
  fieldName,
207
263
  tableName: table,
208
264
  isRoot,
265
+ hasSoftDelete: effectiveSoftDelete,
209
266
  auditable: auditable === true, // Legacy support
210
267
  audit: auditConfig, // New audit configuration
211
268
  fields,
@@ -235,7 +292,7 @@ function buildAnnotationString(validation) {
235
292
  if (value !== undefined) params.push(`value = ${value}`);
236
293
  if (min !== undefined) params.push(`min = ${min}`);
237
294
  if (max !== undefined) params.push(`max = ${max}`);
238
- if (regexp !== undefined) params.push(`regexp = "${regexp}"`);
295
+ if (regexp !== undefined) params.push(`regexp = "${regexp.replace(/\\/g, '\\\\')}"`);
239
296
  if (integer !== undefined) params.push(`integer = ${integer}`);
240
297
  if (fraction !== undefined) params.push(`fraction = ${fraction}`);
241
298
  if (inclusive !== undefined) params.push(`inclusive = ${inclusive}`);
@@ -990,6 +1047,212 @@ function parseEndpoints(domainData) {
990
1047
  };
991
1048
  }
992
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
+
993
1256
  module.exports = {
994
1257
  parseDomainYaml,
995
1258
  parseAggregate,
@@ -998,5 +1261,7 @@ module.exports = {
998
1261
  generateAggregateMethods,
999
1262
  generateEntityImports,
1000
1263
  generateValidationImports,
1001
- generateAggregateMethodImports
1264
+ generateAggregateMethodImports,
1265
+ parseListeners,
1266
+ parsePorts
1002
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,9 +17,10 @@ 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
+ <% } %>
23
24
  <% if (findByOps && findByOps.length > 0) { %>
24
25
  <% findByOps.forEach(function(op) { %>
25
26
  Page<<%= rootEntity.name %>> <%= op.jpaMethodName %>(<%= op.fieldJavaType %> <%= op.fieldName %>, Pageable pageable);
@@ -49,15 +49,16 @@ public class <%= rootEntity.name %>RepositoryImpl implements <%= rootEntity.name
49
49
  .map(mapper::toDomain);
50
50
  }
51
51
 
52
- @Override
53
- public void deleteById(<%= rootEntity.fields[0].javaType %> id) {
54
- jpaRepository.deleteById(id);
55
- }
56
-
57
52
  @Override
58
53
  public boolean existsById(<%= rootEntity.fields[0].javaType %> id) {
59
54
  return jpaRepository.existsById(id);
60
55
  }
56
+ <% if (!hasSoftDelete) { %>
57
+ @Override
58
+ public void deleteById(<%= rootEntity.fields[0].javaType %> id) {
59
+ jpaRepository.deleteById(id);
60
+ }
61
+ <% } %>
61
62
  <% if (findByOps && findByOps.length > 0) { %>
62
63
  <% findByOps.forEach(function(op) { %>
63
64
  @Override
@@ -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
  }
@@ -55,7 +55,10 @@ public class <%= aggregateName %>DomainEventHandler {
55
55
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
56
56
  public void on<%= event.name %>(<%= event.name %> event) {
57
57
  <% if (broker) { %>
58
- messageBroker.publish<%= event.integrationEventClassName %>(new <%= event.integrationEventClassName %>(<% if (event.fields && event.fields.length > 0) { event.fields.forEach(function(field, idx) { %>event.get<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>()<%= idx < event.fields.length - 1 ? ', ' : '' %><% }); } %>));
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 ? ', ' : '' %><% }); } %>));
59
62
  <% } else { %>
60
63
  // TODO: handle <%= event.name %> — add your side-effect logic here
61
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
  }
@@ -0,0 +1,46 @@
1
+ package <%= packageName %>.<%= moduleName %>.domain.models.events;
2
+ <%
3
+ const needsBigDecimal = fields && fields.some(f => f.javaType === 'BigDecimal');
4
+ const needsLocalDate = fields && fields.some(f => ['LocalDate','LocalDateTime','LocalTime'].includes(f.javaType));
5
+ const needsInstant = fields && fields.some(f => f.javaType === 'Instant');
6
+ const needsUUID = fields && fields.some(f => f.javaType === 'UUID');
7
+ const needsList = fields && fields.some(f => f.javaType && f.javaType.startsWith('List'));
8
+ %>
9
+ <% if (needsBigDecimal) { %>
10
+ import java.math.BigDecimal;
11
+ <% } %>
12
+ <% if (needsLocalDate) { %>
13
+ import java.time.LocalDate;
14
+ import java.time.LocalDateTime;
15
+ <% } %>
16
+ <% if (needsInstant) { %>
17
+ import java.time.Instant;
18
+ <% } %>
19
+ <% if (needsUUID) { %>
20
+ import java.util.UUID;
21
+ <% } %>
22
+ <% if (needsList) { %>
23
+ import java.util.List;
24
+ <% } %>
25
+
26
+ /**
27
+ * <%= name %> — snapshot value type carried in domain events.
28
+ *
29
+ * This is a pure domain record with no infrastructure dependencies.
30
+ * TODO: Define the fields this snapshot should carry.
31
+ * Example for an order item:
32
+ * String productId,
33
+ * String productName,
34
+ * Integer quantity,
35
+ * BigDecimal unitPrice
36
+ */
37
+ public record <%= name %>(
38
+ <% if (fields && fields.length > 0) { %>
39
+ <% fields.forEach((field, idx) => { %>
40
+ <%- field.javaType %> <%= field.name %><%= idx < fields.length - 1 ? ',' : '' %>
41
+ <% }); %>
42
+ <% } else { %>
43
+ // TODO: Add the fields this snapshot should carry
44
+ <% } %>
45
+ ) {
46
+ }
@@ -22,11 +22,17 @@ import <%= packageName %>.shared.domain.AuditableEntity;
22
22
  <% } else if (auditable) { %>
23
23
  import <%= packageName %>.shared.domain.AuditableEntity;
24
24
  <% } %>
25
+ <% if (hasSoftDelete) { %>
26
+ import org.hibernate.annotations.SQLRestriction;
27
+ <% } %>
25
28
 
26
29
  /**
27
30
  * <%= name %>Jpa - JPA Entity
28
31
  * Persistence entity with JPA annotations
29
32
  */
33
+ <% if (hasSoftDelete) { %>
34
+ @SQLRestriction("deleted_at IS NULL")
35
+ <% } %>
30
36
  @Entity
31
37
  @Table(name = "<%= tableName %>")
32
38
  @Getter
@@ -0,0 +1,21 @@
1
+ FROM eclipse-temurin:<%= javaVersion %>-jdk-alpine AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ COPY build.gradle settings.gradle ./
6
+ COPY gradle ./gradle
7
+ COPY gradlew ./
8
+ RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
9
+
10
+ COPY src ./src
11
+ RUN ./gradlew bootJar --no-daemon -x test
12
+
13
+ FROM eclipse-temurin:<%= javaVersion %>-jre-alpine
14
+
15
+ WORKDIR /app
16
+
17
+ COPY --from=builder /app/build/libs/*.jar app.jar
18
+
19
+ EXPOSE <%= serverPort %>
20
+
21
+ ENTRYPOINT ["java", "-jar", "app.jar"]