- Book: Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your checkout tests are green. One of them replays a request the way a flaky mobile client would: same order reference, sent twice. The in-memory repository stores both orders without a word, so the use case's "reference already used" branch never runs and the test passes. In production, Postgres rejects the second insert with SQLSTATE 23505, unique_violation, nothing maps it to a domain error, and the client gets a 500.
Same suite, another test. The order-history endpoint should return the newest order first. The fake appends to a slice, the test happens to insert in the order it asserts, and it passes. The Postgres adapter forgot its ORDER BY, and the Postgres docs are blunt about what that means: without sorting, "the rows will be returned in an unspecified order."
Neither bug is in the fake's code. The fake never agreed to the same rules as the database. Martin Fowler's definition of a fake already warns you: fakes "have working implementations, but usually take some shortcut." The fix he describes for doubles is a contract test, which checks that calls against your double "return the same results as a call to the external service would." In a hexagonal Go service you can do that with one exported function and a single call per adapter.
The port, with its rules written down
The contract starts as prose on the interface. If a rule isn't written here, no adapter can be held to it.
package domain
import (
"context"
"errors"
"time"
)
var (
ErrNotFound = errors.New("order not found")
ErrDuplicateRef = errors.New("order ref already used")
ErrVersionConflict = errors.New("order changed since read")
)
type Order struct {
ID string
Ref string
CustomerID string
Status string
CreatedAt time.Time
Version int64
}
// OrderRepository is the port. Every adapter must:
// - Create: store o with Version 1. ErrDuplicateRef if
// another order already has o.Ref.
// - Get: ErrNotFound if no order has that ID.
// - ListByCustomer: newest CreatedAt first, ties broken
// by ID in byte order.
// - Update: write o.Status if the stored Version equals
// o.Version, then bump it. ErrVersionConflict if not,
// ErrNotFound if the order does not exist.
type OrderRepository interface {
Create(ctx context.Context, o Order) error
Get(ctx context.Context, id string) (Order, error)
ListByCustomer(
ctx context.Context, customerID string,
) ([]Order, error)
Update(ctx context.Context, o Order) error
}
The errors live in the domain because they are business facts. "That reference is taken" means something to a checkout use case. SQLSTATE 23505 does not, and it should never cross the port.
One exported function, every adapter
The suite goes in its own package, domain/ordertest, in a normal .go file rather than a _test.go file. go build ignores files that end in _test.go, so nothing declared in one can be imported by another package, and both adapter packages need to call this. Importing testing from a non-test package has a precedent in the Go project itself: nettest.TestConn "tests that a net.Conn implementation properly satisfies the interface," and it takes a constructor the same way.

One set of checks, two implementations. Each door has to open the same way.
The constructor gives each subtest a fresh, empty repository, so subtests never see each other's rows.
package ordertest
import (
"errors"
"slices"
"testing"
"time"
"example.com/shop/domain"
)
var t0 = time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)
func RunOrderRepoContract(
t *testing.T,
newRepo func(t *testing.T) domain.OrderRepository,
) {
t.Run("missing id", func(t *testing.T) {
repo := newRepo(t)
_, err := repo.Get(t.Context(), "nope")
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
})
t.Run("round trip", func(t *testing.T) {
repo := newRepo(t)
mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
got, err := repo.Get(t.Context(), "o-1")
if err != nil {
t.Fatal(err)
}
if got.Ref != "R-1" || got.Version != 1 ||
!got.CreatedAt.Equal(t0) {
t.Fatalf("got %+v", got)
}
})
t.Run("duplicate ref", func(t *testing.T) {
repo := newRepo(t)
ctx := t.Context()
mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
err := repo.Create(ctx, ord("o-2", "R-1", "c-2", t0))
if !errors.Is(err, domain.ErrDuplicateRef) {
t.Fatalf("got %v, want ErrDuplicateRef", err)
}
_, err = repo.Get(ctx, "o-2")
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("o-2 was stored anyway: %v", err)
}
})
t.Context() needs Go 1.24 or later. Timestamps are compared with Equal, because the time docs note that == "compares not just the time instant but also the Location," and a database adapter is free to hand back the same instant in a different location.
The function continues with the ordering rule:
t.Run("list order", func(t *testing.T) {
repo := newRepo(t)
later := t0.Add(time.Hour)
mustCreate(t, repo, ord("o-a", "R-1", "c-1", t0))
mustCreate(t, repo, ord("o-c", "R-2", "c-1", later))
mustCreate(t, repo, ord("o-B", "R-3", "c-1", t0))
mustCreate(t, repo, ord("o-x", "R-4", "c-2", later))
got, err := repo.ListByCustomer(t.Context(), "c-1")
if err != nil {
t.Fatal(err)
}
var ids []string
for _, o := range got {
ids = append(ids, o.ID)
}
want := []string{"o-c", "o-B", "o-a"}
if !slices.Equal(ids, want) {
t.Fatalf("got %v, want %v", ids, want)
}
})
The list order subtest is built to fail a lazy fake. Insertion order is o-a, o-c, o-B, and neither that order nor its reverse matches the expected o-c, o-B, o-a. A fake that returns map iteration order fails too, at least some of the time, since the Go spec says map iteration order "is not specified and is not guaranteed to be the same from one iteration to the next." The uppercase B is there on purpose, and it comes back in the Postgres section.
The last two subtests cover updates, followed by the helpers:
t.Run("stale version", func(t *testing.T) {
repo := newRepo(t)
ctx := t.Context()
mustCreate(t, repo, ord("o-1", "R-1", "c-1", t0))
a, err := repo.Get(ctx, "o-1")
if err != nil {
t.Fatal(err)
}
b := a
a.Status = "paid"
if err := repo.Update(ctx, a); err != nil {
t.Fatal(err)
}
b.Status = "cancelled"
err = repo.Update(ctx, b)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("got %v, want ErrVersionConflict", err)
}
got, err := repo.Get(ctx, "o-1")
if err != nil {
t.Fatal(err)
}
if got.Status != "paid" || got.Version != 2 {
t.Fatalf("got %+v", got)
}
})
t.Run("update missing", func(t *testing.T) {
repo := newRepo(t)
o := ord("nope", "R-9", "c-1", t0)
o.Version = 1
err := repo.Update(t.Context(), o)
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
})
}
func ord(id, ref, cust string, at time.Time) domain.Order {
return domain.Order{
ID: id, Ref: ref, CustomerID: cust,
Status: "pending", CreatedAt: at,
}
}
func mustCreate(
t *testing.T, r domain.OrderRepository, o domain.Order,
) {
t.Helper()
if err := r.Create(t.Context(), o); err != nil {
t.Fatalf("create %s: %v", o.ID, err)
}
}
The stale version subtest is the optimistic-locking rule from an earlier post on version conflicts in a Go repository adapter, folded into the shared suite instead of standing alone.
The fake, held to it
This map-backed fake passes the suite, and it is still small.
package memory
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"example.com/shop/domain"
)
type OrderRepo struct {
mu sync.Mutex
byID map[string]domain.Order
byRef map[string]string
}
func NewOrderRepo() *OrderRepo {
return &OrderRepo{
byID: map[string]domain.Order{},
byRef: map[string]string{},
}
}
func (r *OrderRepo) Create(
_ context.Context, o domain.Order,
) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byRef[o.Ref]; ok {
return domain.ErrDuplicateRef
}
if _, ok := r.byID[o.ID]; ok {
return fmt.Errorf("memory: id %s exists", o.ID)
}
o.Version = 1
r.byID[o.ID] = o
r.byRef[o.Ref] = o.ID
return nil
}
func (r *OrderRepo) Get(
_ context.Context, id string,
) (domain.Order, error) {
r.mu.Lock()
defer r.mu.Unlock()
o, ok := r.byID[id]
if !ok {
return domain.Order{}, domain.ErrNotFound
}
return o, nil
}
func (r *OrderRepo) ListByCustomer(
_ context.Context, customerID string,
) ([]domain.Order, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []domain.Order
for _, o := range r.byID {
if o.CustomerID == customerID {
out = append(out, o)
}
}
slices.SortFunc(out, func(a, b domain.Order) int {
if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
return c
}
return strings.Compare(a.ID, b.ID)
})
return out, nil
}
func (r *OrderRepo) Update(
_ context.Context, o domain.Order,
) error {
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.byID[o.ID]
if !ok {
return domain.ErrNotFound
}
if cur.Version != o.Version {
return domain.ErrVersionConflict
}
cur.Status = o.Status
cur.Version++
r.byID[o.ID] = cur
return nil
}
The byRef index is the fake's version of a unique constraint, and the sort is its version of ORDER BY. Without those two pieces, the duplicate and ordering bugs from the opening are exactly what you would have shipped.
Wiring it to the suite is one call:
package memory_test
import (
"testing"
"example.com/shop/adapter/memory"
"example.com/shop/domain"
"example.com/shop/domain/ordertest"
)
func TestOrderRepoContract(t *testing.T) {
ordertest.RunOrderRepoContract(t,
func(*testing.T) domain.OrderRepository {
return memory.NewOrderRepo()
})
}
The pgx adapter, mapping SQLSTATE 23505
The real adapter uses pgx v5. A failed insert carries a *pgconn.PgError with a Code (the SQLSTATE) and a ConstraintName. Checking both matters: a primary-key collision is also 23505, and it is not the same domain fact as a reused reference.
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"example.com/shop/domain"
)
const cols = `id, ref, customer_id, status, created_at, version`
type OrderRepo struct{ pool *pgxpool.Pool }
func NewOrderRepo(p *pgxpool.Pool) *OrderRepo {
return &OrderRepo{pool: p}
}
func (r *OrderRepo) Create(
ctx context.Context, o domain.Order,
) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO orders (`+cols+`)
VALUES ($1, $2, $3, $4, $5, 1)`,
o.ID, o.Ref, o.CustomerID, o.Status, o.CreatedAt)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" &&
pgErr.ConstraintName == "orders_ref_key" {
return domain.ErrDuplicateRef
}
if err != nil {
return fmt.Errorf("create order %s: %w", o.ID, err)
}
return nil
}
func (r *OrderRepo) Get(
ctx context.Context, id string,
) (domain.Order, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+cols+` FROM orders WHERE id = $1`, id)
if err != nil {
return domain.Order{}, fmt.Errorf("get %s: %w", id, err)
}
o, err := pgx.CollectExactlyOneRow(rows, scanOrder)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Order{}, domain.ErrNotFound
}
if err != nil {
return domain.Order{}, fmt.Errorf("get %s: %w", id, err)
}
return o, nil
}
func (r *OrderRepo) ListByCustomer(
ctx context.Context, customerID string,
) ([]domain.Order, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+cols+` FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC, id COLLATE "C"`,
customerID)
if err != nil {
return nil, fmt.Errorf("list %s: %w", customerID, err)
}
return pgx.CollectRows(rows, scanOrder)
}
func (r *OrderRepo) Update(
ctx context.Context, o domain.Order,
) error {
tag, err := r.pool.Exec(ctx, `
UPDATE orders SET status = $3, version = version + 1
WHERE id = $1 AND version = $2`,
o.ID, o.Version, o.Status)
if err != nil {
return fmt.Errorf("update %s: %w", o.ID, err)
}
if tag.RowsAffected() == 1 {
return nil
}
if _, err := r.Get(ctx, o.ID); err != nil {
return err // ErrNotFound, or a real failure
}
return domain.ErrVersionConflict
}
func scanOrder(row pgx.CollectableRow) (domain.Order, error) {
var o domain.Order
err := row.Scan(&o.ID, &o.Ref, &o.CustomerID,
&o.Status, &o.CreatedAt, &o.Version)
return o, err
}
CollectExactlyOneRow returns an error matching pgx.ErrNoRows when the query finds nothing, which is the one place the adapter translates to ErrNotFound. CollectRows closes the rows for you.
Now the uppercase B. The fake breaks ties with strings.Compare, and the Go spec says strings "are compared lexically byte-wise." Postgres sorts text by the column's collation, and a linguistic collation such as en_US.utf8 does not have to put o-B before o-a the way byte order does. The C collation sorts "by byte values rather than natural language order," so COLLATE "C" on the tiebreak makes both adapters agree no matter what the database default is. Drop it, and the list order subtest is where you find out.
Real Postgres, behind a build tag
The integration test starts Postgres with the testcontainers-go Postgres module, runs the schema, and hands the suite a constructor that truncates the table. A //go:build line keeps it out of the default run.

The model and the real engine go on the same gauge. Only one of them needs Docker.
//go:build integration
package postgres_test
import (
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
tcpg "github.com/testcontainers/testcontainers-go/modules/postgres"
"example.com/shop/adapter/postgres"
"example.com/shop/domain"
"example.com/shop/domain/ordertest"
)
const schema = `
CREATE TABLE orders (
id text PRIMARY KEY,
ref text NOT NULL,
customer_id text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
version bigint NOT NULL,
CONSTRAINT orders_ref_key UNIQUE (ref)
)`
func TestOrderRepoContract(t *testing.T) {
ctx := t.Context()
ctr, err := tcpg.Run(ctx, "postgres:16-alpine",
tcpg.WithDatabase("shop"),
tcpg.WithUsername("shop"),
tcpg.WithPassword("shop"),
tcpg.BasicWaitStrategies(),
)
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatal(err)
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(ctx, schema); err != nil {
t.Fatal(err)
}
ordertest.RunOrderRepoContract(t,
func(t *testing.T) domain.OrderRepository {
_, err := pool.Exec(t.Context(), "TRUNCATE orders")
if err != nil {
t.Fatal(err)
}
return postgres.NewOrderRepo(pool)
})
}
CleanupContainer goes before the error check because its docs say a nil container is a no-op, so a half-started container still gets removed. The module page also says it supplies no default wait strategy, which is why BasicWaitStrategies() is there. One container serves all six subtests, and TRUNCATE gives each one an empty table, which keeps the run to a single container start.
The constraint is named explicitly, orders_ref_key, because the adapter matches on that name. In a real service, replace the inline schema with your migration runner, so the test checks the constraint names your migrations actually create.
Two commands now cover both worlds:
go test -race ./... # fake only, no Docker
go test -race -tags integration ./... # fake and Postgres
What the suite catches next
The suite only knows the rules you gave it, and Postgres has more. Two worth adding as subtests before they bite:
Timestamp precision. Go's time.Time holds nanoseconds, and Postgres timestamptz resolution is 1 microsecond. The suite uses whole-second timestamps, so it can't see this. A subtest that creates an order with time.Now() and compares CreatedAt after a round trip will pass on the fake and, whenever the clock reading has sub-microsecond digits, fail on Postgres. Decide in the port whether the repository truncates, then make the fake do the same.
Empty results. The two adapters in this post already disagree here. The fake above returns a nil slice for a customer with no orders. The pgx adapter does not: CollectRows is return AppendRows([]T{}, rows, fn), so it starts from an empty, non-nil slice. Marshal both and the encoding/json docs say "a nil slice encodes as the null JSON value," so one adapter gives the client null and the other [], and the suite as written passes both. Add a subtest that pins the shape, or normalise it in the handler.
When a third adapter shows up (a read-through cache wrapping the Postgres repo, a SQLite build for a CLI), its test is one RunOrderRepoContract call with a different constructor. If it disagrees with the other two, the suite names the rule.
If this was useful
A contract suite only works if the port says what the rules are and database errors stay in the adapter. Hexagonal Architecture in Go is the book I wrote about that layer: where repositories, adapters and their tests sit in a Go service, and how to test both sides of a port without a pile of mocks. It is part of the Thinking in Go series, with a companion book on the language itself.

Top comments (0)