Lesson 2 · Hexagonal — the shape of a v2 module

Ports & adapters

The single idea the whole architecture turns on — an interface the core owns, and a concrete implementation at the edge that the core never names.

Your win: point at any repository in your services and say which file is the port and which is the adapter, and explain why the arrow between them points the way it does.

Where the name comes from

Alistair Cockburn named "hexagonal architecture" (a.k.a. ports & adapters) to solve one problem: let an application be driven equally by users, other programs, automated tests, or batch scripts — and be "developed and tested in isolation from its eventual run-time devices and databases."1 The hexagon is just a box with several sides; each side is a port, and each port has one or more adapters plugged into it.

TermWhat it isIn this repo
Portan interface the core owns — "here's what I need from the outside"repo_port.go, core/port/…, storage.go
Adaptera concrete implementation that talks to a real technologyinfrastructure/repositories/, infrastructure/postgres/

A port is an interface — in your own code

Here is a real port from spike. Notice what it mentions and what it doesn't:

internal/spike/modules/email/infrastructure/repo_port.go:11
type EmailRepo interface {
    UpsertEmail(ctx context.Context, db database.Ext, e *model.Email) error
    // ... more methods
}

It takes a *model.Email and a database.Ext — and nothing else. No pgx, no SQL, no connection pool. That's the port: a promise about behaviour, stated entirely in the vocabulary of the domain. The command handler (Lesson 6) depends on this.

An adapter is the implementation — at the edge

internal/spike/modules/email/infrastructure/repositories/email.go:19
type EmailRepo struct{}   // the adapter — same name, different package

func (e *EmailRepo) UpsertEmail(ctx context.Context, db database.Ext, ent *model.Email) error {
    // builds an INSERT ... ON CONFLICT string, binds $1,$2,… , calls db.Exec — the pgx lives HERE
}

The adapter is where all the database detail lives — the SQL, the placeholders, the driver. The handler never sees any of it; it only ever holds the interface. Swap this adapter for an in-memory fake in a unit test and the handler can't tell the difference — which is the entire point Cockburn was making.

The arrow: dependency inversion

Why is this more than "use an interface"? Because of which way the dependency points:

handler (core) ───depends on──▶ EmailRepo (PORT / interface) ▲ │ implements repositories.EmailRepo (ADAPTER / postgres)

Both the core and the adapter depend on the port. The core does not depend on the adapter — it doesn't import the repositories package, doesn't know postgres exists. That inversion (the concrete depends on the abstract, never the reverse) is what makes the database a detail you can replace, mock, or defer.

The purity rule (this is testable at a glance) A port interface must reference only domain types and database.Ext — never pgx or pgtype. If you open a storage.go / repo_port.go and see a driver type in a method signature, the abstraction has already leaked. database.Ext is deliberately generic: it accepts both a real pool and a transaction handle — which is how a command can run several writes in one transaction (Lesson 6) without the port knowing about transactions at all.2

Same shape, every service

Once you see it in spike, you see it everywhere. conversationmgmt spells the two sides out in the folder names:

ServicePort (interface)Adapter (impl)
spikeinfrastructure/repo_port.go:11infrastructure/repositories/email.go:19
conversationmgmtcore/port/repository/conversation.go:10infrastructure/postgres/conversation.go:23
eureka v2repository/storage.go:11repository/postgres/…_postgres.go:22

conversationmgmt even splits it physically: core/port/ holds every interface, infrastructure/ holds every implementation. The word core is the hexagon's inside; infrastructure is the plugged-in edge. (Whether that boundary actually holds in conversationmgmt is Lesson 4 — hold that thought.)

Read this next

Hexagonal Architecture — the original

Cockburn's own page. Short, and the source of the vocabulary. Read the intent paragraph and the "driven / driving" distinction.

alistair.cockburn.us — Hexagonal Architecture
→ in-repo internal/spike/modules/email/infrastructure/repo_port.go, .../repositories/email.go

Check yourself (from memory)

Q1. In ports & adapters, what is a port?

A port is an interface the core defines — what it needs from the outside. The concrete impl that plugs into it is the adapter.

Q2. Which way does the dependency point?

Dependency inversion: both core and adapter depend on the port interface; the core never imports the adapter. That's what makes the DB swappable.

Q3. You open a storage.go and a method takes a pgtype.Text. What is that?

Ports take only domain types + database.Ext. A pgtype in the interface couples the core to the driver — the leak we watch for.
Recall: port vs adapter + the dependency arrow + the purity rule.
two definitions + direction + the rule, then reveal
Port = an interface the core owns, stated in domain terms (repo_port.go / core/port/ / storage.go). Adapter = concrete impl at the edge, where pgx/SQL lives (infrastructure/repositories / postgres). Dependency inversion: core → port ← adapter; the core NEVER imports the adapter → DB is swappable/mockable/testable in isolation (Cockburn's whole point). Purity rule: a port references only domain types + database.Ext, never pgx/pgtype. database.Ext accepts a pool OR a tx handle → transaction injection with no coupling. A driver type in a port signature = a leak.
🎯 Interview one-liner "Explain ports and adapters." → "The core defines an interface — a port — describing what it needs, like a repository, in domain terms only. Infrastructure provides an adapter that implements it against a real technology. The core depends on the port, never the adapter, so I can swap postgres for a mock and test the core in isolation. That dependency inversion is the whole value."
Next: the domain layer at the centre of the hexagon — and why this repo keeps two kinds of "entity" (model vs dto). Ask me if the dependency arrow still feels backwards — it's the crux of everything.

1. Alistair Cockburn, Hexagonal Architecture.

2. In-repo: internal/spike/modules/email/infrastructure/repo_port.go:11, .../repositories/email.go:19, .claude/rules/eureka-v2-conventions.md (storage.go purity).