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
{
public async Task Dispatch()
{
_logger.LogInformation("Dispatching delivery {Id}", Id); // Infrastructure!
await _db.BeginTransactionAsync(); // Infrastructure!
if (_status != "ready") throw new Exception("Not ready");
_status = "dispatched";
await _db.SaveAsync(this); // Infrastructure!
await _db.CommitAsync(); // Infrastructure!
await _pushNotification.NotifyDriverAsync(); // Infrastructure!
}
}
// ✅ RIGHT - isolated domain logic
class Delivery
{
public void Dispatch()
{
if (_status != DeliveryStatus.Ready)
throw new DeliveryNotReadyError(Id);
_status = DeliveryStatus.Dispatched;
_dispatchedAt = DateTime.UtcNow;
}
}
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
{
public ProcessingResult ProcessClaimData(ClaimDto claimData)
{
return _claimProcessor.Handle(claimData);
}
}
// ✅ RIGHT - domain language
class ClaimAssessor
{
public AssessmentDecision AssessClaim(InsuranceClaim claim)
{
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
// UseCases/CalculateEtaUseCase.cs
public async Task<Eta> CalculateEta(DeliveryId deliveryId)
{
var delivery = await _deliveryRepository.Find(deliveryId);
var driver = await _driverRepository.Find(delivery.DriverId);
return _routeService.EstimateArrival(driver.Location, delivery.Destination);
}
// ✅ RIGHT - actual user goal (appears in menu)
// UseCases/CancelDeliveryUseCase.cs
public async Task CancelDelivery(DeliveryId deliveryId, CancellationReason reason)
{
var 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)
public async Task ConfirmDropoff(DeliveryId deliveryId, ProofPhoto photo)
{
var delivery = await _deliveryRepository.Find(deliveryId);
// Business rules leaked into use case!
if (delivery.Status != "in_transit")
throw new Exception("Delivery not in transit");
if (photo == null && delivery.RequiresSignature)
throw new Exception("Proof of delivery required");
delivery.Status = "delivered";
delivery.ProofPhoto = photo;
delivery.DeliveredAt = DateTime.UtcNow;
await _deliveryRepository.Save(delivery);
}
// ✅ RIGHT - use case orchestrates, domain decides
public async Task ConfirmDropoff(DeliveryId deliveryId, ProofPhoto photo)
{
var 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/DriverLocator.cs
class DriverLocator
{
// Generic retry logic does not belong in domain!
private async Task<T> WithRetry<T>(Func<Task<T>> fn, int attempts)
{
for (var i = 0; i < attempts; i++)
{
try { return await fn(); }
catch { if (i == attempts - 1) throw; }
}
throw new Exception("Retry failed");
}
public Task<Driver> FindAvailableDriver(Zone zone)
=> WithRetry(() => SearchDriversInZone(zone), 3);
private Task<Driver> SearchDriversInZone(Zone zone)
{
// domain logic to find nearest available driver
}
}
// ✅ RIGHT - same behavior, properly separated
// Infrastructure/Retry.cs (generic, reusable in any project)
public static class Retry
{
public static async Task<T> WithRetry<T>(Func<Task<T>> fn, int attempts)
{
for (var i = 0; i < attempts; i++)
{
try { return await fn(); }
catch { if (i == attempts - 1) throw; }
}
throw new Exception("Retry failed");
}
}
// Domain/DriverLocator.cs (pure domain, no infra imports)
class DriverLocator
{
public Task<Driver> FindAvailableDriver(Zone zone)
{
// domain logic to find neare