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
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.
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.
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
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
| Benefit | Why |
|---|---|
| Progressive migration | Module by module, no Big Bang rewrite |
| Fast unit tests | InMemory adapters, no DB required |
| Type safety | Domain = Value Objects only, no raw arrays |
| Legacy isolation | Legacy is unaware of the DDD world |
| Evolvability | Replacing 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
