Skip to main content

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

MetricBeforeAfter
Order-related bugs/month458
Deployment frequencyWeeklyDaily
Time to add new order status2 weeks2 days
Team understanding"It's complicated"Clear ownership

Lessons Learned

  1. Start with bounded contexts - Strategic before tactical
  2. Events are key - Enabled decoupling between contexts
  3. Aggregate size matters - Initially too large, had to split
  4. 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

MetricBeforeAfter
Endorsement processing time15 min2 sec
Audit capabilityNoneComplete history
Business rule clarityIn stored procsIn domain code
Regulatory complianceManualAutomated

Lessons Learned

  1. Event Sourcing fits insurance - Natural audit trail
  2. Ubiquitous language is crucial - "Bind" not "Activate"
  3. Temporal queries are powerful - Retroactive analysis
  4. 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

MetricBeforeAfter
Time to add new feature flag3 days2 hours
Tenant-specific bugs20/month3/month
Onboarding new tenant1 week1 hour
Plan upgrade issuesCommonRare

Lessons Learned

  1. Tenant is an aggregate - Not just a filter
  2. Features are value objects - Composable, testable
  3. Context per capability - Not per tenant
  4. 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

MetricBeforeAfter
Double-bookings50/day0
Resource conflicts30/day0
Compliance violationsUnknownTracked, near-zero
Scheduling auditNoneComplete

Lessons Learned

  1. Domain service for complex rules - Scheduling policy
  2. Invariants prevent invalid states - No double-bookings possible
  3. Events enable audit - Every scheduling decision recorded
  4. Value objects for time - TimeSlot encapsulates slot logic

Summary: Patterns Across Case Studies

PatternE-CommerceInsuranceSaaSHealthcare
Bounded Contexts
Rich Aggregates
Domain Events
Event Sourcing---
Domain Services---
Value Objects

Key Takeaways

  1. Start with strategic DDD - Bounded contexts before tactical patterns
  2. Events are universally useful - Every case study used them
  3. Aggregates protect invariants - Core benefit of DDD
  4. Ubiquitous language matters - Domain experts must recognize the code
  5. 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 │
│ │
└─────────────────────────────────────────────────────────┘