Tactical DDD
Design, refactor, analyze, and review code by applying the principles and patterns of tactical domain-driven design.
This codebase's conventions
This codebase targets .NET 8. It originated on an old .NET version and is being modernized as it is enriched — write idiomatic modern C#, do not copy the legacy style:
- File-scoped namespaces (
namespace Foo;), not bracketed blocks. - Value objects as
record/readonly record struct— you get value equality,ToString, and immutability for free, so do NOT hand-rollEquals/GetHashCode. Useinit-only /requiredmembers and a static factory (or a validating primary constructor) so an instance cannot exist in an invalid state. EF Core maps these via owned types / value converters — keep a way for EF to materialize them, but don't let EF needs dictate a mutable, ctor-less data bag. - Nullable reference types enabled; use them to make "must exist" vs "may be absent" explicit rather than relying on runtime null checks.
- Invariants: throw a domain-specific exception (or return a result type) from the factory /
method that enforces them — not a generic
Exception, and not validation scattered in services. - Layers:
Domain/(entities, value objects, invariants),Application/(services that orchestrate),Controllers/(HTTP + DTOs),Infrastructure/(EF Core config). Domain holds no EF or ASP.NET references. - Keep the public API (controllers, DTOs, endpoints) unchanged when refactoring internals.
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