Testing at the boundary
Hexagonal architecture makes the domain trivially testable: drive through the primary port, substitute in-memory fakes at secondary ports, and the entire domain runs without a database or HTTP server. This structural property defines the test pyramid hexagonal teams actually use.
Before hexagonal architecture, the B2B order platform’s OrderService test suite had a problem the team had learned to live with: every test that touched order confirmation required a running PostgreSQL instance, an active Stripe test account, and a configured SendGrid sandbox. The CI pipeline spun up a Docker Compose stack on every push. Tests took four minutes to execute. Flakiness was a constant: Stripe’s sandbox occasionally returned rate-limit errors; the database container sometimes failed to initialize in time; SendGrid’s sandbox had a daily email cap that the test suite would hit by midday. Engineers ran the full suite locally maybe once per week. The coverage was nominally 72%, but much of it was integration surface, not logic surface. When the order amount validation was changed — a purely domain rule with no infrastructure dependency — it still required a full integration environment to test, because the validation logic was inside OrderService, which imported PostgresOrderRepository, StripeClient, and SendGridClient. The untestability was not a testing problem. It was an architecture problem: the domain’s logic was entangled with its infrastructure dependencies. Hexagonal architecture solves this structurally — not by making mocking easier, but by making infrastructure dependencies structurally absent from the domain.
Why the old architecture was untestable
The opening story’s untestability was structural. OrderService had compile-time dependencies on PostgresOrderRepository, StripeClient, and SendGridClient. To instantiate OrderService in a test, you either provided real infrastructure or mocked those classes with a mocking framework. Both options carry costs:
Real infrastructure in tests requires environment setup (Docker Compose, external accounts, network access), is slow (seconds per test, minutes per suite), and is flaky (external services fail, containers timeout, rate limits hit).
Mock frameworks require the test to know the internal implementation details of OrderService — which methods it calls on which dependencies, in what order, with what arguments. When OrderService is refactored (say, extracting a domain method), the mocks break, even if the behavior is unchanged. The tests become fragile maintenance burden instead of reliable safety nets.
Both problems share a root cause: OrderService has direct source-code dependencies on infrastructure classes. The test cannot isolate the domain logic from those dependencies without either providing them or replacing them with structurally identical copies (mocks).
Hexagonal architecture removes the root cause. The domain does not have source-code dependencies on infrastructure classes — it has source-code dependencies only on its own port interfaces. You cannot instantiate PostgresOrderRepository accidentally inside the domain, because the domain does not import it. The only way to satisfy a secondary port is to provide an implementation — which can be a fast, in-memory fake.
Driving through the primary port with a test driver
The test driver is a driving adapter (lesson 02). It calls the primary port directly, just as the HTTP controller does. There is no HTTP involved — no server, no request parsing, no response formatting.
// domain/ports/OrderConfirmationPort.ts
interface OrderConfirmationPort {
confirmOrder(orderId: string, approverId: string): Promise<ConfirmationResult>;
}
// Test: drive through the primary port
describe('Order confirmation use case', () => {
let useCase: OrderConfirmationPort;
let orderRepo: InMemoryOrderRepository;
let paymentGateway: FakePaymentGateway;
let emailNotifier: FakeEmailNotifier;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
paymentGateway = new FakePaymentGateway();
emailNotifier = new FakeEmailNotifier();
// Wire up the use case with fake driven adapters
useCase = new OrderConfirmationUseCase(orderRepo, paymentGateway, emailNotifier);
});
it('confirms a pending order and charges the customer', async () => {
// Arrange: plant a pending order in the fake repo
const order = Order.pending({ id: 'order-1', customerId: 'cust-42', amount: Money.of(500, 'USD') });
await orderRepo.save(order);
// Act: drive through the primary port
const result = await useCase.confirmOrder('order-1', 'approver-99');
// Assert: domain outcome
expect(result.success).toBe(true);
expect(paymentGateway.chargesIssued).toHaveLength(1);
expect(paymentGateway.chargesIssued[0].amount).toEqual(Money.of(500, 'USD'));
expect(emailNotifier.sentMessages).toHaveLength(1);
});
it('rejects confirmation when order has overdue invoices', async () => {
const order = Order.pending({ id: 'order-2', customerId: 'cust-42', amount: Money.of(200, 'USD') });
order.markInvoiceOverdue('inv-7');
await orderRepo.save(order);
const result = await useCase.confirmOrder('order-2', 'approver-99');
expect(result.success).toBe(false);
expect(result.reason).toBe('overdue-invoice');
expect(paymentGateway.chargesIssued).toHaveLength(0);
});
});No Docker Compose. No Stripe account. No SendGrid sandbox. Each test runs in milliseconds. The test suite can be hundreds of tests and finish in under two seconds.
In-memory fakes at secondary ports
The key enabler is the in-memory fake — a simple, fast implementation of a secondary port that stores state in memory. It is not a mock (which records call expectations); it is a real implementation that happens to use memory instead of a network or disk.
// Implements the secondary port: IOrderRepository
class InMemoryOrderRepository implements IOrderRepository {
private store = new Map<string, Order>();
async findById(id: string): Promise<Order | null> {
return this.store.get(id) ?? null;
}
async save(order: Order): Promise<void> {
this.store.set(order.id, order);
}
async findByCustomerId(customerId: string): Promise<Order[]> {
return [...this.store.values()].filter(o => o.customerId === customerId);
}
}
// Implements the secondary port: IPaymentGateway
class FakePaymentGateway implements IPaymentGateway {
public chargesIssued: Array<{ customerId: string; amount: Money }> = [];
public shouldFail = false;
async charge(customerId: CustomerId, amount: Money): Promise<ChargeResult> {
if (this.shouldFail) return ChargeResult.failure('card-declined');
this.chargesIssued.push({ customerId: customerId.value, amount });
return ChargeResult.success(`charge-${Date.now()}`);
}
async refund(chargeId: ChargeId, amount: Money): Promise<RefundResult> {
return RefundResult.success();
}
}The fakes implement the secondary port interface exactly — the same interface the production Postgres and Stripe adapters implement. The domain use case cannot distinguish between them at compile time. At runtime, the test injects the fake; production wires the real adapter. The same use case runs in both contexts without modification.
▸Why this works
Why is this a structural property rather than a testing technique? Because the testability emerges from the dependency structure, not from test setup discipline. A team using a layered architecture can write in-memory fakes too — but they must mock the concrete classes their service imports, which means their test setup depends on implementation details. When the service is refactored, mocks break. In hexagonal architecture, the domain imports only its own interfaces. The in-memory fake is a legitimate implementation of that interface — not a mock of a concrete class. Refactoring the use case does not break the fakes (the fakes implement the port, which changes only when domain requirements change). The testability is baked in by construction.
The test pyramid hexagonal teams actually use
The structural separation of domain from adapters produces a natural three-level test pyramid:
Level 1 — Domain tests (many, milliseconds): Drive through the primary port with a test driver and fake driven adapters. Test every business rule, every invariant, every edge case in the domain. These tests have no infrastructure dependencies — they run anywhere, instantly. This is the bulk of the test suite: every combination of order state, approval logic, billing rule, and error condition. In the B2B platform, this is hundreds of tests covering the full confirmation, invoicing, and billing approval logic.
Level 2 — Adapter tests (fewer, seconds): Test each adapter in isolation against its port contract. The PostgreSQL repository test starts a real database, saves and retrieves domain objects, and verifies the mapping is correct. The Stripe adapter test (against Stripe’s test API) verifies the charge/refund mapping. The HTTP controller test verifies that HTTP request fields are correctly parsed into port parameters and port results are correctly serialized to HTTP responses. These tests verify that the adapter correctly implements the port — they do not re-test domain logic.
Level 3 — End-to-end tests (very few, seconds to minutes): Start the full application with all real adapters wired. Send real HTTP requests. Verify the whole stack integrates. These tests are expensive — keep them to critical happy paths only.
Level 1: Domain tests ████████████████████████████████████ (hundreds)
Level 2: Adapter tests ██████████ (tens)
Level 3: E2E tests ██ (handful)The B2B platform’s original problem — 72% nominal coverage achieved through slow integration tests — was a symptom of having no Level 1 tests at all. Everything was Level 3. Hexagonal architecture inverts this: Level 1 tests are cheap to write because the domain is fully accessible through the primary port without infrastructure.
▸lesson.inset.note
There is a nuanced failure mode called the “ice cream cone” anti-pattern: many E2E tests, few adapter tests, almost no domain tests. Teams in layered architectures naturally drift toward it because unit-testing the domain requires mocking many concrete infrastructure dependencies — which is tedious and fragile. Hexagonal architecture makes the domain tests cheap to write (no mocking framework, just fakes implementing the ports), so the pyramid inverts naturally. If a hexagonal team’s test suite still looks like an ice cream cone, it usually means the secondary ports were not correctly inverted — the domain is still importing concrete infrastructure classes somewhere.
Adapter tests: testing the boundary from the outside
The adapter tests are structurally independent of the domain tests. They test a different question: “Does this adapter correctly implement the port contract?”
// Adapter test: PostgresOrderRepository implements IOrderRepository
describe('PostgresOrderRepository', () => {
let db: Pool; // Real PostgreSQL connection
let repo: PostgresOrderRepository;
beforeAll(async () => {
db = await createTestDatabase();
repo = new PostgresOrderRepository(db);
});
it('saves and retrieves an order by id', async () => {
const order = Order.pending({ id: 'test-1', customerId: 'c-1', amount: Money.of(100, 'USD') });
await repo.save(order);
const retrieved = await repo.findById('test-1');
expect(retrieved).toEqual(order);
});
it('returns null for a non-existent order', async () => {
const result = await repo.findById('no-such-id');
expect(result).toBeNull();
});
});These tests start the real database. They are slower than domain tests. But they cover the one thing domain tests cannot: the correct mapping between domain objects and database rows (column names, types, NULL handling, serialization). A domain test with a fake repository would never catch a column mapping bug. The adapter test catches it exactly, without testing any domain logic.
A team converts their layered architecture to hexagonal. The OrderConfirmationUseCase now receives IOrderRepository and IPaymentGateway via its constructor (injected at startup). A developer says: 'We already had dependency injection before — we could mock OrderRepository and StripeClient in tests. What does hexagonal give us that we did not already have?' How do you answer?
The team writes a domain test that uses InMemoryOrderRepository and FakePaymentGateway. The test for order confirmation passes. Later, the Postgres adapter is deployed and a production order fails because the amount column stores values as integers (cents) but the domain uses a Money value object that stores as a decimal string. Which test level catches this bug, and why did the domain test not catch it?
A team has 400 domain tests (Level 1), 30 adapter tests (Level 2), and 5 E2E tests (Level 3). CI takes 45 seconds. A new developer says: 'We should add more E2E tests to increase confidence — they test the real system.' How do you evaluate this argument?
- 01What is the structural reason that domain tests in hexagonal architecture do not require a mock framework?
- 02What is the difference between an in-memory fake and a mock object, and why does the distinction matter for test maintenance?
- 03The B2B platform team has 400 domain tests using fakes. A production bug is found: orders with amounts over $10,000 in certain currencies fail the payment charge silently. The fake payment gateway did not surface this. What does this reveal about the fake, and how should the team respond?
The untestability of the original B2B platform was not a test-writing problem. It was a structural problem: the service layer had compile-time dependencies on infrastructure classes, so every test of business logic required the full infrastructure stack.
Hexagonal architecture removes the compile-time dependencies. The domain imports only its own port interfaces. Secondary ports are satisfied at runtime by whichever adapter is injected — production or fake. The domain cannot accidentally import a concrete infrastructure class.
In-memory fakes implement secondary port interfaces fully and correctly, without using any external infrastructure. The domain use case runs identically against fakes as against production adapters, because it only sees the port interfaces it owns.
The test pyramid this produces has three levels. Domain tests (many, milliseconds) drive through the primary port with fake driven adapters — they test every business rule without infrastructure. Adapter tests (fewer, seconds) test each adapter with real infrastructure — they verify the mapping between domain objects and database rows, API responses, or message formats. End-to-end tests (very few) verify integration wiring.
The ice cream cone anti-pattern — many E2E tests, almost no domain tests — is a symptom of insufficient port ownership. When domain tests become cheap by construction, the pyramid inverts naturally.
This structural testability is the practical payoff that makes hexagonal architecture worth its cost. The boundaries that prevent layer-skipping (unit 03’s problem) and the port ownership that inverts infrastructure dependency (unit 02’s DIP) also happen to make the entire domain testable without a database, without a payment SDK, and without a running server.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.