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
@@ -0,0 +1,1663 @@
1
+ # Command `generate entities` (alias: `g entities`)
2
+
3
+ ---
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Description and purpose](#1-description-and-purpose)
8
+ 2. [Syntax and YAML location](#2-syntax-and-yaml-location)
9
+ 3. [Base domain.yaml structure](#3-base-domainyaml-structure)
10
+ 4. [Supported data types](#4-supported-data-types)
11
+ 5. [Field properties](#5-field-properties)
12
+ 6. [JSR-303 Validations](#6-jsr-303-validations)
13
+ 7. [Auditing](#7-auditing)
14
+ 8. [Relationships](#8-relationships)
15
+ 9. [Value Objects](#9-value-objects)
16
+ 10. [Enums and state transitions](#10-enums-and-state-transitions)
17
+ 11. [Domain events](#11-domain-events)
18
+ 12. [Multiple aggregates](#12-multiple-aggregates)
19
+ 13. [Generated files](#13-generated-files)
20
+ 14. [Complete examples](#14-complete-examples)
21
+ 15. [Prerequisites and common errors](#15-prerequisites-and-common-errors)
22
+ 16. [Declarative endpoints (`endpoints:`)](#16-declarative-endpoints-endpoints---use-case-patterns)
23
+ 17. [Consuming external events (`listeners:`)](#17-consuming-external-events-listeners)
24
+ 18. [HTTP outbound clients (`ports:`)](#18-http-outbound-clients-ports)
25
+
26
+ ---
27
+
28
+ ## 1. Description and purpose
29
+
30
+ `generate entities` is the core command of eva4j. From a `domain.yaml` file, it generates the complete hexagonal architecture for the module:
31
+
32
+ - **Domain layer** – Entities, Value Objects, Enums, repository interfaces
33
+ - **Application layer** – Commands, Queries, handlers, DTOs, mappers
34
+ - **Infrastructure layer** – JPA entities, Spring Data repositories, repository implementations, REST controllers
35
+
36
+ The generator understands relationships, auditing, field visibility, validations, state transitions, and domain events.
37
+
38
+ ---
39
+
40
+ ## 2. Syntax and YAML location
41
+
42
+ ```bash
43
+ eva generate entities <module>
44
+ eva g entities <module> # short alias
45
+ ```
46
+
47
+ ### Parameters
48
+
49
+ | Parameter | Required | Description |
50
+ |-----------|----------|-------------|
51
+ | `<module>` | Yes | Module name (must already exist in the project) |
52
+
53
+ ### Options
54
+
55
+ | Option | Description |
56
+ |--------|-------------|
57
+ | `--force` | Overwrite files that have developer changes |
58
+
59
+ ### YAML location
60
+
61
+ The file is read from:
62
+
63
+ ```
64
+ src/main/java/<package>/<module>/domain.yaml
65
+ ```
66
+
67
+ > The generator detects developer changes via checksums. If a file was manually modified, it is **not overwritten** unless you use `--force`.
68
+
69
+ ---
70
+
71
+ ## 3. Base domain.yaml structure
72
+
73
+ The `domain.yaml` file supports three top-level sections besides `aggregates:`:
74
+
75
+ | Root section | Purpose |
76
+ |--------------|---------|
77
+ | `aggregates:` | Domain model: entities, value objects, enums, events |
78
+ | `listeners:` | Integration events this module **consumes** (async, Kafka) |
79
+ | `ports:` | HTTP services this module **calls** outbound (sync, Feign) |
80
+ | `endpoints:` | REST endpoints this module **exposes** (declarative controller generation) |
81
+
82
+ ```yaml
83
+ aggregates: # List of aggregates in the module
84
+ - name: Order # Aggregate name (PascalCase)
85
+ entities: # Entities of the aggregate
86
+ - name: Order # Entity name (PascalCase)
87
+ isRoot: true # true = aggregate root
88
+ tableName: orders # SQL table name (optional)
89
+ audit: # Auditing (optional)
90
+ enabled: true
91
+ trackUser: false
92
+ fields: # Entity fields
93
+ - name: id
94
+ type: String
95
+ - name: status
96
+ type: OrderStatus # Reference to enum or VO
97
+ relationships: # JPA relationships (optional)
98
+ - type: OneToMany
99
+ target: OrderItem
100
+ mappedBy: order
101
+ cascade: [PERSIST, MERGE, REMOVE]
102
+ fetch: LAZY
103
+
104
+ - name: OrderItem # Secondary entity (no isRoot or isRoot: false)
105
+ tableName: order_items
106
+ fields:
107
+ - name: id
108
+ type: Long
109
+ - name: quantity
110
+ type: Integer
111
+
112
+ valueObjects: # Aggregate Value Objects
113
+ - name: Money
114
+ fields:
115
+ - name: amount
116
+ type: BigDecimal
117
+ - name: currency
118
+ type: String
119
+
120
+ enums: # Aggregate enums
121
+ - name: OrderStatus
122
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
123
+
124
+ events: # Domain events (optional)
125
+ - name: OrderPlaced
126
+ fields:
127
+ - name: customerId
128
+ type: String
129
+ ```
130
+
131
+ > **Supported synonyms**: `fields` = `properties`; `target` = `targetEntity`
132
+
133
+ ### The `id` field rule
134
+
135
+ Every entity **must** have a field named exactly `id`:
136
+
137
+ | `id` type | Generated strategy |
138
+ |-----------|--------------------|
139
+ | `String` | `@GeneratedValue(strategy = GenerationType.UUID)` |
140
+ | `Long` | `@GeneratedValue(strategy = GenerationType.IDENTITY)` |
141
+
142
+ ---
143
+
144
+ ## 4. Supported data types
145
+
146
+ | YAML type | Java type | Notes |
147
+ |-----------|-----------|-------|
148
+ | `String` | `String` | For `id` generates UUID |
149
+ | `Integer` | `Integer` | For `id` generates IDENTITY |
150
+ | `Long` | `Long` | For `id` generates IDENTITY |
151
+ | `Double` | `Double` | |
152
+ | `BigDecimal` | `BigDecimal` | |
153
+ | `Boolean` | `Boolean` | |
154
+ | `LocalDate` | `LocalDate` | Auto-imported |
155
+ | `LocalDateTime` | `LocalDateTime` | Auto-imported |
156
+ | `LocalTime` | `LocalTime` | Auto-imported |
157
+ | `UUID` | `UUID` | Auto-imported |
158
+ | `List<String>` | `List<String>` | `@ElementCollection` |
159
+ | `List<VO>` | `List<VoJpa>` | `@ElementCollection` |
160
+ | Enum name | Module enum | `@Enumerated(STRING)` |
161
+ | VO name | Value Object | `@Embedded` |
162
+
163
+ ---
164
+
165
+ ## 5. Field properties
166
+
167
+ ```yaml
168
+ fields:
169
+ - name: fieldName # camelCase, required
170
+ type: String # Java type, required
171
+ readOnly: false # default false
172
+ hidden: false # default false
173
+ defaultValue: null # only meaningful when readOnly: true
174
+ validations: [] # JSR-303 annotations
175
+ annotations: [] # raw JPA annotations
176
+ reference: # semantic reference to another aggregate
177
+ aggregate: Customer
178
+ module: customers
179
+ enumValues: [] # inline enum (alternative to enums:)
180
+ ```
181
+
182
+ ### Visibility matrix
183
+
184
+ | Field | Creation constructor | CreateDto/Command | Full constructor | ResponseDto |
185
+ |-------|---------------------|-------------------|------------------|-------------|
186
+ | normal | ✅ | ✅ | ✅ | ✅ |
187
+ | `readOnly: true` | ❌ | ❌ | ✅ | ✅ |
188
+ | `hidden: true` | ✅ | ✅ | ✅ | ❌ |
189
+ | `readOnly + hidden` | ❌ | ❌ | ✅ | ❌ |
190
+
191
+ ### readOnly
192
+
193
+ Marks a field as calculated/derived: excluded from the business constructor and `CreateDto`/`CreateCommand`, but present in the full constructor (reconstruction from persistence) and in `ResponseDto`.
194
+
195
+ ```yaml
196
+ fields:
197
+ - name: totalAmount
198
+ type: BigDecimal
199
+ readOnly: true # calculated from the sum of items
200
+ ```
201
+
202
+ > When an enum has `initialValue`, the corresponding field is automatically treated as `readOnly`.
203
+
204
+ ### defaultValue
205
+
206
+ Assigns an initial value to a `readOnly` field in the creation constructor. The generator emits the assignment in the business constructor and `@Builder.Default` in the JPA entity. Ignored (with a warning) on non-readOnly fields.
207
+
208
+ ```yaml
209
+ fields:
210
+ - name: totalAmount
211
+ type: BigDecimal
212
+ readOnly: true
213
+ defaultValue: "0.00" # → new BigDecimal("0.00")
214
+
215
+ - name: status
216
+ type: OrderStatus
217
+ readOnly: true
218
+ defaultValue: PENDING # → OrderStatus.PENDING
219
+
220
+ - name: itemCount
221
+ type: Integer
222
+ readOnly: true
223
+ defaultValue: 0 # → 0
224
+ ```
225
+
226
+ Generated in the business constructor:
227
+
228
+ ```java
229
+ public Order(String customerId) {
230
+ this.customerId = customerId;
231
+ this.totalAmount = new BigDecimal("0.00"); // ← defaultValue
232
+ this.status = OrderStatus.PENDING; // ← defaultValue
233
+ this.itemCount = 0; // ← defaultValue
234
+ }
235
+ ```
236
+
237
+ Generated in the JPA entity:
238
+
239
+ ```java
240
+ @Builder.Default
241
+ private BigDecimal totalAmount = new BigDecimal("0.00");
242
+
243
+ @Enumerated(EnumType.STRING)
244
+ @Builder.Default
245
+ private OrderStatus status = OrderStatus.PENDING;
246
+ ```
247
+
248
+ ### hidden
249
+
250
+ Marks a field as sensitive: included on creation but does NOT appear in `ResponseDto`.
251
+
252
+ ```yaml
253
+ fields:
254
+ - name: passwordHash
255
+ type: String
256
+ hidden: true # do not expose in API
257
+ ```
258
+
259
+ ### annotations (raw JPA)
260
+
261
+ Allows adding custom JPA annotations to the generated JPA entity.
262
+
263
+ ```yaml
264
+ fields:
265
+ - name: email
266
+ type: String
267
+ annotations:
268
+ - "@Column(unique = true, nullable = false)"
269
+ ```
270
+
271
+ ### reference
272
+
273
+ Declares a semantic reference to a field in another aggregate. Generates a Javadoc comment indicating the relationship, without creating a code dependency.
274
+
275
+ ```yaml
276
+ fields:
277
+ - name: customerId
278
+ type: String
279
+ reference:
280
+ aggregate: Customer
281
+ module: customers
282
+ ```
283
+
284
+ Generated in the domain entity:
285
+
286
+ ```java
287
+ /** @see customers.Customer */
288
+ private String customerId;
289
+ ```
290
+
291
+ ---
292
+
293
+ ## 6. JSR-303 Validations
294
+
295
+ Validations are declared on the field and applied to `CreateCommand` and `CreateDto`. They are **not** added to domain entities.
296
+
297
+ ```yaml
298
+ fields:
299
+ - name: name
300
+ type: String
301
+ validations:
302
+ - type: NotBlank
303
+ message: "Name is required"
304
+ - type: Size
305
+ min: 2
306
+ max: 100
307
+ ```
308
+
309
+ Auto-generates import: `import jakarta.validation.constraints.*;`
310
+
311
+ ### Supported parameters
312
+
313
+ | Parameter | Description |
314
+ |-----------|-------------|
315
+ | `type` | Annotation name without `@` (required) |
316
+ | `message` | Custom error message |
317
+ | `value` | Single value (for `@Min`, `@Max`) |
318
+ | `min` | Minimum value (for `@Size`, `@DecimalMin`) |
319
+ | `max` | Maximum value (for `@Size`, `@DecimalMax`) |
320
+ | `regexp` | Regular expression (for `@Pattern`) |
321
+ | `integer` | Integer digits (for `@Digits`) |
322
+ | `fraction` | Decimal digits (for `@Digits`) |
323
+ | `inclusive` | Inclusive boundary (for `@DecimalMin`, `@DecimalMax`) |
324
+
325
+ ### Examples by type
326
+
327
+ ```yaml
328
+ # @NotBlank
329
+ - type: NotBlank
330
+ message: "Field is required"
331
+
332
+ # @NotNull
333
+ - type: NotNull
334
+
335
+ # @Size
336
+ - type: Size
337
+ min: 2
338
+ max: 255
339
+
340
+ # @Email
341
+ - type: Email
342
+
343
+ # @Min / @Max (for numeric fields)
344
+ - type: Min
345
+ value: 1
346
+ - type: Max
347
+ value: 999
348
+
349
+ # @Pattern
350
+ - type: Pattern
351
+ regexp: "^[A-Z]{2}[0-9]{6}$"
352
+ message: "Invalid format"
353
+
354
+ # @DecimalMin / @DecimalMax
355
+ - type: DecimalMin
356
+ min: "0.01"
357
+ inclusive: true
358
+ - type: DecimalMax
359
+ max: "9999.99"
360
+
361
+ # @Digits
362
+ - type: Digits
363
+ integer: 6
364
+ fraction: 2
365
+ ```
366
+
367
+ ---
368
+
369
+ ## 7. Auditing
370
+
371
+ ### Syntax
372
+
373
+ ```yaml
374
+ # New (recommended)
375
+ audit:
376
+ enabled: true # adds createdAt, updatedAt
377
+ trackUser: true # also adds createdBy, updatedBy
378
+
379
+ # Legacy (equivalent to audit.enabled: true, trackUser: false)
380
+ auditable: true
381
+ ```
382
+
383
+ ### Generated JPA inheritance
384
+
385
+ | Configuration | JPA base class |
386
+ |---------------|----------------|
387
+ | No auditing | no inheritance |
388
+ | `audit.enabled: true` | `extends AuditableEntity` |
389
+ | `audit.trackUser: true` | `extends FullAuditableEntity` |
390
+
391
+ ### Generated fields
392
+
393
+ | Field | `audit.enabled` | `audit.trackUser` | In ResponseDto |
394
+ |-------|-----------------|-------------------|----------------|
395
+ | `createdAt` | ✅ | ✅ | ✅ |
396
+ | `updatedAt` | ✅ | ✅ | ✅ |
397
+ | `createdBy` | ❌ | ✅ | ❌ |
398
+ | `updatedBy` | ❌ | ✅ | ❌ |
399
+
400
+ > `createdBy` and `updatedBy` are administrative metadata: they are never exposed in response DTOs.
401
+
402
+ ### Infrastructure generated with `trackUser: true`
403
+
404
+ When `trackUser` is enabled, eva4j automatically generates:
405
+
406
+ | File | Purpose |
407
+ |------|---------|
408
+ | `UserContextHolder.java` | ThreadLocal for the current user |
409
+ | `UserContextFilter.java` | Captures the `X-User` header from each request |
410
+ | `AuditorAwareImpl.java` | Provides the current user to JPA Auditing |
411
+
412
+ `Application.java` is configured with `@EnableJpaAuditing(auditorAwareRef = "auditorProvider")`.
413
+
414
+ ### Example
415
+
416
+ ```yaml
417
+ entities:
418
+ - name: Order
419
+ isRoot: true
420
+ tableName: orders
421
+ audit:
422
+ enabled: true
423
+ trackUser: true
424
+ fields:
425
+ - name: id
426
+ type: String
427
+ - name: amount
428
+ type: BigDecimal
429
+ ```
430
+
431
+ > Audit fields **must not be defined manually** in `fields:`; they are inherited from the JPA base class.
432
+
433
+ ---
434
+
435
+ ## 7b. Soft Delete
436
+
437
+ 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.
438
+
439
+ > **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.
440
+
441
+ ### Syntax
442
+
443
+ ```yaml
444
+ entities:
445
+ - name: order
446
+ isRoot: true # ← mandatory: only root entities support this
447
+ tableName: orders
448
+ hasSoftDelete: true # ✅ enables soft delete
449
+ audit:
450
+ enabled: true
451
+ fields:
452
+ - name: id
453
+ type: String
454
+ - name: orderNumber
455
+ type: String
456
+
457
+ - name: orderItem # ← secondary entity
458
+ tableName: order_items
459
+ # hasSoftDelete: true ← ❌ ignored with warning
460
+ ```
461
+
462
+ ### What is generated
463
+
464
+ | Artefact | `deletedAt` included | Notes |
465
+ |---|---|---|
466
+ | Full constructor (reconstruction) | ✅ | Required to hydrate persisted state |
467
+ | Business constructor (new object) | ❌ | Starts as `null` |
468
+ | `CreateDto` / `CreateCommand` | ❌ | Cannot create an already-deleted object |
469
+ | `ResponseDto` | ❌ | Internal metadata, not exposed in API |
470
+ | `toJpa()` in mapper | ✅ | Persists the timestamp after `softDelete()` |
471
+
472
+ **Domain entity:**
473
+ ```java
474
+ public class Order {
475
+ private LocalDateTime deletedAt; // injected automatically
476
+
477
+ public void softDelete() {
478
+ if (this.deletedAt != null) {
479
+ throw new IllegalStateException("Order is already deleted");
480
+ }
481
+ this.deletedAt = java.time.LocalDateTime.now();
482
+ }
483
+
484
+ public boolean isDeleted() {
485
+ return this.deletedAt != null;
486
+ }
487
+ }
488
+ ```
489
+
490
+ **JPA entity:**
491
+ ```java
492
+ @SQLRestriction("deleted_at IS NULL") // filters ALL queries automatically
493
+ @Entity
494
+ @Table(name = "orders")
495
+ public class OrderJpa extends AuditableEntity {
496
+ private LocalDateTime deletedAt;
497
+ }
498
+ ```
499
+
500
+ **DeleteCommandHandler** (generated when `hasSoftDelete: true`):
501
+ ```java
502
+ // Finds → marks → saves — never deleteById
503
+ Order order = repository.findById(id)
504
+ .orElseThrow(() -> new OrderNotFoundException(id));
505
+ order.softDelete();
506
+ repository.save(order);
507
+ ```
508
+
509
+ > `deletedAt` **must not be defined manually** in `fields:` — the generator injects it automatically.
510
+
511
+ ---
512
+
513
+ ## 8. Relationships
514
+
515
+ ### Properties
516
+
517
+ | Property | Values | Description |
518
+ |----------|--------|-------------|
519
+ | `type` | `OneToMany`, `ManyToOne`, `OneToOne`, `ManyToMany` | Relationship type |
520
+ | `target` / `targetEntity` | Entity name | Related entity |
521
+ | `mappedBy` | field name | Inverse side of the relationship |
522
+ | `joinColumn` | column name | FK column name |
523
+ | `cascade` | array of `PERSIST`, `MERGE`, `REMOVE`, `REFRESH`, `DETACH`, `ALL` | Cascade operations |
524
+ | `fetch` | `LAZY` (default), `EAGER` | Loading strategy |
525
+
526
+ ### Automatic inverse side generation
527
+
528
+ When you define `OneToMany` with `mappedBy`, eva4j automatically generates `@ManyToOne` in the target JPA entity. **Defining both sides is not required.**
529
+
530
+ ```yaml
531
+ # ✅ Only this is needed
532
+ entities:
533
+ - name: Order
534
+ isRoot: true
535
+ relationships:
536
+ - type: OneToMany
537
+ target: OrderItem
538
+ mappedBy: order
539
+ cascade: [PERSIST, MERGE, REMOVE]
540
+ fetch: LAZY
541
+
542
+ # eva4j generates in OrderItemJpa:
543
+ # @ManyToOne(fetch = FetchType.LAZY)
544
+ # @JoinColumn(name = "order_id")
545
+ # private OrderJpa order;
546
+ ```
547
+
548
+ > If you define `ManyToOne` manually, that definition takes priority over auto-generation.
549
+
550
+ ### OneToMany
551
+
552
+ ```yaml
553
+ relationships:
554
+ - type: OneToMany
555
+ target: OrderItem
556
+ mappedBy: order
557
+ cascade: [PERSIST, MERGE, REMOVE]
558
+ fetch: LAZY
559
+ ```
560
+
561
+ Generated in domain:
562
+
563
+ ```java
564
+ private List<OrderItem> orderItems = new ArrayList<>();
565
+ public void addOrderItem(OrderItem item) { orderItems.add(item); }
566
+ public void removeOrderItem(OrderItem item) { orderItems.remove(item); }
567
+ ```
568
+
569
+ ### ManyToOne (manual, when you need a specific FK)
570
+
571
+ ```yaml
572
+ relationships:
573
+ - type: ManyToOne
574
+ target: Order
575
+ joinColumn: fk_order_uuid
576
+ fetch: LAZY
577
+ ```
578
+
579
+ ### OneToOne
580
+
581
+ ```yaml
582
+ # Inverse side (with mappedBy)
583
+ relationships:
584
+ - type: OneToOne
585
+ target: OrderSummary
586
+ mappedBy: order
587
+ cascade: [PERSIST, MERGE]
588
+ fetch: LAZY
589
+
590
+ # Owner side (with FK)
591
+ relationships:
592
+ - type: OneToOne
593
+ target: Order
594
+ joinColumn: order_id
595
+ fetch: LAZY
596
+ ```
597
+
598
+ ### When to define ManyToOne manually
599
+
600
+ | Scenario | Define ManyToOne? |
601
+ |----------|------------------|
602
+ | Standard relationship with `mappedBy` | ❌ eva4j generates it |
603
+ | FK with custom name | ✅ Yes, to control `joinColumn` |
604
+ | Multiple FKs to the same entity | ✅ Yes, for distinct names |
605
+ | Unidirectional relationship (no inverse) | ✅ Yes |
606
+
607
+ ### Recommended cascade
608
+
609
+ ```yaml
610
+ # Child has no meaning without parent → include REMOVE
611
+ cascade: [PERSIST, MERGE, REMOVE]
612
+
613
+ # Child has an independent lifecycle
614
+ cascade: [PERSIST, MERGE]
615
+ ```
616
+
617
+ ---
618
+
619
+ ## 9. Value Objects
620
+
621
+ Immutable objects that represent domain concepts without their own identity.
622
+
623
+ ```yaml
624
+ valueObjects:
625
+ - name: Money
626
+ fields:
627
+ - name: amount
628
+ type: BigDecimal
629
+ - name: currency
630
+ type: String
631
+ ```
632
+
633
+ Generates:
634
+
635
+ - `Money.java` – immutable domain class with constructor, getters, `equals()`, `hashCode()`
636
+ - `MoneyJpa.java` – `@Embeddable` with Lombok
637
+
638
+ Usage in a field:
639
+
640
+ ```yaml
641
+ - name: totalAmount
642
+ type: Money # automatically detected as @Embedded
643
+ ```
644
+
645
+ ### Value Objects with business methods
646
+
647
+ Value Objects can declare methods directly in `domain.yaml`. The generator emits the method body verbatim inside the VO class.
648
+
649
+ ```yaml
650
+ valueObjects:
651
+ - name: Money
652
+ fields:
653
+ - name: amount
654
+ type: BigDecimal
655
+ - name: currency
656
+ type: String
657
+ methods:
658
+ - name: add
659
+ returnType: Money
660
+ parameters:
661
+ - name: other
662
+ type: Money
663
+ body: "return new Money(this.amount.add(other.getAmount()), this.currency);"
664
+ - name: isPositive
665
+ returnType: boolean
666
+ parameters: []
667
+ body: "return this.amount.compareTo(BigDecimal.ZERO) > 0;"
668
+ ```
669
+
670
+ Generated in the domain VO:
671
+
672
+ ```java
673
+ public Money add(Money other) {
674
+ return new Money(this.amount.add(other.getAmount()), this.currency);
675
+ }
676
+
677
+ public boolean isPositive() {
678
+ return this.amount.compareTo(BigDecimal.ZERO) > 0;
679
+ }
680
+ ```
681
+
682
+ > Methods are only generated in the domain VO (`Money.java`), **not** in the JPA embeddable (`MoneyJpa.java`).
683
+
684
+ ### List of Value Objects
685
+
686
+ ```yaml
687
+ - name: addresses
688
+ type: List<Address>
689
+ ```
690
+
691
+ Generates:
692
+
693
+ ```java
694
+ @ElementCollection
695
+ @CollectionTable(name = "entity_addresses", joinColumns = @JoinColumn(name = "entity_id"))
696
+ @Builder.Default
697
+ private List<AddressJpa> addresses = new ArrayList<>();
698
+ ```
699
+
700
+ ---
701
+
702
+ ## 10. Enums and state transitions
703
+
704
+ ### Simple enum
705
+
706
+ ```yaml
707
+ enums:
708
+ - name: OrderStatus
709
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
710
+ ```
711
+
712
+ Generates `OrderStatus.java` with the enumerated values. In JPA: `@Enumerated(EnumType.STRING)`.
713
+
714
+ ### Enum with state transitions
715
+
716
+ Transitions generate business methods in the entity, validation logic in the enum, and prevent invalid states.
717
+
718
+ ```yaml
719
+ enums:
720
+ - name: OrderStatus
721
+ initialValue: PENDING # assigns an initial value; field becomes readOnly
722
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
723
+ transitions:
724
+ - from: PENDING # can be a string or [array]
725
+ to: CONFIRMED
726
+ method: confirm # name of the method generated in the entity
727
+ - from: [PENDING, CONFIRMED]
728
+ to: CANCELLED
729
+ method: cancel
730
+ guard: "this.status == OrderStatus.DELIVERED" # throws BusinessException if true
731
+ - from: CONFIRMED
732
+ to: SHIPPED
733
+ method: ship
734
+ ```
735
+
736
+ #### What is generated in the Enum
737
+
738
+ ```java
739
+ private static final Map<OrderStatus, List<OrderStatus>> VALID_TRANSITIONS = Map.of(
740
+ PENDING, List.of(CONFIRMED, CANCELLED),
741
+ CONFIRMED, List.of(SHIPPED, CANCELLED),
742
+ SHIPPED, List.of(DELIVERED));
743
+
744
+ public boolean canTransitionTo(OrderStatus next) {
745
+ return VALID_TRANSITIONS.getOrDefault(this, List.of()).contains(next);
746
+ }
747
+
748
+ public OrderStatus transitionTo(OrderStatus next) {
749
+ if (!canTransitionTo(next)) {
750
+ throw new InvalidStateTransitionException(this, next);
751
+ }
752
+ return next;
753
+ }
754
+ ```
755
+
756
+ #### What is generated in the aggregate root
757
+
758
+ One method per transition, plus `is*()` and `can*()` helpers:
759
+
760
+ ```java
761
+ public void confirm() {
762
+ this.status = this.status.transitionTo(OrderStatus.CONFIRMED);
763
+ }
764
+
765
+ public void cancel() {
766
+ if (this.status == OrderStatus.DELIVERED) {
767
+ throw new BusinessException("Cannot cancel a delivered order");
768
+ }
769
+ this.status = this.status.transitionTo(OrderStatus.CANCELLED);
770
+ }
771
+
772
+ public boolean isPending() { return this.status == OrderStatus.PENDING; }
773
+ public boolean canConfirm() { return this.status.canTransitionTo(OrderStatus.CONFIRMED); }
774
+ ```
775
+
776
+ ### `initialValue`
777
+
778
+ Assigns a default value to the status field in the creation constructor. The field is automatically marked as `readOnly` (does not appear in `CreateDto`/`CreateCommand`).
779
+
780
+ ```yaml
781
+ enums:
782
+ - name: OrderStatus
783
+ initialValue: PENDING
784
+ ```
785
+
786
+ ### `guard`
787
+
788
+ Java condition evaluated in the transition method. If the expression is `true`, a `BusinessException` is thrown.
789
+
790
+ ```yaml
791
+ - from: [PENDING, CONFIRMED]
792
+ to: CANCELLED
793
+ method: cancel
794
+ guard: "this.totalAmount.compareTo(BigDecimal.ZERO) == 0"
795
+ ```
796
+
797
+ ---
798
+
799
+ ## 11. Domain events
800
+
801
+ 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.
802
+
803
+ ```yaml
804
+ aggregates:
805
+ - name: Order
806
+ enums:
807
+ - name: OrderStatus
808
+ initialValue: DRAFT
809
+ transitions:
810
+ - from: DRAFT
811
+ to: PLACED
812
+ method: place
813
+ - from: PLACED
814
+ to: CANCELLED
815
+ method: cancel
816
+ values: [DRAFT, PLACED, CANCELLED]
817
+ events:
818
+ - name: OrderPlaced
819
+ triggers:
820
+ - place # transition method name that publishes this event
821
+ fields:
822
+ - name: orderId # declared for cross-module Kafka consumers
823
+ type: String
824
+ - name: customerId
825
+ type: String
826
+ - name: totalAmount
827
+ type: BigDecimal
828
+ - name: placedAt
829
+ type: LocalDateTime
830
+ - name: OrderCancelled
831
+ triggers:
832
+ - cancel
833
+ fields:
834
+ - name: reason # unresolvable → null /* TODO: provide reason */
835
+ type: String
836
+ entities:
837
+ - name: Order
838
+ isRoot: true
839
+ # ...
840
+ ```
841
+
842
+ ### `triggers` — argument resolution rules (in order)
843
+
844
+ | Field condition | Generated argument |
845
+ |---|---|
846
+ | Always (first arg — `aggregateId` from `DomainEvent` base) | `this.getId()` |
847
+ | Name = `{entityName}Id` (e.g. `orderId` in `Order`) | **Skipped in Domain Event class** — mapped to `event.getAggregateId()` in the Integration Event handler |
848
+ | Name matches a field of the entity | `this.get{Field}()` |
849
+ | Name ends in `At` + type `LocalDateTime` | `LocalDateTime.now()` |
850
+ | Not resolvable | `null /* TODO: provide {fieldName} */` |
851
+
852
+ > **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.
853
+
854
+ ### `topic` — Kafka topic name for the event
855
+
856
+ Optional but **recommended**. Declares the Kafka topic name for this event explicitly.
857
+
858
+ ```yaml
859
+ events:
860
+ - name: OrderPlacedEvent
861
+ topic: ORDER_PLACED # ✅ preferred: explicit, matches listeners[].topic in consumers
862
+ triggers: [place]
863
+ fields: [...]
864
+ ```
865
+
866
+ **Default derivation (when `topic:` is omitted):** the generator strips the `Event` suffix from the class name:
867
+ - `OrderPlacedEvent` → `ORDER_PLACED` ✓
868
+ - `OrderCancelled` *(no suffix)* → `ORDER_CANCELLED` ✓
869
+
870
+ **Why prefer explicit `topic:`:** the consumer's `listeners[].topic` must match the producer's topic exactly. Declaring it explicitly in both places eliminates any risk of mismatch.
871
+
872
+ If an event has **no `triggers`**, the developer must call `raise()` manually inside the business method.
873
+
874
+ ### Generated files
875
+
876
+ | File | Description |
877
+ |------|-------------|
878
+ | `shared/domain/DomainEvent.java` | Abstract base class (generated once per project) |
879
+ | `domain/models/events/OrderPlaced.java` | Concrete event extending `DomainEvent` |
880
+ | `domain/models/events/OrderCancelled.java` | Concrete event |
881
+ | `raise()` / `pullDomainEvents()` in the aggregate root | Event infrastructure in the entity |
882
+ | `OrderRepositoryImpl.java` | Calls `eventPublisher.publishEvent()` when saving |
883
+ | `OrderDomainEventHandler.java` | Class with `@TransactionalEventListener` per event |
884
+
885
+ ### Generated event
886
+
887
+ Fields declared as `{entityName}Id` are excluded from the record — consumers use `getAggregateId()` instead.
888
+
889
+ ```java
890
+ public final class OrderPlaced extends DomainEvent {
891
+ // aggregateId (= orderId) inherited from DomainEvent — not repeated here
892
+ private final String customerId;
893
+ private final BigDecimal totalAmount;
894
+ private final LocalDateTime placedAt;
895
+
896
+ public OrderPlaced(String aggregateId, String customerId, BigDecimal totalAmount, LocalDateTime placedAt) {
897
+ super(aggregateId);
898
+ this.customerId = customerId;
899
+ this.totalAmount = totalAmount;
900
+ this.placedAt = placedAt;
901
+ }
902
+
903
+ // getters
904
+ }
905
+ ```
906
+
907
+ ### How to raise an event — auto-generated via `triggers`
908
+
909
+ When `triggers` is declared, the generator emits the `raise()` call automatically inside each transition method:
910
+
911
+ ```java
912
+ public void place() {
913
+ this.status = this.status.transitionTo(OrderStatus.PLACED);
914
+ raise(new OrderPlaced(this.getId(), this.getCustomerId(), this.getTotalAmount(), LocalDateTime.now()));
915
+ // ^—aggregateId ^—customerId ^—totalAmount ^—placedAt
916
+ }
917
+
918
+ public void cancel() {
919
+ this.status = this.status.transitionTo(OrderStatus.CANCELLED);
920
+ raise(new OrderCancelled(this.getId(), null /* TODO: provide reason */));
921
+ }
922
+ ```
923
+
924
+ For events **without `triggers`**, call `raise()` manually:
925
+
926
+ ```java
927
+ public class Order {
928
+ private final List<DomainEvent> domainEvents = new ArrayList<>();
929
+
930
+ public void someBusinessAction() {
931
+ // business logic...
932
+ raise(new OrderPlaced(this.getId(), this.customerId, this.totalAmount, LocalDateTime.now()));
933
+ }
934
+
935
+ protected void raise(DomainEvent event) {
936
+ domainEvents.add(event);
937
+ }
938
+
939
+ public List<DomainEvent> pullDomainEvents() {
940
+ List<DomainEvent> events = new ArrayList<>(domainEvents);
941
+ domainEvents.clear();
942
+ return events;
943
+ }
944
+ }
945
+ ```
946
+
947
+ ### Validator checks
948
+
949
+ | Code | Severity | Condition |
950
+ |------|----------|-----------|
951
+ | C2-001 | warning | Transition without a use-case — silenced when `triggers` is present |
952
+ | C2-004 | error | `triggers` references a method that does not exist in any transition |
953
+ | C2-005 | info | Transition method with no associated event — consider adding `triggers` |
954
+
955
+ ---
956
+
957
+ ## 12. Multiple aggregates
958
+
959
+ A `domain.yaml` can contain multiple aggregates. Each one generates its own set of files.
960
+
961
+ ```yaml
962
+ aggregates:
963
+ - name: Customer
964
+ entities:
965
+ - name: Customer
966
+ isRoot: true
967
+ fields:
968
+ - name: id
969
+ type: String
970
+ - name: email
971
+ type: String
972
+
973
+ - name: Product
974
+ entities:
975
+ - name: Product
976
+ isRoot: true
977
+ fields:
978
+ - name: id
979
+ type: String
980
+ - name: name
981
+ type: String
982
+ enums:
983
+ - name: ProductCategory
984
+ values: [ELECTRONICS, CLOTHING, FOOD]
985
+ ```
986
+
987
+ > Enums and Value Objects are local to the aggregate where they are defined. If two aggregates need the same VO, it must be declared in each one.
988
+
989
+ ---
990
+
991
+ ## 13. Generated files
992
+
993
+ For each aggregate, approximately the following files are generated:
994
+
995
+ | File | Layer | Description |
996
+ |------|-------|-------------|
997
+ | `{Root}.java` | Domain | Aggregate root entity |
998
+ | `{Entity}.java` | Domain | Secondary entities |
999
+ | `{Vo}.java` | Domain | Value Objects |
1000
+ | `{Enum}.java` | Domain | Enums (with VALID_TRANSITIONS if transitions exist) |
1001
+ | `{Event}.java` | Domain | Domain events (`domain/models/events/`) |
1002
+ | `{Root}Repository.java` | Domain | Repository interface (port) |
1003
+ | `Create{Root}Command.java` | Application | Create command |
1004
+ | `Create{Root}CommandHandler.java` | Application | Command handler |
1005
+ | `Get{Root}Query.java` | Application | Get by ID query |
1006
+ | `Get{Root}QueryHandler.java` | Application | Query handler |
1007
+ | `List{Root}Query.java` | Application | Paginated list query |
1008
+ | `List{Root}QueryHandler.java` | Application | List handler |
1009
+ | `{Root}ResponseDto.java` | Application | Response DTO |
1010
+ | `Create{Root}Dto.java` | Application | Create DTO |
1011
+ | `{Root}ApplicationMapper.java` | Application | Mapper Command/DTO ↔ Domain |
1012
+ | `{Aggregate}DomainEventHandler.java` | Application | `@TransactionalEventListener` per declared event |
1013
+ | `{Event}IntegrationEvent.java` | Application | Integration event record (when broker installed) |
1014
+ | `MessageBroker.java` | Application | Broker port interface (created/updated) |
1015
+ | `{Root}Jpa.java` | Infrastructure | JPA entity |
1016
+ | `{Entity}Jpa.java` | Infrastructure | Secondary JPA entities |
1017
+ | `{Vo}Jpa.java` | Infrastructure | JPA Value Objects (`@Embeddable`) |
1018
+ | `{Root}Mapper.java` | Infrastructure | Mapper Domain ↔ JPA |
1019
+ | `{Root}JpaRepository.java` | Infrastructure | Spring Data repository |
1020
+ | `{Root}RepositoryImpl.java` | Infrastructure | Repository implementation |
1021
+ | `{Root}Controller.java` | Infrastructure | REST controller |
1022
+ | `{Broker}MessageBroker.java` | Infrastructure | Broker adapter (created/updated) |
1023
+
1024
+ **Additional files when `listeners:` is declared:**
1025
+
1026
+ | File | Layer | Description |
1027
+ |------|-------|-------------|
1028
+ | `{Event}IntegrationEvent.java` | Application | Typed record for the consumed event payload |
1029
+ | `{NestedType}.java` | Application | Auxiliary record for object-typed fields |
1030
+ | `{UseCase}Command.java` | Application | Command dispatched from the listener |
1031
+ | `{UseCase}CommandHandler.java` | Application | Handler stub — implement business logic here |
1032
+ | `{Event}KafkaListener.java` | Infrastructure | `@KafkaListener` that deserializes and dispatches |
1033
+ | `kafka.yaml` (all envs) | Infrastructure | Topic registration |
1034
+
1035
+ **Additional files when `ports:` is declared:**
1036
+
1037
+ | File | Layer | Description |
1038
+ |------|-------|-------------|
1039
+ | `{ServiceName}.java` | Domain | Port interface (returns domain models) |
1040
+ | `{DomainType}.java` | Domain | Domain model per unique type (`domain/models/{service}/`) |
1041
+ | `{ServiceName}FeignClient.java` | Infrastructure | Feign client (returns infra DTOs) |
1042
+ | `{ServiceName}FeignAdapter.java` | Infrastructure | ACL adapter mapping infra DTO → domain model |
1043
+ | `{ServiceName}FeignConfig.java` | Infrastructure | Feign timeouts config |
1044
+ | `{Method}Dto.java` | Infrastructure | Infra DTO per method with `fields:` |
1045
+ | `{Method}RequestDto.java` | Application | Request DTO per method with `body:` |
1046
+ | `{NestedType}.java` | Application | Auxiliary record for `nestedTypes:` |
1047
+ | `urls.yaml` (all envs) | Infrastructure | Base URL per service |
1048
+
1049
+ ### Generated REST endpoints
1050
+
1051
+ | Method | Path | Description |
1052
+ |--------|------|-------------|
1053
+ | `POST` | `/api/{module}/{entity}` | Create |
1054
+ | `GET` | `/api/{module}/{entity}/{id}` | Get by ID |
1055
+ | `GET` | `/api/{module}/{entity}?page=0&size=20` | Paginated list |
1056
+ | `PUT` | `/api/{module}/{entity}/{id}` | Update |
1057
+ | `DELETE` | `/api/{module}/{entity}/{id}` | Delete |
1058
+
1059
+ ---
1060
+
1061
+ ## 14. Complete examples
1062
+
1063
+ ### Example 1: Order with transitions and events
1064
+
1065
+ ```yaml
1066
+ aggregates:
1067
+ - name: Order
1068
+ entities:
1069
+ - name: Order
1070
+ isRoot: true
1071
+ tableName: orders
1072
+ audit:
1073
+ enabled: true
1074
+ fields:
1075
+ - name: id
1076
+ type: String
1077
+ - name: customerId
1078
+ type: String
1079
+ reference:
1080
+ aggregate: Customer
1081
+ module: customers
1082
+ - name: status
1083
+ type: OrderStatus
1084
+ - name: totalAmount
1085
+ type: BigDecimal
1086
+ readOnly: true
1087
+ relationships:
1088
+ - type: OneToMany
1089
+ target: OrderItem
1090
+ mappedBy: order
1091
+ cascade: [PERSIST, MERGE, REMOVE]
1092
+ fetch: LAZY
1093
+
1094
+ - name: OrderItem
1095
+ tableName: order_items
1096
+ fields:
1097
+ - name: id
1098
+ type: Long
1099
+ - name: productId
1100
+ type: String
1101
+ - name: quantity
1102
+ type: Integer
1103
+ validations:
1104
+ - type: Min
1105
+ value: 1
1106
+ - name: unitPrice
1107
+ type: BigDecimal
1108
+
1109
+ enums:
1110
+ - name: OrderStatus
1111
+ initialValue: PENDING
1112
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
1113
+ transitions:
1114
+ - from: PENDING
1115
+ to: CONFIRMED
1116
+ method: confirm
1117
+ - from: CONFIRMED
1118
+ to: SHIPPED
1119
+ method: ship
1120
+ - from: [PENDING, CONFIRMED]
1121
+ to: CANCELLED
1122
+ method: cancel
1123
+ guard: "this.status == OrderStatus.DELIVERED"
1124
+
1125
+ events:
1126
+ - name: OrderPlaced
1127
+ fields:
1128
+ - name: customerId
1129
+ type: String
1130
+ - name: OrderCancelled
1131
+ fields:
1132
+ - name: reason
1133
+ type: String
1134
+ ```
1135
+
1136
+ ### Example 2: User with auditing and a sensitive field
1137
+
1138
+ ```yaml
1139
+ aggregates:
1140
+ - name: User
1141
+ entities:
1142
+ - name: User
1143
+ isRoot: true
1144
+ tableName: users
1145
+ audit:
1146
+ enabled: true
1147
+ trackUser: true
1148
+ fields:
1149
+ - name: id
1150
+ type: String
1151
+ - name: username
1152
+ type: String
1153
+ validations:
1154
+ - type: NotBlank
1155
+ - type: Size
1156
+ min: 3
1157
+ max: 50
1158
+ - name: email
1159
+ type: String
1160
+ validations:
1161
+ - type: Email
1162
+ annotations:
1163
+ - "@Column(unique = true)"
1164
+ - name: passwordHash
1165
+ type: String
1166
+ hidden: true
1167
+ - name: role
1168
+ type: UserRole
1169
+ - name: active
1170
+ type: Boolean
1171
+
1172
+ enums:
1173
+ - name: UserRole
1174
+ values: [ADMIN, USER, MODERATOR]
1175
+ ```
1176
+
1177
+ ---
1178
+
1179
+ ## 15. Prerequisites and common errors
1180
+
1181
+ ### Prerequisites
1182
+
1183
+ - Project created with `eva create`
1184
+ - Existing module (`eva add module <module>`)
1185
+ - `domain.yaml` file at `src/main/java/<package>/<module>/`
1186
+
1187
+ ### Common errors
1188
+
1189
+ | Error | Cause | Solution |
1190
+ |-------|-------|----------|
1191
+ | `Module does not exist` | Module was not created | Run `eva add module <module>` |
1192
+ | `YAML file not found` | No `domain.yaml` at the expected path | Check `src/main/java/<pkg>/<module>/domain.yaml` |
1193
+ | `Invalid relationship target` | Target entity not defined in the same YAML | Define the target entity in the same `domain.yaml` |
1194
+ | `Column 'x_id' is duplicated` | ManyToOne defined manually + auto-generated | Remove the manual ManyToOne; let eva4j generate it |
1195
+ | File not regenerated | File was manually modified (checksum) | Use `--force` to overwrite |
1196
+ | Import errors | Field `type` doesn't match name in `enums:` or `valueObjects:` | Verify names match exactly |
1197
+
1198
+ ---
1199
+
1200
+ ## 16. Declarative endpoints (`endpoints:`) — Use case patterns
1201
+
1202
+ 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.
1203
+
1204
+ ### 16.1 Pattern table
1205
+
1206
+ | Pattern | Category | Recognition condition | What is generated |
1207
+ |---------|----------|----------------------|-------------------|
1208
+ | `Create{Aggregate}` | **standard** | Exact string match | Full `CreateCommand` + `CreateCommandHandler` (`ApplicationMapper.fromCommand → save`) |
1209
+ | `Update{Aggregate}` | **standard** | Exact string match | Full `UpdateCommand` + `UpdateCommandHandler` |
1210
+ | `Delete{Aggregate}` | **standard** | Exact string match | Full `DeleteCommand` + `DeleteCommandHandler` |
1211
+ | `Get{Aggregate}` | **standard** | Exact string match | Full `GetQuery` + `GetQueryHandler` (find + `mapper.toDto`) |
1212
+ | `FindAll{PluralAggregate}` | **standard** | Proper English plural of aggregate name (via `pluralize` library) | Full `ListQuery` + `ListQueryHandler` (paginated) |
1213
+ | `{MethodPascal}{Aggregate}` | **transition** | `MethodPascal` is `toPascalCase(transitions[n].method)` for any enum in the aggregate | Full `TransitionCommand(id)` + handler that calls `entity.{method}() → save()` |
1214
+ | `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()` |
1215
+ | `Remove{EntityName}` | **subEntityRemove** | Same `target` from a `OneToMany` relationship | Full `RemoveCommand(id, itemId)` + handler that calls `entity.remove{Entity}ById(itemId) → save()` |
1216
+ | `FindAll{PluralAggregate}By{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` |
1217
+ | _anything else_ | **scaffold** | No pattern matched | `*Command(id)` or `*Query(id)` + handler that throws `UnsupportedOperationException` with a TODO comment |
1218
+
1219
+ > **Note on `FindAll{PluralAggregate}`:** The aggregate name is pluralized using the `pluralize` library, which handles English irregular plurals correctly. For example: `Order` → `FindAllOrders`, `Delivery` → `FindAllDeliveries`, `Category` → `FindAllCategories`. Using the wrong plural (e.g. `FindAllDeliverys`) will cause it to be classified as scaffold.
1220
+
1221
+ ### 16.2 Transition pattern in detail
1222
+
1223
+ Enumerate state transitions in the `enums:` block using `transitions`:
1224
+
1225
+ ```yaml
1226
+ enums:
1227
+ - name: OrderStatus
1228
+ transitions:
1229
+ - from: PENDING
1230
+ to: CONFIRMED
1231
+ method: confirm # → recognized as ConfirmOrder (PascalCase(confirm) + Order)
1232
+ - from: [PENDING, CONFIRMED]
1233
+ to: CANCELLED
1234
+ method: cancel # → CancelOrder
1235
+ - from: CONFIRMED
1236
+ to: SHIPPED
1237
+ method: ship # → ShipOrder
1238
+ values: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED]
1239
+ ```
1240
+
1241
+ Declare the corresponding use cases in `endpoints:`:
1242
+
1243
+ ```yaml
1244
+ endpoints:
1245
+ basePath: /orders
1246
+ versions:
1247
+ - version: v1
1248
+ operations:
1249
+ - method: PUT
1250
+ path: /{id}/confirm
1251
+ useCase: ConfirmOrder # ← transition pattern: confirm + Order
1252
+ type: command
1253
+ - method: PUT
1254
+ path: /{id}/cancel
1255
+ useCase: CancelOrder
1256
+ type: command
1257
+ ```
1258
+
1259
+ **Generated output:**
1260
+
1261
+ ```java
1262
+ // ConfirmOrderCommand.java
1263
+ public record ConfirmOrderCommand(String id) implements Command {}
1264
+
1265
+ // ConfirmOrderCommandHandler.java
1266
+ @Transactional
1267
+ public void handle(ConfirmOrderCommand command) {
1268
+ Order entity = repository.findById(command.id())
1269
+ .orElseThrow(() -> new NotFoundException("Order not found with id: " + command.id()));
1270
+ entity.confirm(); // ← the domain method from transitions[].method
1271
+ repository.save(entity);
1272
+ }
1273
+ ```
1274
+
1275
+ ### 16.3 Sub-entity add/remove pattern in detail
1276
+
1277
+ Requirements:
1278
+ - A `OneToMany` relationship must be declared on the root entity pointing to the target entity.
1279
+ - The aggregate root must expose `add{EntityName}({EntityName} item)` and `remove{EntityName}ById(String id)` domain methods (generated automatically by `eva g entities`).
1280
+
1281
+ ```yaml
1282
+ # aggregates: section
1283
+ relationships:
1284
+ - type: OneToMany
1285
+ target: OrderItem # ← entityName used to match Add/Remove pattern
1286
+ fieldName: items
1287
+ ...
1288
+
1289
+ # endpoints: section
1290
+ operations:
1291
+ - method: POST
1292
+ path: /{id}/items
1293
+ useCase: AddOrderItem # ← Add + OrderItem (target name)
1294
+ type: command
1295
+ - method: DELETE
1296
+ path: /{id}/items/{itemId}
1297
+ useCase: RemoveOrderItem # ← Remove + OrderItem
1298
+ type: command
1299
+ ```
1300
+
1301
+ **Generated output:**
1302
+
1303
+ ```java
1304
+ // AddOrderItemCommand.java — fields taken from OrderItem (non-id, non-audit, non-readOnly)
1305
+ public record AddOrderItemCommand(
1306
+ String id,
1307
+ String productId,
1308
+ String productName,
1309
+ Integer quantity,
1310
+ BigDecimal unitPrice
1311
+ ) implements Command {}
1312
+
1313
+ // AddOrderItemCommandHandler.java
1314
+ @Transactional
1315
+ public void handle(AddOrderItemCommand command) {
1316
+ Order entity = repository.findById(command.id()) ...;
1317
+ OrderItem item = new OrderItem(command.productId(), command.productName(),
1318
+ command.quantity(), command.unitPrice());
1319
+ entity.addOrderItem(item);
1320
+ repository.save(entity);
1321
+ }
1322
+
1323
+ // RemoveOrderItemCommand.java
1324
+ public record RemoveOrderItemCommand(String id, String itemId) implements Command {}
1325
+
1326
+ // RemoveOrderItemCommandHandler.java
1327
+ @Transactional
1328
+ public void handle(RemoveOrderItemCommand command) {
1329
+ Order entity = repository.findById(command.id()) ...;
1330
+ entity.removeOrderItemById(command.itemId());
1331
+ repository.save(entity);
1332
+ }
1333
+ ```
1334
+
1335
+ ### 16.4 FindBy pattern in detail
1336
+
1337
+ Strict pattern: `FindAll{PluralAggregate}By{FieldPascal}` — both parts are required.
1338
+
1339
+ When detected, the generator:
1340
+ 1. Creates a paginated `FindBy{Field}Query` + `FindBy{Field}QueryHandler`.
1341
+ 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.
1342
+
1343
+ ```yaml
1344
+ # Root entity field
1345
+ fields:
1346
+ - name: customerId
1347
+ type: String
1348
+
1349
+ # Endpoint
1350
+ operations:
1351
+ - method: GET
1352
+ path: /customer/{customerId}
1353
+ useCase: FindAllOrdersByCustomerId # ← FindAll + Orders + By + CustomerId
1354
+ type: query
1355
+ ```
1356
+
1357
+ **Generated output:**
1358
+
1359
+ ```java
1360
+ // FindAllOrdersByCustomerIdQuery.java
1361
+ public record FindAllOrdersByCustomerIdQuery(
1362
+ String customerId, int page, int size, String sortBy, String sortDirection
1363
+ ) implements Query<PagedResponse<OrderResponseDto>> {}
1364
+
1365
+ // OrderRepository.java (domain interface — method appended)
1366
+ Page<Order> findByCustomerId(String customerId, Pageable pageable);
1367
+
1368
+ // OrderJpaRepository.java (Spring Data JPA — method auto-implemented)
1369
+ Page<OrderJpa> findByCustomerId(String customerId, Pageable pageable);
1370
+ ```
1371
+
1372
+ ### 16.5 Scaffold (fallback)
1373
+
1374
+ Any `useCase` name that does not match any pattern above becomes a scaffold. A scaffold generates:
1375
+
1376
+ - A minimal `{UseCase}Command(String id)` or `{UseCase}Query(String id)` record.
1377
+ - A handler that throws `UnsupportedOperationException` and includes a step-by-step TODO comment.
1378
+
1379
+ This is intentional: the developer fills in the custom business logic while the wiring (registration, mediator dispatch, controller method) is already in place.
1380
+
1381
+ ### 16.6 Naming rules
1382
+
1383
+ | What | Convention | Example |
1384
+ |------|-----------|---------|
1385
+ | Aggregate name | PascalCase | `Order` |
1386
+ | Use case name in YAML | PascalCase | `ConfirmOrder`, `FindAllOrdersByCustomerId` |
1387
+ | Transition method in YAML | camelCase | `confirm`, `cancelOrder` |
1388
+ | Pattern `{MethodPascal}` | `toPascalCase(method)` | `confirm` → `Confirm` |
1389
+ | Pattern `{FieldPascal}` | `toPascalCase(fieldName)` | `customerId` → `CustomerId` |
1390
+ | Sub-entity target | PascalCase (must match entity `name:`) | `OrderItem` |
1391
+
1392
+ ---
1393
+
1394
+ ## 17. Consuming external events (`listeners:`)
1395
+
1396
+ The `listeners:` section declares integration events that this module **consumes** from other modules or external systems. It lives at the **root level** of `domain.yaml`, as a sibling of `aggregates:`.
1397
+
1398
+ > **Requires a broker installed:** `eva add kafka-client` — without it, the section is parsed but no files are generated.
1399
+
1400
+ ### Syntax
1401
+
1402
+ ```yaml
1403
+ # Root level — sibling of aggregates:
1404
+ listeners:
1405
+ - event: PaymentApprovedEvent # PascalCase + suffix Event
1406
+ producer: payments # Producing module (documentary reference)
1407
+ topic: PAYMENT_APPROVED # Kafka topic — SCREAMING_SNAKE_CASE, mandatory
1408
+ useCase: ConfirmReservation # PascalCase use case executed when the event is consumed
1409
+ fields: # Payload fields received
1410
+ - name: reservationId
1411
+ type: String
1412
+ - name: approvedAt
1413
+ type: LocalDateTime
1414
+ - name: details # Object-typed field → declare in nestedTypes:
1415
+ type: PaymentDetails
1416
+ nestedTypes: # Auxiliary records for object-typed fields
1417
+ - name: paymentDetails # camelCase → normalized to PaymentDetails
1418
+ fields:
1419
+ - name: paymentId
1420
+ type: String
1421
+ - name: method
1422
+ type: String
1423
+ - name: amount
1424
+ type: BigDecimal
1425
+ ```
1426
+
1427
+ ### Generated files per listener entry
1428
+
1429
+ | File | Description |
1430
+ |------|-------------|
1431
+ | `application/events/{NestedType}.java` | Auxiliary record per `nestedTypes` entry |
1432
+ | `application/events/{Event}IntegrationEvent.java` | Typed record with the declared `fields` |
1433
+ | `infrastructure/kafkaListener/{Event}KafkaListener.java` | `@KafkaListener` → deserializes and dispatches to `useCase` |
1434
+ | `parameters/*/kafka.yaml` | Topic registered under `topics:` |
1435
+ | `application/commands/{UseCase}Command.java` | Typed command for the `useCase` |
1436
+ | `application/usecases/{UseCase}CommandHandler.java` | Handler stub — implement business logic here |
1437
+
1438
+ ### Rules
1439
+
1440
+ | Property | Convention | Notes |
1441
+ |----------|------------|-------|
1442
+ | `event:` | PascalCase + suffix `Event` | `PaymentApprovedEvent` ✅ |
1443
+ | `topic:` | SCREAMING_SNAKE_CASE | **Mandatory** for standalone modules |
1444
+ | `useCase:` | PascalCase, verb + noun | Describes the consumer's action, not the event name |
1445
+ | `fields:` | same as entity fields | Defines the typed payload record |
1446
+ | `nestedTypes:` | camelCase name, normalized internally | One record per object-typed field |
1447
+
1448
+ - `topic:` is **mandatory** when there is no `system.yaml`. When a `system.yaml` exists, it can be inferred from `integrations.async[].topic`, but an explicit value always takes precedence.
1449
+ - `nestedTypes:` — declare one entry per object-typed field (not a scalar). The generated record is placed in `application/events/` and used by both the integration event and the command.
1450
+ - Verb hints for `useCase`: `Handle`, `Process`, `Confirm`, `Cancel`, `Accumulate`, `Release`, `Notify`, `Update`.
1451
+
1452
+ ### Example — generated Kafka listener
1453
+
1454
+ ```java
1455
+ @Component("<moduleName>.PaymentApprovedKafkaListener") // qualified to avoid cross-module collisions
1456
+ public class PaymentApprovedKafkaListener {
1457
+
1458
+ private final UseCaseMediator useCaseMediator;
1459
+ private final ObjectMapper objectMapper;
1460
+
1461
+ @Value("${topics.payment-approved}")
1462
+ private String paymentApprovedTopic;
1463
+
1464
+ @KafkaListener(topics = "${topics.payment-approved}")
1465
+ public void handle(EventEnvelope<Map<String, Object>> event, Acknowledgment ack) {
1466
+ String reservationId = objectMapper.convertValue(
1467
+ event.data().get("reservationId"), String.class);
1468
+ LocalDateTime approvedAt = objectMapper.convertValue(
1469
+ event.data().get("approvedAt"), LocalDateTime.class);
1470
+ PaymentDetails details = objectMapper.convertValue(
1471
+ event.data().get("details"), PaymentDetails.class);
1472
+ useCaseMediator.dispatch(new ConfirmReservationCommand(reservationId, approvedAt, details));
1473
+ ack.acknowledge();
1474
+ }
1475
+ }
1476
+ ```
1477
+
1478
+ ### Example — generated command handler stub
1479
+
1480
+ ```java
1481
+ @Component
1482
+ public class ConfirmReservationCommandHandler
1483
+ implements CommandHandler<ConfirmReservationCommand, Void> {
1484
+
1485
+ @Override
1486
+ @Transactional
1487
+ public Void handle(ConfirmReservationCommand command) {
1488
+ // TODO: implement
1489
+ // 1. Find the reservation by command.reservationId()
1490
+ // 2. Confirm the reservation business rule
1491
+ // 3. Save and return null
1492
+ throw new UnsupportedOperationException("ConfirmReservationCommandHandler not implemented yet");
1493
+ }
1494
+ }
1495
+ ```
1496
+
1497
+ ### Cross-module listener collision safety
1498
+
1499
+ When multiple modules consume the **same** Kafka event, the generator produces listener classes with identical names (e.g. `PaymentApprovedKafkaListener` in both `orders` and `notifications`). This is safe because the generator qualifies each bean with `@Component("<moduleName>.<listenerClassName>")`, preventing `ConflictingBeanDefinitionException`. **No special naming action is required** — unlike `ports[]` where `service:` must be unique per module.
1500
+
1501
+ ### Contrast: produced vs. consumed events
1502
+
1503
+ ```
1504
+ aggregates:
1505
+ └── events: → Domain Events this module PRODUCES (domain/models/events/)
1506
+
1507
+ listeners: → Integration Events this module CONSUMES (infrastructure/kafkaListener/)
1508
+ ```
1509
+
1510
+ ---
1511
+
1512
+ ## 18. HTTP outbound clients (`ports:`)
1513
+
1514
+ The `ports:` section declares synchronous HTTP services that this module **calls outbound**. It lives at the **root level** of `domain.yaml`, as a sibling of `aggregates:` and `listeners:`.
1515
+
1516
+ Each entry is one method. Entries with the same `service:` are grouped into a single Feign client.
1517
+
1518
+ > Uses Spring Cloud OpenFeign. Requires `spring-cloud-starter-openfeign` in the project's dependencies.
1519
+
1520
+ ### Syntax
1521
+
1522
+ ```yaml
1523
+ # Root level — sibling of aggregates: and listeners:
1524
+ ports:
1525
+ - name: findScreeningById # method name (camelCase) → drives all naming
1526
+ service: ScreeningService # groups into one FeignClient (PascalCase)
1527
+ target: screenings # destination module (documentary reference)
1528
+ baseUrl: http://localhost:8081 # declared once per service; stored in urls.yaml
1529
+ http: GET /screenings/{id} # verb + path
1530
+ fields: # response fields → domain model + infra DTO
1531
+ - name: id
1532
+ type: String
1533
+ - name: startTime
1534
+ type: LocalDateTime
1535
+
1536
+ - name: findAvailableSeats
1537
+ service: ScreeningService # same service → same FeignClient
1538
+ target: screenings
1539
+ http: GET /screenings/{id}/seats
1540
+ returnList: true # → List<Seat> in the interface
1541
+ domainType: Seat # override auto-derived domain type name
1542
+ fields:
1543
+ - name: seatId
1544
+ type: String
1545
+ - name: seatType
1546
+ type: String
1547
+
1548
+ - name: processPayment
1549
+ service: PaymentGateway
1550
+ target: payment-gateway-external
1551
+ baseUrl: https://api.payments.example.com
1552
+ http: POST /payments
1553
+ body: # @RequestBody → {Method}RequestDto.java
1554
+ - name: amount
1555
+ type: BigDecimal
1556
+ - name: paymentMethod
1557
+ type: PaymentMethodInput # object type → declare in nestedTypes:
1558
+ nestedTypes:
1559
+ - name: paymentMethodInput
1560
+ fields:
1561
+ - name: type
1562
+ type: String
1563
+ - name: cardToken
1564
+ type: String
1565
+ fields: # response fields
1566
+ - name: paymentId
1567
+ type: String
1568
+ - name: status
1569
+ type: String
1570
+
1571
+ - name: cancelPayment
1572
+ service: PaymentGateway
1573
+ target: payment-gateway-external
1574
+ http: DELETE /payments/{id}
1575
+ # fields: omitted → void return
1576
+ ```
1577
+
1578
+ ### Generated files per unique `service:`
1579
+
1580
+ | File | Description |
1581
+ |------|-------------|
1582
+ | `domain/repositories/{ServiceName}.java` | Port interface (returns domain models) |
1583
+ | `infrastructure/adapters/{service}/{ServiceName}FeignClient.java` | Feign client (returns infra DTOs) |
1584
+ | `infrastructure/adapters/{service}/{ServiceName}FeignAdapter.java` | `@Component implements {ServiceName}` — ACL mapper |
1585
+ | `infrastructure/adapters/{service}/{ServiceName}FeignConfig.java` | Feign timeout configuration |
1586
+ | `parameters/*/urls.yaml` | Base URL registered |
1587
+
1588
+ ### Generated files per method
1589
+
1590
+ | File | Condition |
1591
+ |------|-----------|
1592
+ | `domain/models/{service}/{DomainType}.java` | When `fields:` present — domain-side abstraction |
1593
+ | `infrastructure/adapters/{service}/{Method}Dto.java` | When `fields:` present — infra DTO (external shape) |
1594
+ | `application/dtos/{Method}RequestDto.java` | When `body:` present (POST/PUT/PATCH) |
1595
+ | `application/dtos/{NestedType}.java` | When `nestedTypes:` declared |
1596
+
1597
+ ### Rules
1598
+
1599
+ | Property | Convention | Notes |
1600
+ |----------|------------|-------|
1601
+ | `service:` | PascalCase | Groups methods into one FeignClient. **Must be unique across modules** — if multiple modules call the same external service, each must use a context-specific name (e.g. `OrderCustomerService` in `orders`, `DeliveryCustomerService` in `deliveries`) to avoid Spring bean name collisions |
1602
+ | `baseUrl:` | URL string | Declare on **first entry** of each `service:` only |
1603
+ | `http:` | `VERB /path` | Same format as `exposes:` in `system.yaml` |
1604
+ | `returnList:` | boolean | `true` → `List<{DomainType}>` return type (default: `false`) |
1605
+ | `domainType:` | PascalCase | Overrides the domain type name auto-derived from the method name |
1606
+ | `body:` | list of fields | Only for POST/PUT/PATCH; emits warning and is ignored on GET/DELETE |
1607
+ | `nestedTypes:` | list of type defs | Records in `application/dtos/` for object-typed body fields |
1608
+ | `fields:` omitted | — | Return type is `void` in both the interface and Feign client |
1609
+
1610
+ ### ACL pattern
1611
+
1612
+ The generator follows the Anti-Corruption Layer (ACL) pattern to isolate the module from the shape of external APIs:
1613
+
1614
+ - **Infra DTOs** (external shape) live in `infrastructure/adapters/{service}/` and match the remote API exactly.
1615
+ - **Domain models** (internal abstraction) live in `domain/models/{service}/` and reflect what this module's business logic needs.
1616
+ - The `FeignAdapter` maps each `InfraDto → DomainModel` inline using private `to{Type}()` methods.
1617
+ - If the external API changes shape, **only the adapter needs to change**; domain logic is unaffected.
1618
+
1619
+ ### Example — generated port interface and adapter
1620
+
1621
+ ```java
1622
+ // domain/repositories/ScreeningService.java
1623
+ public interface ScreeningService {
1624
+ Screening findScreeningById(String id);
1625
+ List<Seat> findAvailableSeats(String id);
1626
+ }
1627
+
1628
+ // infrastructure/adapters/screeningService/ScreeningServiceFeignAdapter.java
1629
+ @Component
1630
+ public class ScreeningServiceFeignAdapter implements ScreeningService {
1631
+
1632
+ private final ScreeningServiceFeignClient feignClient;
1633
+
1634
+ @Override
1635
+ public Screening findScreeningById(String id) {
1636
+ FindScreeningByIdDto dto = feignClient.findScreeningById(id);
1637
+ return toScreening(dto);
1638
+ }
1639
+
1640
+ @Override
1641
+ public List<Seat> findAvailableSeats(String id) {
1642
+ return feignClient.findAvailableSeats(id)
1643
+ .stream().map(this::toSeat).toList();
1644
+ }
1645
+
1646
+ private Screening toScreening(FindScreeningByIdDto dto) {
1647
+ return new Screening(dto.getId(), dto.getStartTime());
1648
+ }
1649
+
1650
+ private Seat toSeat(FindAvailableSeatDto dto) {
1651
+ return new Seat(dto.getSeatId(), dto.getSeatType());
1652
+ }
1653
+ }
1654
+ ```
1655
+
1656
+ ### Contrast: async vs. sync
1657
+
1658
+ ```
1659
+ aggregates:
1660
+ └── events: → Domain Events this module PRODUCES (async, broker)
1661
+ listeners: → Integration Events this module CONSUMES (async, broker)
1662
+ ports: → HTTP services this module CALLS outbound (sync, Feign)
1663
+ ```