- Book: Hexagonal Architecture in Go
- 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
You open a domain test in a Go service. The first 40 lines are
mock setup. repo.On("Save", mock.Anything).Return(nil).Once().
repo.On("FindByID", "ord-1").Return(order, nil). Then three
more .On(...) lines for calls the code path might or might not
make. The actual assertion — the thing the test is supposed to
prove — is one line near the bottom, buried under the scaffolding.
Change the order in which the service calls the repository and
half those tests go red, even though the behavior is identical.
The mock asserted on the call sequence, not on the outcome. You
spend the afternoon re-recording expectations to match the new
sequence. Nothing about the domain got safer.
There is a better default for hexagonal Go. Write the test double
by hand: a small in-memory struct that implements the port like a
real adapter would. It stores data, it returns data, it behaves.
Your domain test then asserts on what came out, not on which
methods got poked in which order.
The port you are testing against
Hexagonal architecture gives you a clean seam for this. The domain
depends on a port (an interface) and never on a concrete adapter.
Here is the order type, an order repository port, and the service
that uses it.
// domain/order.go
package domain
type Order struct {
ID string
CustomerID string
Total int // minor units, e.g. cents
}
// port/order_repo.go
package port
import (
"context"
"yourapp/domain"
)
type OrderRepo interface {
Save(ctx context.Context, o domain.Order) error
FindByID(
ctx context.Context, id string,
) (domain.Order, error)
ListByCustomer(
ctx context.Context, cid string,
) ([]domain.Order, error)
}
// app/place_order.go
package app
import (
"context"
"fmt"
"yourapp/domain"
"yourapp/port"
)
type PlaceOrder struct {
repo port.OrderRepo
}
func NewPlaceOrder(r port.OrderRepo) *PlaceOrder {
return &PlaceOrder{repo: r}
}
func (p *PlaceOrder) Place(
ctx context.Context, o domain.Order,
) error {
if o.Total <= 0 {
return fmt.Errorf("order total must be positive")
}
existing, err := p.repo.ListByCustomer(ctx, o.CustomerID)
if err != nil {
return err
}
if len(existing) >= 100 {
return fmt.Errorf("customer order limit reached")
}
return p.repo.Save(ctx, o)
}
The production adapter behind OrderRepo talks to Postgres. The
test does not want Postgres. It wants something that behaves like
a repository so it can check the business rules: positive total,
per-customer limit, the save that follows.
The hand-written fake
A fake is a working implementation backed by a map. It is the
real behavior of a repository, minus the database.
// port/fake/order_repo.go
package fake
import (
"context"
"sync"
"yourapp/domain"
)
type OrderRepo struct {
mu sync.Mutex
byID map[string]domain.Order
SaveErr error // force an error when a test needs one
}
func NewOrderRepo() *OrderRepo {
return &OrderRepo{byID: map[string]domain.Order{}}
}
func (f *OrderRepo) Save(
_ context.Context, o domain.Order,
) error {
if f.SaveErr != nil {
return f.SaveErr
}
f.mu.Lock()
defer f.mu.Unlock()
f.byID[o.ID] = o
return nil
}
func (f *OrderRepo) FindByID(
_ context.Context, id string,
) (domain.Order, error) {
f.mu.Lock()
defer f.mu.Unlock()
o, ok := f.byID[id]
if !ok {
return domain.Order{}, domain.ErrNotFound
}
return o, nil
}
func (f *OrderRepo) ListByCustomer(
_ context.Context, cid string,
) ([]domain.Order, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []domain.Order
for _, o := range f.byID {
if o.CustomerID == cid {
out = append(out, o)
}
}
return out, nil
}
Now the domain test reads like the behavior it checks.
// app/place_order_test.go
package app_test
import (
"context"
"testing"
"yourapp/app"
"yourapp/domain"
"yourapp/port/fake"
)
func TestPlace_SavesValidOrder(t *testing.T) {
repo := fake.NewOrderRepo()
svc := app.NewPlaceOrder(repo)
o := domain.Order{
ID: "ord-1", CustomerID: "cust-1", Total: 500,
}
if err := svc.Place(context.Background(), o); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, err := repo.FindByID(context.Background(), "ord-1")
if err != nil {
t.Fatalf("order not saved: %v", err)
}
if got.Total != 500 {
t.Fatalf("got total %d, want 500", got.Total)
}
}
func TestPlace_RejectsZeroTotal(t *testing.T) {
repo := fake.NewOrderRepo()
svc := app.NewPlaceOrder(repo)
err := svc.Place(context.Background(), domain.Order{
ID: "ord-2", CustomerID: "cust-1", Total: 0,
})
if err == nil {
t.Fatal("expected error for zero total")
}
}
No .On(...), no call-order coupling. The test sets up state,
runs the behavior, reads the result. Reorder the calls inside
Place and these tests stay green as long as the outcome holds.
That is the property you want: tests that break when behavior
breaks, not when implementation shuffles.
Why this beats a mock for the domain
A mock framework records expected calls and verifies them. That
is the right tool when the interaction itself is the behavior
under test — you genuinely need to prove that a payment was
charged exactly once, or that an email was never sent on a
validation failure. For those, asserting on the call is correct.
For most domain logic the call is not the point. The point is the
state and the value that come out. When you assert on calls, three
things go wrong:
- Tests couple to the implementation. Swap two read calls and the mock fails on order. The behavior did not change; the test did its job badly.
-
Setup grows faster than coverage. Every new branch in the
code path needs another
.On(...). The test file fills with call choreography. -
The double lies easily. A mock returns whatever you told it
to.
FindByIDcan return an order you never saved. The fake cannot — it only returns what went in.
A fake stays close to the real adapter's contract because you
wrote it to behave, not to record. That same fake works across
every test that touches the port, so you write it once and reuse
it everywhere.
Keep fakes honest with a contract test
The objection to hand-written fakes is fair: the fake and the
Postgres adapter can drift. The fake says FindByID returns
ErrNotFound for a missing ID; the real adapter returns
sql.ErrNoRows wrapped in something else. Your green tests prove
nothing about production.
A contract test closes that gap. Write one table of behavior
assertions and run it against every implementation of the port —
the fake and the real adapter. Both must pass the same suite.
// port/contract/order_repo.go
package contract
import (
"context"
"errors"
"testing"
"yourapp/domain"
"yourapp/port"
)
// OrderRepoContract runs the same behavior suite against any
// OrderRepo. newRepo returns a fresh, empty repo per subtest.
func OrderRepoContract(
t *testing.T, newRepo func() port.OrderRepo,
) {
t.Run("save then find", func(t *testing.T) {
r := newRepo()
o := domain.Order{ID: "a", CustomerID: "c", Total: 1}
if err := r.Save(context.Background(), o); err != nil {
t.Fatal(err)
}
got, err := r.FindByID(context.Background(), "a")
if err != nil {
t.Fatal(err)
}
if got.Total != 1 {
t.Fatalf("got %d, want 1", got.Total)
}
})
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
r := newRepo()
_, err := r.FindByID(context.Background(), "nope")
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
})
}
The fake runs the contract with a trivial constructor:
// port/fake/order_repo_contract_test.go
package fake_test
import (
"testing"
"yourapp/port"
"yourapp/port/contract"
"yourapp/port/fake"
)
func TestFakeOrderRepo_Contract(t *testing.T) {
contract.OrderRepoContract(t, func() port.OrderRepo {
return fake.NewOrderRepo()
})
}
The Postgres adapter runs the identical suite against a real
database, gated behind a build tag so the fast unit run skips it.
//go:build integration
package postgres_test
import (
"testing"
"yourapp/port"
"yourapp/port/contract"
"yourapp/adapter/postgres"
)
func TestPostgresOrderRepo_Contract(t *testing.T) {
contract.OrderRepoContract(t, func() port.OrderRepo {
db := newTestDB(t) // truncates tables per call
return postgres.NewOrderRepo(db)
})
}
Run the fast suite on every save: go test ./.... Run the
integration suite in CI against a throwaway Postgres:
go test -tags=integration ./.... The moment the real adapter
returns a wrapped error the fake does not, the contract goes red
on the Postgres side, and you fix the fake to match. The fake
earns its trust instead of assuming it.
Where mocks still belong
This is not a rule against mock libraries. It is a rule about
where each tool fits. Reach for a mock when the assertion is
genuinely about the call: a notifier that must fire exactly once,
a payment gateway that must never be charged twice, an audit log
that must record every state change. Counting and verifying calls
is what those tests exist to do.
For the repositories, caches, and stores your domain reads and
writes through, a hand-written fake plus one contract test gives
you faster tests, fewer false failures, and a double that cannot
lie about data it never received. Make the fake the default and
keep the mock for the cases where the interaction is the thing.
If this was useful
Ports, adapters, and the fakes that test them are the working
spine of Hexagonal Architecture in Go. The book walks the same
seam end to end: how to shape ports so they are easy to fake, how
to structure contract suites that both fake and real adapters
share, and how the layering keeps your domain tests free of
infrastructure. The Complete Guide to Go Programming covers the
language pieces underneath — interfaces, error wrapping with
errors.Is, build tags, and the testing package.

Top comments (1)
The contract test is what earns the whole approach for me. A hand-written fake drifts from the real adapter the moment an error type or a null case differs, and without one suite running against both you're trusting a double you wrote to be charitable to itself. Running the fake and the Postgres adapter through the same table closes that. The one place I still reach for a mock is exactly where you drew the line, when the call itself is the behaviour, charging once or sending nothing on a validation failure. Curious whether you version the contract suite separately so a port change forces both implementations to update together.