Testing Hexagonal Go: Put Fakes at the Port Boundary
#Dev

Testing Hexagonal Go: Put Fakes at the Port Boundary

Backend Reporter
8 min read

The article argues for hand-written fakes in hexagonal Go services because they test domain behavior instead of freezing incidental call order.

Problem

Mock-heavy domain tests often fail for the wrong reason. In a Go service using hexagonal architecture, the domain usually depends on ports, not concrete infrastructure adapters. That should make tests simpler, but many codebases replace every port with a mock framework and then encode a script of expected method calls.

Featured image

The failure mode is familiar. A test for PlaceOrder starts with a page of repo.On("Save", ...), repo.On("FindByID", ...), and repo.On("ListByCustomer", ...) expectations. The business rule under test is small: reject zero totals, enforce a per-customer order limit, and save valid orders. Yet the test mostly verifies choreography. Change the order of two repository reads and the test fails, even if the externally visible behavior is identical.

That is a poor fit for domain logic. In most application services, the repository call is not the product behavior. The behavior is the state transition, returned value, emitted error, or API response. When the test asserts on the call sequence instead of the outcome, it couples itself to implementation detail. The test suite becomes sensitive without becoming more trustworthy.

The article’s recommendation is pragmatic: for repository-style ports in Go, default to hand-written fakes. A fake is a small working implementation of the port, often backed by a map and guarded by a mutex. It stores data, returns data, and follows the same contract as the real adapter. That lets tests set state, run behavior, and inspect state after the operation.

Solution approach

Hexagonal architecture, as described in Alistair Cockburn’s original Ports and Adapters pattern, makes this possible because the domain talks to interfaces. In Go, those interfaces are usually small ports shaped around what the application needs. A simplified order repository might expose Save, FindByID, and ListByCustomer. The production adapter can use PostgreSQL, while the domain test uses an in-memory implementation.

That gives the test an API boundary that behaves like infrastructure without requiring infrastructure. The fake repository can enforce basic storage semantics: if an order was saved, FindByID returns it. If no order exists, it returns the domain’s ErrNotFound. If the service lists orders for a customer, the fake filters by CustomerID rather than returning a canned list unrelated to prior writes.

This matters because a mock can lie cheaply. A mock can return an order from FindByID even if the test never saved it. That may be fine when the test is explicitly about an interaction, but it is weak evidence for stateful domain behavior. A fake has a small internal model. It rejects impossible histories unless the fake itself is wrong.

The API pattern is similar to designing a public service boundary. A port is not just a bag of methods. It is a contract about consistency, error behavior, idempotency, and ownership of data. If Save followed by FindByID in the same request should provide read-after-write consistency, the fake should model that. If the real system uses read replicas and FindByID may lag after Save, the port needs to say that clearly, or the application service will accidentally depend on stronger semantics than production provides.

That is where contract tests become the stabilizer. The article proposes writing one shared test suite for the port and running it against both the fake and the real adapter. The fake test runs in the normal go test ./... path. The real adapter test runs behind an integration build tag, using Go’s documented build constraints, for example go test -tags=integration ./....

The contract test should assert behavior at the port boundary: save then find, missing ID returns a domain error, listing by customer does not leak other customers’ orders, duplicate saves have whatever semantics the system promises, and wrapped errors still satisfy errors.Is. Go’s standard testing package is enough for this pattern. Mock libraries such as Testify mock still have a place, but they should not become the default shape of every domain test.

Scalability implications

The obvious scalability win is test runtime. A fake-backed domain test runs in process and does not need Docker, network I/O, migrations, database cleanup, or fixture coordination. That means the fast suite can run on every edit, not just in CI. For a team, that feedback loop is an architectural concern. Slow tests push developers toward batching changes, skipping local verification, or trusting only the pipeline. Those habits increase the size of failures.

There is also scaling at the codebase level. Mock expectations tend to grow with branch count. As services gain more behavior, each test copies more setup. The result is a test suite where the cost of changing internals rises faster than confidence. A reusable fake changes that curve. The fake is maintained once per port, while tests remain focused on business scenarios.

Distributed systems add another angle: contract clarity. A repository port may start as a simple PostgreSQL adapter, then later gain a cache, an async projection, a search index, or a read replica. Each addition weakens some assumptions. Reads may become eventually consistent. Writes may become idempotent. Deletes may become tombstones. Pagination may become cursor-based instead of offset-based. If the fake is the only implementation tested, it can hide these changes. If the fake and real adapter share contract tests, drift becomes visible.

A useful contract suite should encode the consistency model the application is allowed to rely on. For example, if Save returns only after the primary database commit, then FindByID on the same repository can reasonably be expected to find the record. If the port fronts an eventually consistent store, the contract might instead expose a different API shape: Save returns an accepted write token, FindByID may return not found until propagation completes, and callers that require confirmation use a separate read model or polling operation. The fake should model that weaker guarantee, because tests that assume immediate consistency will pass locally and fail under load.

This is the part many teams learn through incidents. The fake that always reads its own writes is convenient. The production topology might not. If a service writes to a primary and reads from a replica, a fake with strict read-after-write semantics can approve code that fails in production. The contract must describe the real guarantee, not the wishful one.

API patterns

A good port is narrow and behavioral. OrderRepo should expose operations the application needs, not mirror every SQL table or database client method. That keeps the fake small and makes the contract meaningful. A port with twenty generic methods usually means the domain has started speaking infrastructure language.

Error design is part of that API. Domain code should not need to know about sql.ErrNoRows, driver-specific timeout types, or database duplicate-key strings. The adapter can translate those into domain-level errors such as ErrNotFound or ErrConflict, with wrapping when diagnostics matter. Tests can then assert with errors.Is, which keeps the contract stable while preserving lower-level context for logs and debugging.

Context handling deserves care too. Ports in Go commonly accept context.Context, but fakes often ignore cancellation and deadlines. That is acceptable for many domain tests, but contract tests around cancellation may be useful for adapters that participate in request budgets. If production must stop work when a request is canceled, the fake should be able to simulate cancellation-sensitive paths where those paths affect application behavior.

The same thinking applies to idempotency. In payment, fulfillment, and order-processing systems, retries are normal. A port method named Save might be too vague if duplicate calls can happen after network failures. A better API may be CreateOrder with conflict semantics, or UpsertOrder with explicit replacement behavior. The fake should enforce the chosen behavior. Otherwise, tests may pass while production silently overwrites data or rejects retries in a way callers do not handle.

Trade-offs

Fakes are not free. They are code, and code can drift. A fake repository can accidentally implement nicer behavior than PostgreSQL, MongoDB, Redis, or an external API. It can ignore transaction isolation, ordering, pagination, uniqueness, or partial failures. That is why the contract suite is not optional once a fake becomes shared infrastructure for tests.

There is also a temptation to build a miniature database in memory. That usually means the port is too broad or the fake is carrying responsibilities that belong in integration tests. A fake should implement enough behavior to test application logic. It should not try to reproduce query planners, isolation levels, connection pooling, or storage engine behavior. Use focused integration tests for the adapter and contract tests for the shared boundary.

Mocks still fit when the interaction itself is the behavior. Charging a payment exactly once, publishing an audit event, sending a notification, or avoiding an external call after validation fails are interaction requirements. In those cases, call verification is not incidental. It is the assertion. A mock or spy can express that better than an in-memory fake.

The practical rule is simple: use fakes for stateful ports where the domain cares about resulting state, and use mocks for side-effect ports where the domain cares that a call did or did not happen. Mixing those tools intentionally gives better tests than banning either one.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

The broader lesson is that test doubles are API design pressure. If a port is easy to fake, it is probably small, explicit, and close to the domain. If it requires pages of setup, the interface may be too infrastructure-shaped. Hexagonal Go works best when ports encode business contracts and adapters translate messy reality into those contracts.

For teams building services that need to survive scale, retries, and partial failure, that distinction is not academic. A clean fake makes local tests fast. A contract suite keeps the fake honest. Integration tests prove the adapter against real infrastructure. Together, they form a testing stack that catches behavioral regressions without turning every refactor into a mock maintenance session.

Comments

Loading comments...