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 is a movie-rental backend targeting .NET 8, persisted with NHibernate. 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 Logic.Entities;), 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. - Do NOT introduce a functional-extensions /
Result<T>library (e.g. CSharpFunctionalExtensions). It is not referenced by the code you start from. Enforce invariants with a validating factory that throws a domain-specific exception. Keep the dependency set as-is — adding a value-object base-class library is exactly the kind of detour that breaks the build; stay with plain modern C#. - Nullable reference types enabled; use them to make "must exist" vs "may be absent" explicit rather than relying on runtime null checks.
- NHibernate constraints: entities are mapped by NHibernate, which needs
virtualmembers and a (protected/private) parameterless constructor to materialize them — add only those. NHibernate does not require the legacy MVC/serialization attributes that the starting code carries; do not treat them as something to be "preserved". Extract cohesive concepts into immutablerecordvalue objects around the entities. - Strip web/serialization/validation attributes off the domain entities: the starting
Customer/Movie/PurchasedMoviecarry[Required],[RegularExpression],[MaxLength],[JsonConverter],[JsonIgnore],Newtonsoft.Json, andSystem.ComponentModel.DataAnnotations— these are presentation/validation leaks, not domain concerns. Move validation into the domain (a factory or value object that throws a domain exception); JSON shaping belongs in the API/DTO layer, not on the entity. The domain layer must import no web or serialization namespace. - Layout:
Logic/Entities(domain entities),Logic/Services(services that orchestrate),Logic/Repositories+Logic/Mappings(NHibernate persistence),Api/Controllers(HTTP + DTOs). Keep domain logic out of services and out of the persistence/API layers. - 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)
pub