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
|
@@ -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
|
+
```
|