@nt-ai-lab/opencode-skillz 0.2.1

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.
@@ -0,0 +1,499 @@
1
+ ---
2
+ description: "Design, refactor, analyze, and review code by applying the principles and patterns of tactical domain-driven design. Triggers on: domain modeling, aggregate design, 'entity', 'value object', 'repository', 'bounded context', 'domain event', 'domain service', code touching domain/ directories, rich domain model discussions."
3
+ ---
4
+
5
+ Appyl the following tactical ddd principles when writing domain code.
6
+
7
+ # Tactical DDD
8
+
9
+ Design, refactor, analyze, and review code by applying the principles and patterns of tactical domain-driven design.
10
+
11
+ ## Principles
12
+
13
+ 1. **Isolate domain logic**
14
+ 2. **Use rich domain language**
15
+ 3. **Orchestrate with use cases**
16
+ 4. **Avoid anemic domain model**
17
+ 5. **Separate generic concepts**
18
+ 6. **Make the implicit explicit... like your life depends on it**
19
+ 7. **Design aggregates around invariants**
20
+ 8. **Extract immutable value objects liberally**
21
+ 9. **Repositories are for loading and saving full aggregates**
22
+
23
+ ---
24
+
25
+ ## 1. Isolate domain logic
26
+
27
+ **What:** Domain logic is not mixed with technical code like HTTP and database transactions.
28
+
29
+ **Why:** Easier to understand the most important part of the code, easier to validate with domain experts, easier to test and evolve, easier to plan and implement new features.
30
+
31
+ **Test:** Could a domain expert read the code? Can the code be unit tested without mocks or spinning up databases?
32
+
33
+ ```typescript
34
+ // ❌ WRONG - domain polluted with infrastructure
35
+ class Delivery {
36
+ async dispatch() {
37
+ this.logger.info('Dispatching delivery', { id: this.id }) // Infrastructure!
38
+ await this.db.beginTransaction() // Infrastructure!
39
+ if (this.status !== 'ready') throw new Error('Not ready')
40
+ this.status = 'dispatched'
41
+ await this.db.save(this) // Infrastructure!
42
+ await this.db.commit() // Infrastructure!
43
+ await this.pushNotification.notifyDriver() // Infrastructure!
44
+ }
45
+ }
46
+
47
+ // ✅ RIGHT - isolated domain logic
48
+ class Delivery {
49
+ dispatch(): void {
50
+ if (this.status !== DeliveryStatus.Ready) {
51
+ throw new DeliveryNotReadyError(this.id)
52
+ }
53
+ this.status = DeliveryStatus.Dispatched
54
+ this.dispatchedAt = new Date()
55
+ }
56
+ }
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 2. Use rich domain language
62
+
63
+ **What:** Names in code match exactly what domain experts say. No programmer jargon. No generic names.
64
+
65
+ **Why:** Translation between code-speak and business-speak causes bugs. When a domain expert says "assess a claim" and the code says "processEntity", someone will misunderstand something.
66
+
67
+ **Test:** Would a domain expert recognize this name? If you'd need to translate it for them, it's wrong.
68
+
69
+ **Common generic terms to watch for:**
70
+ - `Manager`, `Handler`, `Processor`, `Helper`, `Util`
71
+ - `Data`, `Info`, `Item` (when domain terms exist)
72
+ - `process`, `handle`, `execute` (what does it actually DO?)
73
+
74
+ ```typescript
75
+ // ❌ WRONG - programmer jargon
76
+ class ClaimHandler {
77
+ processClaimData(claimData: ClaimDTO): ProcessingResult {
78
+ return this.claimProcessor.handle(claimData)
79
+ }
80
+ }
81
+
82
+ // ✅ RIGHT - domain language
83
+ class ClaimAssessor {
84
+ assessClaim(claim: InsuranceClaim): AssessmentDecision {
85
+ if (claim.exceedsCoverageLimit()) {
86
+ return AssessmentDecision.deny(DenialReason.ExceedsCoverage)
87
+ }
88
+ return AssessmentDecision.approve()
89
+ }
90
+ }
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 3. Orchestrate with use cases
96
+
97
+ **What:** A use case is a user goal—something a user would recognize as an action they can perform in your application.
98
+
99
+ **Why:** Use cases define the entry points to your domain. They answer "what can a user do?" If something isn't a user goal, it's supporting machinery that belongs elsewhere.
100
+
101
+ **Test (the menu test):** If you described your application's features to a user like a menu, would this be on it?
102
+
103
+ ```
104
+ DELIVERY APP MENU:
105
+ ├── Request Delivery ← Use case: user goal
106
+ ├── Track Delivery ← Use case: user goal
107
+ ├── Cancel Delivery ← Use case: user goal
108
+ ├── Calculate ETA ← NOT a use case: internal machinery
109
+ └── Check Delivery Radius ← NOT a use case: domain rule
110
+ ```
111
+
112
+ ```typescript
113
+ // ❌ WRONG - not a user goal, this is internal machinery
114
+ // use-cases/calculate-eta.use-case.ts
115
+ async function calculateETA(deliveryId: DeliveryId) {
116
+ const delivery = await deliveryRepository.find(deliveryId)
117
+ const driver = await driverRepository.find(delivery.driverId)
118
+ return routeService.estimateArrival(driver.location, delivery.destination)
119
+ }
120
+
121
+ // ✅ RIGHT - actual user goal (appears in menu)
122
+ // use-cases/cancel-delivery.use-case.ts
123
+ async function cancelDelivery(deliveryId: DeliveryId, reason: CancellationReason) {
124
+ const delivery = await deliveryRepository.find(deliveryId)
125
+ delivery.cancel(reason)
126
+ await deliveryRepository.save(delivery)
127
+ }
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 4. Avoid anemic domain model
133
+
134
+ **What:** Domain logic lives in domain objects, not in use cases. Use cases orchestrate; domain objects decide.
135
+
136
+ **Why:** When business rules leak into use cases, they scatter across the codebase, duplicate, and diverge. The domain becomes a dumb data carrier.
137
+
138
+ **Test:** Is your use case making business decisions, or just coordinating? If the use case contains if/else business logic, you likely have an anemic model.
139
+
140
+ ```typescript
141
+ // ❌ WRONG - business logic in use case (anemic domain)
142
+ async function confirmDropoff(deliveryId: DeliveryId, photo: ProofPhoto) {
143
+ const delivery = await deliveryRepository.find(deliveryId)
144
+
145
+ // Business rules leaked into use case!
146
+ if (delivery.status !== 'in_transit') {
147
+ throw new Error('Delivery not in transit')
148
+ }
149
+ if (!photo && delivery.requiresSignature) {
150
+ throw new Error('Proof of delivery required')
151
+ }
152
+
153
+ delivery.status = 'delivered'
154
+ delivery.proofPhoto = photo
155
+ delivery.deliveredAt = new Date()
156
+ await deliveryRepository.save(delivery)
157
+ }
158
+
159
+ // ✅ RIGHT - use case orchestrates, domain decides
160
+ async function confirmDropoff(deliveryId: DeliveryId, photo: ProofPhoto) {
161
+ const delivery = await deliveryRepository.find(deliveryId)
162
+
163
+ delivery.confirmDropoff(photo) // Domain enforces the rules
164
+
165
+ await deliveryRepository.save(delivery)
166
+ }
167
+ ```
168
+
169
+ **Signs of anemic model:**
170
+ - Use cases full of if/else business logic
171
+ - Domain objects are just data with getters/setters
172
+ - Business rules duplicated across multiple use cases
173
+ - Validation logic outside the object being validated
174
+
175
+ ---
176
+
177
+ ## 5. Separate generic concepts
178
+
179
+ **What:** Generic capabilities that aren't specific to your domain live separately from domain-specific logic.
180
+
181
+ **Why:** A retry mechanism, a caching layer, a validation framework—these aren't YOUR domain. Mixing them with domain logic obscures what's actually specific to your business.
182
+
183
+ **Test:** Would this code exist in a completely different business domain? If yes, it's generic. If it's specific to YOUR business rules, it's domain.
184
+
185
+ ```typescript
186
+ // ❌ WRONG - generic retry logic mixed with domain
187
+ // domain/driver-locator.ts
188
+ class DriverLocator {
189
+ // Generic retry logic does not belong in domain!
190
+ private async withRetry<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
191
+ for (let i = 0; i < attempts; i++) {
192
+ try { return await fn() }
193
+ catch (e) { if (i === attempts - 1) throw e }
194
+ }
195
+ throw new Error('Retry failed')
196
+ }
197
+
198
+ async findAvailableDriver(zone: Zone): Promise<Driver> {
199
+ return this.withRetry(() => this.searchDriversInZone(zone), 3)
200
+ }
201
+
202
+ private async searchDriversInZone(zone: Zone): Promise<Driver> {
203
+ // domain logic to find nearest available driver
204
+ }
205
+ }
206
+
207
+ // ✅ RIGHT - same behavior, properly separated
208
+ // infra/retry.ts (generic, reusable in any project)
209
+ export async function withRetry<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
210
+ for (let i = 0; i < attempts; i++) {
211
+ try { return await fn() }
212
+ catch (e) { if (i === attempts - 1) throw e }
213
+ }
214
+ throw new Error('Retry failed')
215
+ }
216
+
217
+ // domain/driver-locator.ts (pure domain, no infra imports)
218
+ class DriverLocator {
219
+ async findAvailableDriver(zone: Zone): Promise<Driver> {
220
+ // domain logic to find nearest available driver
221
+ }
222
+ }
223
+
224
+ // use-cases/dispatch-delivery.ts (orchestrates domain + infra)
225
+ async function dispatchDelivery(deliveryId: DeliveryId) {
226
+ const delivery = await deliveryRepository.find(deliveryId)
227
+ const driver = await withRetry(
228
+ () => driverLocator.findAvailableDriver(delivery.zone), 3
229
+ )
230
+ delivery.assignDriver(driver)
231
+ await deliveryRepository.save(delivery)
232
+ }
233
+ ```
234
+
235
+ ---
236
+
237
+ ## 6. Make the implicit explicit... like your life depends on it
238
+
239
+ **What:** Strive for maximum expressiveness. Go as far as possible to identify and name domain concepts in code. Don't settle for "good enough"—push until the code speaks the domain fluently.
240
+
241
+ **Why:** Maximum alignment optimizes communication between engineers and domain experts. Easier to discuss nuances and avoid misconceptions. Easier to plan and implement features and detect when the design of code is causing unnecessary friction.
242
+
243
+ **Test:** Could you discuss this code with a domain expert without translation? Are there concepts they use that don't exist in your code?
244
+
245
+ ```typescript
246
+ // This code looks fine - isolated, uses domain terms
247
+ class Delivery {
248
+ status: DeliveryStatus
249
+ driver: Driver | null
250
+ pickupTime: Date | null
251
+ dropoffTime: Date | null
252
+ proofOfDelivery: Photo | null
253
+
254
+ assignDriver(driver: Driver): void {
255
+ if (this.status !== DeliveryStatus.Confirmed) throw new Error('...')
256
+ this.driver = driver
257
+ this.status = DeliveryStatus.Assigned
258
+ }
259
+
260
+ recordPickup(): void {
261
+ if (this.status !== DeliveryStatus.Assigned) throw new Error('...')
262
+ this.pickupTime = new Date()
263
+ this.status = DeliveryStatus.InTransit
264
+ }
265
+
266
+ recordDropoff(photo: Photo): void {
267
+ if (this.status !== DeliveryStatus.InTransit) throw new Error('...')
268
+ this.proofOfDelivery = photo
269
+ this.dropoffTime = new Date()
270
+ this.status = DeliveryStatus.Delivered
271
+ }
272
+ }
273
+
274
+ // But the TYPES can describe the domain! Each state is a distinct concept.
275
+ // Reading the types alone tells you how deliveries work.
276
+
277
+ type Delivery =
278
+ | RequestedDelivery // Customer placed request
279
+ | ConfirmedDelivery // Restaurant accepted
280
+ | AssignedDelivery // Driver assigned, heading to restaurant
281
+ | InTransitDelivery // Driver picked up, heading to customer
282
+ | DeliveredDelivery // Complete with proof
283
+
284
+ interface RequestedDelivery {
285
+ kind: 'requested'
286
+ customer: Customer
287
+ restaurant: Restaurant
288
+ items: MenuItem[]
289
+ }
290
+
291
+ interface ConfirmedDelivery {
292
+ kind: 'confirmed'
293
+ customer: Customer
294
+ restaurant: Restaurant
295
+ items: MenuItem[]
296
+ estimatedPrepTime: Duration
297
+ }
298
+
299
+ interface AssignedDelivery {
300
+ kind: 'assigned'
301
+ customer: Customer
302
+ restaurant: Restaurant
303
+ items: MenuItem[]
304
+ driver: Driver // Now guaranteed to exist
305
+ estimatedPickup: Time
306
+ }
307
+
308
+ interface InTransitDelivery {
309
+ kind: 'in_transit'
310
+ customer: Customer
311
+ restaurant: Restaurant
312
+ items: MenuItem[]
313
+ driver: Driver
314
+ pickupTime: Time // Now guaranteed to exist
315
+ estimatedDropoff: Time
316
+ }
317
+
318
+ interface DeliveredDelivery {
319
+ kind: 'delivered'
320
+ customer: Customer
321
+ restaurant: Restaurant
322
+ items: MenuItem[]
323
+ driver: Driver
324
+ pickupTime: Time
325
+ dropoffTime: Time // Now guaranteed to exist
326
+ proofOfDelivery: Photo // Now guaranteed to exist
327
+ }
328
+
329
+ // State transitions are explicit functions
330
+ function confirmDelivery(d: RequestedDelivery, prepTime: Duration): ConfirmedDelivery
331
+ function assignDriver(d: ConfirmedDelivery, driver: Driver): AssignedDelivery
332
+ function recordPickup(d: AssignedDelivery): InTransitDelivery
333
+ function recordDropoff(d: InTransitDelivery, photo: Photo): DeliveredDelivery
334
+ ```
335
+
336
+ **Smaller improvements matter too:**
337
+
338
+ ```typescript
339
+ // Extract an if statement to a named method
340
+ if (distance.kilometers > 10 && !driver.hasLongRangeVehicle) { ... }
341
+ if (delivery.exceedsDriverRange(driver)) { ... }
342
+
343
+ // Name a boolean expression
344
+ const canAssign = driver.isAvailable && driver.isInZone(delivery.zone) && !driver.atCapacity
345
+ const canAssign = driver.canAccept(delivery)
346
+
347
+ // Rename to use domain language
348
+ const fee = customFee ?? standardFee
349
+ const fee = customFee ?? defaultDeliveryFee
350
+ ```
351
+
352
+ **Ways to increase expressiveness:**
353
+ - Model states as distinct types (Delivery with status → RequestedDelivery, ConfirmedDelivery, etc.)
354
+ - Make optional fields guaranteed at the right state (driver: Driver | null → driver: Driver)
355
+ - Extract conditionals to named methods (complex if → exceedsDriverRange)
356
+ - Rename variables to use domain language (standardFee → defaultDeliveryFee)
357
+
358
+ ---
359
+
360
+ ## 7. Design aggregates around invariants
361
+
362
+ **What:** An aggregate is a cluster of objects that must be consistent together. The aggregate root enforces the rules. External code cannot violate invariants.
363
+
364
+ **Why:** Without clear boundaries, inconsistent states creep in. One piece of code updates the delivery, another updates the route, and suddenly the ETA is wrong.
365
+
366
+ **Test:** What must be true at all times? What rules must never be broken? The objects involved in those rules form an aggregate.
367
+
368
+ ```typescript
369
+ // ❌ WRONG - no aggregate boundary, invariants violated
370
+ class Delivery {
371
+ stops: DeliveryStop[] // Exposed!
372
+ totalDistance: Distance
373
+ }
374
+
375
+ // External code can break invariants
376
+ delivery.stops.push(new DeliveryStop(location))
377
+ // Oops - totalDistance is now wrong!
378
+
379
+ // ✅ RIGHT - aggregate protects invariants
380
+ class Delivery {
381
+ private stops: DeliveryStop[] = []
382
+ private _totalDistance: Distance = Distance.zero()
383
+
384
+ addStop(location: Location): void {
385
+ if (this.status !== DeliveryStatus.Planning) {
386
+ throw new DeliveryNotModifiableError(this.id)
387
+ }
388
+ const previousStop = this.stops[this.stops.length - 1]
389
+ const stop = new DeliveryStop(location)
390
+ this.stops.push(stop)
391
+ this._totalDistance = this._totalDistance.add(
392
+ previousStop.distanceTo(location) // Invariant maintained!
393
+ )
394
+ }
395
+
396
+ removeStop(stopId: StopId): void {
397
+ if (this.stops.length <= 2) {
398
+ throw new MinimumStopsRequiredError(this.id)
399
+ }
400
+ // Recalculate total distance after removal
401
+ this.stops = this.stops.filter(s => !s.id.equals(stopId))
402
+ this._totalDistance = this.calculateTotalDistance() // Invariant maintained!
403
+ }
404
+
405
+ get totalDistance(): Distance {
406
+ return this._totalDistance
407
+ }
408
+ }
409
+ ```
410
+
411
+ **Aggregate rules:**
412
+ - One root entity per aggregate
413
+ - External code accesses only through the root
414
+ - The root enforces all invariants
415
+ - Reference other aggregates by ID, not object
416
+ - Methods should operate on the same state—if they don't, split the aggregate
417
+
418
+ ---
419
+
420
+ ## 8. Extract immutable value objects liberally
421
+
422
+ **What:** When something is defined by its attributes (not identity), make it an immutable value object. Do this liberally—more value objects is usually better.
423
+
424
+ **Why:** Value objects are simple. They can't change unexpectedly. They're easy to test. They make domain concepts explicit. They're also a good way to extract logic from aggregates and entities that can easily get large—keep entities focused by pulling cohesive concepts into value objects.
425
+
426
+ **Test:** Does this need a unique ID to track it over time? No? It's probably a value object.
427
+
428
+ ```typescript
429
+ // Entity with primitives that should be a value object
430
+ class Delivery {
431
+ id: DeliveryId
432
+ feeAmount: number
433
+ feeCurrency: string
434
+ }
435
+
436
+ // Extract the value object
437
+ class Delivery {
438
+ id: DeliveryId
439
+ fee: Money
440
+ }
441
+
442
+ class Money {
443
+ constructor(
444
+ readonly amount: number,
445
+ readonly currency: Currency
446
+ ) {}
447
+
448
+ add(other: Money): Money {
449
+ if (this.currency !== other.currency) {
450
+ throw new CurrencyMismatchError(this.currency, other.currency)
451
+ }
452
+ return new Money(this.amount + other.amount, this.currency)
453
+ }
454
+
455
+ equals(other: Money): boolean {
456
+ return this.amount === other.amount && this.currency === other.currency
457
+ }
458
+ }
459
+ ```
460
+
461
+ **Good candidates for value objects:**
462
+ - Money, Currency, Percentage
463
+ - DateRange, TimeSlot, Duration
464
+ - Address, Coordinates, Distance
465
+ - EmailAddress, PhoneNumber, URL
466
+ - Quantity, Weight, Temperature
467
+ - PersonName, CompanyName
468
+
469
+ ---
470
+
471
+ ## 9. Repositories are for loading and saving full aggregates
472
+
473
+ The job of a repository is to load and save entire aggregates - not partial aggregates or nested entities inside an aggregate. The `load` method takes an ID and returns the full aggregate.
474
+
475
+ A repository should not exist for a domain object that is not an aggregate. Entity that is part of an aggreate -> does not have a repository. It is loaded via the aggregate root's repository.
476
+
477
+ The `hydrate` method is used ONLY for constructing an aggregate from it's persisted state. It should not be abused for other use cases like creating new instances. Each creation flow should have a dedicated factory method, e.g. `Order.fromExisting()`, `Order.new()`, `Order.draft()`.
478
+
479
+ The `save` method of a repository should take the full aggregate.
480
+
481
+ If you just want to query information to display without modifying state and applying business rules, create a separate read model object and don't use a repository.
482
+
483
+ ---
484
+
485
+ ## Mandatory Checklist
486
+
487
+ When designing, refactoring, analyzing, or reviewing code:
488
+
489
+ 1. [ ] Verify domain is isolated from infrastructure (no DB/HTTP/logging in domain; generic utilities in infra; domain doesn't import infra)
490
+ 2. [ ] Verify names are from YOUR domain, not generic developer jargon
491
+ 3. [ ] Verify use cases are intentions of users, human or automated (apply the menu test)
492
+ 4. [ ] Verify business logic lives in domain objects, use cases only orchestrate
493
+ 5. [ ] Verify states are modeled as distinct types where appropriate
494
+ 6. [ ] Verify hidden domain concepts are extracted and named explicitly
495
+ 7. [ ] Verify aggregates are designed around invariants, not naive mapping of domain nouns
496
+ 8. [ ] Verify values are extracted into value objects expressing a domain concept
497
+ 9. [ ] Veirfy no abuse of hydrate methods for creation scenarios. Each creation scenario must have dedicated factory method
498
+
499
+ Do not proceed until all checks pass.