DDD in a Legacy Monolith: The Art of Coexisting Without Contamination

This article comes from a real challenge I faced at work.

I was working on a large-scale project built on a legacy monolith — the kind of codebase that has grown over years, where every team has added its own layer, its own conventions, its own shortcuts. At some point, we started introducing new modules using a modern hexagonal architecture (DDD, Ports & Adapters). The goal was clear: improve maintainability, testability, and long-term evolvability — without stopping the world to rewrite everything from scratch.

But quickly, a question emerged: how do you make a clean DDD module consume a legacy service without letting the legacy contaminate the domain? How do you keep your domain pure — with its own Value Objects, its own types, its own invariants — while still relying on old repositories that return raw arrays, mutable entities, and inconsistent naming?

After experimenting and iterating, I landed on a pattern that worked really well in practice. I wanted to write it down and share it — because I believe this kind of architectural challenge is far more common than most articles acknowledge, and the solutions are often buried in team knowledge rather than documented anywhere.

In this article, I’ll walk you through this pattern using a fictional e-commerce domain (order fulfillment), which mirrors the structure of the real problem without exposing any confidential context. The key insight: legacy ports live in the Infrastructure layer of your DDD bounded context, acting as a controlled bridge between the two worlds.

The Context: Two Worlds in One Project

Imagine an e-commerce platform organized as follows:

src/
  Legacy/           ← old code (services, mutable Doctrine entities, repositories)
  App/              ← new bounded contexts (hexagonal architecture / DDD)
    Fulfillment/    ← new DDD module for shipment management
      Domain/
      Infrastructure/
      Application/
      Ui/

The App\Fulfillment module is a new DDD bounded context responsible for managing order shipments. It needs to retrieve orders from… the Legacy module. No question of rewriting the legacy repository right now. But no question either of letting the DDD domain depend directly on legacy code.

Architecture Overview

Legacy Monolith Service Layer Repositories Mutable Entities Controllers DB (Doctrine ORM) DDD Bounded Context Ui / Http Application (CQRS) Domain Ports / Entities Infrastructure Adapter/ OrderRepositoryAdapter Port/ ← bridge LegacyOrderRepositoryInterface TestAdapter/ InMemoryOrderRepository DoctrineOrderRepository DI alias
Architecture: DDD Bounded Context consuming a Legacy service via Infrastructure/Port bridge

The Problem: Direct Dependency Is a Trap

If you simply do:

// ❌ Do NOT do this in the DDD Domain
class ShipOrderService
{
    public function __construct(
        private readonly \Legacy\Repository\OrderRepository $orderRepository,
    ) {}
}

You’ve broken domain isolation. The Domain now knows the legacy world: its mutable entities, its naming conventions, its array|\stdClass return types. Any evolution in the legacy breaks the DDD domain.

❌ Direct Dependency Domain\ShipOrderService Legacy\OrderRepository Domain knows Legacy types ✅ Port Abstraction Domain\ShipOrderService OrderRepositoryInterface (Domain/Ports/ — pure contract) Domain only knows its own types
The wrong way vs. the right way

The Solution: The Legacy Port in Infrastructure

The idea is simple: we don’t let the Domain talk to legacy. We create an interface in the bounded context’s Infrastructure, and the legacy service implements it.

Domain/Ports/ OrderRepositoryInterface findById(OrderId): Order | findPendingByWarehouse(WarehouseId): Order[] implements Infrastructure/Adapter/ OrderRepositoryAdapter translates array → Value Objects (Order, OrderId, Address…) uses Infrastructure/Port/ ← bridge LegacyOrderRepositoryInterface findById(int): array | findPendingByWarehouseId(int): array[] DI alias Legacy Module Legacy\Repository\OrderRepository unchanged — unaware of DDD context Anti-Corruption Layer — the Domain never touches Legacy types
Anti-Corruption Layer: four layers separating Domain from Legacy

Here is the pattern in three steps.

Step 1 — The Domain Port (Pure Contract)

In Domain/Ports/, we define what the domain needs, using its own types:

<?php
// src/App/Fulfillment/Domain/Ports/OrderRepositoryInterface.php
declare(strict_types=1);
namespace App\Fulfillment\Domain\Ports;
interface OrderRepositoryInterface
{
    public function findById(OrderId $id): Order;
    /** @return Order[] */
    public function findPendingByWarehouse(WarehouseId $warehouseId): array;
}

This port only knows domain objects: Order, OrderId, WarehouseId. No legacy dependency.

Step 2 — The Legacy Port in Infrastructure (The Bridge)

In Infrastructure/Port/, we define a second interface — the one we expect from the legacy world:

<?php
// src/App/Fulfillment/Infrastructure/Port/LegacyOrderRepositoryInterface.php
declare(strict_types=1);
namespace App\Fulfillment\Infrastructure\Port;
interface LegacyOrderRepositoryInterface
{
    public function findById(int $id): array;
    /** @return array[] */
    public function findPendingByWarehouseId(int $warehouseId): array;
}

This interface speaks “legacy language”: int, array. It’s the facade we impose on legacy.

In config/services.yaml, we wire the existing legacy service to this interface:

# config/services.yaml
App\Fulfillment\Infrastructure\Port\LegacyOrderRepositoryInterface:
    alias: Legacy\Repository\OrderRepository

The legacy service doesn’t even need to know it’s being used by a DDD context. It simply implements the interface.

Step 3 — The Infrastructure Adapter (The Translation)

The adapter implements the Domain Port and translates legacy data into domain objects:

<?php
// src/App/Fulfillment/Infrastructure/Adapter/OrderRepositoryAdapter.php
declare(strict_types=1);
namespace App\Fulfillment\Infrastructure\Adapter;
use App\Fulfillment\Domain\Ports\OrderRepositoryInterface;
use App\Fulfillment\Infrastructure\Port\LegacyOrderRepositoryInterface;
final class OrderRepositoryAdapter implements OrderRepositoryInterface
{
    public function __construct(
        private readonly LegacyOrderRepositoryInterface $legacyRepository,
    ) {}
    public function findById(OrderId $id): Order
    {
        $row = $this->legacyRepository->findById($id->value());
        return Order::reconstitute(
            id: new OrderId($row['id']),
            reference: new OrderReference($row['reference']),
            status: OrderStatus::from($row['status']),
            shippingAddress: new Address(
                street: $row['shipping_street'],
                city: $row['shipping_city'],
                postalCode: $row['shipping_postal_code'],
            ),
            items: \array_map(
                static fn (array $item) => new OrderItem(
                    sku: new Sku($item['sku']),
                    quantity: new Quantity($item['qty']),
                ),
                $row['items'],
            ),
        );
    }
    // findPendingByWarehouse follows the same pattern...
}

And we bind the adapter to the Domain Port:

# config/services.yaml
App\Fulfillment\Domain\Ports\OrderRepositoryInterface:
    alias: App\Fulfillment\Infrastructure\Adapter\OrderRepositoryAdapter

Full Request Flow

HTTP Request POST /api/fulfillment/ship ShipOrderController Ui/Http/Api/ — dispatches ShipOrderCommand via MessageBus (Symfony Messenger) ShipOrderCommandHandler Application/Command/ — uses OrderRepositoryInterface (Domain Port — resolved by DI to the adapter) OrderRepositoryAdapter Infrastructure/Adapter/ — translates array → Value Objects uses LegacyOrderRepositoryInterface Legacy\Repository\OrderRepository Legacy module — unchanged returns raw array data from the database Each layer has a single responsibility. No layer knows what’s above it.
Request flow from HTTP to legacy DB — each layer unaware of the next

Testability: The InMemory Adapter

One of the biggest benefits of this pattern is testability. We can substitute the adapter with an in-memory implementation for unit tests, without touching either the domain or the legacy:

<?php
// src/App/Fulfillment/Infrastructure/TestAdapter/InMemoryOrderRepository.php
declare(strict_types=1);
namespace App\Fulfillment\Infrastructure\TestAdapter;
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    /** @var Order[] */
    private array $orders = [];
    public function save(Order $order): void
    {
        $this->orders[$order->id()->value()] = $order;
    }
    public function findById(OrderId $id): Order
    {
        return $this->orders[$id->value()]
            ?? throw OrderNotFoundException::withId($id);
    }
    public function findPendingByWarehouse(WarehouseId $warehouseId): array
    {
        return \array_values(\array_filter(
            $this->orders,
            static fn (Order $o) => $o->warehouseId()->equals($warehouseId)
                && $o->status()->isPending(),
        ));
    }
}

Your unit test instantiates InMemoryOrderRepository, seeds it with Order objects built via a builder, and passes it to ShipOrderCommandHandler — no database, no legacy, no HTTP. Fast and deterministic.

What This Pattern Makes Possible

BenefitWhy
Progressive migrationModule by module, no Big Bang rewrite
Fast unit testsInMemory adapters, no DB required
Type safetyDomain = Value Objects only, no raw arrays
Legacy isolationLegacy is unaware of the DDD world
EvolvabilityReplacing the legacy = changing one DI alias

Conclusion

Introducing DDD into a legacy project is not a revolution. It’s a controlled evolution, bounded context by bounded context.

The Legacy Port in Infrastructure pattern is the cement that lets the two worlds collaborate without contaminating each other:

  • The DDD Domain stays pure and testable
  • The Legacy continues working without modification
  • The Adapter alone bears the responsibility of translation

Leave a Reply

Your email address will not be published. Required fields are marked *