Skip to content
Leo Jung
Go back

[DDD Intro #27] Applying DDD to legacy systems

Edit page

When studying DDD, we see many clean examples. Domain objects are clear, layers are separated, and Aggregate boundaries are well defined. But real systems are often not like that.

Legacy systems mix old tables, huge service classes, unclear status values, common modules shared by several features, and exception rules known only by operators. Trying to apply DDD to this kind of system can feel overwhelming.

The most dangerous thought is, “We need to rebuild everything.” Applying DDD to legacy is not replacing the whole system at once. It is rebuilding important boundaries first and gradually recovering the model starting from the core domain.

Accept the Big Ball of Mud

The first attitude needed when looking at a legacy system is acceptance. We must accept that the current system is complex and messy. At the same time, we must also accept that this system has supported the business until now.

Legacy does not appear for no reason. It is created by accumulated practical reasons: fast releases, frequent policy changes, limited staffing, ambiguous requirements, organizational changes, and technology transitions.

So if we ignore legacy and design a new model ideally, failure is likely. We need to find the domain knowledge hidden in the existing system.

Why is this status value needed?
Who changes this flag, and when?
What exceptions are manually handled by operators?
Which business process stops if this batch fails?
What words does customer support use to explain this state?

Legacy code can be messy, but real business rules remain inside it.

Find the Core Domain first

If we try to change the entire legacy system to DDD, the scope becomes too large. We must first find the Core Domain.

In a subscription service, which of the following is most important?

Subscription renewal and expiration policy
Plan change policy
Refund and cancellation policy
Admin notices
Email template management
Banner exposure settings

Everything is needed, but not at the same level of importance. It is better to start with areas that directly affect business outcomes and customer experience, change often, and have complex rules.

For example, if “subscription renewal policy” is core, first isolate and model the renewal-related code rather than changing the whole system. The goal here is not a perfect rewrite, but creating a boundary.

Do not immediately mix the new model with the old model

A common mistake when creating a new domain model in a legacy system is immediately mixing the old model and the new model.

Suppose the legacy subscription table has status values like this:

0: pending
1: normal
2: suspended
3: canceled
4: expired
9: other

Even if we create SubscriptionStatus in the new model, bringing these values as they are ties the new model to the ambiguity of the legacy system.

SubscriptionStatus.fromLegacyCode(code);

The conversion itself may be necessary. But this conversion should not spread throughout the domain model. A translation layer is needed between the legacy model and the new model.

This is where an Anti-Corruption Layer is useful.

LegacySubscriptionRecord record = legacyRepository.findById(id);
Subscription subscription = legacySubscriptionTranslator.toDomain(record);

The new domain model does not directly know legacy column names and code values. The translation layer interprets legacy meaning and changes it into domain language.

Wrap with the Strangler Fig approach

If replacing legacy at once is difficult, it is better to wrap some features with the new model. This approach is often called the Strangler Fig pattern.

Keep the existing system running, and implement new features or core features that change often inside a new boundary. Then gradually reduce the responsibility of the legacy system.

Suppose we move subscription cancellation policy to a new model.

1. Analyze the existing cancellation logic.
2. With domain experts, organize the language of cancellation, refund, expiration, and administrative termination.
3. Create a new Subscription model and CancelPolicy.
4. Place a Translator between the existing DB and the new model.
5. Make only the cancellation use case use the new model.
6. Later expand the scope to renewal, plan change, and so on.

This approach may look slow. But it is much safer than a full rewrite. The business must keep running, and the new model must be verified inside real operational requirements.

Move transaction scripts into the domain model

Legacy systems often have long service methods.

public void cancel(Long subscriptionId) {
    // load
    // check state
    // calculate refund eligibility
    // update state
    // save history
    // send notification
    // change settlement flag
}

It is hard to make this code beautiful all at once. Instead, we can find and move domain rules one by one.

First, move state checks into Subscription.

subscription.cancelByMember(reason, now);

Separate refund eligibility into RefundPolicy.

RefundDecision decision = refundPolicy.decide(subscription, payment, now);

Separate external system calls into Application Service or Adapter.

As this work continues, service methods gradually become closer to use case coordinators, and domain rules gather inside the model.

The existing DB does not need to change immediately

Applying DDD does not mean the DB schema must immediately be changed into an ideal form. A legacy DB may be shared by several systems, and changing it can be costly.

At first, we can keep the existing DB and place a domain model and mapping layer.

Legacy Table -> Mapper/Translator -> Domain Model

This structure has mapping cost, but it protects the new domain model to some degree from the existing table structure. Later, when the boundary becomes stable and there are enough reasons to change, schema improvements can be considered.

What matters is not immediately changing the DB structure, but recovering domain language inside the code.

Accumulate small successes

Applying DDD to legacy is a long effort. Small successes matter more than big declarations.

Translate one status code into a domain concept.
Gather one scattered conditional into a Policy.
Change one setter-based state change into domain behavior.
Hide one external API DTO behind an ACL.
Write one test as a domain sentence.

These changes may look small, but they change the team’s language and design sense. A legacy system does not become clean all at once. But starting from the core domain, boundaries and language can be recovered little by little.

Closing

Applying DDD to a legacy system is not rebuilding everything. It is finding domain knowledge hidden inside legacy, rebuilding important boundaries first, and gradually recovering the model from the core domain.

We should accept the Big Ball of Mud, but not give up. Put an ACL between the old model and the new model, and move by wrapping rather than replacing at once. Rules scattered across transaction scripts can gradually move into domain objects and policy objects.

DDD is not a method only for clean new projects. It can be especially powerful for finding the language and boundaries of the domain again inside complex legacy.

What matters is not a perfect rewrite, but small improvements in the right direction.


Edit page
Share this post:

Previous Post
[DDD Intro #28] Modular Monolith: What to think about before microservices
Next Post
[DDD Intro #26] Explaining domain models with tests