Loading...
Loading...
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.
npx skill4agent add ntcoding/claude-skillz tactical-ddd// ❌ 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()
}
}ManagerHandlerProcessorHelperUtilDataInfoItemprocesshandleexecute// ❌ 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()
}
}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)
}// ❌ 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)
}// ❌ 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): Promise<Driver> {
// domain logic to find nearest available driver
}
}
// use-cases/dispatch-delivery.ts (orchestrates domain + infra)
async function dispatchDelivery(deliveryId: DeliveryId) {
const delivery = await deliveryRepository.find(deliveryId)
const driver = await withRetry(
() => driverLocator.findAvailableDriver(delivery.zone), 3
)
delivery.assignDriver(driver)
await deliveryRepository.save(delivery)
}// This code looks fine - isolated, uses domain terms
class Delivery {
status: DeliveryStatus
driver: Driver | null
pickupTime: Date | null
dropoffTime: Date | null
proofOfDelivery: Photo | null
assignDriver(driver: Driver): void {
if (this.status !== DeliveryStatus.Confirmed) throw new Error('...')
this.driver = driver
this.status = DeliveryStatus.Assigned
}
recordPickup(): void {
if (this.status !== DeliveryStatus.Assigned) throw new Error('...')
this.pickupTime = new Date()
this.status = DeliveryStatus.InTransit
}
recordDropoff(photo: Photo): void {
if (this.status !== DeliveryStatus.InTransit) throw new Error('...')
this.proofOfDelivery = photo
this.dropoffTime = new Date()
this.status = DeliveryStatus.Delivered
}
}
// But the TYPES can describe the domain! Each state is a distinct concept.
// Reading the types alone tells you how deliveries work.
type Delivery =
| RequestedDelivery // Customer placed request
| ConfirmedDelivery // Restaurant accepted
| AssignedDelivery // Driver assigned, heading to restaurant
| InTransitDelivery // Driver picked up, heading to customer
| DeliveredDelivery // Complete with proof
interface RequestedDelivery {
kind: 'requested'
customer: Customer
restaurant: Restaurant
items: MenuItem[]
}
interface ConfirmedDelivery {
kind: 'confirmed'
customer: Customer
restaurant: Restaurant
items: MenuItem[]
estimatedPrepTime: Duration
}
interface AssignedDelivery {
kind: 'assigned'
customer: Customer
restaurant: Restaurant
items: MenuItem[]
driver: Driver // Now guaranteed to exist
estimatedPickup: Time
}
interface InTransitDelivery {
kind: 'in_transit'
customer: Customer
restaurant: Restaurant
items: MenuItem[]
driver: Driver
pickupTime: Time // Now guaranteed to exist
estimatedDropoff: Time
}
interface DeliveredDelivery {
kind: 'delivered'
customer: Customer
restaurant: Restaurant
items: MenuItem[]
driver: Driver
pickupTime: Time
dropoffTime: Time // Now guaranteed to exist
proofOfDelivery: Photo // Now guaranteed to exist
}
// State transitions are explicit functions
function confirmDelivery(d: RequestedDelivery, prepTime: Duration): ConfirmedDelivery
function assignDriver(d: ConfirmedDelivery, driver: Driver): AssignedDelivery
function recordPickup(d: AssignedDelivery): InTransitDelivery
function recordDropoff(d: InTransitDelivery, photo: Photo): DeliveredDelivery// Extract an if statement to a named method
if (distance.kilometers > 10 && !driver.hasLongRangeVehicle) { ... }
if (delivery.exceedsDriverRange(driver)) { ... }
// Name a boolean expression
const canAssign = driver.isAvailable && driver.isInZone(delivery.zone) && !driver.atCapacity
const canAssign = driver.canAccept(delivery)
// Rename to use domain language
const fee = customFee ?? standardFee
const fee = customFee ?? defaultDeliveryFee// ❌ WRONG - no aggregate boundary, invariants violated
class Delivery {
stops: DeliveryStop[] // Exposed!
totalDistance: Distance
}
// External code can break invariants
delivery.stops.push(new DeliveryStop(location))
// Oops - totalDistance is now wrong!
// ✅ RIGHT - aggregate protects invariants
class Delivery {
private stops: DeliveryStop[] = []
private _totalDistance: Distance = Distance.zero()
addStop(location: Location): void {
if (this.status !== DeliveryStatus.Planning) {
throw new DeliveryNotModifiableError(this.id)
}
const previousStop = this.stops[this.stops.length - 1]
const stop = new DeliveryStop(location)
this.stops.push(stop)
this._totalDistance = this._totalDistance.add(
previousStop.distanceTo(location) // Invariant maintained!
)
}
removeStop(stopId: StopId): void {
if (this.stops.length <= 2) {
throw new MinimumStopsRequiredError(this.id)
}
// Recalculate total distance after removal
this.stops = this.stops.filter(s => !s.id.equals(stopId))
this._totalDistance = this.calculateTotalDistance() // Invariant maintained!
}
get totalDistance(): Distance {
return this._totalDistance
}
}// Entity with primitives that should be a value object
class Delivery {
id: DeliveryId
feeAmount: number
feeCurrency: string
}
// Extract the value object
class Delivery {
id: DeliveryId
fee: Money
}
class Money {
constructor(
readonly amount: number,
readonly currency: Currency
) {}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new CurrencyMismatchError(this.currency, other.currency)
}
return new Money(this.amount + other.amount, this.currency)
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency
}
}