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.
- package/AGENTS.md +314 -10
- package/COMMAND_EVALUATION.md +15 -16
- package/DOMAIN_YAML_GUIDE.md +576 -10
- package/FUTURE_FEATURES.md +1627 -1168
- package/README.md +318 -13
- package/bin/eva4j.js +34 -0
- package/config/defaults.json +1 -0
- package/design-system.md +797 -0
- package/docs/commands/EVALUATE_SYSTEM.md +994 -0
- package/docs/commands/GENERATE_ENTITIES.md +795 -6
- package/docs/commands/INDEX.md +10 -1
- package/examples/domain-endpoints-relations.yaml +353 -0
- package/examples/domain-endpoints-versioned.yaml +144 -0
- package/examples/domain-endpoints.yaml +135 -0
- package/examples/domain-events.yaml +166 -20
- package/examples/domain-listeners.yaml +212 -0
- package/examples/domain-one-to-many.yaml +1 -0
- package/examples/domain-one-to-one.yaml +1 -0
- package/examples/domain-ports.yaml +414 -0
- package/examples/domain-soft-delete.yaml +47 -44
- package/examples/system/notification.yaml +147 -0
- package/examples/system/product.yaml +185 -0
- package/examples/system/system.yaml +112 -0
- package/examples/system-report.html +971 -0
- package/examples/system.yaml +332 -0
- package/package.json +2 -1
- package/src/commands/build.js +714 -0
- package/src/commands/create.js +7 -3
- package/src/commands/detach.js +1 -0
- package/src/commands/evaluate-system.js +610 -0
- package/src/commands/generate-entities.js +1331 -49
- package/src/commands/generate-http-exchange.js +2 -0
- package/src/commands/generate-kafka-event.js +98 -11
- package/src/generators/base-generator.js +8 -1
- package/src/generators/postman-generator.js +188 -0
- package/src/generators/shared-generator.js +10 -0
- package/src/utils/config-manager.js +54 -0
- package/src/utils/context-builder.js +1 -0
- package/src/utils/domain-diagram.js +192 -0
- package/src/utils/domain-validator.js +970 -0
- package/src/utils/fake-data.js +376 -0
- package/src/utils/naming.js +3 -2
- package/src/utils/system-validator.js +434 -0
- package/src/utils/yaml-to-entity.js +302 -8
- package/templates/aggregate/AggregateMapper.java.ejs +3 -2
- package/templates/aggregate/AggregateRepository.java.ejs +8 -2
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
- package/templates/aggregate/AggregateRoot.java.ejs +60 -2
- package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
- package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
- package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
- package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
- package/templates/aggregate/JpaRepository.java.ejs +5 -0
- package/templates/base/gradle/build.gradle.ejs +3 -2
- package/templates/base/root/AGENTS.md.ejs +306 -45
- package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
- package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
- package/templates/base/root/system.yaml.ejs +97 -0
- package/templates/crud/ApplicationMapper.java.ejs +4 -0
- package/templates/crud/Controller.java.ejs +4 -4
- package/templates/crud/CreateCommand.java.ejs +4 -0
- package/templates/crud/CreateItemDto.java.ejs +4 -0
- package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
- package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
- package/templates/crud/EndpointsController.java.ejs +178 -0
- package/templates/crud/FindByQuery.java.ejs +17 -0
- package/templates/crud/FindByQueryHandler.java.ejs +57 -0
- package/templates/crud/ListQuery.java.ejs +1 -1
- package/templates/crud/ListQueryHandler.java.ejs +8 -8
- package/templates/crud/ScaffoldCommand.java.ejs +12 -0
- package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
- package/templates/crud/ScaffoldQuery.java.ejs +13 -0
- package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
- package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
- package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
- package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
- package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
- package/templates/crud/TransitionCommand.java.ejs +9 -0
- package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
- package/templates/crud/UpdateCommand.java.ejs +4 -0
- package/templates/evaluate/report.html.ejs +1363 -0
- package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
- package/templates/kafka-event/Event.java.ejs +16 -0
- package/templates/kafka-listener/KafkaController.java.ejs +1 -1
- package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
- package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
- package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
- package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
- package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
- package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
- package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
- package/templates/mock/MockEvent.java.ejs +10 -0
- package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
- package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
- package/templates/mock/SpringEventListener.java.ejs +61 -0
- package/templates/ports/PortDomainModel.java.ejs +35 -0
- package/templates/ports/PortFeignAdapter.java.ejs +67 -0
- package/templates/ports/PortFeignClient.java.ejs +45 -0
- package/templates/ports/PortFeignConfig.java.ejs +24 -0
- package/templates/ports/PortInterface.java.ejs +45 -0
- package/templates/ports/PortNestedType.java.ejs +28 -0
- package/templates/ports/PortRequestDto.java.ejs +30 -0
- package/templates/ports/PortResponseDto.java.ejs +28 -0
- package/templates/postman/Collection.json.ejs +1 -1
- package/templates/postman/UnifiedCollection.json.ejs +185 -0
- 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
|
|
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
|
|
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
|
+
|