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
@@ -19,6 +19,9 @@
19
19
  13. [Generated files](#13-generated-files)
20
20
  14. [Complete examples](#14-complete-examples)
21
21
  15. [Prerequisites and common errors](#15-prerequisites-and-common-errors)
22
+ 16. [Declarative endpoints — use case patterns](#16-declarative-endpoints-endpoints--use-case-patterns)
23
+ 17. [Consuming external events (listeners:)](#17-consuming-external-events-listeners)
24
+ 18. [Synchronous HTTP clients (ports:)](#18-synchronous-http-clients-ports)
22
25
 
23
26
  ---
24
27
 
@@ -375,6 +378,84 @@ entities:
375
378
 
376
379
  ---
377
380
 
381
+ ## 7b. Soft Delete
382
+
383
+ When `hasSoftDelete: true` is set on the aggregate root, eva4j generates logical deletion instead of physical: the record is not removed from the database but stamped with a `deletedAt` timestamp.
384
+
385
+ > **Scope rule:** `hasSoftDelete` is **only valid on the aggregate root** (`isRoot: true`). Setting it on a secondary entity emits a warning and is silently ignored — secondary entities are deleted physically via CASCADE from the root.
386
+
387
+ ### Syntax
388
+
389
+ ```yaml
390
+ entities:
391
+ - name: order
392
+ isRoot: true # ← mandatory: only root entities support this
393
+ tableName: orders
394
+ hasSoftDelete: true # ✅ enables soft delete
395
+ audit:
396
+ enabled: true
397
+ fields:
398
+ - name: id
399
+ type: String
400
+ - name: orderNumber
401
+ type: String
402
+
403
+ - name: orderItem # ← secondary entity
404
+ tableName: order_items
405
+ # hasSoftDelete: true ← ❌ ignored with warning
406
+ ```
407
+
408
+ ### What is generated
409
+
410
+ | Artefact | `deletedAt` included | Notes |
411
+ |---|---|---|
412
+ | Full constructor (reconstruction) | ✅ | Required to hydrate persisted state |
413
+ | Business constructor (new object) | ❌ | Starts as `null` |
414
+ | `CreateDto` / `CreateCommand` | ❌ | Cannot create an already-deleted object |
415
+ | `ResponseDto` | ❌ | Internal metadata, not exposed in API |
416
+ | `toJpa()` in mapper | ✅ | Persists the timestamp after `softDelete()` |
417
+
418
+ **Domain entity:**
419
+ ```java
420
+ public class Order {
421
+ private LocalDateTime deletedAt; // injected automatically
422
+
423
+ public void softDelete() {
424
+ if (this.deletedAt != null) {
425
+ throw new IllegalStateException("Order is already deleted");
426
+ }
427
+ this.deletedAt = java.time.LocalDateTime.now();
428
+ }
429
+
430
+ public boolean isDeleted() {
431
+ return this.deletedAt != null;
432
+ }
433
+ }
434
+ ```
435
+
436
+ **JPA entity:**
437
+ ```java
438
+ @SQLRestriction("deleted_at IS NULL") // filters ALL queries automatically
439
+ @Entity
440
+ @Table(name = "orders")
441
+ public class OrderJpa extends AuditableEntity {
442
+ private LocalDateTime deletedAt;
443
+ }
444
+ ```
445
+
446
+ **DeleteCommandHandler** (generated when `hasSoftDelete: true`):
447
+ ```java
448
+ // Finds → marks → saves — never deleteById
449
+ Order order = repository.findById(id)
450
+ .orElseThrow(() -> new OrderNotFoundException(id));
451
+ order.softDelete();
452
+ repository.save(order);
453
+ ```
454
+
455
+ > `deletedAt` **must not be defined manually** in `fields:` — the generator injects it automatically.
456
+
457
+ ---
458
+
378
459
  ## 8. Relationships
379
460
 
380
461
  ### Properties
@@ -624,27 +705,83 @@ Java condition evaluated in the transition method. If the expression is `true`,
624
705
 
625
706
  ## 11. Domain events
626
707
 
627
- Events are declared under the aggregate (at the same level as `entities:`, `enums:`, `valueObjects:`).
708
+ Events are declared under the aggregate (at the same level as `entities:`, `enums:`, `valueObjects:`). Use the optional `triggers` property to connect an event to one or more state transition methods — the generator then emits `raise()` automatically inside each method.
628
709
 
629
710
  ```yaml
630
711
  aggregates:
631
712
  - name: Order
713
+ enums:
714
+ - name: OrderStatus
715
+ initialValue: DRAFT
716
+ transitions:
717
+ - from: DRAFT
718
+ to: PLACED
719
+ method: place
720
+ - from: PLACED
721
+ to: CANCELLED
722
+ method: cancel
723
+ values: [DRAFT, PLACED, CANCELLED]
632
724
  events:
633
725
  - name: OrderPlaced
726
+ topic: ORDER_PLACED # preferred: explicit — must match listeners[].topic in consumers
727
+ # default derivation: strips 'Event' suffix → OrderPlacedEvent → ORDER_PLACED
728
+ triggers:
729
+ - place # transition method name that publishes this event
634
730
  fields:
731
+ # orderId declared for cross-module Kafka consumers — generator maps it to event.getAggregateId()
732
+ - name: orderId
733
+ type: String
635
734
  - name: customerId
636
735
  type: String
637
736
  - name: totalAmount
638
737
  type: BigDecimal
738
+ - name: placedAt
739
+ type: LocalDateTime
639
740
  - name: OrderCancelled
741
+ topic: ORDER_CANCELLED # preferred: explicit topic
742
+ triggers:
743
+ - cancel
640
744
  fields:
641
- - name: reason
745
+ - name: reason # unresolvable → null /* TODO: provide reason */
642
746
  type: String
643
747
  entities:
644
748
  - name: Order
749
+ isRoot: true
645
750
  # ...
646
751
  ```
647
752
 
753
+ ### `triggers` — argument resolution rules (in order)
754
+
755
+ | Field condition | Generated argument |
756
+ |---|---|
757
+ | Always (first arg — `aggregateId` from `DomainEvent` base) | `this.getId()` |
758
+ | Name = `{entityName}Id` (e.g. `orderId` in `Order`) | **Skipped in Domain Event class** — mapped to `event.getAggregateId()` in the Integration Event handler |
759
+ | Name matches a field of the entity | `this.get{Field}()` |
760
+ | Name ends in `At` + type `LocalDateTime` | `LocalDateTime.now()` |
761
+ | Not resolvable | `null /* TODO: provide {fieldName} */` |
762
+
763
+ > **Convention:** Declare `{entityName}Id` in `events[].fields` when the event **crosses module boundaries via Kafka** — it is required so the id travels in the Integration Event payload. The generator automatically maps it to `event.getAggregateId()` in the handler, preventing duplication in the internal Domain Event class. If the event is only consumed within the same bounded context (Spring event bus), `{entityName}Id` can be omitted since `getAggregateId()` is already available.
764
+
765
+ ### `topic` — Kafka topic name for the event
766
+
767
+ Optional but **recommended**. Declares the Kafka topic name for this event explicitly.
768
+
769
+ ```yaml
770
+ events:
771
+ - name: OrderPlacedEvent
772
+ topic: ORDER_PLACED # ✅ preferred: explicit, matches listeners[].topic in consumers
773
+ triggers: [place]
774
+ fields: [...]
775
+ ```
776
+
777
+ **Default derivation (when `topic:` is omitted):** the generator strips the `Event` suffix from the class name before converting to SCREAMING_SNAKE_CASE:
778
+ - `OrderPlacedEvent` → `ORDER_PLACED` ✓
779
+ - `OrderCancelled` *(no suffix)* → `ORDER_CANCELLED` ✓
780
+
781
+ **Why prefer explicit `topic:`:** when a consumer module declares `listeners[].topic: ORDER_PLACED`, the value must match the producer's topic exactly. Declaring it explicitly in both places eliminates any risk of mismatch and makes the contract visible without having to understand the derivation rule.
782
+
783
+ If an event has **no `triggers`**, the developer must call `raise()` manually inside the business method.
784
+
648
785
  ### Generated files
649
786
 
650
787
  | File | Description |
@@ -658,29 +795,52 @@ aggregates:
658
795
 
659
796
  ### Generated event
660
797
 
798
+ Fields declared as `{entityName}Id` are excluded from the **Domain Event class** (the aggregate id is already available via `getAggregateId()` inherited from `DomainEvent`), but they **are included** in the Integration Event record and the Kafka payload — the handler maps them to `event.getAggregateId()` automatically.
799
+
661
800
  ```java
662
801
  public final class OrderPlaced extends DomainEvent {
802
+ // aggregateId (= orderId) inherited from DomainEvent — not repeated here
663
803
  private final String customerId;
664
804
  private final BigDecimal totalAmount;
805
+ private final LocalDateTime placedAt;
665
806
 
666
- public OrderPlaced(String customerId, BigDecimal totalAmount) {
807
+ public OrderPlaced(String aggregateId, String customerId, BigDecimal totalAmount, LocalDateTime placedAt) {
808
+ super(aggregateId);
667
809
  this.customerId = customerId;
668
810
  this.totalAmount = totalAmount;
811
+ this.placedAt = placedAt;
669
812
  }
670
813
 
671
814
  // getters
672
815
  }
673
816
  ```
674
817
 
675
- ### How to raise an event in the entity
818
+ ### How to raise an event auto-generated via `triggers`
819
+
820
+ When `triggers` is declared, the generator emits the `raise()` call automatically inside each transition method:
821
+
822
+ ```java
823
+ public void place() {
824
+ this.status = this.status.transitionTo(OrderStatus.PLACED);
825
+ raise(new OrderPlaced(this.getId(), this.getCustomerId(), this.getTotalAmount(), LocalDateTime.now()));
826
+ // ^—aggregateId ^—customerId ^—totalAmount ^—placedAt
827
+ }
828
+
829
+ public void cancel() {
830
+ this.status = this.status.transitionTo(OrderStatus.CANCELLED);
831
+ raise(new OrderCancelled(this.getId(), null /* TODO: provide reason */));
832
+ }
833
+ ```
834
+
835
+ For events **without `triggers`**, call `raise()` manually:
676
836
 
677
837
  ```java
678
838
  public class Order {
679
839
  private final List<DomainEvent> domainEvents = new ArrayList<>();
680
840
 
681
- public void place(String customerId, BigDecimal totalAmount) {
841
+ public void someBusinessAction() {
682
842
  // business logic...
683
- raise(new OrderPlaced(customerId, totalAmount));
843
+ raise(new OrderPlaced(this.getId(), this.customerId, this.totalAmount, LocalDateTime.now()));
684
844
  }
685
845
 
686
846
  protected void raise(DomainEvent event) {
@@ -695,6 +855,14 @@ public class Order {
695
855
  }
696
856
  ```
697
857
 
858
+ ### Validator checks
859
+
860
+ | Code | Severity | Condition |
861
+ |------|----------|-----------|
862
+ | C2-001 | warning | Transition without a use-case — silenced when `triggers` is present |
863
+ | C2-004 | error | `triggers` references a method that does not exist in any transition |
864
+ | C2-005 | info | Transition method with no associated event — consider adding `triggers` |
865
+
698
866
  ---
699
867
 
700
868
  ## 12. Multiple aggregates
@@ -837,10 +1005,19 @@ aggregates:
837
1005
 
838
1006
  events:
839
1007
  - name: OrderPlaced
1008
+ triggers:
1009
+ - confirm
840
1010
  fields:
1011
+ # orderId declared for cross-module Kafka consumers — generator maps it to event.getAggregateId()
1012
+ - name: orderId
1013
+ type: String
841
1014
  - name: customerId
842
1015
  type: String
1016
+ - name: confirmedAt
1017
+ type: LocalDateTime
843
1018
  - name: OrderCancelled
1019
+ triggers:
1020
+ - cancel
844
1021
  fields:
845
1022
  - name: reason
846
1023
  type: String
@@ -907,3 +1084,615 @@ aggregates:
907
1084
  | `Column 'x_id' is duplicated` | ManyToOne defined manually + auto-generated | Remove the manual ManyToOne; let eva4j generate it |
908
1085
  | File not regenerated | File was manually modified (checksum) | Use `--force` to overwrite |
909
1086
  | Import errors | Field `type` doesn't match name in `enums:` or `valueObjects:` | Verify names match exactly |
1087
+
1088
+ ---
1089
+
1090
+ ## 16. Declarative endpoints (`endpoints:`) — Use case patterns
1091
+
1092
+ When `domain.yaml` includes an `endpoints:` section, the generator examines each `useCase` name and classifies it semantically before generating code. This determines whether a full, working implementation or a scaffold stub is produced.
1093
+
1094
+ ### 16.1 Pattern table
1095
+
1096
+ | Pattern | Category | Recognition condition | What is generated |
1097
+ |---------|----------|----------------------|-------------------|
1098
+ | `Create{Aggregate}` | **standard** | Exact string match | Full `CreateCommand` + `CreateCommandHandler` (`ApplicationMapper.fromCommand → save`) |
1099
+ | `Update{Aggregate}` | **standard** | Exact string match | Full `UpdateCommand` + `UpdateCommandHandler` |
1100
+ | `Delete{Aggregate}` | **standard** | Exact string match | Full `DeleteCommand` + `DeleteCommandHandler` |
1101
+ | `Get{Aggregate}` | **standard** | Exact string match | Full `GetQuery` + `GetQueryHandler` (find + `mapper.toDto`) |
1102
+ | `FindAll{Aggregate}s` | **standard** | Exact string match (trailing literal `s`) | Full `ListQuery` + `ListQueryHandler` (paginated) |
1103
+ | `{MethodPascal}{Aggregate}` | **transition** | `MethodPascal` is `toPascalCase(transitions[n].method)` for any enum in the aggregate | Full `TransitionCommand(id)` + handler that calls `entity.{method}() → save()` |
1104
+ | `Add{EntityName}` | **subEntityAdd** | `EntityName` is the `target` of a `OneToMany` relationship on the root | Full `AddCommand(id, entityFields…)` + handler that calls `entity.add{Entity}(new {Entity}(…)) → save()` |
1105
+ | `Remove{EntityName}` | **subEntityRemove** | Same `target` from a `OneToMany` relationship | Full `RemoveCommand(id, itemId)` + handler that calls `entity.remove{Entity}ById(itemId) → save()` |
1106
+ | `FindAll{Aggregate}sBy{FieldPascal}` | **findBy** | `FieldPascal` is `toPascalCase(fieldName)` for any field in the root entity | Full `FindByQuery` + `FindByQueryHandler` + `findBy{FieldPascal}` added to `{Aggregate}Repository`, `{Aggregate}RepositoryImpl`, and `{Aggregate}JpaRepository` |
1107
+ | _anything else_ | **scaffold** | No pattern matched | `*Command(id)` or `*Query(id)` + handler that throws `UnsupportedOperationException` with a TODO comment |
1108
+
1109
+ > **Note on `FindAll{Aggregate}s`:** The trailing `s` is a literal character. For `Aggregate = Order` the standard name is `FindAllOrders` (not `FindAllOrder`). Irregular plurals are not supported — use the exact pattern or it will be classified as scaffold.
1110
+
1111
+ ### 16.2 Transition pattern in detail
1112
+
1113
+ Enumerate state transitions in the `enums:` block using `transitions`:
1114
+
1115
+ ```yaml
1116
+ enums:
1117
+ - name: OrderStatus
1118
+ transitions:
1119
+ - from: PENDING
1120
+ to: CONFIRMED
1121
+ method: confirm # → recognized as ConfirmOrder (PascalCase(confirm) + Order)
1122
+ - from: [PENDING, CONFIRMED]
1123
+ to: CANCELLED
1124
+ method: cancel # → CancelOrder
1125
+ - from: CONFIRMED
1126
+ to: SHIPPED
1127
+ method: ship # → ShipOrder
1128
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
1129
+ ```
1130
+
1131
+ Declare the corresponding use cases in `endpoints:`:
1132
+
1133
+ ```yaml
1134
+ endpoints:
1135
+ basePath: /orders
1136
+ versions:
1137
+ - version: v1
1138
+ operations:
1139
+ - method: PUT
1140
+ path: /{id}/confirm
1141
+ useCase: ConfirmOrder # ← transition pattern: confirm + Order
1142
+ type: command
1143
+ - method: PUT
1144
+ path: /{id}/cancel
1145
+ useCase: CancelOrder
1146
+ type: command
1147
+ ```
1148
+
1149
+ **Generated output:**
1150
+
1151
+ ```java
1152
+ // ConfirmOrderCommand.java
1153
+ public record ConfirmOrderCommand(String id) implements Command {}
1154
+
1155
+ // ConfirmOrderCommandHandler.java
1156
+ @Transactional
1157
+ public void handle(ConfirmOrderCommand command) {
1158
+ Order entity = repository.findById(command.id())
1159
+ .orElseThrow(() -> new NotFoundException("Order not found with id: " + command.id()));
1160
+ entity.confirm(); // ← the domain method from transitions[].method
1161
+ repository.save(entity);
1162
+ }
1163
+ ```
1164
+
1165
+ ### 16.3 Sub-entity add/remove pattern in detail
1166
+
1167
+ Requirements:
1168
+ - A `OneToMany` relationship must be declared on the root entity pointing to the target entity.
1169
+ - The aggregate root must expose `add{EntityName}({EntityName} item)` and `remove{EntityName}ById(String id)` domain methods (generated automatically by `eva g entities`).
1170
+
1171
+ ```yaml
1172
+ # aggregates: section
1173
+ relationships:
1174
+ - type: OneToMany
1175
+ target: OrderItem # ← entityName used to match Add/Remove pattern
1176
+ fieldName: items
1177
+ ...
1178
+
1179
+ # endpoints: section
1180
+ operations:
1181
+ - method: POST
1182
+ path: /{id}/items
1183
+ useCase: AddOrderItem # ← Add + OrderItem (target name)
1184
+ type: command
1185
+ - method: DELETE
1186
+ path: /{id}/items/{itemId}
1187
+ useCase: RemoveOrderItem # ← Remove + OrderItem
1188
+ type: command
1189
+ ```
1190
+
1191
+ **Generated output:**
1192
+
1193
+ ```java
1194
+ // AddOrderItemCommand.java — fields taken from OrderItem (non-id, non-audit, non-readOnly)
1195
+ public record AddOrderItemCommand(
1196
+ String id,
1197
+ String productId,
1198
+ String productName,
1199
+ Integer quantity,
1200
+ BigDecimal unitPrice
1201
+ ) implements Command {}
1202
+
1203
+ // AddOrderItemCommandHandler.java
1204
+ @Transactional
1205
+ public void handle(AddOrderItemCommand command) {
1206
+ Order entity = repository.findById(command.id()) ...;
1207
+ OrderItem item = new OrderItem(command.productId(), command.productName(),
1208
+ command.quantity(), command.unitPrice());
1209
+ entity.addOrderItem(item);
1210
+ repository.save(entity);
1211
+ }
1212
+
1213
+ // RemoveOrderItemCommand.java
1214
+ public record RemoveOrderItemCommand(String id, String itemId) implements Command {}
1215
+
1216
+ // RemoveOrderItemCommandHandler.java
1217
+ @Transactional
1218
+ public void handle(RemoveOrderItemCommand command) {
1219
+ Order entity = repository.findById(command.id()) ...;
1220
+ entity.removeOrderItemById(command.itemId());
1221
+ repository.save(entity);
1222
+ }
1223
+ ```
1224
+
1225
+ ### 16.4 FindBy pattern in detail
1226
+
1227
+ Strict pattern: `FindAll{Aggregate}sBy{FieldPascal}` — both parts are required.
1228
+
1229
+ When detected, the generator:
1230
+ 1. Creates a paginated `FindBy{Field}Query` + `FindBy{Field}QueryHandler`.
1231
+ 2. Re-generates `{Aggregate}Repository.java` (domain interface), `{Aggregate}JpaRepository.java`, and `{Aggregate}RepositoryImpl.java` with the `findBy{FieldPascal}(FieldType value, Pageable pageable)` method added. Checksum protection still applies — manually modified files are skipped unless `--force` is used.
1232
+
1233
+ ```yaml
1234
+ # Root entity field
1235
+ fields:
1236
+ - name: customerId
1237
+ type: String
1238
+
1239
+ # Endpoint
1240
+ operations:
1241
+ - method: GET
1242
+ path: /customer/{customerId}
1243
+ useCase: FindAllOrdersByCustomerId # ← FindAll + Order + s + By + CustomerId
1244
+ type: query
1245
+ ```
1246
+
1247
+ **Generated output:**
1248
+
1249
+ ```java
1250
+ // FindAllOrdersByCustomerIdQuery.java
1251
+ public record FindAllOrdersByCustomerIdQuery(
1252
+ String customerId, int page, int size, String sortBy, String sortDirection
1253
+ ) implements Query<PagedResponse<OrderResponseDto>> {}
1254
+
1255
+ // OrderRepository.java (domain interface — method appended)
1256
+ Page<Order> findByCustomerId(String customerId, Pageable pageable);
1257
+
1258
+ // OrderJpaRepository.java (Spring Data JPA — method auto-implemented)
1259
+ Page<OrderJpa> findByCustomerId(String customerId, Pageable pageable);
1260
+ ```
1261
+
1262
+ ### 16.5 Scaffold (fallback)
1263
+
1264
+ Any `useCase` name that does not match any pattern above becomes a scaffold. A scaffold generates:
1265
+
1266
+ - A minimal `{UseCase}Command(String id)` or `{UseCase}Query(String id)` record.
1267
+ - A handler that throws `UnsupportedOperationException` and includes a step-by-step TODO comment.
1268
+
1269
+ This is intentional: the developer fills in the custom business logic while the wiring (registration, mediator dispatch, controller method) is already in place.
1270
+
1271
+ ### 16.6 Naming rules
1272
+
1273
+ | What | Convention | Example |
1274
+ |------|-----------|---------|
1275
+ | Aggregate name | PascalCase | `Order` |
1276
+ | Use case name in YAML | PascalCase | `ConfirmOrder`, `FindAllOrdersByCustomerId` |
1277
+ | Transition method in YAML | camelCase | `confirm`, `cancelOrder` |
1278
+ | Pattern `{MethodPascal}` | `toPascalCase(method)` | `confirm` → `Confirm` |
1279
+ | Pattern `{FieldPascal}` | `toPascalCase(fieldName)` | `customerId` → `CustomerId` |
1280
+ | Sub-entity target | PascalCase (must match entity `name:`) | `OrderItem` |
1281
+
1282
+ ---
1283
+
1284
+ ## 17. Consuming external events (`listeners:`)
1285
+
1286
+ The `listeners:` section declares the integration events that this module **consumes** from external producers. It lives at the **root level** of `domain.yaml` as a sibling of `aggregates:`, because it is an infrastructure/integration concern rather than a domain concern.
1287
+
1288
+ > **Requires a broker.** Files are only generated when `eva add kafka-client` has been executed in the project. Without a broker, the section is silently ignored.
1289
+
1290
+ ### Syntax
1291
+
1292
+ ```yaml
1293
+ # Root level — sibling of aggregates:
1294
+ listeners:
1295
+ - event: PaymentApprovedEvent # PascalCase + Event suffix
1296
+ producer: payments # Module that produces the event (documentary only)
1297
+ topic: PAYMENT_APPROVED # Kafka topic — required in standalone modules
1298
+ useCase: ConfirmOrder # Use case invoked when the event is received
1299
+ fields: # Payload fields of the Integration Event
1300
+ - name: orderId
1301
+ type: String
1302
+ - name: approvedAt
1303
+ type: LocalDateTime
1304
+ - name: details
1305
+ type: PaymentDetails # Object field → declare in nestedTypes:
1306
+ nestedTypes: # Optional: auxiliary records for object-typed fields
1307
+ - name: paymentDetails # camelCase → PascalCase in the generated record
1308
+ fields:
1309
+ - name: paymentId
1310
+ type: String
1311
+ - name: amount
1312
+ type: BigDecimal
1313
+ ```
1314
+
1315
+ ### Properties
1316
+
1317
+ | Property | Required | Description |
1318
+ |----------|----------|-------------|
1319
+ | `event` | ✅ | Event name in PascalCase with `Event` suffix |
1320
+ | `producer` | ✅ | Module that produces the event (documentary reference, no code generated) |
1321
+ | `topic` | ✅ | Kafka topic. With `system.yaml` can be inferred; in **standalone** modules it is mandatory. |
1322
+ | `useCase` | ✅ | Use case name that handles the event (PascalCase) |
1323
+ | `fields` | ✅ | Payload fields; generates the `IntegrationEvent` record and types the dispatched Command |
1324
+ | `nestedTypes` | ❌ | Auxiliary record classes for object-typed fields in `fields:`. Each entry generates a separate `.java` record in `application/events/`. |
1325
+
1326
+ ### Generated files
1327
+
1328
+ For each `listeners:` entry, eva4j generates **5 files** (plus one record per `nestedTypes:` entry):
1329
+
1330
+ | # | File | Location | Description |
1331
+ |---|------|----------|-------------|
1332
+ | 0 | `{NestedName}.java` *(per nestedType)* | `application/events/` | Auxiliary record for object-typed fields |
1333
+ | 1 | `{Name}IntegrationEvent.java` | `application/events/` | Typed contract record (documentation + tests) |
1334
+ | 2 | `{Name}KafkaListener.java` | `infrastructure/kafkaListener/` | `@KafkaListener` — deserializes and dispatches |
1335
+ | 3 | `kafka.yaml` *(all envs)* | `resources/parameters/*/` | Topic registered under `topics:` |
1336
+ | 4 | `{UseCase}Command.java` | `application/commands/` | Typed command dispatched from the listener |
1337
+ | 5 | `{UseCase}CommandHandler.java` | `application/usecases/` | Handler stub (implement business logic here) |
1338
+
1339
+ ### Generated code example
1340
+
1341
+ **`PaymentDetails.java`** — `application/events/` (from `nestedTypes:`)
1342
+ ```java
1343
+ public record PaymentDetails(
1344
+ String paymentId,
1345
+ BigDecimal amount
1346
+ ) {}
1347
+ ```
1348
+
1349
+ **`PaymentApprovedIntegrationEvent.java`** — `application/events/`
1350
+ ```java
1351
+ public record PaymentApprovedIntegrationEvent(
1352
+ String orderId,
1353
+ LocalDateTime approvedAt,
1354
+ PaymentDetails details
1355
+ ) {}
1356
+ ```
1357
+
1358
+ **`ConfirmOrderCommand.java`** — `application/commands/`
1359
+ ```java
1360
+ import com.example.orders.application.events.PaymentDetails;
1361
+
1362
+ public record ConfirmOrderCommand(
1363
+ String orderId,
1364
+ LocalDateTime approvedAt,
1365
+ PaymentDetails details
1366
+ ) implements Command {}
1367
+ ```
1368
+
1369
+ **`PaymentApprovedKafkaListener.java`** — `infrastructure/kafkaListener/`
1370
+ ```java
1371
+ import com.example.orders.application.events.PaymentDetails;
1372
+
1373
+ @Component
1374
+ public class PaymentApprovedKafkaListener {
1375
+
1376
+ private final UseCaseMediator useCaseMediator;
1377
+ private final ObjectMapper objectMapper;
1378
+
1379
+ @Value("${topics.payment-approved}")
1380
+ private String paymentApprovedTopic;
1381
+
1382
+ public PaymentApprovedKafkaListener(UseCaseMediator useCaseMediator,
1383
+ ObjectMapper objectMapper) {
1384
+ this.useCaseMediator = useCaseMediator;
1385
+ this.objectMapper = objectMapper;
1386
+ }
1387
+
1388
+ @KafkaListener(topics = "${topics.payment-approved}")
1389
+ public void handle(EventEnvelope<Map<String, Object>> event, Acknowledgment ack) {
1390
+ String orderId = objectMapper.convertValue(event.data().get("orderId"), String.class);
1391
+ LocalDateTime approvedAt = objectMapper.convertValue(
1392
+ event.data().get("approvedAt"), LocalDateTime.class);
1393
+ PaymentDetails details = objectMapper.convertValue(
1394
+ event.data().get("details"), PaymentDetails.class);
1395
+ useCaseMediator.dispatch(new ConfirmOrderCommand(orderId, approvedAt, details));
1396
+ ack.acknowledge();
1397
+ }
1398
+ }
1399
+ ```
1400
+
1401
+ **`ConfirmOrderCommandHandler.java`** — `application/usecases/`
1402
+ ```java
1403
+ @ApplicationComponent
1404
+ public class ConfirmOrderCommandHandler implements CommandHandler<ConfirmOrderCommand> {
1405
+
1406
+ @Override
1407
+ public void handle(ConfirmOrderCommand command) {
1408
+ // TODO: implement ConfirmOrder business logic
1409
+ throw new UnsupportedOperationException("ConfirmOrderCommandHandler not yet implemented");
1410
+ }
1411
+ }
1412
+ ```
1413
+
1414
+ ### `topic:` resolution rule
1415
+
1416
+ | Scenario | Behaviour |
1417
+ |----------|-----------|
1418
+ | Standalone module (only `domain.yaml`) | `topic:` **required** — no other source of truth |
1419
+ | Project with `system.yaml` | `topic:` optional; inferred from `integrations.async[].topic` |
1420
+ | `topic:` declared explicitly with `system.yaml` | Declared value takes **precedence** over inference |
1421
+
1422
+ ### Deserialization — how it works
1423
+
1424
+ The Kafka consumer delivers an `EventEnvelope<Map<String, Object>>`. The generated listener extracts each field using `objectMapper.convertValue()`, which handles:
1425
+
1426
+ - Primitives and strings: `convertValue(map.get("field"), String.class)`
1427
+ - Dates: `convertValue(map.get("date"), LocalDateTime.class)` — requires Jackson JavaTime module
1428
+ - Objects / nested types: `convertValue(map.get("details"), PaymentDetails.class)` — works automatically for Java records
1429
+ - Lists: `convertValue(map.get("items"), typeFactory.constructCollectionType(List.class, ItemType.class))`
1430
+
1431
+ ### `nestedTypes:` — when to use it
1432
+
1433
+ Use `nestedTypes:` when one of the `fields:` entries is a structured object (not a primitive Java type). Each entry generates a Java record in `application/events/` that is automatically imported in both the `KafkaListener` and the `Command`.
1434
+
1435
+ The name is declared in `camelCase` and normalised to `PascalCase` by the generator:
1436
+ ```yaml
1437
+ nestedTypes:
1438
+ - name: paymentDetails # → PaymentDetails.java
1439
+ fields:
1440
+ - name: paymentId
1441
+ type: String
1442
+ - name: amount
1443
+ type: BigDecimal
1444
+ ```
1445
+
1446
+ `{Name}IntegrationEvent.java` does **not** need an import for nested types because it lives in the same `application/events/` package.
1447
+
1448
+ ### Contrast: producing vs. consuming
1449
+
1450
+ ```
1451
+ domain.yaml
1452
+ ├── aggregates:
1453
+ │ └── [Aggregate]
1454
+ │ └── events: → Domain Events that this module PRODUCES (domain/models/events/)
1455
+
1456
+ └── listeners: → Integration Events that this module CONSUMES (infrastructure/kafkaListener/)
1457
+ ```
1458
+
1459
+ ---
1460
+
1461
+ ## 18. Synchronous HTTP clients (`ports:`)
1462
+
1463
+ The `ports:` section declares synchronous HTTP dependencies — external services this module **calls** via Feign. It lives at the **root level** of `domain.yaml` as a sibling of `aggregates:` and `listeners:`, because it represents a secondary port of the hexagonal architecture (not domain logic).
1464
+
1465
+ > **Requires OpenFeign.** The generated `FeignClient` and `FeignAdapter` depend on `spring-cloud-starter-openfeign`. The project must include it as a dependency.
1466
+
1467
+ ### 18.1 Syntax
1468
+
1469
+ ```yaml
1470
+ # Root level — sibling of aggregates: and listeners:
1471
+ # One entry = one method. Entries with the same service: form a single FeignClient.
1472
+
1473
+ ports:
1474
+ - name: findScreeningById # method name (camelCase)
1475
+ service: ScreeningService # groups into one interface/FeignClient (PascalCase)
1476
+ target: screenings # destination module (documentary reference only)
1477
+ baseUrl: http://localhost:8081 # → parameters/*/urls.yaml (first entry per service only)
1478
+ http: GET /screenings/{id} # HTTP verb + path
1479
+ fields: # response fields → domain model + infra DTO
1480
+ - name: id
1481
+ type: String
1482
+ - name: startTime
1483
+ type: LocalDateTime
1484
+
1485
+ - name: findAvailableSeats
1486
+ service: ScreeningService # same service → same FeignClient
1487
+ target: screenings
1488
+ http: GET /screenings/{id}/seats
1489
+ returnList: true # → List<Seat> in port interface
1490
+ domainType: Seat # override auto-derived type (default: FindAvailableSeat)
1491
+ fields:
1492
+ - name: seatId
1493
+ type: String
1494
+ - name: seatType
1495
+ type: String
1496
+
1497
+ - name: processPayment
1498
+ service: PaymentGateway
1499
+ target: payment-gateway-external
1500
+ baseUrl: https://api.payments.example.com
1501
+ http: POST /payments
1502
+ body: # @RequestBody → ProcessPaymentRequestDto.java
1503
+ - name: amount
1504
+ type: BigDecimal
1505
+ - name: paymentMethod
1506
+ type: PaymentMethodInput # object field → declare in nestedTypes:
1507
+ nestedTypes:
1508
+ - name: paymentMethodInput # camelCase → PascalCase record
1509
+ fields:
1510
+ - name: type
1511
+ type: String
1512
+ - name: cardToken
1513
+ type: String
1514
+ fields: # response fields → Payment domain model + infra DTO
1515
+ - name: paymentId
1516
+ type: String
1517
+ - name: status
1518
+ type: String
1519
+
1520
+ - name: cancelPayment
1521
+ service: PaymentGateway
1522
+ target: payment-gateway-external
1523
+ http: DELETE /payments/{id}
1524
+ # fields: omitted → void return
1525
+ ```
1526
+
1527
+ ### 18.2 Properties
1528
+
1529
+ | Property | Required | Description |
1530
+ |----------|----------|-------------|
1531
+ | `name` | ✅ | Method name in camelCase. Auto-derives the domain type (e.g. `findScreeningById` → `Screening`). |
1532
+ | `service` | ✅ | PascalCase service name. All entries sharing the same `service:` are grouped into one FeignClient. |
1533
+ | `target` | ✅ | Destination module or service name (documentary reference — no code generated). |
1534
+ | `baseUrl` | ❌ | Base URL for the service. Declare only on the **first entry** of each `service:`. Becomes a `urls.yaml` property. Defaults to `http://localhost:8080` with a warning if omitted. |
1535
+ | `http` | ✅ | `VERB /path` (e.g. `GET /users/{id}`, `POST /payments`). Path variables are `@PathVariable`. |
1536
+ | `fields` | ❌ | Response payload fields. Generates a domain model record and an infra DTO. Omit for `void` return. |
1537
+ | `domainType` | ❌ | Overrides the domain type name auto-derived from `name`. Use when the default derivation is ambiguous or wrong. |
1538
+ | `returnList` | ❌ | `true` → return type is `List<{DomainType}>` in the interface and `List<{InfraDto}>` in the FeignClient. Default: `false`. |
1539
+ | `body` | ❌ | Request body fields (POST/PUT/PATCH only). Generates a `{MethodPascal}RequestDto.java`. Ignored with a warning on GET/DELETE. |
1540
+ | `nestedTypes` | ❌ | Auxiliary record classes for object-typed fields in `body:`. Each entry generates a Java record in `application/dtos/`. |
1541
+
1542
+ ### 18.3 Domain type derivation
1543
+
1544
+ The domain type is derived automatically from the method name: the leading verb is stripped and the aggregate/entity name is extracted.
1545
+
1546
+ | Method name | Auto-derived type | Override needed? |
1547
+ |-------------|------------------|-----------------|
1548
+ | `findScreeningById` | `Screening` | No |
1549
+ | `findAvailableSeats` | `AvailableSeat` | Yes — `domainType: Seat` |
1550
+ | `processPayment` | `Payment` | No |
1551
+ | `findPaymentStatus` | `PaymentStatus` | Yes — avoids collision with `Payment` if both exist |
1552
+
1553
+ > **Rule:** if two methods in the same `service:` derive the same domain type, `generate entities` will fail. Declare `domainType:` on one of them to resolve the collision.
1554
+
1555
+ ### 18.4 Generated files
1556
+
1557
+ **Per unique `service:` name:**
1558
+
1559
+ | File | Description |
1560
+ |------|-------------|
1561
+ | `domain/repositories/{ServiceName}.java` | Secondary port interface — returns domain models |
1562
+ | `infrastructure/adapters/{service}/{ServiceName}FeignClient.java` | Typed Feign interface — returns infra DTOs |
1563
+ | `infrastructure/adapters/{service}/{ServiceName}FeignAdapter.java` | `@Component implements {ServiceName}` — ACL mapper |
1564
+ | `infrastructure/adapters/{service}/{ServiceName}FeignConfig.java` | Feign timeouts configuration |
1565
+ | `parameters/*/urls.yaml` | Base URL registered under a property key |
1566
+
1567
+ **Per unique domain model derived from methods with `fields:`:**
1568
+
1569
+ | File | Description |
1570
+ |------|-------------|
1571
+ | `domain/models/{service}/{DomainType}.java` | Domain model record (internal abstraction — ACL) |
1572
+
1573
+ **Per method:**
1574
+
1575
+ | File | Condition |
1576
+ |------|-----------|
1577
+ | `infrastructure/adapters/{service}/{MethodPascal}Dto.java` | When `fields:` present — infra DTO (external shape) |
1578
+ | `application/dtos/{MethodPascal}RequestDto.java` | When `body:` present (POST/PUT/PATCH) |
1579
+ | `application/dtos/{NestedTypePascal}.java` | When `nestedTypes:` declared |
1580
+
1581
+ ### 18.5 ACL pattern
1582
+
1583
+ The generated code follows the Anti-Corruption Layer (ACL) pattern to isolate the domain from external API shapes.
1584
+
1585
+ ```
1586
+ External API shape (infra DTO) Domain abstraction
1587
+ ──────────────────────────── ──────────────────
1588
+ infrastructure/adapters/{service}/ domain/models/{service}/
1589
+ {MethodPascal}Dto.java → {DomainType}.java
1590
+ {ServiceName}FeignClient.java → domain/repositories/{ServiceName}.java
1591
+ {ServiceName}FeignAdapter.java (maps DTO → domain model inline)
1592
+ ```
1593
+
1594
+ When the external API changes its field names or structure, **only the adapter is modified** — the domain model and the port interface stay stable.
1595
+
1596
+ ### 18.6 Generated code example
1597
+
1598
+ **`ScreeningService.java`** — `domain/repositories/` (port interface returning domain models)
1599
+ ```java
1600
+ public interface ScreeningService {
1601
+ Screening findScreeningById(String id);
1602
+ List<Seat> findAvailableSeats(String id);
1603
+ }
1604
+ ```
1605
+
1606
+ **`ScreeningServiceFeignClient.java`** — `infrastructure/adapters/screeningService/`
1607
+ ```java
1608
+ @FeignClient(name = "screeningService", url = "${urls.screening-service}")
1609
+ public interface ScreeningServiceFeignClient {
1610
+
1611
+ @GetMapping("/screenings/{id}")
1612
+ FindScreeningByIdDto findScreeningById(@PathVariable("id") String id);
1613
+
1614
+ @GetMapping("/screenings/{id}/seats")
1615
+ List<FindAvailableSeatsDto> findAvailableSeats(@PathVariable("id") String id);
1616
+ }
1617
+ ```
1618
+
1619
+ **`ScreeningServiceFeignAdapter.java`** — `infrastructure/adapters/screeningService/`
1620
+ ```java
1621
+ @Component
1622
+ public class ScreeningServiceFeignAdapter implements ScreeningService {
1623
+
1624
+ private final ScreeningServiceFeignClient feignClient;
1625
+
1626
+ public ScreeningServiceFeignAdapter(ScreeningServiceFeignClient feignClient) {
1627
+ this.feignClient = feignClient;
1628
+ }
1629
+
1630
+ @Override
1631
+ public Screening findScreeningById(String id) {
1632
+ return toScreening(feignClient.findScreeningById(id));
1633
+ }
1634
+
1635
+ @Override
1636
+ public List<Seat> findAvailableSeats(String id) {
1637
+ return feignClient.findAvailableSeats(id)
1638
+ .stream().map(this::toSeat).collect(Collectors.toList());
1639
+ }
1640
+
1641
+ private Screening toScreening(FindScreeningByIdDto dto) {
1642
+ return new Screening(dto.id(), dto.startTime());
1643
+ }
1644
+
1645
+ private Seat toSeat(FindAvailableSeatsDto dto) {
1646
+ return new Seat(dto.seatId(), dto.seatType());
1647
+ }
1648
+ }
1649
+ ```
1650
+
1651
+ **`Screening.java`** — `domain/models/screeningService/` (domain-side record)
1652
+ ```java
1653
+ public record Screening(String id, LocalDateTime startTime) {}
1654
+ ```
1655
+
1656
+ ### 18.7 `nestedTypes:` — when to use it
1657
+
1658
+ Use `nestedTypes:` under a port entry when one of the `body:` fields is a structured object (not a primitive). Each `nestedTypes:` entry generates a Java record in `application/dtos/` that is used by the request DTO.
1659
+
1660
+ ```yaml
1661
+ - name: processPayment
1662
+ service: PaymentGateway
1663
+ http: POST /payments
1664
+ body:
1665
+ - name: amount
1666
+ type: BigDecimal
1667
+ - name: paymentMethod
1668
+ type: PaymentMethodInput # object → declare in nestedTypes:
1669
+ nestedTypes:
1670
+ - name: paymentMethodInput # → PaymentMethodInput.java in application/dtos/
1671
+ fields:
1672
+ - name: type
1673
+ type: String
1674
+ - name: cardToken
1675
+ type: String
1676
+ ```
1677
+
1678
+ ### 18.8 `baseUrl:` resolution rule
1679
+
1680
+ | Scenario | Behaviour |
1681
+ |----------|-----------|
1682
+ | `baseUrl:` on the first entry of a `service:` | Registered in `parameters/*/urls.yaml` as `urls.{service-kebab}` |
1683
+ | `baseUrl:` omitted for all entries of a `service:` | Warning printed; defaults to `http://localhost:8080` |
1684
+ | `baseUrl:` on non-first entry of a `service:` | Ignored — only the first entry's value is used |
1685
+
1686
+ ### 18.9 Contrast: async vs sync
1687
+
1688
+ ```
1689
+ domain.yaml
1690
+ ├── aggregates:
1691
+ │ └── [Aggregate]
1692
+ │ └── events: → Domain Events that this module PRODUCES (async / broker)
1693
+
1694
+ ├── listeners: → Integration Events that this module CONSUMES (async / broker)
1695
+
1696
+ └── ports: → External HTTP services that this module CALLS (sync / Feign)
1697
+ ```
1698
+