dflow-sdd-ddd 0.10.0 → 0.12.0
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/CHANGELOG.md +79 -0
- package/README.en.md +57 -43
- package/README.md +36 -33
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +1 -0
- package/bin/dflow.js +4 -8
- package/docs/why-dflow.en.md +72 -0
- package/docs/why-dflow.md +72 -0
- package/lib/init.js +114 -77
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +40 -6
- package/templates/brownfield/references/finish-feature-flow.md +85 -29
- package/templates/brownfield/references/git-integration.md +29 -9
- package/templates/brownfield/references/modify-existing-flow.md +61 -0
- package/templates/brownfield/references/new-feature-flow.md +62 -1
- package/templates/brownfield/references/new-phase-flow.md +19 -1
- package/templates/brownfield/references/pr-review-checklist.md +7 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +46 -33
- package/templates/brownfield/scaffolding/Git-principles-gitflow.md +1 -1
- package/templates/brownfield/scaffolding/Git-principles-trunk.md +2 -2
- package/templates/brownfield/templates/_index.md +23 -4
- package/templates/brownfield/templates/context-map.md +12 -4
- package/templates/brownfield/templates/lightweight-spec.md +3 -3
- package/templates/brownfield/templates/phase-spec.md +3 -3
- package/templates/common/references/ddd-modeling-guide.md +837 -0
- package/templates/greenfield/references/drift-verification.md +60 -12
- package/templates/greenfield/references/finish-feature-flow.md +86 -29
- package/templates/greenfield/references/git-integration.md +29 -9
- package/templates/greenfield/references/modify-existing-flow.md +23 -0
- package/templates/greenfield/references/new-feature-flow.md +70 -8
- package/templates/greenfield/references/new-phase-flow.md +15 -1
- package/templates/greenfield/references/pr-review-checklist.md +9 -1
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +40 -33
- package/templates/greenfield/scaffolding/Git-principles-gitflow.md +1 -1
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +4 -2
- package/templates/greenfield/templates/_index.md +23 -4
- package/templates/greenfield/templates/aggregate-design.md +8 -1
- package/templates/greenfield/templates/context-map.md +13 -4
- package/templates/greenfield/templates/events.md +5 -1
- package/templates/greenfield/templates/lightweight-spec.md +3 -3
- package/templates/greenfield/templates/phase-spec.md +3 -3
- package/docs/migrating-to-dflow-v1.md +0 -234
- package/templates/greenfield/references/ddd-modeling-guide.md +0 -351
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
# DDD Modeling Guide
|
|
2
|
+
|
|
3
|
+
When a developer asks "How should I model X?" or is designing domain structures,
|
|
4
|
+
use this guide to walk them through DDD tactical patterns.
|
|
5
|
+
|
|
6
|
+
> **Edition note — where to record decisions.** The artifact names below are the
|
|
7
|
+
> Greenfield spec surfaces (`aggregate-design.md` worksheet, `events.md`, ADRs
|
|
8
|
+
> under `architecture/decisions/`). Brownfield projects do not seed those, so
|
|
9
|
+
> record the same decision on the surface you have:
|
|
10
|
+
>
|
|
11
|
+
> - **Aggregate / invariant design** → the `models.md` Aggregate-Root Entity row
|
|
12
|
+
> + `rules.md` (Brownfield has no separate `aggregate-design.md` worksheet —
|
|
13
|
+
> do not invent one).
|
|
14
|
+
> - **Event contracts / failure-path notes** → `behavior.md` (as a `BR-*`
|
|
15
|
+
> scenario) and/or `migration/tech-debt.md` (Brownfield has no `events.md`).
|
|
16
|
+
> - **Event Sourcing / architecture decisions** → an existing ADR or migration
|
|
17
|
+
> plan if the project already has one; otherwise `migration/tech-debt.md`
|
|
18
|
+
> § Follow-up Notes and/or the target-architecture strategy section of
|
|
19
|
+
> `_overview.md`.
|
|
20
|
+
>
|
|
21
|
+
> The tactical patterns themselves are identical across editions; only the
|
|
22
|
+
> recording surface differs.
|
|
23
|
+
|
|
24
|
+
## The Modeling Conversation
|
|
25
|
+
|
|
26
|
+
Start with the business, not the code:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
"Let's forget about databases and classes for a moment.
|
|
30
|
+
Tell me: what are the rules? What must always be true?
|
|
31
|
+
What can never happen?"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
These invariants drive the entire model design.
|
|
35
|
+
|
|
36
|
+
## Subdomain-Aware Modeling Depth
|
|
37
|
+
|
|
38
|
+
Before choosing tactical patterns, decide *how much* DDD this Bounded Context
|
|
39
|
+
deserves. Not every context earns a rich domain model — over-modeling a
|
|
40
|
+
commodity capability is as wasteful as under-modeling a core one.
|
|
41
|
+
|
|
42
|
+
Classify the context (recorded as **Subdomain Type** in
|
|
43
|
+
`dflow/specs/domain/context-map.md`). The discriminator: **"if you replaced
|
|
44
|
+
this with an off-the-shelf SaaS / package, would the system's differentiation
|
|
45
|
+
disappear?"** (For internal systems "differentiation" means a unique
|
|
46
|
+
operational advantage or mission outcome, not only competitive edge.)
|
|
47
|
+
|
|
48
|
+
- **Core** — yes, it would disappear. This is the differentiator.
|
|
49
|
+
- **Supporting** — no, but it still needs customization to your process.
|
|
50
|
+
Necessary, often customized, not a differentiator.
|
|
51
|
+
- **Generic** — no, and off-the-shelf options exist (auth, notifications,
|
|
52
|
+
file upload, audit log are common examples).
|
|
53
|
+
|
|
54
|
+
Rule of thumb against inflation: most contexts are *supporting*; *core* is
|
|
55
|
+
usually only one or two. If everything is core, you haven't classified.
|
|
56
|
+
|
|
57
|
+
Modeling depth follows the type:
|
|
58
|
+
|
|
59
|
+
| Subdomain | Modeling depth | aggregate-design worksheet | Domain Events |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| core | **Full** — rich model, full invariants, the patterns below | required for a new Aggregate | full catalog |
|
|
62
|
+
| supporting | **Standard** — a sufficient model, avoid speculative abstraction | created but kept lean for a new Aggregate | events that are actually needed |
|
|
63
|
+
| generic | **Minimal** — thin wrapper / CRUD / off-the-shelf package behind an interface (ACL seam) | **not created** | usually none (adapter-boundary events excepted) |
|
|
64
|
+
|
|
65
|
+
**Precedence.** This subdomain-aware depth *refines / caps* the structural
|
|
66
|
+
DDD Modeling Depth in `AI-AGENT-GUIDE.md` § Ceremony Scaling and
|
|
67
|
+
`_conventions.md`: when the structural rule says "new Aggregate / BC → Full"
|
|
68
|
+
but the subdomain says Standard / Minimal, follow the **shallower sufficient
|
|
69
|
+
depth** — unless the developer explicitly chooses to model deeper and records
|
|
70
|
+
why (in the context-map Notes or the `aggregate-design.md` Design Decisions).
|
|
71
|
+
|
|
72
|
+
**What "shallower" does NOT mean** — three boundaries the classification must
|
|
73
|
+
never cross:
|
|
74
|
+
|
|
75
|
+
1. **Business rules are not exempt.** A generic context's rules still get a
|
|
76
|
+
BR-ID in `rules.md` and a scenario in `behavior.md`; `/dflow:verify` works
|
|
77
|
+
the same. What shrinks is the *domain model's thickness*, not the spec's
|
|
78
|
+
existence.
|
|
79
|
+
2. **The Tier system is unchanged.** A bug fix in a generic context is still a
|
|
80
|
+
T2; a new feature still runs the flow. Subdomain governs *how deep to model
|
|
81
|
+
a context*; Tier governs *how much ceremony a single change needs* — they
|
|
82
|
+
are orthogonal.
|
|
83
|
+
3. **Criticality is not downgraded.** Security, tests, SLA, audit,
|
|
84
|
+
permissions, and data integrity do **not** drop with the subdomain type —
|
|
85
|
+
auth or notifications can be generic *and* mission-critical. Generic lowers
|
|
86
|
+
the modeling investment, nothing else.
|
|
87
|
+
|
|
88
|
+
## Pattern Selection Flowchart
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
Is it identified by an ID that persists over time?
|
|
92
|
+
│
|
|
93
|
+
├─ Yes → Is it the "boss" that protects a consistency boundary?
|
|
94
|
+
│ ├─ Yes → AGGREGATE ROOT
|
|
95
|
+
│ └─ No → ENTITY (belongs inside an Aggregate)
|
|
96
|
+
│
|
|
97
|
+
└─ No → Is it defined entirely by its properties?
|
|
98
|
+
├─ Yes → VALUE OBJECT
|
|
99
|
+
└─ No → Does it represent an operation spanning multiple Aggregates?
|
|
100
|
+
├─ Yes → DOMAIN SERVICE
|
|
101
|
+
└─ No → Re-examine — it's probably one of the above
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A stateful multi-step flow is not a Domain Service — see "Long-Running
|
|
105
|
+
Processes".
|
|
106
|
+
|
|
107
|
+
## Aggregate Design
|
|
108
|
+
|
|
109
|
+
Aggregates are the most important and most commonly misunderstood DDD concept.
|
|
110
|
+
|
|
111
|
+
### What is an Aggregate?
|
|
112
|
+
|
|
113
|
+
A cluster of objects treated as a single unit for data changes. The Aggregate Root
|
|
114
|
+
is the only entry point — outside code cannot reach inside and modify child entities
|
|
115
|
+
or value objects directly.
|
|
116
|
+
|
|
117
|
+
### Aggregate Design Rules
|
|
118
|
+
|
|
119
|
+
1. **Protect invariants** — The Aggregate exists to enforce business rules that span
|
|
120
|
+
multiple objects within it.
|
|
121
|
+
|
|
122
|
+
2. **One Aggregate per transaction** — A single operation should modify only ONE
|
|
123
|
+
Aggregate. If you need to modify two Aggregates, use Domain Events for eventual
|
|
124
|
+
consistency.
|
|
125
|
+
|
|
126
|
+
3. **Reference other Aggregates by ID only** — Never hold a direct object reference
|
|
127
|
+
to another Aggregate. Store its ID instead.
|
|
128
|
+
|
|
129
|
+
4. **Keep them small** — Large Aggregates cause concurrency issues. If two users can
|
|
130
|
+
independently modify different parts, those parts should probably be separate Aggregates.
|
|
131
|
+
Watch the time axis too: a child collection that grows without bound over the
|
|
132
|
+
Aggregate's lifetime (audit trail, comments, history) makes every load heavier and
|
|
133
|
+
every save more contended — split it out and reference by ID.
|
|
134
|
+
|
|
135
|
+
### Example: Expense Report Aggregate
|
|
136
|
+
|
|
137
|
+
```csharp
|
|
138
|
+
// ExpenseReport is the Aggregate Root
|
|
139
|
+
public class ExpenseReport : AggregateRoot
|
|
140
|
+
{
|
|
141
|
+
private readonly List<ExpenseLineItem> _lineItems = new();
|
|
142
|
+
|
|
143
|
+
public EmployeeId SubmittedBy { get; private set; } // Reference by ID
|
|
144
|
+
public ReportPeriod Period { get; private set; } // Value Object
|
|
145
|
+
public Money TotalAmount => CalculateTotal(); // Derived
|
|
146
|
+
public ReportStatus Status { get; private set; } // Value Object (enum-like)
|
|
147
|
+
|
|
148
|
+
// State change through explicit methods — not property setters
|
|
149
|
+
public void AddLineItem(string description, Money amount, ExpenseCategory category)
|
|
150
|
+
{
|
|
151
|
+
// Enforce invariants
|
|
152
|
+
if (Status != ReportStatus.Draft)
|
|
153
|
+
throw new DomainException("Cannot add items to a submitted report.");
|
|
154
|
+
|
|
155
|
+
if (_lineItems.Count >= 50)
|
|
156
|
+
throw new DomainException("Maximum 50 line items per report.");
|
|
157
|
+
|
|
158
|
+
var lineItem = new ExpenseLineItem(description, amount, category);
|
|
159
|
+
_lineItems.Add(lineItem);
|
|
160
|
+
|
|
161
|
+
// Raise domain event
|
|
162
|
+
AddDomainEvent(new LineItemAddedEvent(Id, lineItem.Id, amount));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public void Submit()
|
|
166
|
+
{
|
|
167
|
+
if (Status != ReportStatus.Draft)
|
|
168
|
+
throw new DomainException("Only draft reports can be submitted.");
|
|
169
|
+
|
|
170
|
+
if (!_lineItems.Any())
|
|
171
|
+
throw new DomainException("Cannot submit an empty report.");
|
|
172
|
+
|
|
173
|
+
Status = ReportStatus.Submitted;
|
|
174
|
+
AddDomainEvent(new ExpenseReportSubmittedEvent(Id, SubmittedBy, TotalAmount));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Design Questions to Ask
|
|
180
|
+
|
|
181
|
+
When designing a new Aggregate:
|
|
182
|
+
|
|
183
|
+
1. **What are the invariants?** What business rules must ALWAYS be true?
|
|
184
|
+
(Classify each one — see "Invariant Classification" below.)
|
|
185
|
+
2. **What's the consistency boundary?** What must be updated atomically?
|
|
186
|
+
3. **What can change independently?** Separate Aggregates for separate concerns.
|
|
187
|
+
4. **Who modifies this?** How many concurrent users? (Affects Aggregate size)
|
|
188
|
+
5. **What events does this produce?** What other parts of the system need to know?
|
|
189
|
+
6. **Will any child collection grow without bound over time?** (history,
|
|
190
|
+
comments, audit entries) Unbounded growth is a split signal — move it to
|
|
191
|
+
its own Aggregate or a read model and reference by ID.
|
|
192
|
+
7. **What would make this boundary wrong?** Record the answer in the
|
|
193
|
+
worksheet's Design Decisions as a re-evaluation condition ("revisit
|
|
194
|
+
when …") — it is what "Revising an Established Model" re-reads later.
|
|
195
|
+
|
|
196
|
+
### Invariant Classification
|
|
197
|
+
|
|
198
|
+
Not every `if (…) throw` is an Aggregate invariant. Before adding a rule to an
|
|
199
|
+
Aggregate design, ask: **how many objects' state must you see at once to check
|
|
200
|
+
this rule?**
|
|
201
|
+
|
|
202
|
+
1. **Local constraint (VO / Entity invariant)** — checkable inside a single
|
|
203
|
+
object. Route it by nature:
|
|
204
|
+
- Pure input shape (required, format, max length) → request validation in
|
|
205
|
+
the Application layer (e.g. a Validator).
|
|
206
|
+
- Domain meaning, even within one object → enforce it in the Value Object /
|
|
207
|
+
Entity constructor or method. `DateRange` rejecting `Start > End` (see
|
|
208
|
+
the Value Objects section below) is a **domain invariant**, not input
|
|
209
|
+
validation — it lives in the VO so an invalid range can never exist.
|
|
210
|
+
2. **Aggregate boundary invariant** — requires the state of **multiple objects
|
|
211
|
+
inside one Aggregate instance** ("total across all line items must not
|
|
212
|
+
exceed the limit", "cannot submit an empty report"). These are the reason
|
|
213
|
+
the boundary exists — they are the main entries of an `aggregate-design.md`
|
|
214
|
+
Invariants table.
|
|
215
|
+
3. **Set-based invariant** — spans **multiple Aggregate instances** selected
|
|
216
|
+
by a key or status ("email unique across all Users", "one active session
|
|
217
|
+
per connector"). The Aggregate alone can never enforce it; list it tagged
|
|
218
|
+
`set-based` with its store-level guard named, and see "Set-Based /
|
|
219
|
+
Uniqueness Invariants" below.
|
|
220
|
+
|
|
221
|
+
Mis-filing level 1 rules into the Invariants table dilutes it — reviewers can
|
|
222
|
+
no longer see why the boundary exists. Missing the store-level guard on a
|
|
223
|
+
level 3 rule ships a race condition.
|
|
224
|
+
|
|
225
|
+
### Set-Based / Uniqueness Invariants
|
|
226
|
+
|
|
227
|
+
Some invariants are not about one Aggregate but about a **set selected by a
|
|
228
|
+
business key or status**: "email (normalized) is unique across all Users", "each
|
|
229
|
+
seat holds at most one active booking", "each connector has at most one
|
|
230
|
+
in-progress charging session". A single Aggregate instance cannot see the rest of
|
|
231
|
+
that set, so it cannot enforce the rule on its own.
|
|
232
|
+
|
|
233
|
+
Handle them the same way **regardless of which Aggregate boundary you choose**:
|
|
234
|
+
|
|
235
|
+
1. **As separate Aggregates** (e.g. `User` and `Booking` are distinct): the
|
|
236
|
+
Application layer *orchestrates* the check — via a repository query, a
|
|
237
|
+
Specification, or a domain service (the rule stays domain-named; it is not an
|
|
238
|
+
inline `if-else` in the command handler) — and the **database enforces it with
|
|
239
|
+
a unique / partial (filtered) unique index**. The DB constraint is the real
|
|
240
|
+
guarantee under concurrency; the orchestrated check just returns a friendlier
|
|
241
|
+
error first.
|
|
242
|
+
|
|
243
|
+
2. **Folded into one Aggregate** (e.g. the active session lives *inside* a
|
|
244
|
+
`Connector` as a child entity): the in-memory check (`if (Status == InUse)
|
|
245
|
+
throw …`) is logically correct, **but is still not concurrency-safe by
|
|
246
|
+
itself**. Two concurrent commands can each load the Aggregate, both pass the
|
|
247
|
+
check, and both save. Close the race with **optimistic concurrency** (a
|
|
248
|
+
`rowversion` / version token on the Aggregate root) or a DB constraint — but
|
|
249
|
+
the version check only protects you if every save actually touches the root's
|
|
250
|
+
token: inserting a child row without bumping the root's version leaves the
|
|
251
|
+
race open. Translate the resulting concurrency exception / unique violation
|
|
252
|
+
into a meaningful business conflict (e.g. HTTP 409), not a generic 500.
|
|
253
|
+
|
|
254
|
+
**Key point:** an in-memory check — at *any* layer — is never the final guarantee
|
|
255
|
+
for a uniqueness / "only one active X" rule under concurrent requests. The durable
|
|
256
|
+
enforcement is a DB unique / filtered index, an optimistic-concurrency token, or
|
|
257
|
+
an equivalent conditional write / compare-and-swap. Pick the Aggregate boundary on
|
|
258
|
+
modeling grounds (does the inner thing have an independent lifecycle / history
|
|
259
|
+
worth querying?), then add the store-level guard either way. (Heavier
|
|
260
|
+
serialization tactics — distributed locks, per-key actors, aggregate-per-key
|
|
261
|
+
sharding — exist but are advanced; reach for a store-level constraint or version
|
|
262
|
+
check first.)
|
|
263
|
+
|
|
264
|
+
## Revising an Established Model
|
|
265
|
+
|
|
266
|
+
The sections above are about getting a boundary right the first time. This
|
|
267
|
+
one is about the other half of a model's life: an established model whose
|
|
268
|
+
original decision was right — until the conditions changed. **A recorded
|
|
269
|
+
design decision is not settled law.** It is a decision *plus the conditions
|
|
270
|
+
under which it was right*; when those conditions expire, the decision is
|
|
271
|
+
due for review, not deference.
|
|
272
|
+
|
|
273
|
+
### The re-read rule
|
|
274
|
+
|
|
275
|
+
**When extending an existing Aggregate, re-read its recorded Design
|
|
276
|
+
Decisions before adding to it** (the `aggregate-design.md` worksheet;
|
|
277
|
+
Brownfield — see the Edition note). You are not reading for format — you
|
|
278
|
+
are checking two things:
|
|
279
|
+
|
|
280
|
+
1. Does any recorded **re-evaluation condition** ("revisit when …") match
|
|
281
|
+
the change in front of you?
|
|
282
|
+
2. Did the original rationale assume something that is no longer true?
|
|
283
|
+
|
|
284
|
+
### Signals that the model is resisting
|
|
285
|
+
|
|
286
|
+
Any of these appearing in your change is a signal — go to the ladder below:
|
|
287
|
+
|
|
288
|
+
- **Bending a field to fit** — a field that was previously required by the
|
|
289
|
+
entity's lifecycle is made nullable to fit a new case.
|
|
290
|
+
- **Discriminator creep** — adding a `Purpose` / `Type` discriminator so
|
|
291
|
+
one entity carries materially different lifecycles, required fields, or
|
|
292
|
+
rules.
|
|
293
|
+
- **Stacking another branch** — a third or later *distinct business branch*
|
|
294
|
+
on the same decision axis (validation / error variants don't count; the
|
|
295
|
+
count is an anchor, not an automatic refactor rule).
|
|
296
|
+
- **Qualifying a term to use it** — you keep saying "the Order here means
|
|
297
|
+
the cart-order"; one glossary term now covers two lifecycles.
|
|
298
|
+
- **A recorded re-evaluation condition matches** the current change.
|
|
299
|
+
- **Cross-instance transaction pressure** — a new invariant or operation
|
|
300
|
+
needs same-transaction writes across Aggregate instances because the
|
|
301
|
+
current boundary cannot own the rule (see Aggregate Design Rules #2; a
|
|
302
|
+
flow over *time* is a different topic — see "Long-Running Processes").
|
|
303
|
+
- **A child collection grows without bound** — the split signal from
|
|
304
|
+
Aggregate Design Rules #4 and Common Mistakes #7 applies to established
|
|
305
|
+
models too.
|
|
306
|
+
|
|
307
|
+
### What to do when a signal fires — take the lowest rung that fits
|
|
308
|
+
|
|
309
|
+
1. **Name it in the spec** (mandatory when a signal fires; zero design
|
|
310
|
+
cost). One short passage in the spec's design decisions / open
|
|
311
|
+
questions: which signal fired, the options, and the decision —
|
|
312
|
+
**proceed as-is, split, or rename — with the reason**. Deciding *not*
|
|
313
|
+
to split, recorded, is a perfectly good outcome when the rationale
|
|
314
|
+
still holds. What is not acceptable is extending the model as if the
|
|
315
|
+
question did not exist.
|
|
316
|
+
2. **Treat the revision as its own change** when the answer is "split" or
|
|
317
|
+
"rename": record it as tech debt or a follow-up feature — or, if the
|
|
318
|
+
feature cannot proceed sanely on the old boundary, make the split /
|
|
319
|
+
rename the feature's first phase (T1 ceremony; the glossary moves
|
|
320
|
+
first, RENAMED deltas, code follows).
|
|
321
|
+
|
|
322
|
+
Two guards, so this section cannot become its own kind of
|
|
323
|
+
over-engineering:
|
|
324
|
+
|
|
325
|
+
> Signals are the trigger, not a schedule. Do not re-litigate the model on
|
|
326
|
+
> every touch: no signal → no ceremony; one signal → name it; several
|
|
327
|
+
> signals, or a matched re-evaluation condition → evaluate seriously.
|
|
328
|
+
|
|
329
|
+
> A signal triggers **review, not redesign**. Do not split or rename just
|
|
330
|
+
> because a signal fired. A short recorded decision to keep the current
|
|
331
|
+
> model is a valid outcome when the rationale still holds.
|
|
332
|
+
|
|
333
|
+
This applies where a model exists to revise — core / supporting contexts.
|
|
334
|
+
A generic context's thin wrapper (see "Subdomain-Aware Modeling Depth")
|
|
335
|
+
has no worksheet and needs none of this ceremony.
|
|
336
|
+
|
|
337
|
+
## Value Objects
|
|
338
|
+
|
|
339
|
+
### When to Use Value Objects
|
|
340
|
+
|
|
341
|
+
If the answer to ALL of these is "yes", it's a Value Object:
|
|
342
|
+
- Is it defined by its properties, not by an ID?
|
|
343
|
+
- Is it immutable once created?
|
|
344
|
+
- Can two instances with the same properties be considered equal?
|
|
345
|
+
|
|
346
|
+
### Common Value Objects
|
|
347
|
+
|
|
348
|
+
```csharp
|
|
349
|
+
// Money — the classic example
|
|
350
|
+
public record Money(decimal Amount, Currency Currency)
|
|
351
|
+
{
|
|
352
|
+
public static Money Zero(Currency currency) => new(0, currency);
|
|
353
|
+
|
|
354
|
+
public Money Add(Money other)
|
|
355
|
+
{
|
|
356
|
+
if (Currency != other.Currency)
|
|
357
|
+
throw new CurrencyMismatchException(Currency, other.Currency);
|
|
358
|
+
return new Money(Amount + other.Amount, Currency);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
public Money ConvertTo(Currency target, ExchangeRate rate)
|
|
362
|
+
{
|
|
363
|
+
return new Money(rate.Convert(Amount), target);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// DateRange
|
|
368
|
+
public record DateRange
|
|
369
|
+
{
|
|
370
|
+
public DateOnly Start { get; }
|
|
371
|
+
public DateOnly End { get; }
|
|
372
|
+
|
|
373
|
+
public DateRange(DateOnly start, DateOnly end)
|
|
374
|
+
{
|
|
375
|
+
if (start > end) throw new DomainException("Start must be before End.");
|
|
376
|
+
Start = start;
|
|
377
|
+
End = end;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public bool Contains(DateOnly date) => date >= Start && date <= End;
|
|
381
|
+
public int Days => End.DayNumber - Start.DayNumber + 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Currency (constrained string)
|
|
385
|
+
public record Currency
|
|
386
|
+
{
|
|
387
|
+
public string Code { get; }
|
|
388
|
+
public int DecimalPlaces { get; }
|
|
389
|
+
|
|
390
|
+
public static readonly Currency TWD = new("TWD", 0);
|
|
391
|
+
public static readonly Currency USD = new("USD", 2);
|
|
392
|
+
public static readonly Currency JPY = new("JPY", 0);
|
|
393
|
+
|
|
394
|
+
private Currency(string code, int decimalPlaces)
|
|
395
|
+
{
|
|
396
|
+
Code = code;
|
|
397
|
+
DecimalPlaces = decimalPlaces;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
public decimal Round(decimal amount) =>
|
|
401
|
+
Math.Round(amount, DecimalPlaces, MidpointRounding.AwayFromZero);
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Value Object Design Questions
|
|
406
|
+
|
|
407
|
+
1. **Does it have behavior?** Good VOs have methods, not just properties.
|
|
408
|
+
2. **Does it enforce constraints?** Constructor should reject invalid states.
|
|
409
|
+
3. **Is it reusable?** `Money` can be used across many Aggregates.
|
|
410
|
+
|
|
411
|
+
### Strong-Typed IDs and Domain Primitives
|
|
412
|
+
|
|
413
|
+
**IDs should be strong-typed.** Use a typed id (`ExpenseReportId`, `EmployeeId`
|
|
414
|
+
— the examples above already do) instead of a raw `Guid` / `int` / `string`, so
|
|
415
|
+
the compiler rejects passing one Aggregate's id where another's is expected.
|
|
416
|
+
|
|
417
|
+
```csharp
|
|
418
|
+
// Raw: both are Guid — swap them and the compiler stays silent
|
|
419
|
+
void Transfer(Guid from, Guid to, Money amount)
|
|
420
|
+
|
|
421
|
+
// Typed: passing a CustomerId where an AccountId is expected won't compile
|
|
422
|
+
void Transfer(AccountId from, AccountId to, Money amount)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
A typed id is a **Value Object wrapping the identifier value** — a
|
|
426
|
+
value-by-identity special case — not the Entity it identifies. (So it does not
|
|
427
|
+
contradict the Pattern Selection flowchart's "identified by an ID → Entity":
|
|
428
|
+
that question is about the *concept*; this is about giving the *id value* a type.)
|
|
429
|
+
|
|
430
|
+
**For other primitives, wrap only when the value carries a rule** — a format
|
|
431
|
+
(`Email`), a unit (`Money`, `Weight`), a constrained comparison / value semantic
|
|
432
|
+
(`DateRange` with start ≤ end), or an **allowed value set / constrained
|
|
433
|
+
vocabulary / domain code** (the `Currency` constrained string and the
|
|
434
|
+
`ReportStatus` enum-like value above are exactly this). A plain rule-less
|
|
435
|
+
`string` / `int` should **not** be wrapped — that is over-engineering. The point
|
|
436
|
+
of wrapping a primitive is to **bind a rule to the type**; with no rule, there is
|
|
437
|
+
no reason to wrap.
|
|
438
|
+
|
|
439
|
+
## Domain Events
|
|
440
|
+
|
|
441
|
+
### What Are Domain Events?
|
|
442
|
+
|
|
443
|
+
Something that happened in the domain that other parts of the system care about.
|
|
444
|
+
Past tense naming: `ExpenseReportSubmitted`, `LineItemAdded`, `ReportApproved`.
|
|
445
|
+
|
|
446
|
+
> **Not event sourcing.** Dflow's Domain Events are state-change
|
|
447
|
+
> *notifications* — the Aggregate's persisted state stays the source of truth,
|
|
448
|
+
> and events are never replayed to rebuild it. Adopting event sourcing is a
|
|
449
|
+
> separate, heavyweight architecture decision: if genuinely needed, record it
|
|
450
|
+
> as an ADR (`dflow/specs/architecture/decisions/`; Brownfield — see the
|
|
451
|
+
> Edition note); never introduce it as a side effect of modeling.
|
|
452
|
+
|
|
453
|
+
### When to Use Domain Events
|
|
454
|
+
|
|
455
|
+
- When one Aggregate needs to trigger changes in another Aggregate
|
|
456
|
+
- When side effects (email, notification, audit log) should happen after a domain action
|
|
457
|
+
- When different Bounded Contexts need to communicate
|
|
458
|
+
|
|
459
|
+
### Event Design
|
|
460
|
+
|
|
461
|
+
```csharp
|
|
462
|
+
public record ExpenseReportSubmittedEvent(
|
|
463
|
+
ExpenseReportId ReportId,
|
|
464
|
+
EmployeeId SubmittedBy,
|
|
465
|
+
Money TotalAmount
|
|
466
|
+
) : IDomainEvent;
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Payload Guideline
|
|
470
|
+
|
|
471
|
+
Default to a **thin payload**: the IDs involved, the fact that happened, and
|
|
472
|
+
the values that changed — not a snapshot of the whole Aggregate.
|
|
473
|
+
|
|
474
|
+
- Same-context consumers that need more state should load it by ID — a fat
|
|
475
|
+
payload is a stale copy the moment it is published.
|
|
476
|
+
- Across Bounded Contexts, every field you publish is something downstream may
|
|
477
|
+
start depending on — a fat payload becomes an implicit contract. If a
|
|
478
|
+
consumer genuinely needs state transfer, make that an explicit **integration
|
|
479
|
+
event** decision (record the Delivery expectation in `events.md`; Brownfield —
|
|
480
|
+
see the Edition note) and treat
|
|
481
|
+
the payload as a published contract: add fields if you must, never change
|
|
482
|
+
their meaning. Ceremony-wise, extending an event payload that crosses
|
|
483
|
+
contexts is a T1 contract change (see the ceremony examples in
|
|
484
|
+
`_conventions.md`).
|
|
485
|
+
|
|
486
|
+
### Event Flow
|
|
487
|
+
|
|
488
|
+
```
|
|
489
|
+
1. Aggregate method called → state changes → event added to DomainEvents list
|
|
490
|
+
2. Repository saves Aggregate
|
|
491
|
+
3. After save (in same transaction or via outbox):
|
|
492
|
+
- In-process handlers: update read models, trigger other commands
|
|
493
|
+
- Cross-context: publish to message queue
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Event Handling Guidelines
|
|
497
|
+
|
|
498
|
+
- **Same Bounded Context**: Handle synchronously (same transaction OK for read models)
|
|
499
|
+
- **Cross Bounded Context**: Handle asynchronously (eventual consistency)
|
|
500
|
+
- Event handlers should be idempotent (safe to process multiple times)
|
|
501
|
+
- **The failure path is a business scenario, not a technical detail**: when a
|
|
502
|
+
cross-Aggregate chain is async (eventually consistent) **and** a handler's
|
|
503
|
+
final failure — after retries are exhausted — would be business-visible
|
|
504
|
+
(compensation, customer entitlements, money, inventory, compliance, manual
|
|
505
|
+
reconciliation), write that outcome as a `BR-*` with its own Given/When/Then
|
|
506
|
+
in `behavior.md` and list the failure as an `EC-*` edge case in the spec —
|
|
507
|
+
even if the documented answer is "ops reconciles manually". Best-effort side
|
|
508
|
+
effects (notification email, logging) don't need a BR; a line in `events.md`
|
|
509
|
+
Event Flow Notes or a tech-debt entry is enough.
|
|
510
|
+
- **Dispatch / clear lifecycle**: clear `DomainEvents` at the Repository / Unit
|
|
511
|
+
of Work **save boundary** — the UoW clears them *after* the save succeeds and
|
|
512
|
+
the events have been dispatched (or the outbox row persisted, or dispatch
|
|
513
|
+
explicitly decided to be dropped/deferred). Never clear inside an Aggregate
|
|
514
|
+
method, and never before the save succeeds. A simple in-process dispatcher
|
|
515
|
+
(e.g. `IPublisher` / MediatR) is enough for Phase 1; an **outbox /
|
|
516
|
+
integration-event bridge is the Phase 2+ upgrade** for reliable cross-service
|
|
517
|
+
delivery. If no dispatcher is wired yet, record it as deferred tech debt — but
|
|
518
|
+
still clear on a successful save so events cannot accumulate unbounded.
|
|
519
|
+
|
|
520
|
+
## Long-Running Processes
|
|
521
|
+
|
|
522
|
+
The Aggregate design rules above already cover the simple case: one Aggregate
|
|
523
|
+
per transaction, with cross-Aggregate work flowing through Domain Events and
|
|
524
|
+
eventual consistency. This section is for the flows where that is not the
|
|
525
|
+
whole story — where the multi-step flow is itself a business thing with
|
|
526
|
+
state, and that state needs a home.
|
|
527
|
+
|
|
528
|
+
### Process or event chain?
|
|
529
|
+
|
|
530
|
+
Use an event chain when each reaction can succeed or fail independently — a
|
|
531
|
+
later failure does not change the earlier domain commitment. Treat the flow
|
|
532
|
+
as a **process** when the business must track and decide the multi-step
|
|
533
|
+
**outcome**: compensate or reverse an earlier commitment, answer current
|
|
534
|
+
progress, enforce a deadline, or enforce an ordered cross-Aggregate workflow
|
|
535
|
+
whose intermediate state matters.
|
|
536
|
+
|
|
537
|
+
The quickest entry test: **if a later step fails, must an earlier step be
|
|
538
|
+
undone?** "Payment failed → release the reserved stock" is a process, not an
|
|
539
|
+
event chain.
|
|
540
|
+
|
|
541
|
+
Further signals that the flow is a process:
|
|
542
|
+
|
|
543
|
+
- The business asks "where is order #123 in the flow?" and no single
|
|
544
|
+
Aggregate can answer.
|
|
545
|
+
- A deadline is part of the rules ("if payment is not confirmed within 30
|
|
546
|
+
minutes, release the seats").
|
|
547
|
+
- Steps across Aggregates must run in a prescribed order **and** the
|
|
548
|
+
intermediate state matters to the business — being ordered by itself is
|
|
549
|
+
not enough.
|
|
550
|
+
|
|
551
|
+
**Not a process:** fire-and-forget notifications, read-model updates,
|
|
552
|
+
logging, and other best-effort side effects — even when ordered, even when
|
|
553
|
+
they cross Aggregates. Those remain plain event chains; their failure
|
|
554
|
+
handling is already covered by the failure-path guideline under Event
|
|
555
|
+
Handling Guidelines.
|
|
556
|
+
|
|
557
|
+
### Where does the process state live? Take the lowest rung that fits
|
|
558
|
+
|
|
559
|
+
1. **A status field on the Aggregate that owns the flow** — the first choice
|
|
560
|
+
when the process is naturally part of one Aggregate's lifecycle:
|
|
561
|
+
`Order.Status = PendingPayment → Paid → Shipped`. Event handlers advance
|
|
562
|
+
the status; each compensation is an explicit state-transition method
|
|
563
|
+
(`order.Cancel(reason)`) with its own BR. Most mid-size flows stop here.
|
|
564
|
+
The boundary: a status field is enough only when the process is naturally
|
|
565
|
+
part of that Aggregate's lifecycle — do not store every downstream
|
|
566
|
+
system's bookkeeping on the owner just to avoid a process Aggregate.
|
|
567
|
+
|
|
568
|
+
2. **A dedicated process Aggregate** (the DDD shape of a *process manager*) —
|
|
569
|
+
when the coordination belongs to no existing Aggregate, or the flow needs
|
|
570
|
+
its own bookkeeping (steps completed, retries, deadline): a small
|
|
571
|
+
Aggregate (e.g. `OrderFulfillment`) whose state *is* the flow's progress.
|
|
572
|
+
It reacts to events, issues commands, and its invariants are process
|
|
573
|
+
rules ("cannot ship before payment is confirmed"). It is an ordinary
|
|
574
|
+
Aggregate — same aggregate-design worksheet, same Invariants table,
|
|
575
|
+
events cataloged in `events.md` like any other (Brownfield — see the
|
|
576
|
+
Edition note). Two boundaries: a process Aggregate **coordinates
|
|
577
|
+
progress** — it does not pull the participating Aggregates into one
|
|
578
|
+
transaction or take over their invariants. And do not create one for a
|
|
579
|
+
two-step event reaction with no compensation, no deadline, and no
|
|
580
|
+
business-visible progress to answer — keep that as an event chain, or as
|
|
581
|
+
the owner Aggregate's normal state if it already has one.
|
|
582
|
+
|
|
583
|
+
3. **An orchestration framework / workflow engine** — the Phase 2+ upgrade,
|
|
584
|
+
worth it only at operational scale (versioning long-lived in-flight
|
|
585
|
+
flows, visibility dashboards). Like event sourcing, this is a separate,
|
|
586
|
+
heavyweight architecture decision: record it as an ADR if genuinely
|
|
587
|
+
needed; never introduce it as a side effect of modeling.
|
|
588
|
+
|
|
589
|
+
### Compensation is business behavior, not plumbing
|
|
590
|
+
|
|
591
|
+
A compensating action is not a rollback — the mail was sent, the money
|
|
592
|
+
moved; they cannot un-happen. Compensation is a **new domain fact**
|
|
593
|
+
(`RefundIssued`, `ReservationReleased`) with its own BR and Given/When/Then
|
|
594
|
+
in `behavior.md`. The failure-path guideline under Event Handling Guidelines
|
|
595
|
+
says *which* final failures must become BRs; this section says *where the
|
|
596
|
+
logic that answers them lives*.
|
|
597
|
+
|
|
598
|
+
### Deadlines
|
|
599
|
+
|
|
600
|
+
A deadline is part of the process state. Detecting expiry is infrastructure
|
|
601
|
+
(a scheduled check), but the decision — "expired → release the seats" — is a
|
|
602
|
+
domain rule, expressed as a domain event (`BookingExpired`) and handled like
|
|
603
|
+
any other.
|
|
604
|
+
|
|
605
|
+
Two search terms, so you can find the literature: *choreography* is a plain
|
|
606
|
+
event chain; *orchestration* means an explicit process owner. These are
|
|
607
|
+
search terms, not a framework choice.
|
|
608
|
+
|
|
609
|
+
## Specifications
|
|
610
|
+
|
|
611
|
+
For complex query logic that belongs to the domain:
|
|
612
|
+
|
|
613
|
+
```csharp
|
|
614
|
+
public class PendingApprovalSpec : Specification<ExpenseReport>
|
|
615
|
+
{
|
|
616
|
+
private readonly EmployeeId _approverId;
|
|
617
|
+
|
|
618
|
+
public PendingApprovalSpec(EmployeeId approverId)
|
|
619
|
+
{
|
|
620
|
+
_approverId = approverId;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
public override Expression<Func<ExpenseReport, bool>> ToExpression()
|
|
624
|
+
{
|
|
625
|
+
return report =>
|
|
626
|
+
report.Status == ReportStatus.Submitted &&
|
|
627
|
+
report.ApproverId == _approverId;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Read Models (Query Side)
|
|
633
|
+
|
|
634
|
+
The **write side** goes through an Aggregate (to protect invariants). The
|
|
635
|
+
**read side** usually should not. Lists, reports, dashboards, dropdowns — work
|
|
636
|
+
that only *displays* data — can query a denormalized projection / DTO directly,
|
|
637
|
+
without loading an Aggregate and without going through the Domain layer.
|
|
638
|
+
|
|
639
|
+
```csharp
|
|
640
|
+
// Don't: load 50 full Order Aggregates (each pulling line items, status
|
|
641
|
+
// history, …) just to render an order list → object-graph bloat, N+1
|
|
642
|
+
var orders = orderRepository.GetAll();
|
|
643
|
+
|
|
644
|
+
// Do: project only the columns the screen needs straight into a DTO
|
|
645
|
+
// SELECT OrderNumber, CustomerName, Total, OrderDate FROM Orders
|
|
646
|
+
// → IReadOnlyList<OrderListItemDto>
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**When to still go through the Aggregate**: when you read in order to *change*
|
|
650
|
+
(read-modify-write). "Cancel order #123" must enforce "cannot cancel a shipped
|
|
651
|
+
order" → load the `Order` Aggregate, call `order.Cancel()`, save. Pure display →
|
|
652
|
+
read model; about to mutate → Aggregate.
|
|
653
|
+
|
|
654
|
+
**Read Model vs Specification**: a `Specification` is a domain predicate /
|
|
655
|
+
write-side decision — it carries a rule (see "Specifications" above). A read
|
|
656
|
+
model is a presentation / reporting query shape that carries **no** invariant.
|
|
657
|
+
Don't conflate them.
|
|
658
|
+
|
|
659
|
+
**Keep it simple.** This is the query side of CQRS — it does **not** require
|
|
660
|
+
event sourcing or a separate database. Two common shapes:
|
|
661
|
+
|
|
662
|
+
- A **live query DTO against the same database** — always fresh; the simplest
|
|
663
|
+
and most common read model.
|
|
664
|
+
- An **event-updated denormalized table** — can be **stale**. If that staleness
|
|
665
|
+
is user-visible (e.g. a just-placed order missing from the list for a moment),
|
|
666
|
+
say so in `behavior.md` / an edge case / a design decision. This is the same
|
|
667
|
+
mechanism as the "same-transaction read model update" note under Event
|
|
668
|
+
Handling Guidelines.
|
|
669
|
+
|
|
670
|
+
A read model is a *simplification* of the read path, not an extra layer to
|
|
671
|
+
build. (Read model ≠ event sourcing.)
|
|
672
|
+
|
|
673
|
+
## Domain Services
|
|
674
|
+
|
|
675
|
+
Use Domain Services for operations that:
|
|
676
|
+
- Involve multiple Aggregates (read-only access to the second Aggregate)
|
|
677
|
+
- Require external information (through interfaces) to make domain decisions
|
|
678
|
+
- Don't naturally belong to any single Entity
|
|
679
|
+
|
|
680
|
+
A Domain Service can make a **stateless** cross-Aggregate decision. It is
|
|
681
|
+
not a home for process progress, retries, deadlines, or compensation state.
|
|
682
|
+
When the flow has state, use the "Long-Running Processes" ladder.
|
|
683
|
+
|
|
684
|
+
```csharp
|
|
685
|
+
// Domain Service — in Domain layer
|
|
686
|
+
public class ExpenseApprovalService
|
|
687
|
+
{
|
|
688
|
+
private readonly IApprovalPolicyRepository _policyRepo;
|
|
689
|
+
|
|
690
|
+
public ApprovalResult Evaluate(ExpenseReport report, ApprovalPolicy policy)
|
|
691
|
+
{
|
|
692
|
+
if (report.TotalAmount.Amount > policy.AutoApprovalLimit)
|
|
693
|
+
return ApprovalResult.RequiresManagerApproval;
|
|
694
|
+
|
|
695
|
+
if (policy.RestrictedCategories.Overlaps(report.Categories))
|
|
696
|
+
return ApprovalResult.RequiresComplianceReview;
|
|
697
|
+
|
|
698
|
+
return ApprovalResult.AutoApproved;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
## Factories
|
|
704
|
+
|
|
705
|
+
When *creating* an object is itself complex — many parts to assemble, an
|
|
706
|
+
implementation type to choose, or creation rules that span several objects —
|
|
707
|
+
don't cram it into the constructor. Pick the lightest tool that fits:
|
|
708
|
+
|
|
709
|
+
- **Simple construction → the constructor.** A `new` whose constructor enforces
|
|
710
|
+
the object's own invariants is enough. Don't add a factory just to have one.
|
|
711
|
+
- **Creating a child entity / needs the Aggregate's internal state → a factory
|
|
712
|
+
method on the Aggregate Root** (e.g. `order.AddLine(...)` — the root stays in
|
|
713
|
+
control of its invariants).
|
|
714
|
+
- **Complex creation across several objects / choosing an implementation type →
|
|
715
|
+
a standalone Factory** (or a Domain Service).
|
|
716
|
+
|
|
717
|
+
### Reconstitution ≠ creation
|
|
718
|
+
|
|
719
|
+
The blind spot is the other half of an object's lifecycle: **rebuilding an
|
|
720
|
+
existing object from persistence is not creating a new one.** Reconstitution
|
|
721
|
+
must **not** run the creation workflow, **not** re-raise creation events, and
|
|
722
|
+
**not** reject old data just because a *new* creation rule was added later.
|
|
723
|
+
|
|
724
|
+
```csharp
|
|
725
|
+
// Creation: this Order comes into existence for the first time
|
|
726
|
+
var order = Order.Create(customerId, items);
|
|
727
|
+
// → assigns a new OrderId, checks "at least one line",
|
|
728
|
+
// raises OrderPlaced (which sends mail, decrements stock)
|
|
729
|
+
|
|
730
|
+
// Reconstitution: the same Order loaded back next week
|
|
731
|
+
var order = repository.GetById(orderId);
|
|
732
|
+
// → must NOT raise OrderPlaced again (mail re-sent, stock re-decremented)
|
|
733
|
+
// → must NOT be rejected by a creation rule added after it was placed
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
ORMs reconstitute through a backing / `private` constructor; reserve the
|
|
737
|
+
`Create(...)` factory path for genuinely new instances.
|
|
738
|
+
|
|
739
|
+
**But "reconstitution validates nothing" is the wrong reading.** Structural /
|
|
740
|
+
version-compatibility invariants can and should still be checked on hydration;
|
|
741
|
+
genuinely corrupt or legacy-incompatible data should be routed to migration or
|
|
742
|
+
quarantine, not silently accepted. What you drop is *re-running the creation
|
|
743
|
+
workflow and its side effects* — not *integrity*.
|
|
744
|
+
|
|
745
|
+
## Bounded Context Relationships
|
|
746
|
+
|
|
747
|
+
If `dflow/specs/domain/context-map.md` does not exist yet, create it from `templates/context-map.md` before documenting relationships.
|
|
748
|
+
|
|
749
|
+
Document in `dflow/specs/domain/context-map.md`:
|
|
750
|
+
|
|
751
|
+
| Relationship | Pattern | Example |
|
|
752
|
+
|---|---|---|
|
|
753
|
+
| Context A calls Context B | Customer-Supplier | Expense → HR (get employee info) |
|
|
754
|
+
| Contexts share data | Shared Kernel | Currency, Money in SharedKernel/ |
|
|
755
|
+
| Context A translates B's language | Anti-Corruption Layer | Expense → External Accounting System |
|
|
756
|
+
| Fire and forget | Domain Events | Expense → Notification (report submitted) |
|
|
757
|
+
| Two contexts have no compelling reason to integrate | Separate Ways | Shipping vs Marketing each keep their own product fields |
|
|
758
|
+
| Integrating with an unmodeled legacy region | Big Ball of Mud (+ ACL) | a new Order context wrapping a legacy accounting module |
|
|
759
|
+
|
|
760
|
+
The less-obvious three — when to reach for them:
|
|
761
|
+
|
|
762
|
+
- **Separate Ways** — when integration cost > duplication cost **and** the "shared"
|
|
763
|
+
concept isn't actually the same thing in both contexts: don't build an ACL or a
|
|
764
|
+
Shared Kernel, just duplicate the small overlap; revisit only if the duplication
|
|
765
|
+
painfully diverges later. (Counters "integrate because DDD".) **Do NOT** use
|
|
766
|
+
Separate Ways when one context genuinely needs the other's authoritative
|
|
767
|
+
lifecycle, fresh state, workflow coordination, or invariant protection — that is
|
|
768
|
+
required integration, not a duplication candidate.
|
|
769
|
+
- **Big Ball of Mud (+ ACL)** — when a region has no coherent model (often legacy)
|
|
770
|
+
and you must integrate with it: mark it `BBoM` on the context map, wrap it behind
|
|
771
|
+
an Anti-Corruption Layer, and translate at the boundary so its (lack of) structure
|
|
772
|
+
can't leak into your clean contexts — don't try to model the mud in place. In
|
|
773
|
+
Brownfield extraction, the legacy you are pulling away from is usually this.
|
|
774
|
+
- **Open Host Service (OHS)** — when **many** downstream contexts need the same
|
|
775
|
+
integration from one upstream, publish a single Open Host Service with a Published
|
|
776
|
+
Language (a documented, stable protocol) instead of each consumer integrating
|
|
777
|
+
separately. OHS standardizes the upstream's published surface; it does **not**
|
|
778
|
+
remove a consumer-side ACL where that consumer still needs local translation.
|
|
779
|
+
|
|
780
|
+
## Common Mistakes to Catch
|
|
781
|
+
|
|
782
|
+
1. **Anemic Domain Model** — Entities with only getters/setters, all logic in services
|
|
783
|
+
→ Move behavior INTO the Entity/Aggregate
|
|
784
|
+
|
|
785
|
+
2. **Too-large Aggregates** — Aggregate that loads entire object graph
|
|
786
|
+
→ Split into smaller Aggregates, reference by ID
|
|
787
|
+
|
|
788
|
+
3. **Business logic in Application layer** — If-else rules in command handlers
|
|
789
|
+
→ Move to Domain (Entity methods or Domain Services)
|
|
790
|
+
|
|
791
|
+
4. **Business logic in Infrastructure** — Rules in SQL queries or EF configurations
|
|
792
|
+
→ Domain defines WHAT, Infrastructure defines HOW
|
|
793
|
+
|
|
794
|
+
5. **Direct cross-Aggregate modification** — One command modifying two Aggregates
|
|
795
|
+
→ Use Domain Events for the second Aggregate
|
|
796
|
+
|
|
797
|
+
6. **Set-based invariant guarded only in memory** — A "unique" / "only one active"
|
|
798
|
+
rule enforced solely by an in-app check, with no DB unique constraint or
|
|
799
|
+
optimistic-concurrency token → two concurrent requests can both pass and break it
|
|
800
|
+
→ Back it with a DB constraint / `rowversion`; see "Set-Based / Uniqueness Invariants"
|
|
801
|
+
|
|
802
|
+
7. **Unbounded collection inside an Aggregate** — A child list that grows forever
|
|
803
|
+
(history, comments, audit trail) makes every load heavier and every save more
|
|
804
|
+
contended → Split it into its own Aggregate or a read model, reference by ID
|
|
805
|
+
|
|
806
|
+
8. **Fat event payload as implicit contract** — Publishing whole-Aggregate
|
|
807
|
+
snapshots couples consumers to your internal shape and hands them stale data
|
|
808
|
+
→ Default to IDs + event facts + changed values; cross-context state transfer
|
|
809
|
+
must be an explicit integration-event contract (see "Payload Guideline")
|
|
810
|
+
|
|
811
|
+
9. **Creation validation / events re-run on reconstitution** — Loading an
|
|
812
|
+
existing Aggregate from the database through the creation path re-raises
|
|
813
|
+
creation events (re-sends mail, re-decrements stock) or rejects valid old data
|
|
814
|
+
by a newer creation rule → Reconstitute through a backing constructor; keep
|
|
815
|
+
integrity checks but not the creation workflow; see "Reconstitution ≠ creation"
|
|
816
|
+
|
|
817
|
+
10. **Forcing every query through repository + Aggregate** — Loading full
|
|
818
|
+
Aggregates just to display a list / report causes object-graph bloat and N+1
|
|
819
|
+
→ Use a read model (denormalized projection / DTO) for the display path; see
|
|
820
|
+
"Read Models (Query Side)"
|
|
821
|
+
|
|
822
|
+
11. **Compensation logic scattered across event handlers** — Each handler
|
|
823
|
+
patches state on its own; no one owns the process, and nobody can answer
|
|
824
|
+
"where is order #123 in the flow"
|
|
825
|
+
→ Give the process a home (a status field on the owning Aggregate or a
|
|
826
|
+
dedicated process Aggregate); write each compensating action as a BR; see
|
|
827
|
+
"Long-Running Processes"
|
|
828
|
+
|
|
829
|
+
12. **Treating recorded design decisions as settled law** — Extending an
|
|
830
|
+
Aggregate while bending it to fit (a lifecycle-required field turned
|
|
831
|
+
nullable for a new case, a `Purpose` / `Type` discriminator making one
|
|
832
|
+
entity carry materially different lifecycles or rules), never re-reading
|
|
833
|
+
the Design Decisions whose re-evaluation condition the change just
|
|
834
|
+
triggered
|
|
835
|
+
→ Re-read recorded decisions when extending an existing Aggregate; when a
|
|
836
|
+
resistance signal fires, name the revision question in the spec (proceed /
|
|
837
|
+
split / rename, with reason); see "Revising an Established Model"
|