name: tactical-ddd 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." version: 1.0.0
Tactical DDD
Design, refactor, analyze, and review code by applying the principles and patterns of tactical domain-driven design.
Principles
- Isolate domain logic
- Use rich domain language
- Orchestrate with use cases
- Avoid anemic domain model
- Separate generic concepts
- Make the implicit explicit... like your life depends on it
- Design aggregates around invariants
- Extract immutable value objects liberally
- Repositories are for loading and saving full aggregates
1. Isolate domain logic
What: Domain logic is not mixed with technical code like HTTP and database transactions.
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.
Test: Could a domain expert read the code? Can the code be unit tested without mocks or spinning up databases?
// ❌ WRONG - domain polluted with infrastructure
class Delivery {
async dispatch() {
this.logger.info('Dispatching delivery', { id: this.id }) // Infrastructure!
await this.db.beginTransaction() // Infrastructure!
if (this.status !== 'ready') throw new Error('Not ready')
this.status = 'dispatched'
await this.db.save(this) // Infrastructure!
await this.db.commit() // Infrastructure!
await this.pushNotification.notifyDriver() // Infrastructure!
}
}
// ✅ RIGHT - isolated domain logic
class Delivery {
dispatch(): void {
if (this.status !== DeliveryStatus.Ready) {
throw new DeliveryNotReadyError(this.id)
}
this.status = DeliveryStatus.Dispatched
this.dispatchedAt = new Date()
}
}
2. Use rich domain language
What: Names in code match exactly what domain experts say. No programmer jargon. No generic names.
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.
Test: Would a domain expert recognize this name? If you'd need to translate it for them, it's wrong.
Common generic terms to watch for:
Manager,Handler,Processor,Helper,UtilData,Info,Item(when domain terms exist)process,handle,execute(what does it actually DO?)
// ❌ WRONG - programmer jargon
class ClaimHandler {
processClaimData(claimData: ClaimDTO): ProcessingResult {
return this.claimProcessor.handle(claimData)
}
}
// ✅ RIGHT - domain language
class ClaimAssessor {
assessClaim(claim: InsuranceClaim): AssessmentDecision {
if (claim.exceedsCoverageLimit()) {
return AssessmentDecision.deny(DenialReason.ExceedsCoverage)
}
return AssessmentDecision.approve()
}
}
3. Orchestrate with use cases
What: A use case is a user goal—something a user would recognize as an action they can perform in your application.
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.
Test (the menu test): If you described your application's features to a user like a menu, would this be on it?
DELIVERY APP MENU:
├── Request Delivery ← Use case: user goal
├── Track Delivery ← Use case: user goal
├── Cancel Delivery ← Use case: user goal
├── Calculate ETA ← NOT a use case: internal machinery
└── Check Delivery Radius ← NOT a use case: domain rule
// ❌ WRONG - not a user goal, this is internal machinery
// use-cases/calculate-eta.use-case.ts
async function calculateETA(deliveryId: DeliveryId) {
const delivery = await deliveryRepository.find(deliveryId)
const driver = await driverRepository.find(delivery.driverId)
return routeService.estimateArrival(driver.location, delivery.destination)
}
// ✅ RIGHT - actual user goal (appears in menu)
// use-cases/cancel-delivery.use-case.ts
async function cancelDelivery(deliveryId: DeliveryId, reason: CancellationReason) {
const delivery = await deliveryRepository.find(deliveryId)
delivery.cancel(reason)
await deliveryRepository.save(delivery)
}
4. Avoid anemic domain model
What: Domain logic lives in domain objects, not in use cases. Use cases orchestrate; domain objects decide.
Why: When business rules leak into use cases, they scatter across the codebase, duplicate, and diverge. The domain becomes a dumb data carrier.
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.
// ❌ WRONG - business logic in use case (anemic domain)
async function confirmDropoff(deliveryId: DeliveryId, photo: ProofPhoto) {
const delivery = await deliveryRepository.find(deliveryId)
// Business rules leaked into use case!
if (delivery.status !== 'in_transit') {
throw new Error('Delivery not in transit')
}
if (!photo && delivery.requiresSignature) {
throw new Error('Proof of delivery required')
}
delivery.status = 'delivered'
delivery.proofPhoto = photo
delivery.deliveredAt = new Date()
await deliveryRepository.save(delivery)
}
// ✅ RIGHT - use case orchestrates, domain decides
async function confirmDropoff(deliveryId: DeliveryId, photo: ProofPhoto) {
const delivery = await deliveryRepository.find(deliveryId)
delivery.confirmDropoff(photo) // Domain enforces the rules
await deliveryRepository.save(delivery)
}
Signs of anemic model:
- Use cases full of if/else business logic
- Domain objects are just data with getters/setters
- Business rules duplicated across multiple use cases
- Validation logic outside the object being validated
5. Separate generic concepts
What: Generic capabilities that aren't specific to your domain live separately from domain-specific logic.
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.
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.
// ❌ WRONG - generic retry logic mixed with domain
// domain/driver-locator.ts
class DriverLocator {
// Generic retry logic does not belong in domain!
private async withRetry<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
for (let i = 0; i < attempts; i++) {
try { return await fn() }
catch (e) { if (i === attempts - 1) throw e }
}
throw new Error('Retry failed')
}
async findAvailableDriver(zone: Zone): Promise<Driver> {
return this.withRetry(() => this.searchDriversInZone(zone), 3)
}
private async searchDriversInZone(zone: Zone): Promise<Driver> {
// domain logic to find nearest available driver
}
}
// ✅ RIGHT - same behavior, properly separated
// infra/retry.ts (generic, reusable in any project)
export async function withRetry<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
for (let i = 0; i < attempts; i++) {
try { return await fn() }
catch (e) { if (i === attempts - 1) throw e }
}
throw new Error('Retry failed')
}
// domain/driver-locator.ts (pure domain, no infra imports)
class DriverLocator {
async findAvailableDriver(zone: Zone)