Stage 03 - Ports & Adapters
Give the domain a persistence port it owns, back it with an in-memory adapter, and prove the adapter satisfies the port at compile time.
Prereqs: stage 02 complete · Time: ~20 min
By the end: go test ./catalog/... passes with an in-memory store behind the port.
The port
Goal: declare the persistence interface the domain owns.
Learn
The application will need to save and load products, but the domain must not know how - that would drag a database into the centre. So the domain declares an interface for what it needs and lets someone else implement it.
- A port is an interface the domain owns, describing a capability it needs from the outside.
- An adapter is a concrete type that satisfies a port (in-memory now, Postgres in stage 11).
Because the interface lives in domain/, the dependency arrow points inward: the adapter imports the domain to implement the port; the domain imports nothing. That is the Dependency Rule made concrete.
Every method takes ctx context.Context as its first parameter, from the very first version. The in-memory store ignores it, but a real database adapter needs it for cancellation, deadlines, tracing, and driver calls. Shaping the port for the real adapter now avoids signature churn later. Do not hide required business inputs in ctx, though - if a user ID, role, or tenant is needed to make a decision, pass it as a typed argument where the compiler can see it.
A useful smell test for a port: could memory, Postgres, and maybe a remote API all implement this interface without changing its vocabulary? If a domain port mentions pgx, SQL, transactions, table names, or HTTP status codes, infrastructure leaked in.
Build
Create catalog/domain/repository.go:
package domain
import "context"
// Repository is the persistence port the catalog domain needs. The domain owns
// this interface; adapters under infrastructure/ implement it (in-memory now,
// Postgres later). ctx is the first parameter of every method from the start -
// the shape a real database adapter requires.
type Repository interface {
Save(ctx context.Context, p Product) error
GetByID(ctx context.Context, id string) (Product, error)
List(ctx context.Context) ([]Product, error)
}GetByID returns ErrProductNotFound (the sentinel from the Valid by construction section) when the id is unknown - the adapter's job in the in-memory adapter section.
In production: sqlc generates typedreader/andwriter/query packages per domain, and a hand-written repository implements this same port over twopgxpoolpools - reads to the reader, writes to the writer,pgx.ErrNoRowstranslated toErrProductNotFound. The port does not change; only the adapter does.
Verify
go build ./catalog/domain/Prints nothing, exits 0. An interface with no implementation still compiles.
Exercise
Why put the Repository interface in domain/ rather than beside the in-memory adapter that implements it?
Answer
So the dependency points inward. The domain and application depend only on the port they own; adapters depend on the domain. If the interface lived with the adapter, the core would import infrastructure - the exact coupling the architecture forbids, and what makes swapping storage a one-line change.
The in-memory adapter
Goal: implement the Repository port with an in-memory store, and prove it fits.
Learn
An adapter lives in infrastructure/ and implements a domain port. The simplest one keeps products in a map. It has no dependencies, starts instantly, and stays useful as a fast test double even after Postgres becomes the runtime path.
One line earns its keep:
var _ domain.Repository = (*Repository)(nil)This is a compile-time assertion. It assigns a nil *Repository to a throwaway domain.Repository variable, forcing the compiler to check that *Repository satisfies the interface. If a method signature drifts, the build fails here, at the adapter, with a clear message - not at runtime, and not at some distant call site.
Build
Create catalog/infrastructure/memory/repository.go:
// Package memory is an in-memory adapter for the catalog Repository port. Data
// lives only for the life of the process. It is the simplest possible adapter and
// stays useful forever as a fast, dependency-free double in tests.
package memory
import (
"context"
"sync"
"minimart/catalog/domain"
)
// Repository stores products in a map guarded by a mutex, safe for concurrent use.
type Repository struct {
mu sync.RWMutex
products map[string]domain.Product
}
// Compile-time proof that Repository satisfies the port. If a method drifts, the
// build fails here, not at runtime.
var _ domain.Repository = (*Repository)(nil)
func New() *Repository {
return &Repository{products: make(map[string]domain.Product)}
}
func (r *Repository) Save(ctx context.Context, p domain.Product) error {
r.mu.Lock()
defer r.mu.Unlock()
r.products[p.ID] = p
return nil
}
func (r *Repository) GetByID(ctx context.Context, id string) (domain.Product, error) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.products[id]
if !ok {
return domain.Product{}, domain.ErrProductNotFound
}
return p, nil
}
func (r *Repository) List(ctx context.Context) ([]domain.Product, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]domain.Product, 0, len(r.products))
for _, p := range r.products {
out = append(out, p)
}
return out, nil
}The sync.RWMutex makes concurrent HTTP requests safe. GetByID returns domain.ErrProductNotFound for an unknown id - the port's contract from the port section. The ctx parameters go unused here; a database adapter would honour them.
The map stores domain.Product values, not pointers. That means a caller receives a copy and cannot mutate the repository's internal state behind its back. For simple entities, value copies are a cheap way to keep the adapter honest.
Checkpoint
go build ./catalog/...Prints nothing, exits 0. Rename Save to Store and the build fails at the var _ line - revert it.
Exercise
What does the var _ domain.Repository = (*Repository)(nil) line prove, and what does it not prove?
Answer
It proves the adapter's method set matches the port at compile time. It does not prove the methods do the right thing, handle errors correctly, or preserve values - that is why the next section adds behavior tests.
Test the adapter
Goal: verify the adapter round-trips products and reports not-found correctly.
Learn
The compile-time assertion proves the adapter fits the port; it does not prove the adapter behaves. A short test does that: save a product, read it back, list it, and confirm a missing id returns domain.ErrProductNotFound. Match the sentinel with errors.Is, never by comparing message strings - the mapping at the HTTP edge (stage 06) will depend on it.
Build
Create catalog/infrastructure/memory/repository_test.go:
package memory
import (
"context"
"errors"
"testing"
"minimart/catalog/domain"
)
func TestRepository(t *testing.T) {
ctx := context.Background()
repo := New()
p, err := domain.NewProduct("p-1", "Widget", 500, 3)
if err != nil {
t.Fatalf("NewProduct: %v", err)
}
// Save, then read the same product back.
if err := repo.Save(ctx, p); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := repo.GetByID(ctx, "p-1")
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got != p {
t.Fatalf("GetByID returned %+v, want %+v", got, p)
}
// List returns the one saved product.
all, err := repo.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(all) != 1 {
t.Fatalf("List returned %d products, want 1", len(all))
}
// A missing id returns the domain not-found sentinel.
if _, err := repo.GetByID(ctx, "missing"); !errors.Is(err, domain.ErrProductNotFound) {
t.Fatalf("GetByID(missing): want ErrProductNotFound, got %v", err)
}
}context.Background() is the root context for tests and main - an empty, never-cancelled context. got != p works because Product is a comparable struct (every field is comparable).
Verify
go test ./catalog/infrastructure/memory/ok minimart/catalog/infrastructure/memory 0.002sWhat you learned
- A port is a domain-owned interface; an adapter in
infrastructure/implements it. ctxleads every port method from the start, shaped for the real database.- The
var _ Port = (*Adapter)(nil)assertion catches drift at compile time.
Next: the application layer orchestrates use cases over this port.
Exercise
Why should the not-found check use errors.Is instead of comparing error text?
Answer
Error text is for people and can change. The sentinel is the contract other layers can depend on, and errors.Is keeps working even when a lower layer wraps the sentinel with extra context.