dflow-sdd-ddd 0.9.0 → 0.11.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 +96 -0
- package/README.en.md +73 -48
- package/README.md +46 -36
- package/TEMPLATE-COVERAGE.md +0 -1
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +1 -0
- package/bin/dflow.js +7 -11
- package/docs/evaluating-dflow.en.md +11 -7
- package/docs/evaluating-dflow.md +9 -4
- package/docs/using-with-claude-code.en.md +40 -23
- package/docs/using-with-claude-code.md +34 -23
- package/docs/using-with-codex.en.md +125 -42
- package/docs/using-with-codex.md +93 -34
- package/docs/using-with-github-copilot.en.md +135 -34
- package/docs/using-with-github-copilot.md +120 -43
- package/docs/why-dflow.en.md +72 -0
- package/docs/why-dflow.md +72 -0
- package/lib/init.js +867 -214
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +41 -10
- package/templates/brownfield/references/finish-feature-flow.md +3 -2
- package/templates/brownfield/references/git-integration.md +0 -1
- package/templates/brownfield/references/init-project-flow.md +31 -17
- package/templates/brownfield/references/modify-existing-flow.md +44 -38
- package/templates/brownfield/references/new-feature-flow.md +41 -11
- package/templates/brownfield/references/new-phase-flow.md +9 -2
- package/templates/brownfield/references/pr-review-checklist.md +7 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +258 -29
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +5 -8
- package/templates/brownfield/scaffolding/_conventions.md +10 -9
- package/templates/brownfield/templates/_index.md +1 -1
- package/templates/brownfield/templates/context-map.md +12 -4
- package/templates/brownfield/templates/lightweight-spec.md +1 -1
- package/templates/brownfield/templates/phase-spec.md +1 -1
- package/templates/common/references/ddd-modeling-guide.md +643 -0
- package/templates/common/skill/SKILL.md +9 -6
- package/templates/greenfield/references/drift-verification.md +60 -15
- package/templates/greenfield/references/finish-feature-flow.md +3 -2
- package/templates/greenfield/references/git-integration.md +0 -1
- package/templates/greenfield/references/init-project-flow.md +31 -17
- package/templates/greenfield/references/modify-existing-flow.md +5 -7
- package/templates/greenfield/references/new-feature-flow.md +49 -19
- package/templates/greenfield/references/new-phase-flow.md +5 -2
- package/templates/greenfield/references/pr-review-checklist.md +9 -1
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +221 -29
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +10 -15
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +1 -1
- package/templates/greenfield/scaffolding/_conventions.md +9 -8
- package/templates/greenfield/templates/_index.md +1 -1
- package/templates/greenfield/templates/aggregate-design.md +6 -0
- package/templates/greenfield/templates/context-map.md +13 -4
- package/templates/greenfield/templates/events.md +4 -1
- package/templates/greenfield/templates/lightweight-spec.md +1 -1
- package/templates/greenfield/templates/phase-spec.md +1 -1
- package/docs/migrating-to-dflow-v1.md +0 -230
- package/templates/brownfield/templates/CLAUDE.md +0 -165
- package/templates/greenfield/references/ddd-modeling-guide.md +0 -351
- package/templates/greenfield/templates/CLAUDE.md +0 -172
|
@@ -0,0 +1,643 @@
|
|
|
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
|
+
## Aggregate Design
|
|
105
|
+
|
|
106
|
+
Aggregates are the most important and most commonly misunderstood DDD concept.
|
|
107
|
+
|
|
108
|
+
### What is an Aggregate?
|
|
109
|
+
|
|
110
|
+
A cluster of objects treated as a single unit for data changes. The Aggregate Root
|
|
111
|
+
is the only entry point — outside code cannot reach inside and modify child entities
|
|
112
|
+
or value objects directly.
|
|
113
|
+
|
|
114
|
+
### Aggregate Design Rules
|
|
115
|
+
|
|
116
|
+
1. **Protect invariants** — The Aggregate exists to enforce business rules that span
|
|
117
|
+
multiple objects within it.
|
|
118
|
+
|
|
119
|
+
2. **One Aggregate per transaction** — A single operation should modify only ONE
|
|
120
|
+
Aggregate. If you need to modify two Aggregates, use Domain Events for eventual
|
|
121
|
+
consistency.
|
|
122
|
+
|
|
123
|
+
3. **Reference other Aggregates by ID only** — Never hold a direct object reference
|
|
124
|
+
to another Aggregate. Store its ID instead.
|
|
125
|
+
|
|
126
|
+
4. **Keep them small** — Large Aggregates cause concurrency issues. If two users can
|
|
127
|
+
independently modify different parts, those parts should probably be separate Aggregates.
|
|
128
|
+
Watch the time axis too: a child collection that grows without bound over the
|
|
129
|
+
Aggregate's lifetime (audit trail, comments, history) makes every load heavier and
|
|
130
|
+
every save more contended — split it out and reference by ID.
|
|
131
|
+
|
|
132
|
+
### Example: Expense Report Aggregate
|
|
133
|
+
|
|
134
|
+
```csharp
|
|
135
|
+
// ExpenseReport is the Aggregate Root
|
|
136
|
+
public class ExpenseReport : AggregateRoot
|
|
137
|
+
{
|
|
138
|
+
private readonly List<ExpenseLineItem> _lineItems = new();
|
|
139
|
+
|
|
140
|
+
public EmployeeId SubmittedBy { get; private set; } // Reference by ID
|
|
141
|
+
public ReportPeriod Period { get; private set; } // Value Object
|
|
142
|
+
public Money TotalAmount => CalculateTotal(); // Derived
|
|
143
|
+
public ReportStatus Status { get; private set; } // Value Object (enum-like)
|
|
144
|
+
|
|
145
|
+
// State change through explicit methods — not property setters
|
|
146
|
+
public void AddLineItem(string description, Money amount, ExpenseCategory category)
|
|
147
|
+
{
|
|
148
|
+
// Enforce invariants
|
|
149
|
+
if (Status != ReportStatus.Draft)
|
|
150
|
+
throw new DomainException("Cannot add items to a submitted report.");
|
|
151
|
+
|
|
152
|
+
if (_lineItems.Count >= 50)
|
|
153
|
+
throw new DomainException("Maximum 50 line items per report.");
|
|
154
|
+
|
|
155
|
+
var lineItem = new ExpenseLineItem(description, amount, category);
|
|
156
|
+
_lineItems.Add(lineItem);
|
|
157
|
+
|
|
158
|
+
// Raise domain event
|
|
159
|
+
AddDomainEvent(new LineItemAddedEvent(Id, lineItem.Id, amount));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public void Submit()
|
|
163
|
+
{
|
|
164
|
+
if (Status != ReportStatus.Draft)
|
|
165
|
+
throw new DomainException("Only draft reports can be submitted.");
|
|
166
|
+
|
|
167
|
+
if (!_lineItems.Any())
|
|
168
|
+
throw new DomainException("Cannot submit an empty report.");
|
|
169
|
+
|
|
170
|
+
Status = ReportStatus.Submitted;
|
|
171
|
+
AddDomainEvent(new ExpenseReportSubmittedEvent(Id, SubmittedBy, TotalAmount));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Design Questions to Ask
|
|
177
|
+
|
|
178
|
+
When designing a new Aggregate:
|
|
179
|
+
|
|
180
|
+
1. **What are the invariants?** What business rules must ALWAYS be true?
|
|
181
|
+
(Classify each one — see "Invariant Classification" below.)
|
|
182
|
+
2. **What's the consistency boundary?** What must be updated atomically?
|
|
183
|
+
3. **What can change independently?** Separate Aggregates for separate concerns.
|
|
184
|
+
4. **Who modifies this?** How many concurrent users? (Affects Aggregate size)
|
|
185
|
+
5. **What events does this produce?** What other parts of the system need to know?
|
|
186
|
+
6. **Will any child collection grow without bound over time?** (history,
|
|
187
|
+
comments, audit entries) Unbounded growth is a split signal — move it to
|
|
188
|
+
its own Aggregate or a read model and reference by ID.
|
|
189
|
+
|
|
190
|
+
### Invariant Classification
|
|
191
|
+
|
|
192
|
+
Not every `if (…) throw` is an Aggregate invariant. Before adding a rule to an
|
|
193
|
+
Aggregate design, ask: **how many objects' state must you see at once to check
|
|
194
|
+
this rule?**
|
|
195
|
+
|
|
196
|
+
1. **Local constraint (VO / Entity invariant)** — checkable inside a single
|
|
197
|
+
object. Route it by nature:
|
|
198
|
+
- Pure input shape (required, format, max length) → request validation in
|
|
199
|
+
the Application layer (e.g. a Validator).
|
|
200
|
+
- Domain meaning, even within one object → enforce it in the Value Object /
|
|
201
|
+
Entity constructor or method. `DateRange` rejecting `Start > End` (see
|
|
202
|
+
the Value Objects section below) is a **domain invariant**, not input
|
|
203
|
+
validation — it lives in the VO so an invalid range can never exist.
|
|
204
|
+
2. **Aggregate boundary invariant** — requires the state of **multiple objects
|
|
205
|
+
inside one Aggregate instance** ("total across all line items must not
|
|
206
|
+
exceed the limit", "cannot submit an empty report"). These are the reason
|
|
207
|
+
the boundary exists — they are the main entries of an `aggregate-design.md`
|
|
208
|
+
Invariants table.
|
|
209
|
+
3. **Set-based invariant** — spans **multiple Aggregate instances** selected
|
|
210
|
+
by a key or status ("email unique across all Users", "one active session
|
|
211
|
+
per connector"). The Aggregate alone can never enforce it; list it tagged
|
|
212
|
+
`set-based` with its store-level guard named, and see "Set-Based /
|
|
213
|
+
Uniqueness Invariants" below.
|
|
214
|
+
|
|
215
|
+
Mis-filing level 1 rules into the Invariants table dilutes it — reviewers can
|
|
216
|
+
no longer see why the boundary exists. Missing the store-level guard on a
|
|
217
|
+
level 3 rule ships a race condition.
|
|
218
|
+
|
|
219
|
+
### Set-Based / Uniqueness Invariants
|
|
220
|
+
|
|
221
|
+
Some invariants are not about one Aggregate but about a **set selected by a
|
|
222
|
+
business key or status**: "email (normalized) is unique across all Users", "each
|
|
223
|
+
seat holds at most one active booking", "each connector has at most one
|
|
224
|
+
in-progress charging session". A single Aggregate instance cannot see the rest of
|
|
225
|
+
that set, so it cannot enforce the rule on its own.
|
|
226
|
+
|
|
227
|
+
Handle them the same way **regardless of which Aggregate boundary you choose**:
|
|
228
|
+
|
|
229
|
+
1. **As separate Aggregates** (e.g. `User` and `Booking` are distinct): the
|
|
230
|
+
Application layer *orchestrates* the check — via a repository query, a
|
|
231
|
+
Specification, or a domain service (the rule stays domain-named; it is not an
|
|
232
|
+
inline `if-else` in the command handler) — and the **database enforces it with
|
|
233
|
+
a unique / partial (filtered) unique index**. The DB constraint is the real
|
|
234
|
+
guarantee under concurrency; the orchestrated check just returns a friendlier
|
|
235
|
+
error first.
|
|
236
|
+
|
|
237
|
+
2. **Folded into one Aggregate** (e.g. the active session lives *inside* a
|
|
238
|
+
`Connector` as a child entity): the in-memory check (`if (Status == InUse)
|
|
239
|
+
throw …`) is logically correct, **but is still not concurrency-safe by
|
|
240
|
+
itself**. Two concurrent commands can each load the Aggregate, both pass the
|
|
241
|
+
check, and both save. Close the race with **optimistic concurrency** (a
|
|
242
|
+
`rowversion` / version token on the Aggregate root) or a DB constraint — but
|
|
243
|
+
the version check only protects you if every save actually touches the root's
|
|
244
|
+
token: inserting a child row without bumping the root's version leaves the
|
|
245
|
+
race open. Translate the resulting concurrency exception / unique violation
|
|
246
|
+
into a meaningful business conflict (e.g. HTTP 409), not a generic 500.
|
|
247
|
+
|
|
248
|
+
**Key point:** an in-memory check — at *any* layer — is never the final guarantee
|
|
249
|
+
for a uniqueness / "only one active X" rule under concurrent requests. The durable
|
|
250
|
+
enforcement is a DB unique / filtered index, an optimistic-concurrency token, or
|
|
251
|
+
an equivalent conditional write / compare-and-swap. Pick the Aggregate boundary on
|
|
252
|
+
modeling grounds (does the inner thing have an independent lifecycle / history
|
|
253
|
+
worth querying?), then add the store-level guard either way. (Heavier
|
|
254
|
+
serialization tactics — distributed locks, per-key actors, aggregate-per-key
|
|
255
|
+
sharding — exist but are advanced; reach for a store-level constraint or version
|
|
256
|
+
check first.)
|
|
257
|
+
|
|
258
|
+
## Value Objects
|
|
259
|
+
|
|
260
|
+
### When to Use Value Objects
|
|
261
|
+
|
|
262
|
+
If the answer to ALL of these is "yes", it's a Value Object:
|
|
263
|
+
- Is it defined by its properties, not by an ID?
|
|
264
|
+
- Is it immutable once created?
|
|
265
|
+
- Can two instances with the same properties be considered equal?
|
|
266
|
+
|
|
267
|
+
### Common Value Objects
|
|
268
|
+
|
|
269
|
+
```csharp
|
|
270
|
+
// Money — the classic example
|
|
271
|
+
public record Money(decimal Amount, Currency Currency)
|
|
272
|
+
{
|
|
273
|
+
public static Money Zero(Currency currency) => new(0, currency);
|
|
274
|
+
|
|
275
|
+
public Money Add(Money other)
|
|
276
|
+
{
|
|
277
|
+
if (Currency != other.Currency)
|
|
278
|
+
throw new CurrencyMismatchException(Currency, other.Currency);
|
|
279
|
+
return new Money(Amount + other.Amount, Currency);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
public Money ConvertTo(Currency target, ExchangeRate rate)
|
|
283
|
+
{
|
|
284
|
+
return new Money(rate.Convert(Amount), target);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// DateRange
|
|
289
|
+
public record DateRange(DateOnly Start, DateOnly End)
|
|
290
|
+
{
|
|
291
|
+
public DateRange
|
|
292
|
+
{
|
|
293
|
+
if (Start > End) throw new DomainException("Start must be before End.");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
public bool Contains(DateOnly date) => date >= Start && date <= End;
|
|
297
|
+
public int Days => End.DayNumber - Start.DayNumber + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Currency (constrained string)
|
|
301
|
+
public record Currency
|
|
302
|
+
{
|
|
303
|
+
public string Code { get; }
|
|
304
|
+
public int DecimalPlaces { get; }
|
|
305
|
+
|
|
306
|
+
public static readonly Currency TWD = new("TWD", 0);
|
|
307
|
+
public static readonly Currency USD = new("USD", 2);
|
|
308
|
+
public static readonly Currency JPY = new("JPY", 0);
|
|
309
|
+
|
|
310
|
+
private Currency(string code, int decimalPlaces)
|
|
311
|
+
{
|
|
312
|
+
Code = code;
|
|
313
|
+
DecimalPlaces = decimalPlaces;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public decimal Round(decimal amount) =>
|
|
317
|
+
Math.Round(amount, DecimalPlaces, MidpointRounding.AwayFromZero);
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Value Object Design Questions
|
|
322
|
+
|
|
323
|
+
1. **Does it have behavior?** Good VOs have methods, not just properties.
|
|
324
|
+
2. **Does it enforce constraints?** Constructor should reject invalid states.
|
|
325
|
+
3. **Is it reusable?** `Money` can be used across many Aggregates.
|
|
326
|
+
|
|
327
|
+
### Strong-Typed IDs and Domain Primitives
|
|
328
|
+
|
|
329
|
+
**IDs should be strong-typed.** Use a typed id (`ExpenseReportId`, `EmployeeId`
|
|
330
|
+
— the examples above already do) instead of a raw `Guid` / `int` / `string`, so
|
|
331
|
+
the compiler rejects passing one Aggregate's id where another's is expected.
|
|
332
|
+
|
|
333
|
+
```csharp
|
|
334
|
+
// Raw: both are Guid — swap them and the compiler stays silent
|
|
335
|
+
void Transfer(Guid from, Guid to, Money amount)
|
|
336
|
+
|
|
337
|
+
// Typed: passing a CustomerId where an AccountId is expected won't compile
|
|
338
|
+
void Transfer(AccountId from, AccountId to, Money amount)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
A typed id is a **Value Object wrapping the identifier value** — a
|
|
342
|
+
value-by-identity special case — not the Entity it identifies. (So it does not
|
|
343
|
+
contradict the Pattern Selection flowchart's "identified by an ID → Entity":
|
|
344
|
+
that question is about the *concept*; this is about giving the *id value* a type.)
|
|
345
|
+
|
|
346
|
+
**For other primitives, wrap only when the value carries a rule** — a format
|
|
347
|
+
(`Email`), a unit (`Money`, `Weight`), a constrained comparison / value semantic
|
|
348
|
+
(`DateRange` with start ≤ end), or an **allowed value set / constrained
|
|
349
|
+
vocabulary / domain code** (the `Currency` constrained string and the
|
|
350
|
+
`ReportStatus` enum-like value above are exactly this). A plain rule-less
|
|
351
|
+
`string` / `int` should **not** be wrapped — that is over-engineering. The point
|
|
352
|
+
of wrapping a primitive is to **bind a rule to the type**; with no rule, there is
|
|
353
|
+
no reason to wrap.
|
|
354
|
+
|
|
355
|
+
## Domain Events
|
|
356
|
+
|
|
357
|
+
### What Are Domain Events?
|
|
358
|
+
|
|
359
|
+
Something that happened in the domain that other parts of the system care about.
|
|
360
|
+
Past tense naming: `ExpenseReportSubmitted`, `LineItemAdded`, `ReportApproved`.
|
|
361
|
+
|
|
362
|
+
> **Not event sourcing.** Dflow's Domain Events are state-change
|
|
363
|
+
> *notifications* — the Aggregate's persisted state stays the source of truth,
|
|
364
|
+
> and events are never replayed to rebuild it. Adopting event sourcing is a
|
|
365
|
+
> separate, heavyweight architecture decision: if genuinely needed, record it
|
|
366
|
+
> as an ADR (`dflow/specs/architecture/decisions/`; Brownfield — see the
|
|
367
|
+
> Edition note); never introduce it as a side effect of modeling.
|
|
368
|
+
|
|
369
|
+
### When to Use Domain Events
|
|
370
|
+
|
|
371
|
+
- When one Aggregate needs to trigger changes in another Aggregate
|
|
372
|
+
- When side effects (email, notification, audit log) should happen after a domain action
|
|
373
|
+
- When different Bounded Contexts need to communicate
|
|
374
|
+
|
|
375
|
+
### Event Design
|
|
376
|
+
|
|
377
|
+
```csharp
|
|
378
|
+
public record ExpenseReportSubmittedEvent(
|
|
379
|
+
ExpenseReportId ReportId,
|
|
380
|
+
EmployeeId SubmittedBy,
|
|
381
|
+
Money TotalAmount
|
|
382
|
+
) : IDomainEvent;
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Payload Guideline
|
|
386
|
+
|
|
387
|
+
Default to a **thin payload**: the IDs involved, the fact that happened, and
|
|
388
|
+
the values that changed — not a snapshot of the whole Aggregate.
|
|
389
|
+
|
|
390
|
+
- Same-context consumers that need more state should load it by ID — a fat
|
|
391
|
+
payload is a stale copy the moment it is published.
|
|
392
|
+
- Across Bounded Contexts, every field you publish is something downstream may
|
|
393
|
+
start depending on — a fat payload becomes an implicit contract. If a
|
|
394
|
+
consumer genuinely needs state transfer, make that an explicit **integration
|
|
395
|
+
event** decision (record the Delivery expectation in `events.md`; Brownfield —
|
|
396
|
+
see the Edition note) and treat
|
|
397
|
+
the payload as a published contract: add fields if you must, never change
|
|
398
|
+
their meaning. Ceremony-wise, extending an event payload that crosses
|
|
399
|
+
contexts is a T1 contract change (see the ceremony examples in
|
|
400
|
+
`_conventions.md`).
|
|
401
|
+
|
|
402
|
+
### Event Flow
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
1. Aggregate method called → state changes → event added to DomainEvents list
|
|
406
|
+
2. Repository saves Aggregate
|
|
407
|
+
3. After save (in same transaction or via outbox):
|
|
408
|
+
- In-process handlers: update read models, trigger other commands
|
|
409
|
+
- Cross-context: publish to message queue
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Event Handling Guidelines
|
|
413
|
+
|
|
414
|
+
- **Same Bounded Context**: Handle synchronously (same transaction OK for read models)
|
|
415
|
+
- **Cross Bounded Context**: Handle asynchronously (eventual consistency)
|
|
416
|
+
- Event handlers should be idempotent (safe to process multiple times)
|
|
417
|
+
- **The failure path is a business scenario, not a technical detail**: when a
|
|
418
|
+
cross-Aggregate chain is async (eventually consistent) **and** a handler's
|
|
419
|
+
final failure — after retries are exhausted — would be business-visible
|
|
420
|
+
(compensation, customer entitlements, money, inventory, compliance, manual
|
|
421
|
+
reconciliation), write that outcome as a `BR-*` with its own Given/When/Then
|
|
422
|
+
in `behavior.md` and list the failure as an `EC-*` edge case in the spec —
|
|
423
|
+
even if the documented answer is "ops reconciles manually". Best-effort side
|
|
424
|
+
effects (notification email, logging) don't need a BR; a line in `events.md`
|
|
425
|
+
Event Flow Notes or a tech-debt entry is enough.
|
|
426
|
+
- **Dispatch / clear lifecycle**: clear `DomainEvents` at the Repository / Unit
|
|
427
|
+
of Work **save boundary** — the UoW clears them *after* the save succeeds and
|
|
428
|
+
the events have been dispatched (or the outbox row persisted, or dispatch
|
|
429
|
+
explicitly decided to be dropped/deferred). Never clear inside an Aggregate
|
|
430
|
+
method, and never before the save succeeds. A simple in-process dispatcher
|
|
431
|
+
(e.g. `IPublisher` / MediatR) is enough for Phase 1; an **outbox /
|
|
432
|
+
integration-event bridge is the Phase 2+ upgrade** for reliable cross-service
|
|
433
|
+
delivery. If no dispatcher is wired yet, record it as deferred tech debt — but
|
|
434
|
+
still clear on a successful save so events cannot accumulate unbounded.
|
|
435
|
+
|
|
436
|
+
## Specifications
|
|
437
|
+
|
|
438
|
+
For complex query logic that belongs to the domain:
|
|
439
|
+
|
|
440
|
+
```csharp
|
|
441
|
+
public class PendingApprovalSpec : Specification<ExpenseReport>
|
|
442
|
+
{
|
|
443
|
+
private readonly EmployeeId _approverId;
|
|
444
|
+
|
|
445
|
+
public PendingApprovalSpec(EmployeeId approverId)
|
|
446
|
+
{
|
|
447
|
+
_approverId = approverId;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
public override Expression<Func<ExpenseReport, bool>> ToExpression()
|
|
451
|
+
{
|
|
452
|
+
return report =>
|
|
453
|
+
report.Status == ReportStatus.Submitted &&
|
|
454
|
+
report.ApproverId == _approverId;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Read Models (Query Side)
|
|
460
|
+
|
|
461
|
+
The **write side** goes through an Aggregate (to protect invariants). The
|
|
462
|
+
**read side** usually should not. Lists, reports, dashboards, dropdowns — work
|
|
463
|
+
that only *displays* data — can query a denormalized projection / DTO directly,
|
|
464
|
+
without loading an Aggregate and without going through the Domain layer.
|
|
465
|
+
|
|
466
|
+
```csharp
|
|
467
|
+
// Don't: load 50 full Order Aggregates (each pulling line items, status
|
|
468
|
+
// history, …) just to render an order list → object-graph bloat, N+1
|
|
469
|
+
var orders = orderRepository.GetAll();
|
|
470
|
+
|
|
471
|
+
// Do: project only the columns the screen needs straight into a DTO
|
|
472
|
+
// SELECT OrderNumber, CustomerName, Total, OrderDate FROM Orders
|
|
473
|
+
// → IReadOnlyList<OrderListItemDto>
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
**When to still go through the Aggregate**: when you read in order to *change*
|
|
477
|
+
(read-modify-write). "Cancel order #123" must enforce "cannot cancel a shipped
|
|
478
|
+
order" → load the `Order` Aggregate, call `order.Cancel()`, save. Pure display →
|
|
479
|
+
read model; about to mutate → Aggregate.
|
|
480
|
+
|
|
481
|
+
**Read Model vs Specification**: a `Specification` is a domain predicate /
|
|
482
|
+
write-side decision — it carries a rule (see "Specifications" above). A read
|
|
483
|
+
model is a presentation / reporting query shape that carries **no** invariant.
|
|
484
|
+
Don't conflate them.
|
|
485
|
+
|
|
486
|
+
**Keep it simple.** This is the query side of CQRS — it does **not** require
|
|
487
|
+
event sourcing or a separate database. Two common shapes:
|
|
488
|
+
|
|
489
|
+
- A **live query DTO against the same database** — always fresh; the simplest
|
|
490
|
+
and most common read model.
|
|
491
|
+
- An **event-updated denormalized table** — can be **stale**. If that staleness
|
|
492
|
+
is user-visible (e.g. a just-placed order missing from the list for a moment),
|
|
493
|
+
say so in `behavior.md` / an edge case / a design decision. This is the same
|
|
494
|
+
mechanism as the "same-transaction read model update" note under Event
|
|
495
|
+
Handling Guidelines.
|
|
496
|
+
|
|
497
|
+
A read model is a *simplification* of the read path, not an extra layer to
|
|
498
|
+
build. (Read model ≠ event sourcing.)
|
|
499
|
+
|
|
500
|
+
## Domain Services
|
|
501
|
+
|
|
502
|
+
Use Domain Services for operations that:
|
|
503
|
+
- Involve multiple Aggregates (read-only access to the second Aggregate)
|
|
504
|
+
- Require external information (through interfaces) to make domain decisions
|
|
505
|
+
- Don't naturally belong to any single Entity
|
|
506
|
+
|
|
507
|
+
```csharp
|
|
508
|
+
// Domain Service — in Domain layer
|
|
509
|
+
public class ExpenseApprovalService
|
|
510
|
+
{
|
|
511
|
+
private readonly IApprovalPolicyRepository _policyRepo;
|
|
512
|
+
|
|
513
|
+
public ApprovalResult Evaluate(ExpenseReport report, ApprovalPolicy policy)
|
|
514
|
+
{
|
|
515
|
+
if (report.TotalAmount.Amount > policy.AutoApprovalLimit)
|
|
516
|
+
return ApprovalResult.RequiresManagerApproval;
|
|
517
|
+
|
|
518
|
+
if (policy.RestrictedCategories.Overlaps(report.Categories))
|
|
519
|
+
return ApprovalResult.RequiresComplianceReview;
|
|
520
|
+
|
|
521
|
+
return ApprovalResult.AutoApproved;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## Factories
|
|
527
|
+
|
|
528
|
+
When *creating* an object is itself complex — many parts to assemble, an
|
|
529
|
+
implementation type to choose, or creation rules that span several objects —
|
|
530
|
+
don't cram it into the constructor. Pick the lightest tool that fits:
|
|
531
|
+
|
|
532
|
+
- **Simple construction → the constructor.** A `new` whose constructor enforces
|
|
533
|
+
the object's own invariants is enough. Don't add a factory just to have one.
|
|
534
|
+
- **Creating a child entity / needs the Aggregate's internal state → a factory
|
|
535
|
+
method on the Aggregate Root** (e.g. `order.AddLine(...)` — the root stays in
|
|
536
|
+
control of its invariants).
|
|
537
|
+
- **Complex creation across several objects / choosing an implementation type →
|
|
538
|
+
a standalone Factory** (or a Domain Service).
|
|
539
|
+
|
|
540
|
+
### Reconstitution ≠ creation
|
|
541
|
+
|
|
542
|
+
The blind spot is the other half of an object's lifecycle: **rebuilding an
|
|
543
|
+
existing object from persistence is not creating a new one.** Reconstitution
|
|
544
|
+
must **not** run the creation workflow, **not** re-raise creation events, and
|
|
545
|
+
**not** reject old data just because a *new* creation rule was added later.
|
|
546
|
+
|
|
547
|
+
```csharp
|
|
548
|
+
// Creation: this Order comes into existence for the first time
|
|
549
|
+
var order = Order.Create(customerId, items);
|
|
550
|
+
// → assigns a new OrderId, checks "at least one line",
|
|
551
|
+
// raises OrderPlaced (which sends mail, decrements stock)
|
|
552
|
+
|
|
553
|
+
// Reconstitution: the same Order loaded back next week
|
|
554
|
+
var order = repository.GetById(orderId);
|
|
555
|
+
// → must NOT raise OrderPlaced again (mail re-sent, stock re-decremented)
|
|
556
|
+
// → must NOT be rejected by a creation rule added after it was placed
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
ORMs reconstitute through a backing / `private` constructor; reserve the
|
|
560
|
+
`Create(...)` factory path for genuinely new instances.
|
|
561
|
+
|
|
562
|
+
**But "reconstitution validates nothing" is the wrong reading.** Structural /
|
|
563
|
+
version-compatibility invariants can and should still be checked on hydration;
|
|
564
|
+
genuinely corrupt or legacy-incompatible data should be routed to migration or
|
|
565
|
+
quarantine, not silently accepted. What you drop is *re-running the creation
|
|
566
|
+
workflow and its side effects* — not *integrity*.
|
|
567
|
+
|
|
568
|
+
## Bounded Context Relationships
|
|
569
|
+
|
|
570
|
+
If `dflow/specs/domain/context-map.md` does not exist yet, create it from `templates/context-map.md` before documenting relationships.
|
|
571
|
+
|
|
572
|
+
Document in `dflow/specs/domain/context-map.md`:
|
|
573
|
+
|
|
574
|
+
| Relationship | Pattern | Example |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| Context A calls Context B | Customer-Supplier | Expense → HR (get employee info) |
|
|
577
|
+
| Contexts share data | Shared Kernel | Currency, Money in SharedKernel/ |
|
|
578
|
+
| Context A translates B's language | Anti-Corruption Layer | Expense → External Accounting System |
|
|
579
|
+
| Fire and forget | Domain Events | Expense → Notification (report submitted) |
|
|
580
|
+
| Two contexts have no compelling reason to integrate | Separate Ways | Shipping vs Marketing each keep their own product fields |
|
|
581
|
+
| Integrating with an unmodeled legacy region | Big Ball of Mud (+ ACL) | a new Order context wrapping a legacy accounting module |
|
|
582
|
+
|
|
583
|
+
The less-obvious three — when to reach for them:
|
|
584
|
+
|
|
585
|
+
- **Separate Ways** — when integration cost > duplication cost **and** the "shared"
|
|
586
|
+
concept isn't actually the same thing in both contexts: don't build an ACL or a
|
|
587
|
+
Shared Kernel, just duplicate the small overlap; revisit only if the duplication
|
|
588
|
+
painfully diverges later. (Counters "integrate because DDD".) **Do NOT** use
|
|
589
|
+
Separate Ways when one context genuinely needs the other's authoritative
|
|
590
|
+
lifecycle, fresh state, workflow coordination, or invariant protection — that is
|
|
591
|
+
required integration, not a duplication candidate.
|
|
592
|
+
- **Big Ball of Mud (+ ACL)** — when a region has no coherent model (often legacy)
|
|
593
|
+
and you must integrate with it: mark it `BBoM` on the context map, wrap it behind
|
|
594
|
+
an Anti-Corruption Layer, and translate at the boundary so its (lack of) structure
|
|
595
|
+
can't leak into your clean contexts — don't try to model the mud in place. In
|
|
596
|
+
Brownfield extraction, the legacy you are pulling away from is usually this.
|
|
597
|
+
- **Open Host Service (OHS)** — when **many** downstream contexts need the same
|
|
598
|
+
integration from one upstream, publish a single Open Host Service with a Published
|
|
599
|
+
Language (a documented, stable protocol) instead of each consumer integrating
|
|
600
|
+
separately. OHS standardizes the upstream's published surface; it does **not**
|
|
601
|
+
remove a consumer-side ACL where that consumer still needs local translation.
|
|
602
|
+
|
|
603
|
+
## Common Mistakes to Catch
|
|
604
|
+
|
|
605
|
+
1. **Anemic Domain Model** — Entities with only getters/setters, all logic in services
|
|
606
|
+
→ Move behavior INTO the Entity/Aggregate
|
|
607
|
+
|
|
608
|
+
2. **Too-large Aggregates** — Aggregate that loads entire object graph
|
|
609
|
+
→ Split into smaller Aggregates, reference by ID
|
|
610
|
+
|
|
611
|
+
3. **Business logic in Application layer** — If-else rules in command handlers
|
|
612
|
+
→ Move to Domain (Entity methods or Domain Services)
|
|
613
|
+
|
|
614
|
+
4. **Business logic in Infrastructure** — Rules in SQL queries or EF configurations
|
|
615
|
+
→ Domain defines WHAT, Infrastructure defines HOW
|
|
616
|
+
|
|
617
|
+
5. **Direct cross-Aggregate modification** — One command modifying two Aggregates
|
|
618
|
+
→ Use Domain Events for the second Aggregate
|
|
619
|
+
|
|
620
|
+
6. **Set-based invariant guarded only in memory** — A "unique" / "only one active"
|
|
621
|
+
rule enforced solely by an in-app check, with no DB unique constraint or
|
|
622
|
+
optimistic-concurrency token → two concurrent requests can both pass and break it
|
|
623
|
+
→ Back it with a DB constraint / `rowversion`; see "Set-Based / Uniqueness Invariants"
|
|
624
|
+
|
|
625
|
+
7. **Unbounded collection inside an Aggregate** — A child list that grows forever
|
|
626
|
+
(history, comments, audit trail) makes every load heavier and every save more
|
|
627
|
+
contended → Split it into its own Aggregate or a read model, reference by ID
|
|
628
|
+
|
|
629
|
+
8. **Fat event payload as implicit contract** — Publishing whole-Aggregate
|
|
630
|
+
snapshots couples consumers to your internal shape and hands them stale data
|
|
631
|
+
→ Default to IDs + event facts + changed values; cross-context state transfer
|
|
632
|
+
must be an explicit integration-event contract (see "Payload Guideline")
|
|
633
|
+
|
|
634
|
+
9. **Creation validation / events re-run on reconstitution** — Loading an
|
|
635
|
+
existing Aggregate from the database through the creation path re-raises
|
|
636
|
+
creation events (re-sends mail, re-decrements stock) or rejects valid old data
|
|
637
|
+
by a newer creation rule → Reconstitute through a backing constructor; keep
|
|
638
|
+
integrity checks but not the creation workflow; see "Reconstitution ≠ creation"
|
|
639
|
+
|
|
640
|
+
10. **Forcing every query through repository + Aggregate** — Loading full
|
|
641
|
+
Aggregates just to display a list / report causes object-graph bloat and N+1
|
|
642
|
+
→ Use a read model (denormalized projection / DTO) for the display path; see
|
|
643
|
+
"Read Models (Query Side)"
|
|
@@ -5,12 +5,15 @@ description: >
|
|
|
5
5
|
/dflow:* commands (/dflow:new-feature, /dflow:modify-existing, /dflow:bug-fix,
|
|
6
6
|
/dflow:new-phase, /dflow:finish-feature, /dflow:pr-review, /dflow:verify,
|
|
7
7
|
/dflow:report-dflow-feedback, /dflow:status, /dflow:next, /dflow:cancel).
|
|
8
|
-
SECONDARY
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
SECONDARY — engage ONLY for adding or changing product/user-facing/domain
|
|
9
|
+
behavior, a new requirement, a feature or bug-fix workflow, or spec-impacting
|
|
10
|
+
architecture/domain-model decisions. Includes indirect phrasings, e.g. "I want
|
|
11
|
+
to build/add ...", "let's add the ability to ...", "we need to support ...",
|
|
12
|
+
"can you implement ...", "users should be able to ...", "the app should also
|
|
13
|
+
...". Do NOT engage for pure refactors, renames, infra/build chores,
|
|
14
|
+
formatting, dep bumps, or general code questions ("how does X work", "explain
|
|
15
|
+
this"). When engaged by natural language, DO NOT auto-enter a workflow: judge
|
|
16
|
+
the intent, suggest the matching /dflow: command, and wait for confirmation.
|
|
14
17
|
---
|
|
15
18
|
|
|
16
19
|
<!-- dflow-generated: skill-adapter -->
|