Skip to main content
DDD Without the Book Club

Refactoring Legacy Toward DDD

Ravinder··6 min read
DDDDomain-Driven DesignArchitectureRefactoringLegacy Code
Share:
Refactoring Legacy Toward DDD

The most common DDD question isn't "how do I apply this to a greenfield project?" It's "my existing codebase is a mess — where do I even start?" The answer is never "rewrite everything." The answer is a sequence of small, safe moves that shift the code toward better structure while keeping production running.

Legacy codebases resist DDD for a reason: they were built without explicit domain boundaries. Everything touches everything. The database schema is the domain model. Service classes are thousands of lines long. You can't add a feature without reading half the codebase to understand what you'll break.

The moves described here work incrementally. Each one is a safe PR. Combined over months, they transform the structure without a rewrite.

Move 1: Identify the Costliest Seam

Don't start with the most complex part. Start with the part that's causing the most pain right now. That's usually where the domain model is most confused.

Signals of high-value seams:

  • Files that have 10+ authors in git blame
  • Classes that appear in every feature PR
  • Tests that break whenever anyone touches them
  • "God services" with methods like processEverything() or handleRequest()
# Find the most-changed files in the last 6 months — these are your hotspots
git log --since="6 months ago" --name-only --pretty=format: | \
  sort | uniq -c | sort -rn | head -20

The top files are your starting point. They change often because they own too much.

Move 2: Name the Context

Before touching code, name the bounded context you're trying to extract. Have a conversation with a domain expert. Write down the ubiquitous language — the specific terms that have precise meaning inside this context.

# Context: Order Fulfillment
 
## Ubiquitous Language
- **Order**: a confirmed purchase request, immutable once placed
- **Allocation**: the reservation of stock for an order, reversible until dispatched
- **Pick List**: the warehouse instruction to gather items, generated from allocations
- **Dispatch**: the physical handover to the carrier, finalises the allocation
 
## What's NOT in this context
- Payment processing (→ Billing Context)
- Product pricing (→ Pricing Context)
- Customer profile (→ CRM Context)

This document is more valuable than any class diagram. It tells your team what words to use in code.

Move 3: Extract Value Objects First

Value objects are the easiest DDD concept to introduce into legacy code. They're immutable, have no identity, and replace primitive fields. Start by finding strings and numbers that have domain meaning.

// Legacy: primitives everywhere
public class OrderService {
    public void placeOrder(String customerId, String productCode, 
                           int quantity, BigDecimal price, String currency) {
        // Is customerId a UUID? A legacy number? An email?
        // Is price already tax-inclusive? Per-unit? Per-lot?
        // Nobody knows without reading 300 lines.
    }
}
 
// After: value objects make the intent explicit
public class OrderService {
    public void placeOrder(
        CustomerId customerId,
        ProductSku sku,
        Quantity quantity,
        Money unitPrice
    ) {
        // CustomerId knows its format and validates on construction
        // Quantity enforces positivity
        // Money carries currency and provides arithmetic
    }
}
 
// Value object: immutable, self-validating
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.scale() > 2) throw new IllegalArgumentException("Max 2 decimal places");
    }
    
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) 
            throw new IllegalArgumentException("Currency mismatch");
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

This is a safe, mechanical refactor. Every occurrence of (BigDecimal price, String currency) becomes Money. The compiler finds every usage.

Move 4: Introduce Aggregates Around Invariants

Find business rules that are currently scattered across service methods. They belong in an aggregate.

// Legacy: invariants enforced in a service method (fragile, hard to find)
public class OrderService {
    public void addItem(Long orderId, Long productId, int qty) {
        Order order = orderRepo.findById(orderId);
        if (order.getStatus().equals("CONFIRMED")) {
            throw new RuntimeException("order already confirmed");
        }
        if (order.getItems().size() >= 50) {
            throw new RuntimeException("too many items");
        }
        // ... more rule checks scattered across other service methods
    }
}
 
// After: invariants live in the aggregate
public class Order {
    private static final int MAX_LINE_ITEMS = 50;
    
    public void addItem(ProductId productId, Quantity qty, Money unitPrice) {
        if (this.status != OrderStatus.DRAFT) {
            throw new OrderAlreadyConfirmedException(this.id);
        }
        if (this.lineItems.size() >= MAX_LINE_ITEMS) {
            throw new OrderLineItemLimitExceededException(this.id, MAX_LINE_ITEMS);
        }
        this.lineItems.add(new LineItem(productId, qty, unitPrice));
    }
}

Now the rule is in one place, testable without a database, and enforced regardless of which service calls it.

Move 5: Apply the Strangler Fig

For the most tangled code, use the strangler fig pattern: build the new implementation alongside the old, and gradually route traffic to the new.

sequenceDiagram participant CLIENT as Client participant ROUTER as Feature Flag / Router participant LEGACY as Legacy OrderService participant NEW as New Order Aggregate Note over ROUTER: Phase 1: 100% legacy CLIENT->>ROUTER: placeOrder(...) ROUTER->>LEGACY: delegateToLegacy() LEGACY-->>CLIENT: result Note over ROUTER: Phase 2: shadow mode (new runs, result discarded) CLIENT->>ROUTER: placeOrder(...) ROUTER->>LEGACY: delegateToLegacy() ROUTER->>NEW: runShadow() [async, result discarded] LEGACY-->>CLIENT: result Note over ROUTER: Phase 3: 100% new CLIENT->>ROUTER: placeOrder(...) ROUTER->>NEW: placeOrder(...) NEW-->>CLIENT: result

Shadow mode is invaluable: the new implementation runs in parallel with the old, you compare outputs, and you find divergences before they affect customers.

class OrderRouter {
  constructor(
    private legacy: LegacyOrderService,
    private newImpl: OrderApplicationService,
    private flags: FeatureFlags,
    private divergenceLogger: DivergeLogger,
  ) {}
 
  async placeOrder(command: PlaceOrderCommand): Promise<OrderId> {
    if (this.flags.isEnabled('order-new-impl')) {
      return this.newImpl.placeOrder(command);
    }
 
    const legacyResult = await this.legacy.placeOrder(command);
 
    if (this.flags.isEnabled('order-shadow-mode')) {
      // Don't await — don't block the customer
      this.newImpl.placeOrder(command)
        .then(newResult => {
          if (newResult.value !== legacyResult.orderId) {
            this.divergenceLogger.log({ command, legacyResult, newResult });
          }
        })
        .catch(err => this.divergenceLogger.logError({ command, err }));
    }
 
    return new OrderId(legacyResult.orderId);
  }
}

The Sequence That Works

gantt title Refactoring to DDD - Incremental Sequence dateFormat YYYY-MM-DD section Discovery Name contexts, document language :done, 2026-01-01, 14d section Value Objects Extract Money, Quantity, IDs :done, 2026-01-15, 21d section Aggregates Extract first aggregate + tests :active, 2026-02-05, 28d section Services Introduce application service : 2026-03-05, 21d section Strangler Shadow mode + traffic migration : 2026-03-26, 35d section Cleanup Delete legacy code : 2026-04-30, 14d

Each phase produces working, deployable code. The last phase — deleting legacy code — is the reward for doing every other phase carefully.

Key Takeaways

  • Start with the highest-pain seam, not the most complex one — the file that every PR touches is the most valuable to fix first.
  • Extract value objects before aggregates — they're mechanical, safe, and immediately improve the expressiveness of every method signature.
  • Move invariant enforcement from service methods into aggregate methods — rules become testable, discoverable, and impossible to bypass.
  • The strangler fig with shadow mode lets you run new and old implementations in parallel, catching divergences before customers see them.
  • Name the bounded context and document its ubiquitous language before touching code — naming clarity is what prevents the new code from reproducing the old mess.
  • The goal is not a perfect DDD model — it's a codebase where the next engineer can find the invariant, understand the language, and add a feature without reading everything.
Share: