Skip to main content

Refactoring to DDD: Legacy System Modernization

Introducing DDD to an existing codebase requires careful strategy. This chapter covers patterns for gradually migrating from anemic models, transaction scripts, and big ball of mud architectures to clean DDD.

TL;DR

StrategyWhen to UseRisk Level
Strangler FigLarge legacy systemLow
Bubble ContextNeed quick winsLow
Branch by AbstractionGradual migrationMedium
Big BangSmall system, high coverageHigh

Recognizing the Need for DDD

Signs Your Codebase Needs DDD

┌─────────────────────────────────────────────────────────┐
│ SYMPTOMS OF DDD-NEEDING CODEBASE │
├─────────────────────────────────────────────────────────┤
│ │
│ ANEMIC DOMAIN MODEL │
│ • Entities are just data containers │
│ • All logic in services │
│ • Getters/setters everywhere │
│ │
│ TRANSACTION SCRIPT │
│ • Procedural code in service methods │
│ • Duplicated business rules │
│ • No clear domain concepts │
│ │
│ BIG BALL OF MUD │
│ • Everything depends on everything │
│ • No clear boundaries │
│ • Changes ripple everywhere │
│ │
│ SMART UI │
│ • Business logic in controllers/views │
│ • Validation scattered │
│ • No reusable domain layer │
│ │
└─────────────────────────────────────────────────────────┘

Example: Anemic Model

// ❌ Anemic entity - just data
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public List<OrderLine> Lines { get; set; }
}

// ❌ All logic in service
public class OrderService
{
public void PlaceOrder(Order order)
{
if (order.Lines == null || !order.Lines.Any())
throw new Exception("Order must have lines");

if (order.Status != "Draft")
throw new Exception("Only draft orders can be placed");

order.Status = "Placed";
order.Total = order.Lines.Sum(l => l.Quantity * l.Price);

_repository.Save(order);
_emailService.SendConfirmation(order);
}
}

Strategy 1: Strangler Fig Pattern

Gradually replace legacy with new DDD code:

Implementation

// Step 1: Create facade over legacy
public class OrderFacade
{
private readonly LegacyOrderService _legacy;

public async Task<OrderId> PlaceOrder(PlaceOrderCommand command)
{
// Delegate to legacy
return await _legacy.PlaceOrder(command);
}
}

// Step 2: Add feature flag for new implementation
public class OrderFacade
{
private readonly LegacyOrderService _legacy;
private readonly NewOrderService _new;
private readonly IFeatureFlags _flags;

public async Task<OrderId> PlaceOrder(PlaceOrderCommand command)
{
if (_flags.IsEnabled("use-new-order-service"))
{
return await _new.PlaceOrder(command);
}
return await _legacy.PlaceOrder(command);
}
}

// Step 3: Gradually migrate, then remove legacy
public class OrderFacade
{
private readonly NewOrderService _new;

public async Task<OrderId> PlaceOrder(PlaceOrderCommand command)
{
return await _new.PlaceOrder(command);
}
}

Strategy 2: Bubble Context

Create a new bounded context alongside legacy:

Implementation

// New DDD aggregate in bubble
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }

public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException();

Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id));
}
}

// ACL to integrate with legacy
public class LegacyCustomerAdapter : ICustomerService
{
private readonly LegacyCustomerRepository _legacy;

public async Task<Customer> GetById(CustomerId id)
{
var legacyCustomer = await _legacy.GetById(id.Value);

// Translate legacy to domain model
return new Customer(
new CustomerId(legacyCustomer.ID),
new CustomerName(legacyCustomer.CUST_NAME),
new EmailAddress(legacyCustomer.EMAIL));
}
}

Strategy 3: Refactor in Place

Gradually improve existing code:

Step 1: Extract Value Objects

// Before: Primitive obsession
public class Order
{
public decimal TotalAmount { get; set; }
public string TotalCurrency { get; set; }
public string CustomerEmail { get; set; }
}

// After: Value objects
public class Order
{
public Money Total { get; private set; }
public EmailAddress CustomerEmail { get; private set; }
}

public record Money(decimal Amount, Currency Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new CurrencyMismatchException();
return new Money(Amount + other.Amount, Currency);
}
}

Step 2: Move Logic to Entities

// Before: Logic in service
public class OrderService
{
public void AddLine(Order order, Product product, int quantity)
{
var line = new OrderLine
{
ProductId = product.Id,
Quantity = quantity,
Price = product.Price
};
order.Lines.Add(line);
order.Total = order.Lines.Sum(l => l.Price * l.Quantity);
}
}

// After: Logic in aggregate
public class Order : AggregateRoot<OrderId>
{
private readonly List<OrderLine> _lines = new();
public Money Total { get; private set; }

public void AddLine(ProductId productId, int quantity, Money price)
{
var line = new OrderLine(productId, quantity, price);
_lines.Add(line);
RecalculateTotal();
}

private void RecalculateTotal()
{
Total = _lines
.Select(l => l.Price.MultiplyBy(l.Quantity))
.Aggregate(Money.Zero, (a, b) => a.Add(b));
}
}

Step 3: Enforce Invariants

// Before: No invariant protection
public class Order
{
public string Status { get; set; } // Anyone can change
public List<OrderLine> Lines { get; set; } // Anyone can modify
}

// After: Invariants protected
public class Order : AggregateRoot<OrderId>
{
public OrderStatus Status { get; private set; } // Private setter

private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // Read-only

public void AddLine(ProductId productId, int quantity, Money price)
{
// Invariant: Can't modify placed order
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException("Cannot modify non-draft order");

_lines.Add(new OrderLine(productId, quantity, price));
}
}

Step 4: Extract Domain Events

// Before: Side effects scattered
public class OrderService
{
public void PlaceOrder(Order order)
{
order.Status = "Placed";
_repository.Save(order);
_emailService.SendConfirmation(order);
_analyticsService.TrackOrder(order);
_inventoryService.Reserve(order.Lines);
}
}

// After: Domain events
public class Order : AggregateRoot<OrderId>
{
public void Place()
{
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, CustomerId, Lines, Total));
}
}

// Handlers react to events
public class SendOrderConfirmationHandler : IEventHandler<OrderPlaced> { }
public class TrackOrderAnalyticsHandler : IEventHandler<OrderPlaced> { }
public class ReserveInventoryHandler : IEventHandler<OrderPlaced> { }

Refactoring Checklist

Phase 1: Foundation

  • Identify bounded contexts (even if not implemented)
  • Create ubiquitous language glossary
  • Introduce value objects for primitives
  • Add strongly-typed IDs

Phase 2: Aggregates

  • Identify aggregate boundaries
  • Move logic from services to aggregates
  • Make aggregate internals private
  • Enforce invariants in aggregates

Phase 3: Events

  • Introduce domain events
  • Extract side effects to event handlers
  • Consider event sourcing for core aggregates

Phase 4: Boundaries

  • Implement bounded context boundaries
  • Add ACLs for legacy integration
  • Migrate to context-per-service (if microservices)

Common Refactoring Patterns

Replace Setter with Method

// Before
order.Status = "Cancelled";

// After
order.Cancel(reason);

Replace Constructor with Factory

// Before
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = "Draft"
};

// After
var order = Order.Create(customerId, shippingAddress);

Replace Service Method with Aggregate Method

// Before
_orderService.AddLineToOrder(order, productId, quantity, price);

// After
order.AddLine(productId, quantity, price);

Replace Database Query with Repository

// Before
var order = _dbContext.Orders
.Include(o => o.Lines)
.FirstOrDefault(o => o.Id == orderId);

// After
var order = await _orderRepository.GetById(orderId);

Handling Resistance

"It's Too Much Work"

  • Start small with one aggregate
  • Show value with quick wins
  • Refactor incrementally, not all at once

"We Don't Have Time"

  • Refactor as you touch code (Boy Scout Rule)
  • Include refactoring in feature estimates
  • Technical debt has interest

"The Team Doesn't Know DDD"

  • Pair programming sessions
  • Book club (DDD Distilled is quick)
  • Start with tactical patterns (easier)

Quick Reference Card

┌─────────────────────────────────────────────────────────┐
│ REFACTORING TO DDD QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ STRATEGIES │
│ • Strangler Fig: Gradual replacement │
│ • Bubble Context: New context alongside legacy │
│ • Refactor in Place: Improve existing code │
│ │
│ REFACTORING ORDER │
│ 1. Value objects (primitives → VOs) │
│ 2. Strongly-typed IDs │
│ 3. Move logic to entities │
│ 4. Protect invariants │
│ 5. Add domain events │
│ 6. Implement repositories │
│ │
│ KEY PATTERNS │
│ • Setter → Method │
│ • Constructor → Factory │
│ • Service method → Aggregate method │
│ • Raw query → Repository │
│ │
└─────────────────────────────────────────────────────────┘

Next Steps