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
@@ -5,15 +5,86 @@ const fs = require('fs-extra');
5
5
  const inquirer = require('inquirer');
6
6
  const ConfigManager = require('../utils/config-manager');
7
7
  const { isEva4jProject } = require('../utils/validator');
8
- const { toPackagePath, toCamelCase, toKebabCase, getApplicationClassName } = require('../utils/naming');
9
- const { renderAndWrite } = require('../utils/template-engine');
8
+ const { toPackagePath, toCamelCase, toKebabCase, toPascalCase, getApplicationClassName, pluralizeWord } = require('../utils/naming');
9
+ const { renderAndWrite, renderTemplate } = require('../utils/template-engine');
10
10
  const { parseDomainYaml, generateEntityImports, generateValidationImports } = require('../utils/yaml-to-entity');
11
+ const { createOrUpdateUrlsConfig, ensureUrlsImport } = require('./generate-http-exchange');
11
12
  const SharedGenerator = require('../generators/shared-generator');
12
13
  const ChecksumManager = require('../utils/checksum-manager');
14
+ const { generateFakeValue, initSeed } = require('../utils/fake-data');
15
+ const { getInstalledBroker, generateSingleKafkaEvent, buildKafkaEventContext, updateKafkaYml,
16
+ generateEventRecord, createOrUpdateMessageBroker, updateDomainEventHandler } = require('./generate-kafka-event');
13
17
 
14
18
  // Maximum depth for recursive relationship traversal
15
19
  const MAX_DEPTH = 5;
16
20
 
21
+ /**
22
+ * Create or update {Module}KafkaMessageBroker with a mock implementation that
23
+ * uses ApplicationEventPublisher + MockEvent instead of Kafka.
24
+ * Mirrors the logic of createOrUpdateKafkaMessageBroker but uses mock templates.
25
+ */
26
+ async function createOrUpdateMockMessageBroker(projectDir, packagePath, context) {
27
+ const adapterPath = path.join(
28
+ projectDir, 'src', 'main', 'java', packagePath,
29
+ context.moduleName, 'infrastructure', 'adapters', 'kafkaMessageBroker',
30
+ `${context.kafkaMessageBrokerClassName}.java`
31
+ );
32
+
33
+ const methodName = `publish${context.eventClassName}`;
34
+ const mockTemplatesDir = path.join(__dirname, '..', '..', 'templates', 'mock');
35
+
36
+ if (await fs.pathExists(adapterPath)) {
37
+ let content = await fs.readFile(adapterPath, 'utf-8');
38
+
39
+ // If this file is still a Kafka implementation, replace it wholesale
40
+ const isKafkaImpl = content.includes('KafkaTemplate') || content.includes('kafkaTemplate');
41
+ if (isKafkaImpl) {
42
+ // Re-create from mock template (overwrite)
43
+ await renderAndWrite(
44
+ path.join(mockTemplatesDir, 'MockMessageBrokerImpl.java.ejs'),
45
+ adapterPath,
46
+ { ...context, topicName: context.topicNameSnake },
47
+ { force: true }
48
+ );
49
+ return;
50
+ }
51
+
52
+ // Already a mock impl — check if method already exists
53
+ if (content.includes(methodName)) {
54
+ return;
55
+ }
56
+
57
+ // Inject event import if missing
58
+ const eventImport = `import ${context.packageName}.${context.moduleName}.application.events.${context.eventClassName};`;
59
+ if (!content.includes(eventImport)) {
60
+ const pkgMatch = content.match(/(package\s+[\w.]+;\s*\n)/);
61
+ if (pkgMatch) {
62
+ const imports = [...content.matchAll(/import\s+[\w.]+;\s*\n/g)];
63
+ const insertPos = imports.length
64
+ ? imports[imports.length - 1].index + imports[imports.length - 1][0].length
65
+ : pkgMatch.index + pkgMatch[0].length;
66
+ content = content.slice(0, insertPos) + eventImport + '\n' + content.slice(insertPos);
67
+ }
68
+ }
69
+
70
+ // Append mock method before the last closing brace
71
+ const methodSnippet = await renderTemplate(
72
+ path.join(mockTemplatesDir, 'MockMessageBrokerImplMethod.java.ejs'),
73
+ { ...context, topicName: context.topicNameSnake }
74
+ );
75
+ const lastBrace = content.lastIndexOf('}');
76
+ content = content.slice(0, lastBrace) + '\n' + methodSnippet + '\n}\n';
77
+ await fs.writeFile(adapterPath, content, 'utf-8');
78
+ } else {
79
+ // Create new mock broker from template
80
+ await renderAndWrite(
81
+ path.join(mockTemplatesDir, 'MockMessageBrokerImpl.java.ejs'),
82
+ adapterPath,
83
+ { ...context, topicName: context.topicNameSnake }
84
+ );
85
+ }
86
+ }
87
+
17
88
  /**
18
89
  * Build a relationship graph for secondary entities
19
90
  * @param {Array} secondaryEntities - Array of secondary entities
@@ -173,7 +244,7 @@ async function generateEntitiesCommand(moduleName, options = {}) {
173
244
 
174
245
  try {
175
246
  // Parse domain.yaml
176
- const { aggregates, allEnums } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
247
+ const { aggregates, allEnums, endpoints, listeners, ports } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
177
248
 
178
249
  spinner.succeed(chalk.green(`Found ${aggregates.length} aggregate(s) and ${allEnums.length} enum(s)`));
179
250
 
@@ -201,6 +272,15 @@ async function generateEntitiesCommand(moduleName, options = {}) {
201
272
  await sharedGenerator.generateDomainEvent(sharedBasePath);
202
273
  }
203
274
 
275
+ // Detect installed message broker for auto-wiring integration events
276
+ const installedBroker = (hasDomainEventsInModule || (listeners && listeners.length > 0))
277
+ ? await getInstalledBroker(configManager)
278
+ : null;
279
+ // When brokerMode:'mock' is requested AND a broker is installed, use mock Spring-Events adapter
280
+ const broker = (options.brokerMode === 'mock' && installedBroker)
281
+ ? 'mock'
282
+ : installedBroker;
283
+
204
284
  // Generate audit-related shared components if needed
205
285
  if (hasAuditableEntities || hasTrackUserEntities) {
206
286
 
@@ -299,7 +379,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
299
379
  valueObjects,
300
380
  aggregateMethods: aggregate.aggregateMethods,
301
381
  auditable: rootEntity.auditable,
302
- domainEvents: aggregate.domainEvents || []
382
+ hasSoftDelete: rootEntity.hasSoftDelete || false,
383
+ domainEvents: aggregate.domainEvents || [],
384
+ triggeredEventsMap: aggregate.triggeredEventsMap || {}
303
385
  };
304
386
 
305
387
  await renderAndWrite(
@@ -322,7 +404,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
322
404
  valueObjects,
323
405
  enums: allEnums,
324
406
  auditable: rootEntity.auditable,
325
- audit: rootEntity.audit
407
+ audit: rootEntity.audit,
408
+ hasSoftDelete: rootEntity.hasSoftDelete || false
326
409
  };
327
410
 
328
411
  await renderAndWrite(
@@ -439,7 +522,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
439
522
  const repoContext = {
440
523
  packageName,
441
524
  moduleName,
442
- rootEntity
525
+ rootEntity,
526
+ hasSoftDelete: rootEntity.hasSoftDelete || false,
527
+ findByOps: []
443
528
  };
444
529
 
445
530
  await renderAndWrite(
@@ -465,7 +550,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
465
550
  moduleName,
466
551
  aggregateName,
467
552
  rootEntity,
468
- hasDomainEvents: (aggregate.domainEvents || []).length > 0
553
+ hasDomainEvents: (aggregate.domainEvents || []).length > 0,
554
+ hasSoftDelete: rootEntity.hasSoftDelete || false,
555
+ findByOps: []
469
556
  };
470
557
 
471
558
  await renderAndWrite(
@@ -503,8 +590,11 @@ async function generateEntitiesCommand(moduleName, options = {}) {
503
590
  packageName,
504
591
  moduleName,
505
592
  aggregateName,
506
- domainEvents: aggregateDomainEvents,
507
- hasKafkaEvents: aggregateDomainEvents.some(e => e.kafka)
593
+ domainEvents: aggregateDomainEvents.map(e => ({
594
+ ...e,
595
+ integrationEventClassName: `${e.name}IntegrationEvent`
596
+ })),
597
+ broker
508
598
  };
509
599
  await renderAndWrite(
510
600
  path.join(__dirname, '..', '..', 'templates', 'aggregate', 'DomainEventHandler.java.ejs'),
@@ -513,11 +603,475 @@ async function generateEntitiesCommand(moduleName, options = {}) {
513
603
  writeOptions
514
604
  );
515
605
  generatedFiles.push({ type: 'Domain Event Handler', name: `${aggregateName}DomainEventHandler`, path: `${moduleName}/application/usecases/${aggregateName}DomainEventHandler.java` });
606
+
607
+ // ── Auto-wire broker integration events ────────────────────────────────
608
+ // When a message broker is installed, generate the complete integration
609
+ // event layer (XIntegrationEvent record, MessageBroker port method,
610
+ // KafkaMessageBroker impl method, topic config, KafkaConfig bean) for
611
+ // every domain event declared in this aggregate.
612
+ if (broker === 'kafka') {
613
+ const STANDARD_EVENT_TYPES = new Set([
614
+ 'String','Integer','Long','Double','Float','Boolean',
615
+ 'BigDecimal','LocalDate','LocalDateTime','LocalTime','Instant','UUID'
616
+ ]);
617
+ for (const event of aggregateDomainEvents) {
618
+ const kafkaCtx = buildKafkaEventContext(packageName, moduleName, event);
619
+ await generateSingleKafkaEvent(projectDir, packagePath, kafkaCtx);
620
+ generatedFiles.push({
621
+ type: 'Integration Event',
622
+ name: kafkaCtx.eventClassName,
623
+ path: `${moduleName}/application/events/${kafkaCtx.eventClassName}.java`
624
+ });
625
+
626
+ // Generate stub records for custom collection element types
627
+ // e.g. List<OrderItemSnapshot> → generate OrderItemSnapshot.java
628
+ const customElementTypes = [...new Set(
629
+ (event.fields || [])
630
+ .filter(f => f.isCollection && f.collectionElementType && !STANDARD_EVENT_TYPES.has(f.collectionElementType))
631
+ .map(f => f.collectionElementType)
632
+ )];
633
+ for (const typeName of customElementTypes) {
634
+ const stubPath = path.join(moduleBasePath, 'domain', 'models', 'events', `${typeName}.java`);
635
+ await renderAndWrite(
636
+ path.join(__dirname, '..', '..', 'templates', 'aggregate', 'DomainEventSnapshot.java.ejs'),
637
+ stubPath,
638
+ { packageName, moduleName, name: typeName, fields: [] },
639
+ { ...writeOptions, overwrite: false }
640
+ );
641
+ generatedFiles.push({
642
+ type: 'Event Snapshot Type',
643
+ name: typeName,
644
+ path: `${moduleName}/domain/models/events/${typeName}.java`
645
+ });
646
+ }
647
+ }
648
+ generatedFiles.push({
649
+ type: 'Integration Event',
650
+ name: `${toPascalCase(moduleName)}KafkaMessageBroker (updated)`,
651
+ path: `${moduleName}/infrastructure/adapters/kafkaMessageBroker/${toPascalCase(moduleName)}KafkaMessageBroker.java`
652
+ });
653
+ generatedFiles.push({
654
+ type: 'Integration Event',
655
+ name: 'MessageBroker (updated)',
656
+ path: `${moduleName}/application/ports/MessageBroker.java`
657
+ });
658
+ } else if (broker === 'mock') {
659
+ // ── Mock broker: Spring ApplicationEventPublisher instead of Kafka ──
660
+ // Generate MockEvent record in shared once (idempotent)
661
+ await sharedGenerator.generateMockEvent(sharedBasePath);
662
+
663
+ for (const event of aggregateDomainEvents) {
664
+ const kafkaCtx = buildKafkaEventContext(packageName, moduleName, event);
665
+ // 1. Integration Event record (same as Kafka — only the adapter differs)
666
+ await generateEventRecord(projectDir, packagePath, kafkaCtx);
667
+ generatedFiles.push({
668
+ type: 'Integration Event',
669
+ name: kafkaCtx.eventClassName,
670
+ path: `${moduleName}/application/events/${kafkaCtx.eventClassName}.java`
671
+ });
672
+ // 2. MessageBroker port interface (same as Kafka)
673
+ await createOrUpdateMessageBroker(projectDir, packagePath, kafkaCtx);
674
+ // 3. Mock KafkaMessageBroker impl (uses ApplicationEventPublisher)
675
+ await createOrUpdateMockMessageBroker(projectDir, packagePath, kafkaCtx);
676
+ // 4. DomainEventHandler (same as Kafka — calls messageBroker.publishX)
677
+ await updateDomainEventHandler(projectDir, packagePath, kafkaCtx);
678
+ // 5. NO kafka.yaml update and NO KafkaConfig update in mock mode
679
+ }
680
+ generatedFiles.push({
681
+ type: 'Integration Event',
682
+ name: `${toPascalCase(moduleName)}KafkaMessageBroker (mock)`,
683
+ path: `${moduleName}/infrastructure/adapters/kafkaMessageBroker/${toPascalCase(moduleName)}KafkaMessageBroker.java`
684
+ });
685
+ generatedFiles.push({
686
+ type: 'Integration Event',
687
+ name: 'MessageBroker (updated)',
688
+ path: `${moduleName}/application/ports/MessageBroker.java`
689
+ });
690
+ }
516
691
  }
517
692
  }
518
693
 
519
694
  spinner.succeed(chalk.green(`Generated ${generatedFiles.length} files! ✨`));
520
695
 
696
+ // ── Generate listeners (integration events CONSUMED from external producers) ──
697
+ if (listeners && listeners.length > 0) {
698
+ if (broker === 'kafka') {
699
+ spinner.start(`Generating ${listeners.length} Kafka listener(s)...`);
700
+ for (const listener of listeners) {
701
+ // Validate topic presence (mandatory for standalone modules)
702
+ if (!listener.topic) {
703
+ spinner.warn(chalk.yellow(`⚠ listener '${listener.event}': topic is required when there is no system.yaml. Skipping.`));
704
+ continue;
705
+ }
706
+
707
+ // Strip any prefix before the last dot (e.g. "test-eva.ORDER_PLACED" → "ORDER_PLACED")
708
+ const topicRaw = listener.topic;
709
+ const topicSuffix = topicRaw.includes('.') ? topicRaw.slice(topicRaw.lastIndexOf('.') + 1) : topicRaw;
710
+ const topicKey = topicSuffix.toLowerCase().replace(/_/g, '-');
711
+ const listenerContext = {
712
+ packageName,
713
+ moduleName,
714
+ ...listener,
715
+ topicConstant: topicRaw,
716
+ topicSpringProperty: `\${topics.${topicKey}}`,
717
+ topicVariableName: toCamelCase(topicSuffix.toLowerCase())
718
+ };
719
+
720
+ // 0. Nested type records (auxiliary value objects for object-typed fields)
721
+ for (const nt of (listener.nestedTypes || [])) {
722
+ const ntPath = path.join(
723
+ moduleBasePath, 'application', 'events',
724
+ `${nt.name}.java`
725
+ );
726
+ await renderAndWrite(
727
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerNestedType.java.ejs'),
728
+ ntPath,
729
+ { packageName, moduleName, name: nt.name, fields: nt.fields },
730
+ writeOptions
731
+ );
732
+ generatedFiles.push({
733
+ type: 'Listener Nested Type',
734
+ name: nt.name,
735
+ path: `${moduleName}/application/events/${nt.name}.java`
736
+ });
737
+ }
738
+
739
+ // 1. Integration Event record
740
+ const integrationEventPath = path.join(
741
+ moduleBasePath, 'application', 'events',
742
+ `${listener.integrationEventClassName}.java`
743
+ );
744
+ await renderAndWrite(
745
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerIntegrationEvent.java.ejs'),
746
+ integrationEventPath,
747
+ listenerContext,
748
+ writeOptions
749
+ );
750
+ generatedFiles.push({
751
+ type: 'Listener Integration Event',
752
+ name: listener.integrationEventClassName,
753
+ path: `${moduleName}/application/events/${listener.integrationEventClassName}.java`
754
+ });
755
+
756
+ // 2. Kafka listener class
757
+ // If the file is currently a mock Spring listener, force overwrite with the real Kafka impl
758
+ const kafkaListenerPath = path.join(
759
+ moduleBasePath, 'infrastructure', 'kafkaListener',
760
+ `${listener.listenerClassName}.java`
761
+ );
762
+ let kafkaListenerWriteOpts = writeOptions;
763
+ if (await fs.pathExists(kafkaListenerPath)) {
764
+ const existing = await fs.readFile(kafkaListenerPath, 'utf-8');
765
+ if (existing.includes('@EventListener') && !existing.includes('@KafkaListener')) {
766
+ kafkaListenerWriteOpts = { ...writeOptions, force: true };
767
+ }
768
+ }
769
+ await renderAndWrite(
770
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerClass.java.ejs'),
771
+ kafkaListenerPath,
772
+ listenerContext,
773
+ kafkaListenerWriteOpts
774
+ );
775
+ generatedFiles.push({
776
+ type: 'Kafka Listener',
777
+ name: listener.listenerClassName,
778
+ path: `${moduleName}/infrastructure/kafkaListener/${listener.listenerClassName}.java`
779
+ });
780
+
781
+ // 3. Register topic in kafka.yaml (all environments)
782
+ await updateKafkaYml(projectDir, topicKey, listener.topic);
783
+
784
+ // 4. Typed Command dispatched from the listener
785
+ const commandPath = path.join(
786
+ moduleBasePath, 'application', 'commands',
787
+ `${listener.commandClassName}.java`
788
+ );
789
+ await renderAndWrite(
790
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommand.java.ejs'),
791
+ commandPath,
792
+ listenerContext,
793
+ writeOptions
794
+ );
795
+ generatedFiles.push({
796
+ type: 'Listener Command',
797
+ name: listener.commandClassName,
798
+ path: `${moduleName}/application/commands/${listener.commandClassName}.java`
799
+ });
800
+
801
+ // 5. Use case handler that processes the command
802
+ const handlerPath = path.join(
803
+ moduleBasePath, 'application', 'usecases',
804
+ `${listener.useCase}CommandHandler.java`
805
+ );
806
+ await renderAndWrite(
807
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommandHandler.java.ejs'),
808
+ handlerPath,
809
+ listenerContext,
810
+ writeOptions
811
+ );
812
+ generatedFiles.push({
813
+ type: 'Handler',
814
+ name: `${listener.useCase}CommandHandler`,
815
+ path: `${moduleName}/application/usecases/${listener.useCase}CommandHandler.java`
816
+ });
817
+ }
818
+ spinner.succeed(chalk.green(`Kafka listeners generated! ✨`));
819
+ } else if (broker === 'mock') {
820
+ // ── Mock listeners: @EventListener instead of @KafkaListener ──────────
821
+ spinner.start(`Generating ${listeners.length} Spring Event listener(s) (mock mode)...`);
822
+
823
+ // Ensure MockEvent is available in shared
824
+ await sharedGenerator.generateMockEvent(sharedBasePath);
825
+
826
+ for (const listener of listeners) {
827
+ if (!listener.topic) {
828
+ spinner.warn(chalk.yellow(`⚠ listener '${listener.event}': topic is required. Skipping.`));
829
+ continue;
830
+ }
831
+
832
+ const topicRaw = listener.topic;
833
+ const topicSuffix = topicRaw.includes('.') ? topicRaw.slice(topicRaw.lastIndexOf('.') + 1) : topicRaw;
834
+ const topicKey = topicSuffix.toLowerCase().replace(/_/g, '-');
835
+ const listenerContext = {
836
+ packageName,
837
+ moduleName,
838
+ ...listener,
839
+ topicConstant: topicRaw,
840
+ topicSpringProperty: `\${topics.${topicKey}}`,
841
+ topicVariableName: toCamelCase(topicSuffix.toLowerCase())
842
+ };
843
+
844
+ // 0. Nested type records
845
+ for (const nt of (listener.nestedTypes || [])) {
846
+ const ntPath = path.join(moduleBasePath, 'application', 'events', `${nt.name}.java`);
847
+ await renderAndWrite(
848
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerNestedType.java.ejs'),
849
+ ntPath,
850
+ { packageName, moduleName, name: nt.name, fields: nt.fields },
851
+ writeOptions
852
+ );
853
+ generatedFiles.push({ type: 'Listener Nested Type', name: nt.name, path: `${moduleName}/application/events/${nt.name}.java` });
854
+ }
855
+
856
+ // 1. Integration Event record (same as Kafka)
857
+ const integrationEventPath = path.join(moduleBasePath, 'application', 'events', `${listener.integrationEventClassName}.java`);
858
+ await renderAndWrite(
859
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerIntegrationEvent.java.ejs'),
860
+ integrationEventPath,
861
+ listenerContext,
862
+ writeOptions
863
+ );
864
+ generatedFiles.push({ type: 'Listener Integration Event', name: listener.integrationEventClassName, path: `${moduleName}/application/events/${listener.integrationEventClassName}.java` });
865
+
866
+ // 2. Spring @EventListener class (mock — same file path as Kafka listener)
867
+ const listenerPath = path.join(moduleBasePath, 'infrastructure', 'kafkaListener', `${listener.listenerClassName}.java`);
868
+ await renderAndWrite(
869
+ path.join(__dirname, '..', '..', 'templates', 'mock', 'SpringEventListener.java.ejs'),
870
+ listenerPath,
871
+ listenerContext,
872
+ { ...writeOptions, force: true }
873
+ );
874
+ generatedFiles.push({ type: 'Spring Listener (mock)', name: listener.listenerClassName, path: `${moduleName}/infrastructure/kafkaListener/${listener.listenerClassName}.java` });
875
+
876
+ // 3. NO kafka.yaml update in mock mode
877
+
878
+ // 4. Typed Command dispatched from the listener
879
+ const mockCommandPath = path.join(moduleBasePath, 'application', 'commands', `${listener.commandClassName}.java`);
880
+ await renderAndWrite(
881
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommand.java.ejs'),
882
+ mockCommandPath,
883
+ listenerContext,
884
+ writeOptions
885
+ );
886
+ generatedFiles.push({ type: 'Listener Command', name: listener.commandClassName, path: `${moduleName}/application/commands/${listener.commandClassName}.java` });
887
+
888
+ // 5. Use case handler stub
889
+ const mockHandlerPath = path.join(moduleBasePath, 'application', 'usecases', `${listener.useCase}CommandHandler.java`);
890
+ await renderAndWrite(
891
+ path.join(__dirname, '..', '..', 'templates', 'kafka-listener', 'ListenerCommandHandler.java.ejs'),
892
+ mockHandlerPath,
893
+ listenerContext,
894
+ writeOptions
895
+ );
896
+ generatedFiles.push({ type: 'Handler', name: `${listener.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${listener.useCase}CommandHandler.java` });
897
+ }
898
+ spinner.succeed(chalk.green(`Spring Event listeners generated (mock mode)! ✨`));
899
+ } else if (listeners.length > 0) {
900
+ console.log(chalk.yellow(`⚠ listeners: section found but no broker is installed. Run 'eva add kafka-client' to generate listener classes.`));
901
+ }
902
+ }
903
+
904
+ // ── Generate ports (HTTP clients for synchronous communication) ──────────
905
+ if (ports && ports.length > 0) {
906
+ spinner.start(`Generating ${ports.length} HTTP port(s)...`);
907
+
908
+ for (const portGroup of ports) {
909
+ const {
910
+ serviceName,
911
+ serviceNameCamelCase,
912
+ target,
913
+ baseUrl,
914
+ baseUrlProperty,
915
+ feignClientName,
916
+ feignClientClassName,
917
+ feignAdapterClassName,
918
+ feignConfigClassName,
919
+ adapterPackage,
920
+ methods,
921
+ nestedTypes,
922
+ domainModels
923
+ } = portGroup;
924
+
925
+ const adapterDir = path.join(moduleBasePath, 'infrastructure', 'adapters', adapterPackage);
926
+
927
+ const portContext = {
928
+ packageName,
929
+ moduleName,
930
+ serviceName,
931
+ serviceNameCamelCase,
932
+ target,
933
+ baseUrl,
934
+ baseUrlProperty,
935
+ feignClientName,
936
+ feignClientClassName,
937
+ feignAdapterClassName,
938
+ feignConfigClassName,
939
+ adapterPackage,
940
+ methods,
941
+ nestedTypes,
942
+ domainModels
943
+ };
944
+
945
+ // 0. Nested type records (shared across methods in the same service)
946
+ for (const nt of nestedTypes) {
947
+ const ntPath = path.join(
948
+ moduleBasePath, 'application', 'dtos', `${nt.name}.java`
949
+ );
950
+ await renderAndWrite(
951
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortNestedType.java.ejs'),
952
+ ntPath,
953
+ { packageName, moduleName, name: nt.name, fields: nt.fields },
954
+ writeOptions
955
+ );
956
+ generatedFiles.push({
957
+ type: 'Port DTO',
958
+ name: nt.name,
959
+ path: `${moduleName}/application/dtos/${nt.name}.java`
960
+ });
961
+ }
962
+
963
+ // 1a. Domain models in domain/models/{adapterPackage}/ (ACL: domain-side abstraction)
964
+ for (const dm of (domainModels || [])) {
965
+ const dmPath = path.join(moduleBasePath, 'domain', 'models', adapterPackage, `${dm.name}.java`);
966
+ await renderAndWrite(
967
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortDomainModel.java.ejs'),
968
+ dmPath,
969
+ { packageName, moduleName, name: dm.name, fields: dm.fields, target, serviceName, adapterPackage },
970
+ writeOptions
971
+ );
972
+ generatedFiles.push({
973
+ type: 'Port Domain Model',
974
+ name: dm.name,
975
+ path: `${moduleName}/domain/models/${adapterPackage}/${dm.name}.java`
976
+ });
977
+ }
978
+
979
+ // 1b. Infra DTOs (one per method that has fields:) — live in infrastructure/adapters/{service}/
980
+ for (const method of methods.filter(m => m.hasResponse)) {
981
+ const infraDtoPath = path.join(adapterDir, `${method.infraDtoName}.java`);
982
+ await renderAndWrite(
983
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortResponseDto.java.ejs'),
984
+ infraDtoPath,
985
+ { packageName, moduleName, dtoName: method.infraDtoName, fields: method.fields, adapterPackage },
986
+ writeOptions
987
+ );
988
+ generatedFiles.push({
989
+ type: 'Port Infra DTO',
990
+ name: method.infraDtoName,
991
+ path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${method.infraDtoName}.java`
992
+ });
993
+ }
994
+
995
+ // 2. Request DTOs (one per method that has body:)
996
+ for (const method of methods.filter(m => m.hasBody)) {
997
+ const reqPath = path.join(
998
+ moduleBasePath, 'application', 'dtos', `${method.requestDtoName}.java`
999
+ );
1000
+ await renderAndWrite(
1001
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortRequestDto.java.ejs'),
1002
+ reqPath,
1003
+ { packageName, moduleName, dtoName: method.requestDtoName, bodyFields: method.bodyFields, nestedTypes: method.nestedTypes },
1004
+ writeOptions
1005
+ );
1006
+ generatedFiles.push({
1007
+ type: 'Port DTO',
1008
+ name: method.requestDtoName,
1009
+ path: `${moduleName}/application/dtos/${method.requestDtoName}.java`
1010
+ });
1011
+ }
1012
+
1013
+ // 3. Port interface (domain/repositories/)
1014
+ await renderAndWrite(
1015
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortInterface.java.ejs'),
1016
+ path.join(moduleBasePath, 'domain', 'repositories', `${serviceName}.java`),
1017
+ portContext,
1018
+ writeOptions
1019
+ );
1020
+ generatedFiles.push({
1021
+ type: 'HTTP Port',
1022
+ name: serviceName,
1023
+ path: `${moduleName}/domain/repositories/${serviceName}.java`
1024
+ });
1025
+
1026
+ // 4. Feign Client interface
1027
+ await renderAndWrite(
1028
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignClient.java.ejs'),
1029
+ path.join(adapterDir, `${feignClientClassName}.java`),
1030
+ portContext,
1031
+ writeOptions
1032
+ );
1033
+ generatedFiles.push({
1034
+ type: 'HTTP Port',
1035
+ name: feignClientClassName,
1036
+ path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignClientClassName}.java`
1037
+ });
1038
+
1039
+ // 5. Feign Adapter (@Component implementation)
1040
+ await renderAndWrite(
1041
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignAdapter.java.ejs'),
1042
+ path.join(adapterDir, `${feignAdapterClassName}.java`),
1043
+ portContext,
1044
+ writeOptions
1045
+ );
1046
+ generatedFiles.push({
1047
+ type: 'HTTP Port',
1048
+ name: feignAdapterClassName,
1049
+ path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignAdapterClassName}.java`
1050
+ });
1051
+
1052
+ // 6. Feign Config
1053
+ await renderAndWrite(
1054
+ path.join(__dirname, '..', '..', 'templates', 'ports', 'PortFeignConfig.java.ejs'),
1055
+ path.join(adapterDir, `${feignConfigClassName}.java`),
1056
+ portContext,
1057
+ writeOptions
1058
+ );
1059
+ generatedFiles.push({
1060
+ type: 'HTTP Port',
1061
+ name: feignConfigClassName,
1062
+ path: `${moduleName}/infrastructure/adapters/${adapterPackage}/${feignConfigClassName}.java`
1063
+ });
1064
+
1065
+ // 7. Register base URL in parameters/*/urls.yaml
1066
+ await createOrUpdateUrlsConfig(projectDir, baseUrlProperty, baseUrl);
1067
+ }
1068
+
1069
+ // Ensure urls.yaml is imported in all application-*.yaml files
1070
+ await ensureUrlsImport(projectDir);
1071
+
1072
+ spinner.succeed(chalk.green(`HTTP ports generated! ✨`));
1073
+ }
1074
+
521
1075
  console.log(chalk.blue('\n📦 Generated files:'));
522
1076
  const groupedFiles = generatedFiles.reduce((acc, file) => {
523
1077
  if (!acc[file.type]) acc[file.type] = [];
@@ -541,15 +1095,87 @@ async function generateEntitiesCommand(moduleName, options = {}) {
541
1095
  // Persist checksums to disk before asking about CRUD
542
1096
  await checksumManager.save();
543
1097
 
544
- // Ask user if they want to generate CRUD resources
545
- const { generateCrud } = await inquirer.prompt([
546
- {
547
- type: 'confirm',
548
- name: 'generateCrud',
549
- message: 'Do you want to generate CRUD resources for aggregate roots?',
550
- default: true
1098
+ if (endpoints) {
1099
+ // ── endpoints: section declared skip CRUD prompt, auto-generate ──
1100
+ spinner.start('Generating endpoint-driven resources...');
1101
+
1102
+ // Pre-classify all operations ONCE against all aggregates so that
1103
+ // each per-aggregate pass never overwrites a classification that
1104
+ // belongs to a different aggregate (multi-aggregate modules).
1105
+ for (const version of endpoints.versions) {
1106
+ for (const op of version.operations) {
1107
+ for (const agg of aggregates) {
1108
+ const cl = classifyUseCase(op, agg.name, agg);
1109
+ if (cl.category !== 'scaffold') {
1110
+ op._ownerAggregate = agg.name;
1111
+ op._classification = cl;
1112
+ break;
1113
+ }
1114
+ }
1115
+ if (!op._ownerAggregate) {
1116
+ // True scaffold: heuristic — find aggregate whose name appears inside the use case name
1117
+ const matched = aggregates.find(agg =>
1118
+ op.useCase.toLowerCase().includes(agg.name.toLowerCase())
1119
+ );
1120
+ const owner = matched || aggregates[0];
1121
+ op._ownerAggregate = owner.name;
1122
+ op._classification = { category: 'scaffold' };
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ const sharedGeneratedUseCases = new Set();
1128
+ for (const aggregate of aggregates) {
1129
+ await generateEndpointsResources(
1130
+ aggregate,
1131
+ endpoints,
1132
+ moduleName,
1133
+ moduleBasePath,
1134
+ packageName,
1135
+ generatedFiles,
1136
+ writeOptions,
1137
+ sharedGeneratedUseCases
1138
+ );
551
1139
  }
552
- ]);
1140
+
1141
+ spinner.succeed(chalk.green('Endpoint-driven resources generated! ✨'));
1142
+
1143
+ const epFiles = generatedFiles.filter(f =>
1144
+ f.type.includes('Command') || f.type.includes('Query') ||
1145
+ f.type.includes('Handler') || f.type.includes('DTO') ||
1146
+ f.type.includes('Controller') || f.type.includes('Mapper')
1147
+ );
1148
+ console.log(chalk.blue('\n📄 Generated endpoint files:'));
1149
+ const groupedEp = epFiles.reduce((acc, file) => {
1150
+ if (!acc[file.type]) acc[file.type] = [];
1151
+ acc[file.type].push(file);
1152
+ return acc;
1153
+ }, {});
1154
+ Object.keys(groupedEp).forEach(type => {
1155
+ console.log(chalk.gray(`\n ${type}:`));
1156
+ groupedEp[type].forEach(file => {
1157
+ console.log(chalk.gray(` ├── ${file.name}`));
1158
+ });
1159
+ });
1160
+
1161
+ // Collect versions for summary
1162
+ const versionList = endpoints.versions.map(v => v.version).join(', ');
1163
+ const totalOps = endpoints.versions.reduce((sum, v) => sum + v.operations.length, 0);
1164
+ console.log(chalk.blue(`\n✅ Generated ${totalOps} endpoint(s) across version(s): ${versionList}`));
1165
+
1166
+ await checksumManager.save();
1167
+
1168
+ } else {
1169
+ // ── No endpoints section → original interactive CRUD flow ────────
1170
+ // Ask user if they want to generate CRUD resources
1171
+ const { generateCrud } = await inquirer.prompt([
1172
+ {
1173
+ type: 'confirm',
1174
+ name: 'generateCrud',
1175
+ message: 'Do you want to generate CRUD resources for aggregate roots?',
1176
+ default: true
1177
+ }
1178
+ ]);
553
1179
 
554
1180
  if (generateCrud) {
555
1181
  // Ask for API version
@@ -583,22 +1209,24 @@ async function generateEntitiesCommand(moduleName, options = {}) {
583
1209
  writeOptions
584
1210
  );
585
1211
 
586
- // Generate Postman Collection for this aggregate
587
- const collectionPath = await generatePostmanCollection(
588
- aggregate,
589
- moduleName,
590
- moduleBasePath,
591
- projectDir,
592
- packageName,
593
- apiVersion,
594
- projectConfig,
595
- allEnums,
596
- writeOptions
597
- );
598
- postmanCollections.push({
599
- name: aggregate.name,
600
- path: path.relative(projectDir, collectionPath)
601
- });
1212
+ // Generate Postman Collection for this aggregate (skip when called from eva build)
1213
+ if (!options.skipPostman) {
1214
+ const collectionPath = await generatePostmanCollection(
1215
+ aggregate,
1216
+ moduleName,
1217
+ moduleBasePath,
1218
+ projectDir,
1219
+ packageName,
1220
+ apiVersion,
1221
+ projectConfig,
1222
+ allEnums,
1223
+ writeOptions
1224
+ );
1225
+ postmanCollections.push({
1226
+ name: aggregate.name,
1227
+ path: path.relative(projectDir, collectionPath)
1228
+ });
1229
+ }
602
1230
  }
603
1231
 
604
1232
  spinner.succeed(chalk.green('CRUD resources generated! ✨'));
@@ -641,6 +1269,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
641
1269
  await checksumManager.save();
642
1270
  }
643
1271
 
1272
+ } // end else (no endpoints section)
1273
+
644
1274
  console.log();
645
1275
 
646
1276
  } catch (error) {
@@ -686,20 +1316,659 @@ function transformRelsForApp(rels, validatedVoNames) {
686
1316
  }));
687
1317
  }
688
1318
 
1319
+ /**
1320
+ * Enrich fields with @Schema(example) values for Swagger UI.
1321
+ * Uses the same fake-data heuristics as the Postman collection generator.
1322
+ */
1323
+ function enrichFieldsWithSchemaExamples(fields, allEnums, valueObjects) {
1324
+ return fields.map(f => {
1325
+ // Skip VOs (nested DTOs describe themselves), collections, and already-enriched
1326
+ if (f.originalVoType || f.isValueObject || f.isCollection) return f;
1327
+ const example = generateFakeValue(f, allEnums, valueObjects);
1328
+ if (example === null || example === undefined || typeof example === 'object') return f;
1329
+ return { ...f, schemaExample: String(example) };
1330
+ });
1331
+ }
1332
+
1333
+ /**
1334
+ * Recursively enrich relationship fields with schema examples.
1335
+ */
1336
+ function enrichRelsWithSchemaExamples(rels, allEnums, valueObjects) {
1337
+ return (rels || []).map(rel => ({
1338
+ ...rel,
1339
+ fields: enrichFieldsWithSchemaExamples(rel.fields || [], allEnums, valueObjects),
1340
+ nestedRelationships: enrichRelsWithSchemaExamples(rel.nestedRelationships, allEnums, valueObjects),
1341
+ nestedOneToOneRelationships: (rel.nestedOneToOneRelationships || []).map(otoRel => ({
1342
+ ...otoRel,
1343
+ fields: enrichFieldsWithSchemaExamples(otoRel.fields || [], allEnums, valueObjects)
1344
+ }))
1345
+ }));
1346
+ }
1347
+
1348
+ /**
1349
+ * Classify an endpoint operation into a semantic category.
1350
+ * Returns { category, ...metadata } where category is one of:
1351
+ * 'standard' → matches the 5 CRUD patterns exactly
1352
+ * 'transition' → matches {MethodPascal}{Aggregate} for an enum transition
1353
+ * 'subEntityAdd' → matches Add{EntityName} for a OneToMany secondary entity
1354
+ * 'subEntityRemove' → matches Remove{EntityName} for a OneToMany secondary entity
1355
+ * 'findBy' → matches FindAll{Aggregate}sBy{FieldPascal} for a root field
1356
+ * 'scaffold' → no semantic pattern matched
1357
+ */
1358
+ function classifyUseCase(op, aggregateName, aggregate) {
1359
+ // 1. Standard CRUD
1360
+ const aggregateNamePlural = pluralizeWord(aggregateName);
1361
+ const standardMap = {
1362
+ [`Create${aggregateName}`]: 'create',
1363
+ [`Update${aggregateName}`]: 'update',
1364
+ [`Delete${aggregateName}`]: 'delete',
1365
+ [`Get${aggregateName}`]: 'getById',
1366
+ [`FindAll${aggregateNamePlural}`]: 'findAll'
1367
+ };
1368
+ if (standardMap[op.useCase]) {
1369
+ return { category: 'standard', variant: standardMap[op.useCase] };
1370
+ }
1371
+
1372
+ const rootEntity = aggregate.rootEntity;
1373
+ const enums = aggregate.enums || [];
1374
+
1375
+ // 2. Enum transitions — pattern: {MethodPascal}{Aggregate}
1376
+ for (const enumDef of enums) {
1377
+ for (const transition of (enumDef.transitions || [])) {
1378
+ const methodPascal = toPascalCase(transition.method);
1379
+ if (op.useCase === `${methodPascal}${aggregateName}`) {
1380
+ return {
1381
+ category: 'transition',
1382
+ domainMethod: transition.method,
1383
+ enumName: enumDef.name,
1384
+ targetStatus: Array.isArray(transition.to) ? transition.to[0] : transition.to
1385
+ };
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ // 3. Sub-entity operations — pattern: Add{Entity} / Remove{Entity} (OneToMany only)
1391
+ const oneToManyRels = (rootEntity.relationships || []).filter(r =>
1392
+ r.type === 'OneToMany' && !r.isInverse
1393
+ );
1394
+ for (const rel of oneToManyRels) {
1395
+ if (op.useCase === `Add${rel.target}`) {
1396
+ const targetEntity = (aggregate.secondaryEntities || []).find(e => e.name === rel.target);
1397
+ const entityFields = targetEntity
1398
+ ? targetEntity.fields.filter(f =>
1399
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1400
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
1401
+ )
1402
+ : [];
1403
+ return {
1404
+ category: 'subEntityAdd',
1405
+ entityName: rel.target,
1406
+ fieldName: rel.fieldName,
1407
+ addMethodName: `add${rel.target}`,
1408
+ entityFields,
1409
+ entityImports: targetEntity ? (targetEntity.imports || []) : []
1410
+ };
1411
+ }
1412
+ if (op.useCase === `Remove${rel.target}`) {
1413
+ return {
1414
+ category: 'subEntityRemove',
1415
+ entityName: rel.target,
1416
+ fieldName: rel.fieldName,
1417
+ removeMethodName: `remove${rel.target}ById`
1418
+ };
1419
+ }
1420
+ }
1421
+
1422
+ // 4. FindBy field — pattern: FindAll{Aggregate}sBy{FieldPascal}
1423
+ for (const field of (rootEntity.fields || [])) {
1424
+ const fieldPascal = toPascalCase(field.name);
1425
+ const aggregateNamePlural = pluralizeWord(aggregateName);
1426
+ if (op.useCase === `FindAll${aggregateNamePlural}By${fieldPascal}`) {
1427
+ return {
1428
+ category: 'findBy',
1429
+ fieldName: field.name,
1430
+ fieldPascal,
1431
+ fieldJavaType: field.javaType,
1432
+ jpaMethodName: `findBy${fieldPascal}`
1433
+ };
1434
+ }
1435
+ }
1436
+
1437
+ return { category: 'scaffold' };
1438
+ }
1439
+
1440
+ /**
1441
+ * Enrich a single endpoint operation with derived properties for template rendering.
1442
+ * Expects op._classification to be set by classifyUseCase() before this call.
1443
+ */
1444
+ function enrichEndpointOperation(op, aggregateName, idType) {
1445
+ const httpAnnotationMap = {
1446
+ GET: 'GetMapping', POST: 'PostMapping',
1447
+ PUT: 'PutMapping', PATCH: 'PatchMapping', DELETE: 'DeleteMapping'
1448
+ };
1449
+ const hasPathVar = Boolean(op.path && op.path.includes('{'));
1450
+ const pathVarMatch = hasPathVar ? op.path.match(/\{([^}]+)\}/) : null;
1451
+ const pathVarName = pathVarMatch ? pathVarMatch[1] : 'id';
1452
+
1453
+ const cl = op._classification || { category: 'scaffold' };
1454
+ const isStandard = cl.category === 'standard';
1455
+ const standardType = isStandard ? cl.variant : null;
1456
+
1457
+ // Infer type from HTTP method when not explicitly declared: GET → query, everything else → command
1458
+ const resolvedType = op.type || (op.method === 'GET' ? 'query' : 'command');
1459
+
1460
+ let returnType = 'void';
1461
+ if (standardType === 'getById') returnType = `${aggregateName}ResponseDto`;
1462
+ else if (standardType === 'findAll') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
1463
+ else if (cl.category === 'findBy') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
1464
+ else if (cl.category === 'scaffold' && resolvedType === 'query') returnType = `${aggregateName}ResponseDto`;
1465
+
1466
+ let httpStatus = 'HttpStatus.OK';
1467
+ if (standardType === 'create') httpStatus = 'HttpStatus.CREATED';
1468
+ else if (standardType === 'update') httpStatus = 'HttpStatus.NO_CONTENT';
1469
+ else if (cl.category === 'transition') httpStatus = 'HttpStatus.NO_CONTENT';
1470
+ else if (cl.category === 'subEntityAdd') httpStatus = 'HttpStatus.CREATED';
1471
+ else if (cl.category === 'subEntityRemove') httpStatus = 'HttpStatus.NO_CONTENT';
1472
+
1473
+ return {
1474
+ ...op,
1475
+ type: resolvedType,
1476
+ httpAnnotation: httpAnnotationMap[op.method] || 'PostMapping',
1477
+ methodName: toCamelCase(op.useCase),
1478
+ hasPathVar,
1479
+ pathVarName,
1480
+ isStandard,
1481
+ standardType,
1482
+ classifiedType: cl.category,
1483
+ classification: cl,
1484
+ returnType,
1485
+ httpStatus,
1486
+ idType
1487
+ };
1488
+ }
1489
+
1490
+ /**
1491
+ * Generate endpoint-driven resources (use cases + versioned controllers)
1492
+ * for an aggregate when domain.yaml declares an `endpoints:` section.
1493
+ */
1494
+ async function generateEndpointsResources(aggregate, endpoints, moduleName, moduleBasePath, packageName, generatedFiles, writeOptions = {}, sharedGeneratedUseCases = null) {
1495
+ const { name: aggregateName, rootEntity, secondaryEntities, valueObjects = [] } = aggregate;
1496
+ const aggregateNamePlural = pluralizeWord(aggregateName);
1497
+ const templatesDir = path.join(__dirname, '..', '..', 'templates', 'crud');
1498
+
1499
+ const idField = rootEntity.fields[0];
1500
+ const idType = idField.javaType;
1501
+
1502
+ const commandFields = rootEntity.fields.filter(f =>
1503
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1504
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly
1505
+ );
1506
+
1507
+ const validatedVos = valueObjects.filter(vo =>
1508
+ vo.fields.some(f => f.validationAnnotations && f.validationAnnotations.length > 0)
1509
+ );
1510
+ const validatedVoNames = new Set(validatedVos.map(vo => vo.name));
1511
+
1512
+ const oneToManyRelationships = enrichRelationshipsRecursively(rootEntity, secondaryEntities, 0, new Set());
1513
+ const oneToOneRels = rootEntity.relationships?.filter(r => r.type === 'OneToOne' && !r.isInverse) || [];
1514
+ const oneToOneRelationships = oneToOneRels.map(rel => {
1515
+ const targetEntity = secondaryEntities.find(e => e.name === rel.target);
1516
+ if (!targetEntity) return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: [] };
1517
+ const targetFields = targetEntity.fields.filter(f =>
1518
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1519
+ f.name !== 'createdBy' && f.name !== 'updatedBy'
1520
+ );
1521
+ return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: targetFields, entity: targetEntity };
1522
+ });
1523
+
1524
+ const hasValueObjects = rootEntity.fields.some(f => f.isValueObject);
1525
+ const hasEnums = rootEntity.enums && rootEntity.enums.length > 0;
1526
+ const resourceNameCamel = toCamelCase(aggregateName);
1527
+ const resourceNameKebab = toKebabCase(aggregateName);
1528
+
1529
+ const responseFields = rootEntity.fields.filter(f =>
1530
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden
1531
+ );
1532
+ const responseSecondaryEntities = secondaryEntities.map(entity => ({
1533
+ ...entity,
1534
+ responseFields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
1535
+ nestedRelationships: enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set()),
1536
+ forwardOneToOneRels: (entity.relationships || [])
1537
+ .filter(r => r.type === 'OneToOne' && !r.isInverse)
1538
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }))
1539
+ }));
1540
+
1541
+ // Schema examples for Swagger UI (same heuristics as Postman generator)
1542
+ const localAllEnums = [...(aggregate.enums || []), ...(rootEntity.enums || [])];
1543
+ initSeed(42);
1544
+
1545
+ const commandFieldsApp = enrichFieldsWithSchemaExamples(
1546
+ transformFieldsForApp(commandFields, validatedVoNames), localAllEnums, valueObjects);
1547
+ const oneToOneRelationshipsApp = oneToOneRelationships.map(rel => ({
1548
+ ...rel,
1549
+ fields: enrichFieldsWithSchemaExamples(
1550
+ transformFieldsForApp(rel.fields || [], validatedVoNames), localAllEnums, valueObjects)
1551
+ }));
1552
+ const oneToManyRelationshipsApp = enrichRelsWithSchemaExamples(
1553
+ transformRelsForApp(oneToManyRelationships, validatedVoNames), localAllEnums, valueObjects);
1554
+
1555
+ const baseContext = {
1556
+ packageName, moduleName, aggregateName, aggregateNamePlural, rootEntity, secondaryEntities,
1557
+ responseFields, responseSecondaryEntities, idType,
1558
+ commandFields: commandFieldsApp, oneToManyRelationships, oneToOneRelationships,
1559
+ hasValueObjects, hasEnums, imports: rootEntity.imports,
1560
+ resourceNameCamel, resourceNameKebab,
1561
+ hasSoftDelete: rootEntity.hasSoftDelete || false
1562
+ };
1563
+
1564
+ // ── Step 1: Validated VO Dtos ────────────────────────────────────────
1565
+ for (const vo of validatedVos) {
1566
+ const voDtoContext = {
1567
+ packageName, moduleName, voName: vo.name,
1568
+ fields: enrichFieldsWithSchemaExamples(vo.fields, localAllEnums, valueObjects),
1569
+ hasEnums: (vo.imports || []).some(i => i.includes('.enums.')),
1570
+ imports: [...(vo.imports || []), ...generateValidationImports(vo.fields)]
1571
+ };
1572
+ await renderAndWrite(
1573
+ path.join(templatesDir, 'CreateValueObjectDto.java.ejs'),
1574
+ path.join(moduleBasePath, 'application', 'dtos', `Create${vo.name}Dto.java`),
1575
+ voDtoContext, writeOptions
1576
+ );
1577
+ generatedFiles.push({ type: 'DTO', name: `Create${vo.name}Dto`, path: `${moduleName}/application/dtos/Create${vo.name}Dto.java` });
1578
+ }
1579
+
1580
+ // ── Step 2: ApplicationMapper ────────────────────────────────────────
1581
+ // Only emit Create{Aggregate}Command import and fromCommand() when a
1582
+ // standard CreateOrder operation is declared — other POST use cases
1583
+ // (e.g. PlaceOrder) are scaffolds and never produce that command class.
1584
+ const hasCreateOperation = endpoints.versions.some(v =>
1585
+ v.operations.some(op => op.useCase === `Create${aggregateName}`)
1586
+ );
1587
+ await renderAndWrite(
1588
+ path.join(templatesDir, 'ApplicationMapper.java.ejs'),
1589
+ path.join(moduleBasePath, 'application', 'mappers', `${aggregateName}ApplicationMapper.java`),
1590
+ { ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos, hasCreateOperation },
1591
+ writeOptions
1592
+ );
1593
+ generatedFiles.push({ type: 'Application Mapper', name: `${aggregateName}ApplicationMapper`, path: `${moduleName}/application/mappers/${aggregateName}ApplicationMapper.java` });
1594
+
1595
+ // ── Step 3: ResponseDto ──────────────────────────────────────────────
1596
+ const responseDtoContext = {
1597
+ ...baseContext,
1598
+ allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden),
1599
+ relationships: rootEntity.relationships.filter(r => (r.type === 'OneToMany' || r.type === 'OneToOne') && !r.isInverse)
1600
+ };
1601
+ await renderAndWrite(
1602
+ path.join(templatesDir, 'ResponseDto.java.ejs'),
1603
+ path.join(moduleBasePath, 'application', 'dtos', `${aggregateName}ResponseDto.java`),
1604
+ responseDtoContext, writeOptions
1605
+ );
1606
+ generatedFiles.push({ type: 'DTO', name: `${aggregateName}ResponseDto`, path: `${moduleName}/application/dtos/${aggregateName}ResponseDto.java` });
1607
+
1608
+ // ── Step 4: Secondary entity DTOs ───────────────────────────────────
1609
+ for (const entity of secondaryEntities) {
1610
+ const nestedRelationships = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
1611
+ const forwardOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
1612
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
1613
+
1614
+ await renderAndWrite(
1615
+ path.join(templatesDir, 'SecondaryEntityDto.java.ejs'),
1616
+ path.join(moduleBasePath, 'application', 'dtos', `${entity.name}Dto.java`),
1617
+ { packageName, moduleName, entityName: entity.name,
1618
+ fields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
1619
+ nestedRelationships, hasNestedRelationships: nestedRelationships.length > 0,
1620
+ forwardOneToOneRels: forwardOoO,
1621
+ hasValueObjects: entity.fields.some(f => f.isValueObject),
1622
+ hasEnums: entity.enums && entity.enums.length > 0, imports: entity.imports },
1623
+ writeOptions
1624
+ );
1625
+ generatedFiles.push({ type: 'DTO', name: `${entity.name}Dto`, path: `${moduleName}/application/dtos/${entity.name}Dto.java` });
1626
+
1627
+ const createFields = entity.fields.filter(f =>
1628
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1629
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
1630
+ );
1631
+ const entityNestedRels = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
1632
+ const createFieldsApp = enrichFieldsWithSchemaExamples(
1633
+ transformFieldsForApp(createFields, validatedVoNames), localAllEnums, valueObjects);
1634
+ const dtoVoDtoImports = validatedVos.filter(vo => createFieldsApp.some(f => f.originalVoType === vo.name))
1635
+ .map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
1636
+ const createDtoImports = [...new Set([
1637
+ ...(entity.imports || []), ...generateValidationImports(createFieldsApp), ...dtoVoDtoImports,
1638
+ ...(createFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
1639
+ ])];
1640
+ const fwdOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
1641
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
1642
+
1643
+ await renderAndWrite(
1644
+ path.join(templatesDir, 'CreateItemDto.java.ejs'),
1645
+ path.join(moduleBasePath, 'application', 'dtos', `Create${entity.name}Dto.java`),
1646
+ { packageName, moduleName, entityName: entity.name, fields: createFieldsApp,
1647
+ nestedRelationships: entityNestedRels, hasNestedRelationships: entityNestedRels.length > 0,
1648
+ forwardOneToOneRels: fwdOoO,
1649
+ hasValueObjects: entity.fields.some(f => f.isValueObject),
1650
+ hasEnums: entity.enums && entity.enums.length > 0, imports: createDtoImports },
1651
+ writeOptions
1652
+ );
1653
+ generatedFiles.push({ type: 'DTO', name: `Create${entity.name}Dto`, path: `${moduleName}/application/dtos/Create${entity.name}Dto.java` });
1654
+ }
1655
+
1656
+ // ── Step 5: Generate declared use cases (anti-duplicate across versions) ─
1657
+ const commandVoDtoImports = validatedVos
1658
+ .filter(vo => commandFieldsApp.some(f => f.originalVoType === vo.name))
1659
+ .map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
1660
+ const commandAppImports = [...new Set([
1661
+ ...(rootEntity.imports || []), ...generateValidationImports(commandFieldsApp), ...commandVoDtoImports,
1662
+ ...(commandFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
1663
+ ])];
1664
+
1665
+ // Defensive: classify ops not yet assigned by the outer pre-pass
1666
+ // (single-aggregate modules or direct calls without a shared set).
1667
+ for (const version of endpoints.versions) {
1668
+ for (const op of version.operations) {
1669
+ if (!op._classification) {
1670
+ op._classification = classifyUseCase(op, aggregateName, aggregate);
1671
+ op._ownerAggregate = op._ownerAggregate || aggregateName;
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ const generatedUseCases = sharedGeneratedUseCases ?? new Set();
1677
+ const findByOps = []; // collect FindBy ops for repository re-generation
1678
+
1679
+ for (const version of endpoints.versions) {
1680
+ for (const op of version.operations) {
1681
+ // Skip operations owned by a different aggregate (multi-aggregate endpoints section)
1682
+ // MUST come before the shared-set check so foreign ops don't poison the set.
1683
+ if (op._ownerAggregate !== aggregateName) continue;
1684
+
1685
+ if (generatedUseCases.has(op.useCase)) continue; // anti-duplicate (same use case across versions)
1686
+ generatedUseCases.add(op.useCase);
1687
+
1688
+ const cl = op._classification;
1689
+
1690
+ if (cl.category === 'standard') {
1691
+ const isStandard = true;
1692
+ if (cl.variant === 'create') {
1693
+ await renderAndWrite(
1694
+ path.join(templatesDir, 'CreateCommand.java.ejs'),
1695
+ path.join(moduleBasePath, 'application', 'commands', `Create${aggregateName}Command.java`),
1696
+ { ...baseContext, imports: commandAppImports }, writeOptions
1697
+ );
1698
+ generatedFiles.push({ type: 'Command', name: `Create${aggregateName}Command`, path: `${moduleName}/application/commands/Create${aggregateName}Command.java` });
1699
+
1700
+ await renderAndWrite(
1701
+ path.join(templatesDir, 'CreateCommandHandler.java.ejs'),
1702
+ path.join(moduleBasePath, 'application', 'usecases', `Create${aggregateName}CommandHandler.java`),
1703
+ { ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos }, writeOptions
1704
+ );
1705
+ generatedFiles.push({ type: 'Handler', name: `Create${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Create${aggregateName}CommandHandler.java` });
1706
+
1707
+ } else if (cl.variant === 'update') {
1708
+ await renderAndWrite(
1709
+ path.join(templatesDir, 'UpdateCommand.java.ejs'),
1710
+ path.join(moduleBasePath, 'application', 'commands', `Update${aggregateName}Command.java`),
1711
+ { ...baseContext, imports: commandAppImports }, writeOptions
1712
+ );
1713
+ generatedFiles.push({ type: 'Command', name: `Update${aggregateName}Command`, path: `${moduleName}/application/commands/Update${aggregateName}Command.java` });
1714
+
1715
+ await renderAndWrite(
1716
+ path.join(templatesDir, 'UpdateCommandHandler.java.ejs'),
1717
+ path.join(moduleBasePath, 'application', 'usecases', `Update${aggregateName}CommandHandler.java`),
1718
+ baseContext, writeOptions
1719
+ );
1720
+ generatedFiles.push({ type: 'Handler', name: `Update${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Update${aggregateName}CommandHandler.java` });
1721
+
1722
+ } else if (cl.variant === 'delete') {
1723
+ await renderAndWrite(
1724
+ path.join(templatesDir, 'DeleteCommand.java.ejs'),
1725
+ path.join(moduleBasePath, 'application', 'commands', `Delete${aggregateName}Command.java`),
1726
+ baseContext, writeOptions
1727
+ );
1728
+ generatedFiles.push({ type: 'Command', name: `Delete${aggregateName}Command`, path: `${moduleName}/application/commands/Delete${aggregateName}Command.java` });
1729
+
1730
+ await renderAndWrite(
1731
+ path.join(templatesDir, 'DeleteCommandHandler.java.ejs'),
1732
+ path.join(moduleBasePath, 'application', 'usecases', `Delete${aggregateName}CommandHandler.java`),
1733
+ baseContext, writeOptions
1734
+ );
1735
+ generatedFiles.push({ type: 'Handler', name: `Delete${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Delete${aggregateName}CommandHandler.java` });
1736
+
1737
+ } else if (cl.variant === 'getById') {
1738
+ await renderAndWrite(
1739
+ path.join(templatesDir, 'GetQuery.java.ejs'),
1740
+ path.join(moduleBasePath, 'application', 'queries', `Get${aggregateName}Query.java`),
1741
+ baseContext, writeOptions
1742
+ );
1743
+ generatedFiles.push({ type: 'Query', name: `Get${aggregateName}Query`, path: `${moduleName}/application/queries/Get${aggregateName}Query.java` });
1744
+
1745
+ await renderAndWrite(
1746
+ path.join(templatesDir, 'GetQueryHandler.java.ejs'),
1747
+ path.join(moduleBasePath, 'application', 'usecases', `Get${aggregateName}QueryHandler.java`),
1748
+ baseContext, writeOptions
1749
+ );
1750
+ generatedFiles.push({ type: 'Handler', name: `Get${aggregateName}QueryHandler`, path: `${moduleName}/application/usecases/Get${aggregateName}QueryHandler.java` });
1751
+
1752
+ } else if (cl.variant === 'findAll') {
1753
+ await renderAndWrite(
1754
+ path.join(templatesDir, 'ListQuery.java.ejs'),
1755
+ path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateNamePlural}Query.java`),
1756
+ baseContext, writeOptions
1757
+ );
1758
+ generatedFiles.push({ type: 'Query', name: `FindAll${aggregateNamePlural}Query`, path: `${moduleName}/application/queries/FindAll${aggregateNamePlural}Query.java` });
1759
+
1760
+ await renderAndWrite(
1761
+ path.join(templatesDir, 'ListQueryHandler.java.ejs'),
1762
+ path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateNamePlural}QueryHandler.java`),
1763
+ baseContext, writeOptions
1764
+ );
1765
+ generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateNamePlural}QueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateNamePlural}QueryHandler.java` });
1766
+ }
1767
+
1768
+ } else if (cl.category === 'transition') {
1769
+ // Transition: {MethodPascal}{Aggregate} → findById → entity.{method}() → save
1770
+ const transitionContext = {
1771
+ packageName, moduleName, aggregateName,
1772
+ useCaseName: op.useCase,
1773
+ idType,
1774
+ domainMethod: cl.domainMethod
1775
+ };
1776
+ await renderAndWrite(
1777
+ path.join(templatesDir, 'TransitionCommand.java.ejs'),
1778
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1779
+ transitionContext, writeOptions
1780
+ );
1781
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1782
+
1783
+ await renderAndWrite(
1784
+ path.join(templatesDir, 'TransitionCommandHandler.java.ejs'),
1785
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1786
+ transitionContext, writeOptions
1787
+ );
1788
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1789
+
1790
+ } else if (cl.category === 'subEntityAdd') {
1791
+ // SubEntityAdd: Add{EntityName} → findById → entity.add{Entity}(...) → save
1792
+ const addContext = {
1793
+ packageName, moduleName, aggregateName,
1794
+ useCaseName: op.useCase,
1795
+ idType,
1796
+ entityName: cl.entityName,
1797
+ entityFields: enrichFieldsWithSchemaExamples(cl.entityFields || [], localAllEnums, valueObjects),
1798
+ addMethodName: cl.addMethodName,
1799
+ imports: cl.entityImports
1800
+ };
1801
+ await renderAndWrite(
1802
+ path.join(templatesDir, 'SubEntityAddCommand.java.ejs'),
1803
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1804
+ addContext, writeOptions
1805
+ );
1806
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1807
+
1808
+ await renderAndWrite(
1809
+ path.join(templatesDir, 'SubEntityAddCommandHandler.java.ejs'),
1810
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1811
+ addContext, writeOptions
1812
+ );
1813
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1814
+
1815
+ } else if (cl.category === 'subEntityRemove') {
1816
+ // SubEntityRemove: Remove{EntityName} → findById → entity.remove{Entity}ById(itemId) → save
1817
+ const removeContext = {
1818
+ packageName, moduleName, aggregateName,
1819
+ useCaseName: op.useCase,
1820
+ idType,
1821
+ entityName: cl.entityName,
1822
+ removeMethodName: cl.removeMethodName
1823
+ };
1824
+ await renderAndWrite(
1825
+ path.join(templatesDir, 'SubEntityRemoveCommand.java.ejs'),
1826
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1827
+ removeContext, writeOptions
1828
+ );
1829
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1830
+
1831
+ await renderAndWrite(
1832
+ path.join(templatesDir, 'SubEntityRemoveCommandHandler.java.ejs'),
1833
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1834
+ removeContext, writeOptions
1835
+ );
1836
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1837
+
1838
+ } else if (cl.category === 'findBy') {
1839
+ // FindBy: FindAll{Aggregate}sBy{Field} → paginated query on a root field
1840
+ findByOps.push(cl); // collected for repository re-generation after the loop
1841
+ const findByContext = {
1842
+ packageName, moduleName, aggregateName,
1843
+ useCaseName: op.useCase,
1844
+ idType,
1845
+ fieldName: cl.fieldName,
1846
+ fieldPascal: cl.fieldPascal,
1847
+ fieldJavaType: cl.fieldJavaType,
1848
+ jpaMethodName: cl.jpaMethodName
1849
+ };
1850
+ await renderAndWrite(
1851
+ path.join(templatesDir, 'FindByQuery.java.ejs'),
1852
+ path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
1853
+ findByContext, writeOptions
1854
+ );
1855
+ generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
1856
+
1857
+ await renderAndWrite(
1858
+ path.join(templatesDir, 'FindByQueryHandler.java.ejs'),
1859
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
1860
+ findByContext, writeOptions
1861
+ );
1862
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
1863
+
1864
+ } else {
1865
+ // Scaffold: no semantic pattern matched → generate stub with TODO
1866
+ const scaffoldContext = { packageName, moduleName, aggregateName, useCaseName: op.useCase };
1867
+ const scaffoldType = op.type || (op.method === 'GET' ? 'query' : 'command');
1868
+ if (scaffoldType === 'command') {
1869
+ await renderAndWrite(
1870
+ path.join(templatesDir, 'ScaffoldCommand.java.ejs'),
1871
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1872
+ scaffoldContext, writeOptions
1873
+ );
1874
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1875
+
1876
+ await renderAndWrite(
1877
+ path.join(templatesDir, 'ScaffoldCommandHandler.java.ejs'),
1878
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1879
+ scaffoldContext, writeOptions
1880
+ );
1881
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1882
+ } else {
1883
+ await renderAndWrite(
1884
+ path.join(templatesDir, 'ScaffoldQuery.java.ejs'),
1885
+ path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
1886
+ scaffoldContext, writeOptions
1887
+ );
1888
+ generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
1889
+
1890
+ await renderAndWrite(
1891
+ path.join(templatesDir, 'ScaffoldQueryHandler.java.ejs'),
1892
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
1893
+ scaffoldContext, writeOptions
1894
+ );
1895
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
1896
+ }
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ // ── Step 5b: Re-generate repository files when FindBy ops are present ────
1902
+ // Checksum protection still applies: manually modified files are skipped.
1903
+ if (findByOps.length > 0) {
1904
+ const aggregateTemplatesDir = path.join(__dirname, '..', '..', 'templates', 'aggregate');
1905
+ const repoContext = { packageName, moduleName, rootEntity, findByOps };
1906
+ const repoImplContext = {
1907
+ packageName, moduleName, aggregateName, rootEntity,
1908
+ hasDomainEvents: (aggregate.domainEvents || []).length > 0,
1909
+ findByOps
1910
+ };
1911
+ await renderAndWrite(
1912
+ path.join(aggregateTemplatesDir, 'AggregateRepository.java.ejs'),
1913
+ path.join(moduleBasePath, 'domain', 'repositories', `${rootEntity.name}Repository.java`),
1914
+ repoContext, writeOptions
1915
+ );
1916
+ await renderAndWrite(
1917
+ path.join(aggregateTemplatesDir, 'JpaRepository.java.ejs'),
1918
+ path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}JpaRepository.java`),
1919
+ repoContext, writeOptions
1920
+ );
1921
+ await renderAndWrite(
1922
+ path.join(aggregateTemplatesDir, 'AggregateRepositoryImpl.java.ejs'),
1923
+ path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}RepositoryImpl.java`),
1924
+ repoImplContext, writeOptions
1925
+ );
1926
+ }
1927
+
1928
+ // ── Step 6: Versioned controllers ────────────────────────────────────
1929
+ for (const version of endpoints.versions) {
1930
+ const versionCap = version.version.charAt(0).toUpperCase() + version.version.slice(1);
1931
+ const controllerName = `${aggregateName}${versionCap}Controller`;
1932
+ const enrichedOps = version.operations
1933
+ .filter(op => op._ownerAggregate === aggregateName)
1934
+ .map(op => enrichEndpointOperation(op, aggregateName, idType));
1935
+
1936
+ const controllerContext = {
1937
+ ...baseContext,
1938
+ apiVersion: version.version,
1939
+ controllerName,
1940
+ operations: enrichedOps,
1941
+ basePath: endpoints.basePath,
1942
+ commandFields: commandFieldsApp,
1943
+ oneToManyRelationships,
1944
+ oneToOneRelationships
1945
+ };
1946
+
1947
+ await renderAndWrite(
1948
+ path.join(templatesDir, 'EndpointsController.java.ejs'),
1949
+ path.join(moduleBasePath, 'infrastructure', 'rest', 'controllers', resourceNameCamel, version.version, `${controllerName}.java`),
1950
+ controllerContext, writeOptions
1951
+ );
1952
+ generatedFiles.push({ type: 'Controller', name: controllerName, path: `${moduleName}/infrastructure/rest/controllers/${resourceNameCamel}/${version.version}/${controllerName}.java` });
1953
+ }
1954
+ }
1955
+
689
1956
  /**
690
1957
  * Generate CRUD resources for an aggregate root
691
1958
  */
692
1959
  async function generateCrudResources(aggregate, moduleName, moduleBasePath, packageName, apiVersion, generatedFiles, writeOptions = {}) {
693
1960
  const { name: aggregateName, rootEntity, secondaryEntities, valueObjects = [] } = aggregate;
1961
+ const aggregateNamePlural = pluralizeWord(aggregateName);
694
1962
  const templatesDir = path.join(__dirname, '..', '..', 'templates', 'crud');
695
1963
 
696
1964
  // Get ID field and type
697
1965
  const idField = rootEntity.fields[0];
698
1966
  const idType = idField.javaType;
699
1967
 
700
- // Filter command fields (exclude id, audit fields, and readOnly fields)
1968
+ // Filter command fields (exclude id, audit fields, soft-delete fields, and readOnly fields)
701
1969
  const commandFields = rootEntity.fields.filter(f =>
702
- f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' && f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
1970
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1971
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.readOnly
703
1972
  );
704
1973
 
705
1974
  // Validated VOs: VOs where any field has validation annotations
@@ -764,9 +2033,9 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
764
2033
  const resourceNameCamel = toCamelCase(aggregateName);
765
2034
  const resourceNameKebab = toKebabCase(aggregateName);
766
2035
 
767
- // Filter audit user fields and hidden fields from response DTOs
2036
+ // Filter audit user fields, soft-delete fields, and hidden fields from response DTOs
768
2037
  const responseFields = rootEntity.fields.filter(f =>
769
- f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden
2038
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden
770
2039
  );
771
2040
 
772
2041
  const responseSecondaryEntities = secondaryEntities.map(entity => ({
@@ -781,18 +2050,26 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
781
2050
  }));
782
2051
 
783
2052
  // Apply app-layer field transformation (VO fields become Create<Vo>Dto types)
784
- const commandFieldsApp = transformFieldsForApp(commandFields, validatedVoNames);
2053
+ // Schema examples for Swagger UI (same heuristics as Postman generator)
2054
+ const localAllEnums = [...(aggregate.enums || []), ...(rootEntity.enums || [])];
2055
+ initSeed(42);
2056
+
2057
+ const commandFieldsApp = enrichFieldsWithSchemaExamples(
2058
+ transformFieldsForApp(commandFields, validatedVoNames), localAllEnums, valueObjects);
785
2059
  const oneToOneRelationshipsApp = oneToOneRelationships.map(rel => ({
786
2060
  ...rel,
787
- fields: transformFieldsForApp(rel.fields || [], validatedVoNames)
2061
+ fields: enrichFieldsWithSchemaExamples(
2062
+ transformFieldsForApp(rel.fields || [], validatedVoNames), localAllEnums, valueObjects)
788
2063
  }));
789
- const oneToManyRelationshipsApp = transformRelsForApp(oneToManyRelationships, validatedVoNames);
2064
+ const oneToManyRelationshipsApp = enrichRelsWithSchemaExamples(
2065
+ transformRelsForApp(oneToManyRelationships, validatedVoNames), localAllEnums, valueObjects);
790
2066
 
791
2067
  // Base context for all templates
792
2068
  const baseContext = {
793
2069
  packageName,
794
2070
  moduleName,
795
2071
  aggregateName,
2072
+ aggregateNamePlural,
796
2073
  rootEntity,
797
2074
  secondaryEntities,
798
2075
  responseFields,
@@ -806,7 +2083,9 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
806
2083
  imports: rootEntity.imports,
807
2084
  apiVersion,
808
2085
  resourceNameCamel,
809
- resourceNameKebab
2086
+ resourceNameKebab,
2087
+ hasSoftDelete: rootEntity.hasSoftDelete || false,
2088
+ hasCreateOperation: true // In interactive CRUD flow, Create is always generated
810
2089
  };
811
2090
 
812
2091
  // 0. Generate Create<VoName>Dto for validated Value Objects
@@ -815,7 +2094,7 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
815
2094
  packageName,
816
2095
  moduleName,
817
2096
  voName: vo.name,
818
- fields: vo.fields,
2097
+ fields: enrichFieldsWithSchemaExamples(vo.fields, localAllEnums, valueObjects),
819
2098
  hasEnums: (vo.imports || []).some(i => i.includes('.enums.')),
820
2099
  imports: [...(vo.imports || []), ...generateValidationImports(vo.fields)]
821
2100
  };
@@ -837,7 +2116,8 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
837
2116
  commandFields: commandFieldsApp,
838
2117
  oneToOneRelationships: oneToOneRelationshipsApp,
839
2118
  oneToManyRelationships: oneToManyRelationshipsApp,
840
- validatedVos
2119
+ validatedVos,
2120
+ hasCreateOperation: true
841
2121
  },
842
2122
  writeOptions
843
2123
  );
@@ -888,11 +2168,11 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
888
2168
 
889
2169
  await renderAndWrite(
890
2170
  path.join(templatesDir, 'ListQuery.java.ejs'),
891
- path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateName}sQuery.java`),
2171
+ path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateNamePlural}Query.java`),
892
2172
  baseContext,
893
2173
  writeOptions
894
2174
  );
895
- generatedFiles.push({ type: 'Query', name: `FindAll${aggregateName}sQuery`, path: `${moduleName}/application/queries/FindAll${aggregateName}sQuery.java` });
2175
+ generatedFiles.push({ type: 'Query', name: `FindAll${aggregateNamePlural}Query`, path: `${moduleName}/application/queries/FindAll${aggregateNamePlural}Query.java` });
896
2176
 
897
2177
  // 4. Generate Handlers
898
2178
  await renderAndWrite(
@@ -919,11 +2199,11 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
919
2199
 
920
2200
  await renderAndWrite(
921
2201
  path.join(templatesDir, 'ListQueryHandler.java.ejs'),
922
- path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateName}sQueryHandler.java`),
2202
+ path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateNamePlural}QueryHandler.java`),
923
2203
  baseContext,
924
2204
  writeOptions
925
2205
  );
926
- generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateName}sQueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateName}sQueryHandler.java` });
2206
+ generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateNamePlural}QueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateNamePlural}QueryHandler.java` });
927
2207
 
928
2208
  await renderAndWrite(
929
2209
  path.join(templatesDir, 'DeleteCommandHandler.java.ejs'),
@@ -944,7 +2224,7 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
944
2224
  // 5. Generate DTOs
945
2225
  const responseDtoContext = {
946
2226
  ...baseContext,
947
- allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
2227
+ allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && f.name !== 'deletedAt' && !f.hidden),
948
2228
  relationships: rootEntity.relationships.filter(r => (r.type === 'OneToMany' || r.type === 'OneToOne') && !r.isInverse)
949
2229
  };
950
2230
 
@@ -1008,7 +2288,8 @@ async function generateCrudResources(aggregate, moduleName, moduleBasePath, pack
1008
2288
  new Set()
1009
2289
  );
1010
2290
 
1011
- const createFieldsApp = transformFieldsForApp(createFields, validatedVoNames);
2291
+ const createFieldsApp = enrichFieldsWithSchemaExamples(
2292
+ transformFieldsForApp(createFields, validatedVoNames), localAllEnums, valueObjects);
1012
2293
  const dtoVoDtoImports = validatedVos
1013
2294
  .filter(vo => createFieldsApp.some(f => f.originalVoType === vo.name))
1014
2295
  .map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
@@ -1099,6 +2380,7 @@ async function generatePostmanCollection(
1099
2380
 
1100
2381
  const context = {
1101
2382
  aggregateName,
2383
+ aggregateNamePlural: pluralizeWord(aggregateName),
1102
2384
  moduleName,
1103
2385
  resourceNameKebab,
1104
2386
  apiVersion,