eva4j 1.0.13 → 1.0.14

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 (44) hide show
  1. package/AGENTS.md +51 -9
  2. package/DOMAIN_YAML_GUIDE.md +150 -0
  3. package/bin/eva4j.js +31 -1
  4. package/design-system.md +797 -0
  5. package/docs/commands/EVALUATE_SYSTEM.md +542 -0
  6. package/docs/commands/GENERATE_ENTITIES.md +196 -0
  7. package/docs/commands/INDEX.md +10 -1
  8. package/examples/domain-endpoints-relations.yaml +353 -0
  9. package/examples/domain-endpoints-versioned.yaml +144 -0
  10. package/examples/domain-endpoints.yaml +135 -0
  11. package/examples/system.yaml +289 -0
  12. package/package.json +1 -1
  13. package/src/commands/create.js +6 -3
  14. package/src/commands/evaluate-system.js +384 -0
  15. package/src/commands/generate-entities.js +677 -14
  16. package/src/commands/generate-kafka-event.js +59 -5
  17. package/src/commands/generate-system.js +243 -0
  18. package/src/generators/base-generator.js +9 -1
  19. package/src/utils/naming.js +3 -2
  20. package/src/utils/system-validator.js +314 -0
  21. package/src/utils/yaml-to-entity.js +31 -2
  22. package/templates/aggregate/AggregateRepository.java.ejs +5 -0
  23. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +9 -0
  24. package/templates/aggregate/DomainEventHandler.java.ejs +24 -20
  25. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  26. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1103 -0
  27. package/templates/base/root/skill-build-domain-yaml.ejs +292 -0
  28. package/templates/base/root/skill-build-system-yaml.ejs +252 -0
  29. package/templates/base/root/system.yaml.ejs +97 -0
  30. package/templates/crud/EndpointsController.java.ejs +178 -0
  31. package/templates/crud/FindByQuery.java.ejs +17 -0
  32. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  33. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  34. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  35. package/templates/crud/ScaffoldQuery.java.ejs +12 -0
  36. package/templates/crud/ScaffoldQueryHandler.java.ejs +40 -0
  37. package/templates/crud/SubEntityAddCommand.java.ejs +17 -0
  38. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  39. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  40. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  41. package/templates/crud/TransitionCommand.java.ejs +9 -0
  42. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  43. package/templates/evaluate/report.html.ejs +971 -0
  44. package/templates/kafka-event/Event.java.ejs +7 -0
@@ -5,11 +5,12 @@ 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');
8
+ const { toPackagePath, toCamelCase, toKebabCase, toPascalCase, getApplicationClassName } = require('../utils/naming');
9
9
  const { renderAndWrite } = require('../utils/template-engine');
10
10
  const { parseDomainYaml, generateEntityImports, generateValidationImports } = require('../utils/yaml-to-entity');
11
11
  const SharedGenerator = require('../generators/shared-generator');
12
12
  const ChecksumManager = require('../utils/checksum-manager');
13
+ const { getInstalledBroker, generateSingleKafkaEvent, buildKafkaEventContext } = require('./generate-kafka-event');
13
14
 
14
15
  // Maximum depth for recursive relationship traversal
15
16
  const MAX_DEPTH = 5;
@@ -173,7 +174,7 @@ async function generateEntitiesCommand(moduleName, options = {}) {
173
174
 
174
175
  try {
175
176
  // Parse domain.yaml
176
- const { aggregates, allEnums } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
177
+ const { aggregates, allEnums, endpoints } = await parseDomainYaml(domainYamlPath, packageName, moduleName);
177
178
 
178
179
  spinner.succeed(chalk.green(`Found ${aggregates.length} aggregate(s) and ${allEnums.length} enum(s)`));
179
180
 
@@ -201,6 +202,9 @@ async function generateEntitiesCommand(moduleName, options = {}) {
201
202
  await sharedGenerator.generateDomainEvent(sharedBasePath);
202
203
  }
203
204
 
205
+ // Detect installed message broker for auto-wiring integration events
206
+ const broker = hasDomainEventsInModule ? await getInstalledBroker(configManager) : null;
207
+
204
208
  // Generate audit-related shared components if needed
205
209
  if (hasAuditableEntities || hasTrackUserEntities) {
206
210
 
@@ -439,7 +443,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
439
443
  const repoContext = {
440
444
  packageName,
441
445
  moduleName,
442
- rootEntity
446
+ rootEntity,
447
+ findByOps: []
443
448
  };
444
449
 
445
450
  await renderAndWrite(
@@ -465,7 +470,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
465
470
  moduleName,
466
471
  aggregateName,
467
472
  rootEntity,
468
- hasDomainEvents: (aggregate.domainEvents || []).length > 0
473
+ hasDomainEvents: (aggregate.domainEvents || []).length > 0,
474
+ findByOps: []
469
475
  };
470
476
 
471
477
  await renderAndWrite(
@@ -503,8 +509,11 @@ async function generateEntitiesCommand(moduleName, options = {}) {
503
509
  packageName,
504
510
  moduleName,
505
511
  aggregateName,
506
- domainEvents: aggregateDomainEvents,
507
- hasKafkaEvents: aggregateDomainEvents.some(e => e.kafka)
512
+ domainEvents: aggregateDomainEvents.map(e => ({
513
+ ...e,
514
+ integrationEventClassName: `${e.name}IntegrationEvent`
515
+ })),
516
+ broker
508
517
  };
509
518
  await renderAndWrite(
510
519
  path.join(__dirname, '..', '..', 'templates', 'aggregate', 'DomainEventHandler.java.ejs'),
@@ -513,6 +522,33 @@ async function generateEntitiesCommand(moduleName, options = {}) {
513
522
  writeOptions
514
523
  );
515
524
  generatedFiles.push({ type: 'Domain Event Handler', name: `${aggregateName}DomainEventHandler`, path: `${moduleName}/application/usecases/${aggregateName}DomainEventHandler.java` });
525
+
526
+ // ── Auto-wire broker integration events ────────────────────────────────
527
+ // When a message broker is installed, generate the complete integration
528
+ // event layer (XIntegrationEvent record, MessageBroker port method,
529
+ // KafkaMessageBroker impl method, topic config, KafkaConfig bean) for
530
+ // every domain event declared in this aggregate.
531
+ if (broker === 'kafka') {
532
+ for (const event of aggregateDomainEvents) {
533
+ const kafkaCtx = buildKafkaEventContext(packageName, moduleName, event);
534
+ await generateSingleKafkaEvent(projectDir, packagePath, kafkaCtx);
535
+ generatedFiles.push({
536
+ type: 'Integration Event',
537
+ name: kafkaCtx.eventClassName,
538
+ path: `${moduleName}/application/events/${kafkaCtx.eventClassName}.java`
539
+ });
540
+ }
541
+ generatedFiles.push({
542
+ type: 'Integration Event',
543
+ name: `${toPascalCase(moduleName)}KafkaMessageBroker (updated)`,
544
+ path: `${moduleName}/infrastructure/adapters/kafkaMessageBroker/${toPascalCase(moduleName)}KafkaMessageBroker.java`
545
+ });
546
+ generatedFiles.push({
547
+ type: 'Integration Event',
548
+ name: 'MessageBroker (updated)',
549
+ path: `${moduleName}/application/ports/MessageBroker.java`
550
+ });
551
+ }
516
552
  }
517
553
  }
518
554
 
@@ -541,15 +577,60 @@ async function generateEntitiesCommand(moduleName, options = {}) {
541
577
  // Persist checksums to disk before asking about CRUD
542
578
  await checksumManager.save();
543
579
 
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
580
+ if (endpoints) {
581
+ // ── endpoints: section declared skip CRUD prompt, auto-generate ──
582
+ spinner.start('Generating endpoint-driven resources...');
583
+
584
+ for (const aggregate of aggregates) {
585
+ await generateEndpointsResources(
586
+ aggregate,
587
+ endpoints,
588
+ moduleName,
589
+ moduleBasePath,
590
+ packageName,
591
+ generatedFiles,
592
+ writeOptions
593
+ );
551
594
  }
552
- ]);
595
+
596
+ spinner.succeed(chalk.green('Endpoint-driven resources generated! ✨'));
597
+
598
+ const epFiles = generatedFiles.filter(f =>
599
+ f.type.includes('Command') || f.type.includes('Query') ||
600
+ f.type.includes('Handler') || f.type.includes('DTO') ||
601
+ f.type.includes('Controller') || f.type.includes('Mapper')
602
+ );
603
+ console.log(chalk.blue('\n📄 Generated endpoint files:'));
604
+ const groupedEp = epFiles.reduce((acc, file) => {
605
+ if (!acc[file.type]) acc[file.type] = [];
606
+ acc[file.type].push(file);
607
+ return acc;
608
+ }, {});
609
+ Object.keys(groupedEp).forEach(type => {
610
+ console.log(chalk.gray(`\n ${type}:`));
611
+ groupedEp[type].forEach(file => {
612
+ console.log(chalk.gray(` ├── ${file.name}`));
613
+ });
614
+ });
615
+
616
+ // Collect versions for summary
617
+ const versionList = endpoints.versions.map(v => v.version).join(', ');
618
+ const totalOps = endpoints.versions.reduce((sum, v) => sum + v.operations.length, 0);
619
+ console.log(chalk.blue(`\n✅ Generated ${totalOps} endpoint(s) across version(s): ${versionList}`));
620
+
621
+ await checksumManager.save();
622
+
623
+ } else {
624
+ // ── No endpoints section → original interactive CRUD flow ────────
625
+ // Ask user if they want to generate CRUD resources
626
+ const { generateCrud } = await inquirer.prompt([
627
+ {
628
+ type: 'confirm',
629
+ name: 'generateCrud',
630
+ message: 'Do you want to generate CRUD resources for aggregate roots?',
631
+ default: true
632
+ }
633
+ ]);
553
634
 
554
635
  if (generateCrud) {
555
636
  // Ask for API version
@@ -641,6 +722,8 @@ async function generateEntitiesCommand(moduleName, options = {}) {
641
722
  await checksumManager.save();
642
723
  }
643
724
 
725
+ } // end else (no endpoints section)
726
+
644
727
  console.log();
645
728
 
646
729
  } catch (error) {
@@ -686,6 +769,586 @@ function transformRelsForApp(rels, validatedVoNames) {
686
769
  }));
687
770
  }
688
771
 
772
+ /**
773
+ * Classify an endpoint operation into a semantic category.
774
+ * Returns { category, ...metadata } where category is one of:
775
+ * 'standard' → matches the 5 CRUD patterns exactly
776
+ * 'transition' → matches {MethodPascal}{Aggregate} for an enum transition
777
+ * 'subEntityAdd' → matches Add{EntityName} for a OneToMany secondary entity
778
+ * 'subEntityRemove' → matches Remove{EntityName} for a OneToMany secondary entity
779
+ * 'findBy' → matches FindAll{Aggregate}sBy{FieldPascal} for a root field
780
+ * 'scaffold' → no semantic pattern matched
781
+ */
782
+ function classifyUseCase(op, aggregateName, aggregate) {
783
+ // 1. Standard CRUD
784
+ const standardMap = {
785
+ [`Create${aggregateName}`]: 'create',
786
+ [`Update${aggregateName}`]: 'update',
787
+ [`Delete${aggregateName}`]: 'delete',
788
+ [`Get${aggregateName}`]: 'getById',
789
+ [`FindAll${aggregateName}s`]: 'findAll'
790
+ };
791
+ if (standardMap[op.useCase]) {
792
+ return { category: 'standard', variant: standardMap[op.useCase] };
793
+ }
794
+
795
+ const rootEntity = aggregate.rootEntity;
796
+ const enums = aggregate.enums || [];
797
+
798
+ // 2. Enum transitions — pattern: {MethodPascal}{Aggregate}
799
+ for (const enumDef of enums) {
800
+ for (const transition of (enumDef.transitions || [])) {
801
+ const methodPascal = toPascalCase(transition.method);
802
+ if (op.useCase === `${methodPascal}${aggregateName}`) {
803
+ return {
804
+ category: 'transition',
805
+ domainMethod: transition.method,
806
+ enumName: enumDef.name,
807
+ targetStatus: Array.isArray(transition.to) ? transition.to[0] : transition.to
808
+ };
809
+ }
810
+ }
811
+ }
812
+
813
+ // 3. Sub-entity operations — pattern: Add{Entity} / Remove{Entity} (OneToMany only)
814
+ const oneToManyRels = (rootEntity.relationships || []).filter(r =>
815
+ r.type === 'OneToMany' && !r.isInverse
816
+ );
817
+ for (const rel of oneToManyRels) {
818
+ if (op.useCase === `Add${rel.target}`) {
819
+ const targetEntity = (aggregate.secondaryEntities || []).find(e => e.name === rel.target);
820
+ const entityFields = targetEntity
821
+ ? targetEntity.fields.filter(f =>
822
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
823
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
824
+ )
825
+ : [];
826
+ return {
827
+ category: 'subEntityAdd',
828
+ entityName: rel.target,
829
+ fieldName: rel.fieldName,
830
+ addMethodName: `add${rel.target}`,
831
+ entityFields,
832
+ entityImports: targetEntity ? (targetEntity.imports || []) : []
833
+ };
834
+ }
835
+ if (op.useCase === `Remove${rel.target}`) {
836
+ return {
837
+ category: 'subEntityRemove',
838
+ entityName: rel.target,
839
+ fieldName: rel.fieldName,
840
+ removeMethodName: `remove${rel.target}ById`
841
+ };
842
+ }
843
+ }
844
+
845
+ // 4. FindBy field — pattern: FindAll{Aggregate}sBy{FieldPascal}
846
+ for (const field of (rootEntity.fields || [])) {
847
+ const fieldPascal = toPascalCase(field.name);
848
+ if (op.useCase === `FindAll${aggregateName}sBy${fieldPascal}`) {
849
+ return {
850
+ category: 'findBy',
851
+ fieldName: field.name,
852
+ fieldPascal,
853
+ fieldJavaType: field.javaType,
854
+ jpaMethodName: `findBy${fieldPascal}`
855
+ };
856
+ }
857
+ }
858
+
859
+ return { category: 'scaffold' };
860
+ }
861
+
862
+ /**
863
+ * Enrich a single endpoint operation with derived properties for template rendering.
864
+ * Expects op._classification to be set by classifyUseCase() before this call.
865
+ */
866
+ function enrichEndpointOperation(op, aggregateName, idType) {
867
+ const httpAnnotationMap = {
868
+ GET: 'GetMapping', POST: 'PostMapping',
869
+ PUT: 'PutMapping', PATCH: 'PatchMapping', DELETE: 'DeleteMapping'
870
+ };
871
+ const hasPathVar = Boolean(op.path && op.path.includes('{'));
872
+ const pathVarMatch = hasPathVar ? op.path.match(/\{([^}]+)\}/) : null;
873
+ const pathVarName = pathVarMatch ? pathVarMatch[1] : 'id';
874
+
875
+ const cl = op._classification || { category: 'scaffold' };
876
+ const isStandard = cl.category === 'standard';
877
+ const standardType = isStandard ? cl.variant : null;
878
+
879
+ // Infer type from HTTP method when not explicitly declared: GET → query, everything else → command
880
+ const resolvedType = op.type || (op.method === 'GET' ? 'query' : 'command');
881
+
882
+ let returnType = 'void';
883
+ if (standardType === 'getById') returnType = `${aggregateName}ResponseDto`;
884
+ else if (standardType === 'findAll') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
885
+ else if (cl.category === 'findBy') returnType = `PagedResponse<${aggregateName}ResponseDto>`;
886
+ else if (cl.category === 'scaffold' && resolvedType === 'query') returnType = 'Object';
887
+
888
+ let httpStatus = 'HttpStatus.OK';
889
+ if (standardType === 'create') httpStatus = 'HttpStatus.CREATED';
890
+ else if (standardType === 'update') httpStatus = 'HttpStatus.NO_CONTENT';
891
+ else if (cl.category === 'transition') httpStatus = 'HttpStatus.NO_CONTENT';
892
+ else if (cl.category === 'subEntityAdd') httpStatus = 'HttpStatus.CREATED';
893
+ else if (cl.category === 'subEntityRemove') httpStatus = 'HttpStatus.NO_CONTENT';
894
+
895
+ return {
896
+ ...op,
897
+ type: resolvedType,
898
+ httpAnnotation: httpAnnotationMap[op.method] || 'PostMapping',
899
+ methodName: toCamelCase(op.useCase),
900
+ hasPathVar,
901
+ pathVarName,
902
+ isStandard,
903
+ standardType,
904
+ classifiedType: cl.category,
905
+ classification: cl,
906
+ returnType,
907
+ httpStatus,
908
+ idType
909
+ };
910
+ }
911
+
912
+ /**
913
+ * Generate endpoint-driven resources (use cases + versioned controllers)
914
+ * for an aggregate when domain.yaml declares an `endpoints:` section.
915
+ */
916
+ async function generateEndpointsResources(aggregate, endpoints, moduleName, moduleBasePath, packageName, generatedFiles, writeOptions = {}) {
917
+ const { name: aggregateName, rootEntity, secondaryEntities, valueObjects = [] } = aggregate;
918
+ const templatesDir = path.join(__dirname, '..', '..', 'templates', 'crud');
919
+
920
+ const idField = rootEntity.fields[0];
921
+ const idType = idField.javaType;
922
+
923
+ const commandFields = rootEntity.fields.filter(f =>
924
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
925
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
926
+ );
927
+
928
+ const validatedVos = valueObjects.filter(vo =>
929
+ vo.fields.some(f => f.validationAnnotations && f.validationAnnotations.length > 0)
930
+ );
931
+ const validatedVoNames = new Set(validatedVos.map(vo => vo.name));
932
+
933
+ const oneToManyRelationships = enrichRelationshipsRecursively(rootEntity, secondaryEntities, 0, new Set());
934
+ const oneToOneRels = rootEntity.relationships?.filter(r => r.type === 'OneToOne' && !r.isInverse) || [];
935
+ const oneToOneRelationships = oneToOneRels.map(rel => {
936
+ const targetEntity = secondaryEntities.find(e => e.name === rel.target);
937
+ if (!targetEntity) return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: [] };
938
+ const targetFields = targetEntity.fields.filter(f =>
939
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
940
+ f.name !== 'createdBy' && f.name !== 'updatedBy'
941
+ );
942
+ return { targetEntityName: rel.target, fieldName: rel.fieldName, type: rel.type, fields: targetFields, entity: targetEntity };
943
+ });
944
+
945
+ const hasValueObjects = rootEntity.fields.some(f => f.isValueObject);
946
+ const hasEnums = rootEntity.enums && rootEntity.enums.length > 0;
947
+ const resourceNameCamel = toCamelCase(aggregateName);
948
+ const resourceNameKebab = toKebabCase(aggregateName);
949
+
950
+ const responseFields = rootEntity.fields.filter(f =>
951
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden
952
+ );
953
+ const responseSecondaryEntities = secondaryEntities.map(entity => ({
954
+ ...entity,
955
+ responseFields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
956
+ nestedRelationships: enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set()),
957
+ forwardOneToOneRels: (entity.relationships || [])
958
+ .filter(r => r.type === 'OneToOne' && !r.isInverse)
959
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }))
960
+ }));
961
+
962
+ const commandFieldsApp = transformFieldsForApp(commandFields, validatedVoNames);
963
+ const oneToOneRelationshipsApp = oneToOneRelationships.map(rel => ({
964
+ ...rel,
965
+ fields: transformFieldsForApp(rel.fields || [], validatedVoNames)
966
+ }));
967
+ const oneToManyRelationshipsApp = transformRelsForApp(oneToManyRelationships, validatedVoNames);
968
+
969
+ const baseContext = {
970
+ packageName, moduleName, aggregateName, rootEntity, secondaryEntities,
971
+ responseFields, responseSecondaryEntities, idType,
972
+ commandFields: commandFieldsApp, oneToManyRelationships, oneToOneRelationships,
973
+ hasValueObjects, hasEnums, imports: rootEntity.imports,
974
+ resourceNameCamel, resourceNameKebab
975
+ };
976
+
977
+ // ── Step 1: Validated VO Dtos ────────────────────────────────────────
978
+ for (const vo of validatedVos) {
979
+ const voDtoContext = {
980
+ packageName, moduleName, voName: vo.name, fields: vo.fields,
981
+ hasEnums: (vo.imports || []).some(i => i.includes('.enums.')),
982
+ imports: [...(vo.imports || []), ...generateValidationImports(vo.fields)]
983
+ };
984
+ await renderAndWrite(
985
+ path.join(templatesDir, 'CreateValueObjectDto.java.ejs'),
986
+ path.join(moduleBasePath, 'application', 'dtos', `Create${vo.name}Dto.java`),
987
+ voDtoContext, writeOptions
988
+ );
989
+ generatedFiles.push({ type: 'DTO', name: `Create${vo.name}Dto`, path: `${moduleName}/application/dtos/Create${vo.name}Dto.java` });
990
+ }
991
+
992
+ // ── Step 2: ApplicationMapper ────────────────────────────────────────
993
+ await renderAndWrite(
994
+ path.join(templatesDir, 'ApplicationMapper.java.ejs'),
995
+ path.join(moduleBasePath, 'application', 'mappers', `${aggregateName}ApplicationMapper.java`),
996
+ { ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos },
997
+ writeOptions
998
+ );
999
+ generatedFiles.push({ type: 'Application Mapper', name: `${aggregateName}ApplicationMapper`, path: `${moduleName}/application/mappers/${aggregateName}ApplicationMapper.java` });
1000
+
1001
+ // ── Step 3: ResponseDto ──────────────────────────────────────────────
1002
+ const responseDtoContext = {
1003
+ ...baseContext,
1004
+ allFields: rootEntity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
1005
+ relationships: rootEntity.relationships.filter(r => (r.type === 'OneToMany' || r.type === 'OneToOne') && !r.isInverse)
1006
+ };
1007
+ await renderAndWrite(
1008
+ path.join(templatesDir, 'ResponseDto.java.ejs'),
1009
+ path.join(moduleBasePath, 'application', 'dtos', `${aggregateName}ResponseDto.java`),
1010
+ responseDtoContext, writeOptions
1011
+ );
1012
+ generatedFiles.push({ type: 'DTO', name: `${aggregateName}ResponseDto`, path: `${moduleName}/application/dtos/${aggregateName}ResponseDto.java` });
1013
+
1014
+ // ── Step 4: Secondary entity DTOs ───────────────────────────────────
1015
+ for (const entity of secondaryEntities) {
1016
+ const nestedRelationships = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
1017
+ const forwardOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
1018
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
1019
+
1020
+ await renderAndWrite(
1021
+ path.join(templatesDir, 'SecondaryEntityDto.java.ejs'),
1022
+ path.join(moduleBasePath, 'application', 'dtos', `${entity.name}Dto.java`),
1023
+ { packageName, moduleName, entityName: entity.name,
1024
+ fields: entity.fields.filter(f => f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.hidden),
1025
+ nestedRelationships, hasNestedRelationships: nestedRelationships.length > 0,
1026
+ forwardOneToOneRels: forwardOoO,
1027
+ hasValueObjects: entity.fields.some(f => f.isValueObject),
1028
+ hasEnums: entity.enums && entity.enums.length > 0, imports: entity.imports },
1029
+ writeOptions
1030
+ );
1031
+ generatedFiles.push({ type: 'DTO', name: `${entity.name}Dto`, path: `${moduleName}/application/dtos/${entity.name}Dto.java` });
1032
+
1033
+ const createFields = entity.fields.filter(f =>
1034
+ f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt' &&
1035
+ f.name !== 'createdBy' && f.name !== 'updatedBy' && !f.readOnly
1036
+ );
1037
+ const entityNestedRels = enrichRelationshipsRecursively(entity, secondaryEntities, 0, new Set());
1038
+ const createFieldsApp = transformFieldsForApp(createFields, validatedVoNames);
1039
+ const dtoVoDtoImports = validatedVos.filter(vo => createFieldsApp.some(f => f.originalVoType === vo.name))
1040
+ .map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
1041
+ const createDtoImports = [...new Set([
1042
+ ...(entity.imports || []), ...generateValidationImports(createFieldsApp), ...dtoVoDtoImports,
1043
+ ...(createFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
1044
+ ])];
1045
+ const fwdOoO = (entity.relationships || []).filter(r => r.type === 'OneToOne' && !r.isInverse)
1046
+ .map(r => ({ targetEntityName: r.target, fieldName: r.fieldName }));
1047
+
1048
+ await renderAndWrite(
1049
+ path.join(templatesDir, 'CreateItemDto.java.ejs'),
1050
+ path.join(moduleBasePath, 'application', 'dtos', `Create${entity.name}Dto.java`),
1051
+ { packageName, moduleName, entityName: entity.name, fields: createFieldsApp,
1052
+ nestedRelationships: entityNestedRels, hasNestedRelationships: entityNestedRels.length > 0,
1053
+ forwardOneToOneRels: fwdOoO,
1054
+ hasValueObjects: entity.fields.some(f => f.isValueObject),
1055
+ hasEnums: entity.enums && entity.enums.length > 0, imports: createDtoImports },
1056
+ writeOptions
1057
+ );
1058
+ generatedFiles.push({ type: 'DTO', name: `Create${entity.name}Dto`, path: `${moduleName}/application/dtos/Create${entity.name}Dto.java` });
1059
+ }
1060
+
1061
+ // ── Step 5: Generate declared use cases (anti-duplicate across versions) ─
1062
+ const commandVoDtoImports = validatedVos
1063
+ .filter(vo => commandFieldsApp.some(f => f.originalVoType === vo.name))
1064
+ .map(vo => `import ${packageName}.${moduleName}.application.dtos.Create${vo.name}Dto;`);
1065
+ const commandAppImports = [...new Set([
1066
+ ...(rootEntity.imports || []), ...generateValidationImports(commandFieldsApp), ...commandVoDtoImports,
1067
+ ...(commandFieldsApp.some(f => f.originalVoType) ? ['import jakarta.validation.Valid;'] : [])
1068
+ ])];
1069
+
1070
+ // Pre-classify ALL operations (including cross-version duplicates) so that
1071
+ // enrichEndpointOperation() can read op._classification in step 6.
1072
+ for (const version of endpoints.versions) {
1073
+ for (const op of version.operations) {
1074
+ op._classification = classifyUseCase(op, aggregateName, aggregate);
1075
+ }
1076
+ }
1077
+
1078
+ const generatedUseCases = new Set();
1079
+ const findByOps = []; // collect FindBy ops for repository re-generation
1080
+
1081
+ for (const version of endpoints.versions) {
1082
+ for (const op of version.operations) {
1083
+ if (generatedUseCases.has(op.useCase)) continue; // anti-duplicate
1084
+ generatedUseCases.add(op.useCase);
1085
+
1086
+ const cl = op._classification;
1087
+
1088
+ if (cl.category === 'standard') {
1089
+ const isStandard = true;
1090
+ if (cl.variant === 'create') {
1091
+ await renderAndWrite(
1092
+ path.join(templatesDir, 'CreateCommand.java.ejs'),
1093
+ path.join(moduleBasePath, 'application', 'commands', `Create${aggregateName}Command.java`),
1094
+ { ...baseContext, imports: commandAppImports }, writeOptions
1095
+ );
1096
+ generatedFiles.push({ type: 'Command', name: `Create${aggregateName}Command`, path: `${moduleName}/application/commands/Create${aggregateName}Command.java` });
1097
+
1098
+ await renderAndWrite(
1099
+ path.join(templatesDir, 'CreateCommandHandler.java.ejs'),
1100
+ path.join(moduleBasePath, 'application', 'usecases', `Create${aggregateName}CommandHandler.java`),
1101
+ { ...baseContext, commandFields: commandFieldsApp, oneToOneRelationships: oneToOneRelationshipsApp, oneToManyRelationships: oneToManyRelationshipsApp, validatedVos }, writeOptions
1102
+ );
1103
+ generatedFiles.push({ type: 'Handler', name: `Create${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Create${aggregateName}CommandHandler.java` });
1104
+
1105
+ } else if (cl.variant === 'update') {
1106
+ await renderAndWrite(
1107
+ path.join(templatesDir, 'UpdateCommand.java.ejs'),
1108
+ path.join(moduleBasePath, 'application', 'commands', `Update${aggregateName}Command.java`),
1109
+ { ...baseContext, imports: commandAppImports }, writeOptions
1110
+ );
1111
+ generatedFiles.push({ type: 'Command', name: `Update${aggregateName}Command`, path: `${moduleName}/application/commands/Update${aggregateName}Command.java` });
1112
+
1113
+ await renderAndWrite(
1114
+ path.join(templatesDir, 'UpdateCommandHandler.java.ejs'),
1115
+ path.join(moduleBasePath, 'application', 'usecases', `Update${aggregateName}CommandHandler.java`),
1116
+ baseContext, writeOptions
1117
+ );
1118
+ generatedFiles.push({ type: 'Handler', name: `Update${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Update${aggregateName}CommandHandler.java` });
1119
+
1120
+ } else if (cl.variant === 'delete') {
1121
+ await renderAndWrite(
1122
+ path.join(templatesDir, 'DeleteCommand.java.ejs'),
1123
+ path.join(moduleBasePath, 'application', 'commands', `Delete${aggregateName}Command.java`),
1124
+ baseContext, writeOptions
1125
+ );
1126
+ generatedFiles.push({ type: 'Command', name: `Delete${aggregateName}Command`, path: `${moduleName}/application/commands/Delete${aggregateName}Command.java` });
1127
+
1128
+ await renderAndWrite(
1129
+ path.join(templatesDir, 'DeleteCommandHandler.java.ejs'),
1130
+ path.join(moduleBasePath, 'application', 'usecases', `Delete${aggregateName}CommandHandler.java`),
1131
+ baseContext, writeOptions
1132
+ );
1133
+ generatedFiles.push({ type: 'Handler', name: `Delete${aggregateName}CommandHandler`, path: `${moduleName}/application/usecases/Delete${aggregateName}CommandHandler.java` });
1134
+
1135
+ } else if (cl.variant === 'getById') {
1136
+ await renderAndWrite(
1137
+ path.join(templatesDir, 'GetQuery.java.ejs'),
1138
+ path.join(moduleBasePath, 'application', 'queries', `Get${aggregateName}Query.java`),
1139
+ baseContext, writeOptions
1140
+ );
1141
+ generatedFiles.push({ type: 'Query', name: `Get${aggregateName}Query`, path: `${moduleName}/application/queries/Get${aggregateName}Query.java` });
1142
+
1143
+ await renderAndWrite(
1144
+ path.join(templatesDir, 'GetQueryHandler.java.ejs'),
1145
+ path.join(moduleBasePath, 'application', 'usecases', `Get${aggregateName}QueryHandler.java`),
1146
+ baseContext, writeOptions
1147
+ );
1148
+ generatedFiles.push({ type: 'Handler', name: `Get${aggregateName}QueryHandler`, path: `${moduleName}/application/usecases/Get${aggregateName}QueryHandler.java` });
1149
+
1150
+ } else if (cl.variant === 'findAll') {
1151
+ await renderAndWrite(
1152
+ path.join(templatesDir, 'ListQuery.java.ejs'),
1153
+ path.join(moduleBasePath, 'application', 'queries', `FindAll${aggregateName}sQuery.java`),
1154
+ baseContext, writeOptions
1155
+ );
1156
+ generatedFiles.push({ type: 'Query', name: `FindAll${aggregateName}sQuery`, path: `${moduleName}/application/queries/FindAll${aggregateName}sQuery.java` });
1157
+
1158
+ await renderAndWrite(
1159
+ path.join(templatesDir, 'ListQueryHandler.java.ejs'),
1160
+ path.join(moduleBasePath, 'application', 'usecases', `FindAll${aggregateName}sQueryHandler.java`),
1161
+ baseContext, writeOptions
1162
+ );
1163
+ generatedFiles.push({ type: 'Handler', name: `FindAll${aggregateName}sQueryHandler`, path: `${moduleName}/application/usecases/FindAll${aggregateName}sQueryHandler.java` });
1164
+ }
1165
+
1166
+ } else if (cl.category === 'transition') {
1167
+ // Transition: {MethodPascal}{Aggregate} → findById → entity.{method}() → save
1168
+ const transitionContext = {
1169
+ packageName, moduleName, aggregateName,
1170
+ useCaseName: op.useCase,
1171
+ idType,
1172
+ domainMethod: cl.domainMethod
1173
+ };
1174
+ await renderAndWrite(
1175
+ path.join(templatesDir, 'TransitionCommand.java.ejs'),
1176
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1177
+ transitionContext, writeOptions
1178
+ );
1179
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1180
+
1181
+ await renderAndWrite(
1182
+ path.join(templatesDir, 'TransitionCommandHandler.java.ejs'),
1183
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1184
+ transitionContext, writeOptions
1185
+ );
1186
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1187
+
1188
+ } else if (cl.category === 'subEntityAdd') {
1189
+ // SubEntityAdd: Add{EntityName} → findById → entity.add{Entity}(...) → save
1190
+ const addContext = {
1191
+ packageName, moduleName, aggregateName,
1192
+ useCaseName: op.useCase,
1193
+ idType,
1194
+ entityName: cl.entityName,
1195
+ entityFields: cl.entityFields,
1196
+ addMethodName: cl.addMethodName,
1197
+ imports: cl.entityImports
1198
+ };
1199
+ await renderAndWrite(
1200
+ path.join(templatesDir, 'SubEntityAddCommand.java.ejs'),
1201
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1202
+ addContext, writeOptions
1203
+ );
1204
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1205
+
1206
+ await renderAndWrite(
1207
+ path.join(templatesDir, 'SubEntityAddCommandHandler.java.ejs'),
1208
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1209
+ addContext, writeOptions
1210
+ );
1211
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1212
+
1213
+ } else if (cl.category === 'subEntityRemove') {
1214
+ // SubEntityRemove: Remove{EntityName} → findById → entity.remove{Entity}ById(itemId) → save
1215
+ const removeContext = {
1216
+ packageName, moduleName, aggregateName,
1217
+ useCaseName: op.useCase,
1218
+ idType,
1219
+ entityName: cl.entityName,
1220
+ removeMethodName: cl.removeMethodName
1221
+ };
1222
+ await renderAndWrite(
1223
+ path.join(templatesDir, 'SubEntityRemoveCommand.java.ejs'),
1224
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1225
+ removeContext, writeOptions
1226
+ );
1227
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1228
+
1229
+ await renderAndWrite(
1230
+ path.join(templatesDir, 'SubEntityRemoveCommandHandler.java.ejs'),
1231
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1232
+ removeContext, writeOptions
1233
+ );
1234
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1235
+
1236
+ } else if (cl.category === 'findBy') {
1237
+ // FindBy: FindAll{Aggregate}sBy{Field} → paginated query on a root field
1238
+ findByOps.push(cl); // collected for repository re-generation after the loop
1239
+ const findByContext = {
1240
+ packageName, moduleName, aggregateName,
1241
+ useCaseName: op.useCase,
1242
+ idType,
1243
+ fieldName: cl.fieldName,
1244
+ fieldPascal: cl.fieldPascal,
1245
+ fieldJavaType: cl.fieldJavaType,
1246
+ jpaMethodName: cl.jpaMethodName
1247
+ };
1248
+ await renderAndWrite(
1249
+ path.join(templatesDir, 'FindByQuery.java.ejs'),
1250
+ path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
1251
+ findByContext, writeOptions
1252
+ );
1253
+ generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
1254
+
1255
+ await renderAndWrite(
1256
+ path.join(templatesDir, 'FindByQueryHandler.java.ejs'),
1257
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
1258
+ findByContext, writeOptions
1259
+ );
1260
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
1261
+
1262
+ } else {
1263
+ // Scaffold: no semantic pattern matched → generate stub with TODO
1264
+ const scaffoldContext = { packageName, moduleName, aggregateName, useCaseName: op.useCase };
1265
+ const scaffoldType = op.type || (op.method === 'GET' ? 'query' : 'command');
1266
+ if (scaffoldType === 'command') {
1267
+ await renderAndWrite(
1268
+ path.join(templatesDir, 'ScaffoldCommand.java.ejs'),
1269
+ path.join(moduleBasePath, 'application', 'commands', `${op.useCase}Command.java`),
1270
+ scaffoldContext, writeOptions
1271
+ );
1272
+ generatedFiles.push({ type: 'Command', name: `${op.useCase}Command`, path: `${moduleName}/application/commands/${op.useCase}Command.java` });
1273
+
1274
+ await renderAndWrite(
1275
+ path.join(templatesDir, 'ScaffoldCommandHandler.java.ejs'),
1276
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}CommandHandler.java`),
1277
+ scaffoldContext, writeOptions
1278
+ );
1279
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}CommandHandler`, path: `${moduleName}/application/usecases/${op.useCase}CommandHandler.java` });
1280
+ } else {
1281
+ await renderAndWrite(
1282
+ path.join(templatesDir, 'ScaffoldQuery.java.ejs'),
1283
+ path.join(moduleBasePath, 'application', 'queries', `${op.useCase}Query.java`),
1284
+ scaffoldContext, writeOptions
1285
+ );
1286
+ generatedFiles.push({ type: 'Query', name: `${op.useCase}Query`, path: `${moduleName}/application/queries/${op.useCase}Query.java` });
1287
+
1288
+ await renderAndWrite(
1289
+ path.join(templatesDir, 'ScaffoldQueryHandler.java.ejs'),
1290
+ path.join(moduleBasePath, 'application', 'usecases', `${op.useCase}QueryHandler.java`),
1291
+ scaffoldContext, writeOptions
1292
+ );
1293
+ generatedFiles.push({ type: 'Handler', name: `${op.useCase}QueryHandler`, path: `${moduleName}/application/usecases/${op.useCase}QueryHandler.java` });
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ // ── Step 5b: Re-generate repository files when FindBy ops are present ────
1300
+ // Checksum protection still applies: manually modified files are skipped.
1301
+ if (findByOps.length > 0) {
1302
+ const aggregateTemplatesDir = path.join(__dirname, '..', '..', 'templates', 'aggregate');
1303
+ const repoContext = { packageName, moduleName, rootEntity, findByOps };
1304
+ const repoImplContext = {
1305
+ packageName, moduleName, aggregateName, rootEntity,
1306
+ hasDomainEvents: (aggregate.domainEvents || []).length > 0,
1307
+ findByOps
1308
+ };
1309
+ await renderAndWrite(
1310
+ path.join(aggregateTemplatesDir, 'AggregateRepository.java.ejs'),
1311
+ path.join(moduleBasePath, 'domain', 'repositories', `${rootEntity.name}Repository.java`),
1312
+ repoContext, writeOptions
1313
+ );
1314
+ await renderAndWrite(
1315
+ path.join(aggregateTemplatesDir, 'JpaRepository.java.ejs'),
1316
+ path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}JpaRepository.java`),
1317
+ repoContext, writeOptions
1318
+ );
1319
+ await renderAndWrite(
1320
+ path.join(aggregateTemplatesDir, 'AggregateRepositoryImpl.java.ejs'),
1321
+ path.join(moduleBasePath, 'infrastructure', 'database', 'repositories', `${rootEntity.name}RepositoryImpl.java`),
1322
+ repoImplContext, writeOptions
1323
+ );
1324
+ }
1325
+
1326
+ // ── Step 6: Versioned controllers ────────────────────────────────────
1327
+ for (const version of endpoints.versions) {
1328
+ const versionCap = version.version.charAt(0).toUpperCase() + version.version.slice(1);
1329
+ const controllerName = `${aggregateName}${versionCap}Controller`;
1330
+ const enrichedOps = version.operations.map(op => enrichEndpointOperation(op, aggregateName, idType));
1331
+
1332
+ const controllerContext = {
1333
+ ...baseContext,
1334
+ apiVersion: version.version,
1335
+ controllerName,
1336
+ operations: enrichedOps,
1337
+ basePath: endpoints.basePath,
1338
+ commandFields: commandFieldsApp,
1339
+ oneToManyRelationships,
1340
+ oneToOneRelationships
1341
+ };
1342
+
1343
+ await renderAndWrite(
1344
+ path.join(templatesDir, 'EndpointsController.java.ejs'),
1345
+ path.join(moduleBasePath, 'infrastructure', 'rest', 'controllers', resourceNameCamel, version.version, `${controllerName}.java`),
1346
+ controllerContext, writeOptions
1347
+ );
1348
+ generatedFiles.push({ type: 'Controller', name: controllerName, path: `${moduleName}/infrastructure/rest/controllers/${resourceNameCamel}/${version.version}/${controllerName}.java` });
1349
+ }
1350
+ }
1351
+
689
1352
  /**
690
1353
  * Generate CRUD resources for an aggregate root
691
1354
  */