When I built ScrumBoard — a project management app with a Go API serving 16 endpoints and a WebSocket sync channel — I had a constraint that clarified every architectural decision: I was one person. No reviewer to catch my coupling mistakes, no QA to click through regressions, no ops team to babysit a fragile test suite. If the tests didn’t run fast and honest, they wouldn’t run at all.
That constraint led me somewhere specific: the business logic could not know that HTTP, PostgreSQL, or WebSockets existed. Board rules, card movement, permission checks — all of it had to be callable from a plain Go test, no server, no database, no network. The result was 321 tests that run in seconds, and the same core logic served over two transports without duplication.
I later confirmed I had reinvented, in miniature, a pattern with a name and a 2005 paper behind it: hexagonal architecture, or ports and adapters, by Alistair Cockburn. This article is the pattern as it actually looks in Go — grounded in Cockburn’s original text, which is far more practical than its reputation suggests, and honest about where a small project should break the rules.
The Problem Cockburn Was Actually Solving
The original paper opens with what Cockburn calls “one of the great bugaboos of software applications over the years”: “infiltration of business logic into the user interface code.” His enumeration of the damage: the system “can’t neatly be tested with automated test suites because part of the logic needing to be tested is dependent on oft-changing visual details”; it can’t shift from human-driven to batch-driven use; it can’t be driven by another program when that becomes valuable.
And his diagnosis of why layered architectures keep failing to fix it is the sharpest paragraph in the paper: organizations create a new layer promising “that this time, really and truly, no business logic will be put into the new layer. However, having no mechanism to detect when a violation of that promise occurs, the organization finds a few years later that the new layer is cluttered with business logic and the old problem has reappeared.”
The failure isn’t the layering. It’s the missing enforcement mechanism. Lines in an architecture diagram aren’t boundaries; they’re suggestions. Cockburn’s fix reframes the geometry: forget top (UI) versus bottom (database). The asymmetry that matters is inside versus outside: “The rule to obey is that code pertaining to the inside part should not leak into the outside part.”
The vocabulary: the application core communicates through ports — purposeful conversations, defined as APIs — and each external technology plugs into a port via an adapter that translates. A GUI is an adapter. A test harness is an adapter on the same port. A real database and an in-memory fake are two adapters on another port. Symmetry everywhere: “the application is blissfully ignorant of the nature of the input device.”
The Pattern Is Just Go Interfaces
Go makes this almost free. Its interfaces are satisfied implicitly and defined by the consumer, which is nearly a native implementation of Cockburn’s idea. A port is an interface defined by the domain package; an adapter is any type that happens to satisfy it. The concrete shape, from ScrumBoard’s actual structure:
scrumboard-api/
├── cmd/server/main.go # composition root: wires everything
├── internal/
│ ├── domain/ # THE INSIDE — pure Go, zero external imports
│ │ ├── board.go # entities and business rules
│ │ ├── ports.go # the ports: interfaces the domain needs/offers
│ │ └── board_test.go # tests that need nothing but Go
│ ├── adapters/
│ │ ├── postgres/ # driven adapter: implements domain's storage port
│ │ ├── memory/ # driven adapter: in-memory fake, used by tests
│ │ ├── httpapi/ # driving adapter: REST → domain calls
│ │ └── ws/ # driving adapter: WebSocket → same domain calls
The ports, in domain code — note who owns the interface:
// internal/domain/ports.go — the DOMAIN declares what it needs.
// Adapters conform to the domain, never the reverse.
type BoardRepository interface {
Board(ctx context.Context, id BoardID) (*Board, error)
Save(ctx context.Context, b *Board) error
}
type EventPublisher interface {
Publish(ctx context.Context, e Event)
}
// The driving port: what the outside world may ask of us.
type BoardService struct {
repo BoardRepository
events EventPublisher
}
func (s *BoardService) MoveCard(ctx context.Context, boardID BoardID, cardID CardID, toColumn ColumnID) error {
b, err := s.repo.Board(ctx, boardID)
if err != nil {
return err
}
if err := b.MoveCard(cardID, toColumn); err != nil { // pure business rule
return err
}
if err := s.repo.Save(ctx, b); err != nil {
return err
}
s.events.Publish(ctx, CardMoved{Board: boardID, Card: cardID, To: toColumn})
return nil
}
Cockburn’s development sequence — build against test harness and mock first, add real technology later — maps exactly onto how this gets tested. The in-memory adapter isn’t a “mock” in the brittle, expectation-recording sense; it’s a real implementation of the port, about forty lines, shared by hundreds of tests:
func TestMoveCardRespectsWIPLimit(t *testing.T) {
repo := memory.NewBoardRepo(boardWithWIPLimit(t, 2))
svc := domain.NewBoardService(repo, memory.NewEventLog())
err := svc.MoveCard(ctx, "b1", "card3", "in-progress") // column already full
if !errors.Is(err, domain.ErrWIPLimitExceeded) {
t.Fatalf("want WIP limit error, got %v", err)
}
}
No database container, no HTTP server, no sleeps. This is the mechanism Cockburn said layered architectures lacked: the domain package cannot import database/sql or net/http without the dependency direction visibly breaking — and a trivial CI check (go list -deps grep) turns the promise into an enforced invariant.
The payoff I didn’t anticipate: when I added real-time sync, the WebSocket adapter became a second driving adapter on the same port. MoveCard didn’t change. Netflix’s Studio team describes the same payoff in “Ready for changes with Hexagonal Architecture” (2020): when they had to move a read from a JSON API to a GraphQL data source, it took two hours and “a simple one-line change,” because the new source was just another implementation of a repository interface. They also report running around 3,000 specs in 100 seconds on a single process, which tells you where their tests live.
Where I Broke the Rules (And You Should Too)
Purism is where this pattern goes to die in small projects. Three deliberate violations in ScrumBoard, each defensible:
The domain knows about JSON. Strict hexagonal keeps serialization in adapters; I let domain types carry json: tags. The cost of parallel DTO structs plus mapping functions for 20+ types, maintained by one person, exceeded the coupling cost of some tags. Cockburn himself is relaxed about degrees: “What exactly a port is and isn’t is largely a matter of taste.”
Two ports, not six. His observed range is “two, three, or four” ports, favoring few; my storage port covers boards, cards, and users together rather than one interface each. Interfaces multiply as needed — Go makes splitting them later cheap.
SQL lives in exactly one place, un-abstracted. The Postgres adapter uses raw SQL with no query-builder indirection. The port boundary is the abstraction; abstracting inside the adapter is insulation on top of insulation.
The test for whether your shortcut is fine or fatal: does the domain package still compile without the adapter? If yes, the boundary lives. If your “hexagonal” project has domain importing postgres — Cockburn’s renamed-layers failure, the pattern’s most common cosplay — you have folders, not architecture.
The Objection: “This Is Overengineering for a CRUD App”
Often, yes — and the honest version of this article says so. The counterargument deserves its full strength: for a straight CRUD service — form in, row out, no business rules to speak of — the domain layer is a pass-through, every operation becomes three files instead of one, and the in-memory fake tests nothing the real database wouldn’t catch better in an integration test. The Go community’s standing advice to start with a flat package and let structure emerge is good advice. Adapters-without-a-domain is just… code.
The pattern pays rent in proportion to three factors: rule density (WIP limits, permissions, state machines — things worth testing in isolation), transport multiplicity (HTTP + WebSocket + CLI on one core), and technology uncertainty (will the storage or upstream change?). ScrumBoard scored on all three; a webhook-to-spreadsheet bridge scores zero, and gets written as one flat package without apology.
Doing It Tomorrow
- Find your densest rules. The code with real business decisions — not the CRUD. That’s the only part worth extracting first.
- Create
internal/domainwith a zero-external-imports rule, enforced in CI, not in a README. The enforcement is the architecture. - Let the domain declare its ports — interfaces for what it needs (storage, clock, events), defined in the domain package, satisfied elsewhere.
- Write the in-memory adapter first — Cockburn’s stage one. If writing the fake feels hard, your port is too wide; narrow it.
- Wire in
main. The composition root is the one place allowed to know every concrete type. - Stop there. One extracted domain with a real boundary beats a full ceremonial restructure. Extract the next area when its rules thicken.
The payoff Cockburn names is “the ability to run the application in a fully isolated mode.” Twenty years later, that’s still the test that matters: can you exercise your system’s actual logic with nothing but the language it’s written in? For ScrumBoard, the answer being yes, 321 times in a few seconds is what made solo production development survivable — and it’s what the next article builds on: the testing strategy that boundary made possible.
References
- Alistair Cockburn — Hexagonal Architecture (the original 2005 article)
- Netflix Technology Blog — Ready for changes with Hexagonal Architecture, 2020
- Alistair Cockburn & Juan Manuel Garrido de Paz — Hexagonal Architecture Explained, 2024
- Go Blog — The Go type system: interfaces
- Martin Fowler — Dependency Injection