- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your unit suite is green. Three hundred tests, all passing, in under two seconds. The OrderRepository is a port, and your use case tests run against InMemoryOrderRepository — a tidy little class backed by an array. Save an order, find it by id, get it back. It works every time.
Then production throws a 500. A customer placed an order with a line item whose SKU has an apostrophe in it. The in-memory fake stored the string and handed it back untouched. The Doctrine adapter sent it to MySQL through a column declared VARCHAR(32) and the SKU was 40 characters. The real database truncated it; the array never would.
Your fake lied. It implemented the port's signature but not the port's behavior. And nothing in your suite caught the gap, because the fake was the only thing you ever tested against at speed.
This is the failure that contract tests close. You write one abstract test case that describes what the port promises. You run it against every adapter that claims to implement that port — the fast fake and the real Doctrine one. If both pass, the fake is honest. If the fake passes and Doctrine fails, the test told you before your customer did.
The port and its two adapters
Start with the port. A small interface, stated in domain types, living in the application layer.
<?php
declare(strict_types=1);
namespace App\Application\Port;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
interface OrderRepository
{
public function save(Order $order): void;
public function findById(OrderId $id): ?Order;
}
Two adapters implement it. The fake is backed by an array and lives next to the tests.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\InMemory;
use App\Application\Port\OrderRepository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, 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] ?? null;
}
}
The Doctrine adapter maps an OrderRecord to a table and translates between record and domain. The two could not be more different inside. From the use case's point of view they must be indistinguishable.
The contract: one test, written against the interface
The contract is an abstract test case. It never names a concrete adapter. It asks subclasses for one, through a single abstract method.
<?php
declare(strict_types=1);
namespace App\Tests\Contract;
use App\Application\Port\OrderRepository;
use App\Domain\Order\OrderId;
use App\Tests\Support\OrderMother;
use PHPUnit\Framework\TestCase;
abstract class OrderRepositoryContract extends TestCase
{
abstract protected function repository(): OrderRepository;
public function test_saves_and_finds_by_id(): void
{
$repo = $this->repository();
$order = OrderMother::placed('order-1');
$repo->save($order);
$found = $repo->findById(new OrderId('order-1'));
self::assertNotNull($found);
self::assertSame('order-1', $found->id()->value);
}
public function test_returns_null_for_missing_id(): void
{
$found = $this->repository()
->findById(new OrderId('does-not-exist'));
self::assertNull($found);
}
// This is the test that catches the SKU bug.
public function test_preserves_line_items_and_total(): void
{
$repo = $this->repository();
$order = OrderMother::withItems('order-2', [
['SKU-LONG-VALUE-THAT-FILLS-THE-COLUMN', 2, 1500],
]);
$repo->save($order);
$found = $repo->findById(new OrderId('order-2'));
self::assertSame(3000, $found->total()->amountInMinorUnits);
self::assertCount(1, $found->items());
}
}
OrderMother is a test factory that builds a placed order through Order::place(...), so every test starts from a valid aggregate. The contract describes behavior, not storage: save then find returns the same order, a missing id returns null, and the round trip preserves the data the domain cares about.
That third test is the one that would have caught the SKU bug. It deliberately uses a long SKU. The array fake stores it whole. The Doctrine adapter, if its column is too narrow, fails on the round trip — and now the failure shows up in CI on a developer's branch, not in a customer's checkout.
Two subclasses, two adapters, same assertions
Each adapter gets a thin subclass that does one thing: hand the contract a wired instance.
<?php
declare(strict_types=1);
namespace App\Tests\Contract;
use App\Application\Port\OrderRepository;
use App\Infrastructure\Persistence\InMemory\InMemoryOrderRepository;
final class InMemoryOrderRepositoryTest
extends OrderRepositoryContract
{
protected function repository(): OrderRepository
{
return new InMemoryOrderRepository();
}
}
The Doctrine subclass spins up a real EntityManager against a database and resets schema between tests.
<?php
declare(strict_types=1);
namespace App\Tests\Contract;
use App\Application\Port\OrderRepository;
use App\Infrastructure\Persistence\Doctrine\DoctrineOrderRepository;
use App\Tests\Support\DoctrineTestKernel;
final class DoctrineOrderRepositoryTest
extends OrderRepositoryContract
{
private DoctrineTestKernel $kernel;
protected function setUp(): void
{
$this->kernel = DoctrineTestKernel::boot();
$this->kernel->resetSchema();
}
protected function tearDown(): void
{
$this->kernel->shutdown();
}
protected function repository(): OrderRepository
{
return new DoctrineOrderRepository(
$this->kernel->entityManager(),
new OrderRecordMapper(),
);
}
}
Run the suite and PHPUnit reports both classes. Same three test method names, run twice, once per adapter. When test_preserves_line_items_and_total passes in the in-memory class and fails in the Doctrine class, you read the failure as one sentence: the fake and the real adapter disagree about what they promised. Fix whatever is wrong (the column, the mapper, or the SKU value object) until both agree.
What the contract is allowed to assert
Keep the contract to behavior the port guarantees to its caller. The use case calls save and findById. It expects a saved order to come back equal in the ways the domain reads it: same id, same items, same total, same status. Those are fair game.
What the contract must not assert is anything storage-specific. No SQL, no table names, no flush ordering, no JSON column shape. The moment a contract test reaches into Doctrine internals, the in-memory adapter can no longer pass it, and the shared test stops being shared. The discipline is strict: if an assertion cannot be true of every adapter, it does not belong in the contract.
Some behavior is genuinely port-level but awkward for a fake. Uniqueness on save, for example: saving two orders with the same id should overwrite, or throw, consistently. Decide which, write it into the contract, and make the array fake honor the same rule the database enforces. That is the whole point — the contract forces the fake to grow up.
Why this keeps the pyramid honest
The bottom of your test pyramid is fast because it runs against fakes. The fakes are only worth trusting if they behave like the real thing. Without a contract, a fake drifts: someone adds a method, the array version takes a shortcut, and six months later the unit suite is green against a fiction that production does not share.
The contract is the leash. Every fake that backs a use-case test is itself held to the same behavioral test as the production adapter. Your three hundred fast unit tests stay trustworthy because the handful of contract tests proved the fakes they run on are faithful.
You pay for it once per port. Write the abstract case, write two thin subclasses. From then on, every new adapter for that port (a Redis cache, a read-replica repository, a flat-file export) inherits the same contract by extending one class and returning an instance. If it passes, it is a drop-in. If it fails, you found the incompatibility on the bench instead of in the incident channel.
The seam to watch
One caution. The Doctrine subclass needs a real database, so it is slower and it needs infrastructure in CI. Keep these tests in a Contract group you can run on their own, and run the in-memory contract alongside the unit suite for the fast feedback loop. The Doctrine contract runs in the slower integration stage. Same assertions, different cadence.
Do not let the Doctrine contract test bleed into a full integration test of the use case. It tests the adapter against its port, nothing more. The use case has its own tests, against the fake, and those stay fast precisely because the contract already proved the fake honest.
The next time a fake makes your suite suspiciously green, write the contract before you trust it. One abstract test case, run against the fake and the real adapter, is the cheapest insurance you will buy this quarter.
If this was useful
Contract tests are the connective tissue that makes the ports-and-adapters split pay off instead of just looking tidy in a diagram. Decoupled PHP builds the pattern out in full — the port definitions, the fakes, the shared contracts, and the test pyramid that holds when you swap a backend — alongside the rest of the architecture this post assumes. If the idea of a fake you can actually trust appeals to you, the book is where it gets the room it needs.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)