Real-World Case Studies: DDD in Production
These anonymized case studies illustrate how DDD principles apply to real-world systems. Each case shows the problem, the DDD approach, and lessons learned.
Case Study 1: E-Commerce Order Management
Context
A mid-size e-commerce company with 500K daily orders. Legacy system was a monolith with anemic domain model.
Problem
// Legacy: 15 services touching Order table directly
public class OrderService { /* 2000 lines */ }
public class FulfillmentService { /* 1500 lines */ }
public class RefundService { /* 800 lines */ }
public class ReportingService { /* 1200 lines */ }
// ... 11 more services
// Result: Inconsistent order states, race conditions, bugs
DDD Solution
1. Identified Bounded Contexts
2. Defined Order Aggregate
public class Order : AggregateRoot<OrderId>
{
public OrderStatus Status { get; private set; }
private readonly List<OrderLine> _lines = new();
// Clear state machine
public void Place() { /* Draft → Placed */ }
public void Confirm() { /* Placed → Confirmed */ }
public void Ship(TrackingNumber tracking) { /* Confirmed → Shipped */ }
public void Deliver() { /* Shipped → Delivered */ }
public void Cancel(CancellationReason reason) { /* Various → Cancelled */ }
// Invariants enforced
private void EnsureCanTransitionTo(OrderStatus newStatus)
{
var allowed = _stateTransitions[Status];
if (!allowed.Contains(newStatus))
throw new InvalidOrderStateTransitionException(Status, newStatus);
}
}
3. Event-Driven Integration
// Orders context publishes events
public class Order
{
public void Ship(TrackingNumber tracking)
{
EnsureCanTransitionTo(OrderStatus.Shipped);
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShipped(Id, tracking));
}
}
// Fulfillment context reacts
public class UpdateFulfillmentOnOrderShipped : IEventHandler<OrderShipped>
{
public async Task Handle(OrderShipped @event)
{
var fulfillment = await _repo.GetByOrderId(@event.OrderId);
fulfillment.MarkShipped(@event.TrackingNumber);
await _repo.Save(fulfillment);
}
}
Results
| Metric | Before | After |
|---|---|---|
| Order-related bugs/month | 45 | 8 |
| Deployment frequency | Weekly | Daily |
| Time to add new order status | 2 weeks | 2 days |
| Team understanding | "It's complicated" | Clear ownership |
Lessons Learned
- Start with bounded contexts - Strategic before tactical
- Events are key - Enabled decoupling between contexts
- Aggregate size matters - Initially too large, had to split
- Gradual migration - Strangler fig over 6 months
Case Study 2: Insurance Policy Management
Context
Insurance company managing 2M active policies. Complex business rules for underwriting, endorsements, and claims.
Problem
// Legacy: Business rules scattered in stored procedures
CREATE PROCEDURE sp_ProcessEndorsement
AS
BEGIN
-- 500 lines of T-SQL with business rules
-- Nobody understands it
-- Can't unit test
-- Changes are terrifying
END
DDD Solution
1. Event Storming Discovery
Domain Events Discovered:
- PolicyQuoted
- PolicyBound
- PolicyEndorsed
- PolicyRenewed
- PolicyCancelled
- PolicyLapsed
- ClaimFiled
- ClaimApproved
- ClaimPaid
2. Policy Aggregate with Event Sourcing
public class Policy : EventSourcedAggregate
{
public PolicyNumber Number { get; private set; }
public PolicyStatus Status { get; private set; }
public Coverage Coverage { get; private set; }
public Premium Premium { get; private set; }
public void Bind(UnderwritingDecision decision)
{
if (Status != PolicyStatus.Quoted)
throw new PolicyCannotBeBoundException();
if (!decision.IsApproved)
throw new UnderwritingNotApprovedException();
Apply(new PolicyBound(
Number,
decision.ApprovedCoverage,
decision.CalculatedPremium,
DateTime.UtcNow));
}
public void Endorse(EndorsementRequest request)
{
if (Status != PolicyStatus.Active)
throw new PolicyNotActiveException();
var newPremium = RecalculatePremium(request.CoverageChanges);
Apply(new PolicyEndorsed(
Number,
request.EffectiveDate,
request.CoverageChanges,
newPremium));
}
private void When(PolicyBound @event)
{
Status = PolicyStatus.Active;
Coverage = @event.Coverage;
Premium = @event.Premium;
}
private void When(PolicyEndorsed @event)
{
Coverage = Coverage.ApplyChanges(@event.CoverageChanges);
Premium = @event.NewPremium;
}
}
3. Temporal Queries from Event Store
// "What was the coverage on March 15th?"
public async Task<Coverage> GetCoverageAsOf(PolicyNumber number, DateTime asOf)
{
var events = await _eventStore.ReadStream($"policy-{number}");
var policy = new Policy();
foreach (var @event in events.Where(e => e.Timestamp <= asOf))
{
policy.Apply(@event);
}
return policy.Coverage;
}
Results
| Metric | Before | After |
|---|---|---|
| Endorsement processing time | 15 min | 2 sec |
| Audit capability | None | Complete history |
| Business rule clarity | In stored procs | In domain code |
| Regulatory compliance | Manual | Automated |
Lessons Learned
- Event Sourcing fits insurance - Natural audit trail
- Ubiquitous language is crucial - "Bind" not "Activate"
- Temporal queries are powerful - Retroactive analysis
- Schema evolution is real - Plan for event versioning
Case Study 3: SaaS Multi-Tenant Platform
Context
B2B SaaS platform with 10K tenants. Each tenant has different configuration, pricing, and features.
Problem
// Legacy: Tenant logic everywhere
public class UserService
{
public async Task CreateUser(CreateUserDto dto)
{
var tenant = await _tenantRepo.GetById(dto.TenantId);
// Tenant-specific logic scattered
if (tenant.Plan == "Enterprise")
{
// Different validation
}
if (tenant.Features.Contains("SSO"))
{
// Different auth flow
}
// 200 more lines of if/else for tenant variations
}
}
DDD Solution
1. Tenant as Aggregate with Strategy Pattern
public class Tenant : AggregateRoot<TenantId>
{
public TenantName Name { get; }
public Plan Plan { get; private set; }
public FeatureSet Features { get; private set; }
public TenantConfiguration Configuration { get; private set; }
public bool CanUseFeature(Feature feature)
{
return Features.IsEnabled(feature) && Plan.Includes(feature);
}
public void UpgradePlan(Plan newPlan)
{
if (!newPlan.IsUpgradeFrom(Plan))
throw new InvalidPlanUpgradeException();
var previousPlan = Plan;
Plan = newPlan;
Features = Features.Merge(newPlan.IncludedFeatures);
AddDomainEvent(new TenantPlanUpgraded(Id, previousPlan, newPlan));
}
}
2. Bounded Context per Major Feature
3. Feature Flags as First-Class Concept
public class FeatureSet : ValueObject
{
private readonly HashSet<Feature> _enabledFeatures;
public bool IsEnabled(Feature feature)
{
return _enabledFeatures.Contains(feature);
}
public FeatureSet Enable(Feature feature)
{
var newSet = new HashSet<Feature>(_enabledFeatures) { feature };
return new FeatureSet(newSet);
}
public FeatureSet Merge(FeatureSet other)
{
var merged = new HashSet<Feature>(_enabledFeatures);
merged.UnionWith(other._enabledFeatures);
return new FeatureSet(merged);
}
}
Results
| Metric | Before | After |
|---|---|---|
| Time to add new feature flag | 3 days | 2 hours |
| Tenant-specific bugs | 20/month | 3/month |
| Onboarding new tenant | 1 week | 1 hour |
| Plan upgrade issues | Common | Rare |
Lessons Learned
- Tenant is an aggregate - Not just a filter
- Features are value objects - Composable, testable
- Context per capability - Not per tenant
- Events for cross-tenant operations - Billing reacts to plan changes
Case Study 4: Healthcare Scheduling System
Context
Hospital scheduling 50K appointments/day across 200 departments. Complex rules for availability, resources, and regulations.
Problem
- Double-bookings despite "validation"
- Resource conflicts (rooms, equipment)
- Compliance violations
- No visibility into scheduling decisions
DDD Solution
1. Appointment as Aggregate with Invariants
public class Appointment : AggregateRoot<AppointmentId>
{
public PatientId PatientId { get; }
public ProviderId ProviderId { get; }
public TimeSlot TimeSlot { get; private set; }
public AppointmentType Type { get; }
public List<ResourceId> RequiredResources { get; }
public AppointmentStatus Status { get; private set; }
public static Appointment Schedule(
PatientId patientId,
ProviderId providerId,
TimeSlot timeSlot,
AppointmentType type,
ISchedulingPolicy policy)
{
// Policy enforces all rules
var result = policy.CanSchedule(providerId, timeSlot, type);
if (!result.IsAllowed)
throw new SchedulingPolicyViolationException(result.Violations);
var appointment = new Appointment(
AppointmentId.New(),
patientId,
providerId,
timeSlot,
type);
appointment.AddDomainEvent(new AppointmentScheduled(...));
return appointment;
}
public void Reschedule(TimeSlot newTimeSlot, ISchedulingPolicy policy)
{
if (Status == AppointmentStatus.Completed)
throw new CannotRescheduleCompletedException();
var result = policy.CanSchedule(ProviderId, newTimeSlot, Type);
if (!result.IsAllowed)
throw new SchedulingPolicyViolationException(result.Violations);
var previousSlot = TimeSlot;
TimeSlot = newTimeSlot;
AddDomainEvent(new AppointmentRescheduled(Id, previousSlot, newTimeSlot));
}
}
2. Scheduling Policy as Domain Service
public class SchedulingPolicy : ISchedulingPolicy
{
private readonly IProviderScheduleRepository _scheduleRepo;
private readonly IResourceAvailabilityService _resourceService;
private readonly IComplianceRules _compliance;
public SchedulingResult CanSchedule(
ProviderId providerId,
TimeSlot timeSlot,
AppointmentType type)
{
var violations = new List<SchedulingViolation>();
// Check provider availability
var schedule = await _scheduleRepo.GetSchedule(providerId, timeSlot.Date);
if (!schedule.IsAvailable(timeSlot))
violations.Add(new ProviderNotAvailable(providerId, timeSlot));
// Check resource availability
var requiredResources = type.RequiredResources;
foreach (var resource in requiredResources)
{
if (!await _resourceService.IsAvailable(resource, timeSlot))
violations.Add(new ResourceNotAvailable(resource, timeSlot));
}
// Check compliance
var complianceResult = _compliance.Check(providerId, type, timeSlot);
violations.AddRange(complianceResult.Violations);
return new SchedulingResult(violations);
}
}
Results
| Metric | Before | After |
|---|---|---|
| Double-bookings | 50/day | 0 |
| Resource conflicts | 30/day | 0 |
| Compliance violations | Unknown | Tracked, near-zero |
| Scheduling audit | None | Complete |
Lessons Learned
- Domain service for complex rules - Scheduling policy
- Invariants prevent invalid states - No double-bookings possible
- Events enable audit - Every scheduling decision recorded
- Value objects for time - TimeSlot encapsulates slot logic
Summary: Patterns Across Case Studies
| Pattern | E-Commerce | Insurance | SaaS | Healthcare |
|---|---|---|---|---|
| Bounded Contexts | ✓ | ✓ | ✓ | ✓ |
| Rich Aggregates | ✓ | ✓ | ✓ | ✓ |
| Domain Events | ✓ | ✓ | ✓ | ✓ |
| Event Sourcing | - | ✓ | - | - |
| Domain Services | - | - | - | ✓ |
| Value Objects | ✓ | ✓ | ✓ | ✓ |
Key Takeaways
- Start with strategic DDD - Bounded contexts before tactical patterns
- Events are universally useful - Every case study used them
- Aggregates protect invariants - Core benefit of DDD
- Ubiquitous language matters - Domain experts must recognize the code
- Gradual migration works - No need for big bang rewrites
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ CASE STUDY LESSONS │
├─────────────────────────────────────────────────────────┤
│ │
│ COMMON SUCCESS FACTORS │
│ • Event Storming for discovery │
│ • Bounded contexts for organization │
│ • Aggregates for consistency │
│ • Events for integration │
│ • Gradual migration │
│ │
│ COMMON PITFALLS AVOIDED │
│ • Anemic domain models │
│ • God aggregates │
│ • Distributed monoliths │
│ • Primitive obsession │
│ │
│ MEASURABLE IMPROVEMENTS │
│ • Fewer bugs │
│ • Faster development │
│ • Better auditability │
│ • Clearer ownership │
│ │
└─────────────────────────────────────────────────────────┘