Stage 11 - Real Storage: Postgres
Replace the in-memory adapters with Postgres - swapping the storage engine without touching a line of domain, application, or presentation code.
Prereqs: stage 10 complete · Time: ~40 min
By the end: create a product, restart the server, and it is still there - the same port, a different adapter.
Postgres in Docker
Goal: run Postgres locally and point the service's configuration at it.
Learn
The in-memory adapter forgets everything on restart. From here you replace it with Postgres - but the swap comes last. First stand up the database and give the service a place to read its connection string.
Configuration lives in one package, conf. Every value comes from an environment variable with a default that targets the local compose stack, so the service runs with no .env file in development. Two database DSNs matter:
DATABASE_URL- the primary (read-write) database. Writes go here.DATABASE_REPLICA_URL- a read replica. Reads go here. Unset, it falls back to the primary - the common case locally, where there is only one database.
The read/write split is shaped now so the adapter can route reads to a replica later without any layer above learning of it - the same forward-shaping that put ctx in the port back in stage 03.
Build
1. Create docker-compose.yml with the database service (a Kafka broker joins it in stage 12):
# Local development dependencies. Postgres is added now; Redpanda joins in stage 12.
# Run `docker compose up -d`, then run the app on your host. the **Swap the engine** section shows a
# temporary `main.go`; stage 13 adds the `server` subcommand and shows how to add
# the app itself as a service.
services:
db:
image: postgres:16-alpine
container_name: minimart-db
environment:
POSTGRES_USER: minimart
POSTGRES_PASSWORD: minimart
POSTGRES_DB: minimart
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U minimart"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:The named pgdata volume is what makes the data survive docker compose down - and what will make the restart test in the Swap the engine section mean something.
2. Create conf/environment.go. The database settings are used now; HTTPAddr is used when cmd/server.go appears in the Swap the engine section, and KafkaBrokers is added in stage 12:
// Package conf reads all configuration from environment variables in one place.
// Defaults target the local docker-compose stack, so the service runs with no
// .env file in development. In production every value comes from the environment.
package conf
import (
"os"
)
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// DatabaseURL is the primary (read-write) Postgres DSN. Writes go here.
func DatabaseURL() string {
return env("DATABASE_URL", "postgres://minimart:minimart@localhost:5432/minimart?sslmode=disable")
}
// DatabaseReplicaURL is the read-replica DSN. Reads go here. It falls back to the
// primary when unset - the common case in local development, where there is only
// one database.
func DatabaseReplicaURL() string {
if v := os.Getenv("DATABASE_REPLICA_URL"); v != "" {
return v
}
return DatabaseURL()
}
// HTTPAddr is the address the HTTP server listens on.
func HTTPAddr() string {
return env("HTTP_ADDR", ":8080")
}
// Env identifies the deployment environment for logs or environment-specific
// behavior at the edges.
func Env() string {
return env("ENV", "development")
}3. Record the same knobs in sample.env, so operators know what to set (defaults already match compose, so the file is optional locally):
HTTP_ADDR=":8080"
ENV="development"
# Primary (read-write) Postgres. Reads use DATABASE_REPLICA_URL if set, else this.
DATABASE_URL="postgres://minimart:minimart@localhost:5432/minimart?sslmode=disable"
# DATABASE_REPLICA_URL="postgres://minimart:minimart@localhost:5433/minimart?sslmode=disable"4. Start the database:
docker compose up -dIn production: every configurable value is one env var with a documented line insample.env; adding a knob means adding it to bothconfandsample.env. No setting is ever a string literal buried in code - that is the twelve-factor rule stage 13 leans on for deploys.
Verify
docker compose exec db psql -U minimart -c '\dt'Did not find any relations.Postgres is up and reachable, and the schema is empty - you create the tables next.
Exercise
Why start Postgres before any adapter code uses it?
Answer
The database is now part of the development environment, not just an implementation detail. Proving it starts, accepts connections, and uses the expected URL removes infrastructure uncertainty before schema and adapter code enter the picture.
Migrations
Goal: create the schema with versioned SQL migrations, applied by a migrate command.
Learn
The database is empty. The schema is not something you type into psql by hand - it is versioned SQL, checked into the repo, applied the same way in every environment. The tool is dbmate: each migration is a file with an up block (how to apply it) and a down block (how to undo it), named with a UTC timestamp so files sort into apply order.
MiniMart runs dbmate as a library, not the CLI, so the same binary that serves traffic can also run minimart migrate. That is the whole point of a schema that lives in code: deploys apply it as a one-shot step (stage 13).
Build
1. Create db/migrations/20240101000001_create_products.sql:
-- migrate:up
CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price_cents BIGINT NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- migrate:down
DROP TABLE IF EXISTS products;The literal -- migrate:up and -- migrate:down comments are how dbmate splits the file: everything under up runs on apply, everything under down runs on rollback. price_cents is BIGINT - the database mirrors the domain's int64 cents.
2. Create db/migrations/20240101000002_create_orders.sql:
-- migrate:up
CREATE TABLE orders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
total_cents BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders (id),
product_id TEXT NOT NULL,
name TEXT NOT NULL,
unit_price_cents BIGINT NOT NULL,
quantity INT NOT NULL,
line_total_cents BIGINT NOT NULL
);
CREATE INDEX order_items_order_id_idx ON order_items (order_id);
-- migrate:down
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;Go note: the course hard-codes the timestamps (20240101000001, …) so every learner ends up with the same files. In real work you rundbmate new create_widgets, which stamps the current UTC time - never invent a timestamp by hand.
3. Create the migration runner db/db.go:
// Package db applies schema migrations using dbmate as a library (not the CLI),
// so the same binary that serves traffic can also run `minimart migrate`.
package db
import (
"fmt"
"net/url"
"github.com/amacneil/dbmate/v2/pkg/dbmate"
_ "github.com/amacneil/dbmate/v2/pkg/driver/postgres" // registers the postgres driver
"minimart/conf"
)
type Migrator struct {
m *dbmate.DB
}
// New builds a Migrator pointed at the primary database. dbmate looks for
// migration files in ./db/migrations by default.
func New() (*Migrator, error) {
u, err := url.Parse(conf.DatabaseURL())
if err != nil {
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
}
m := dbmate.New(u)
m.AutoDumpSchema = false // don't write a schema.sql dump; migrations are the source of truth
return &Migrator{m: m}, nil
}
// Migrate creates the database if it does not exist, then applies every pending
// migration in timestamp order. It is safe to run on every deploy.
func (mig *Migrator) Migrate() error {
if err := mig.m.CreateAndMigrate(); err != nil {
return fmt.Errorf("create and migrate: %w", err)
}
return nil
}4. Expose it as a subcommand entry point, cmd/migrate.go:
package cmd
import "minimart/db"
// RunMigrate applies all pending schema migrations, then exits. Deploys run this
// as a one-shot step before starting the server.
func RunMigrate() error {
m, err := db.New()
if err != nil {
return err
}
return m.Migrate()
}The cmd package (server / worker / migrate) fills in over the next stages; stage 13 adds the dispatcher that turns RunMigrate into minimart migrate.
Checkpoint
Resolve the new dependency. The subcommand dispatcher does not exist until stage 13, so run the migrator from a temporary main.go for this check:
package main
import "minimart/cmd"
func main() {
if err := cmd.RunMigrate(); err != nil {
panic(err)
}
}go mod tidy # pulls dbmate; writes go.sum
go run .
docker compose exec db psql -U minimart -c '\dt' List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
public | order_items | table | minimart
public | orders | table | minimart
public | products | table | minimart
public | schema_migrations | table | minimartschema_migrations is dbmate's own bookkeeping table - it records which migrations have run, so a second go run . with this temporary entry point is a no-op. Restore your course main.go before continuing.
Exercise
Why keep migrations as files in the repository instead of applying SQL manually in psql?
Answer
The schema is application behavior. Versioned files make it repeatable in local development, CI, staging, and production, and dbmate records which versions have already run.
sqlc
Goal: turn plain SQL queries into type-safe Go, generated, never hand-written.
Learn
You could write the SQL and the row-scanning by hand, but scanning is repetitive and easy to get subtly wrong. sqlc does it for you: you write the queries in a .sql file, annotate each with the shape it returns, and sqlc generate emits type-safe Go - structs for each row, a method per query.
Two rules make this safe:
- sqlc reads the schema from
db/migrations(the same files dbmate applies), so the generated code and the real database can never drift. Rename a column in a migration and the nextsqlc generatechanges the Go accordingly. - The generated packages are not yours to edit. Change the
.sql, regenerate.
Each domain gets a reader package and a writer package - the read/replica vs. primary split from the Postgres in Docker section, expressed as two generated packages.
Build
1. Create sqlc.yaml at the project root:
version: "2"
# Each domain gets a separate reader and writer package, generated from that
# domain's queries.sql. The schema source of truth is the dbmate migrations, so
# sqlc and the database can never drift. Regenerate with `sqlc generate`.
# Never hand-edit the generated *.sql.go / models.go / db.go files.
sql:
- engine: "postgresql"
schema: "db/migrations"
queries: "catalog/infrastructure/postgres/reader/queries.sql"
gen:
go: &go
package: "reader"
out: "catalog/infrastructure/postgres/reader"
sql_package: "pgx/v5"
emit_pointers_for_null_types: true
- engine: "postgresql"
schema: "db/migrations"
queries: "catalog/infrastructure/postgres/writer/queries.sql"
gen:
go:
<<: *go
package: "writer"
out: "catalog/infrastructure/postgres/writer"
- engine: "postgresql"
schema: "db/migrations"
queries: "order/infrastructure/postgres/reader/queries.sql"
gen:
go:
<<: *go
package: "reader"
out: "order/infrastructure/postgres/reader"
- engine: "postgresql"
schema: "db/migrations"
queries: "order/infrastructure/postgres/writer/queries.sql"
gen:
go:
<<: *go
package: "writer"
out: "order/infrastructure/postgres/writer"2. Write the catalog's read queries, catalog/infrastructure/postgres/reader/queries.sql:
-- name: GetProduct :one
SELECT id, name, price_cents, stock, created_at, updated_at
FROM products
WHERE id = $1;
-- name: ListProducts :many
SELECT id, name, price_cents, stock, created_at, updated_at
FROM products
ORDER BY created_at;The -- name: X :kind annotation is the contract with sqlc. :one generates a method returning a single row, :many a slice, and :exec (next) a method returning only an error. $1 is Postgres's positional parameter.
3. Write the catalog's write query, catalog/infrastructure/postgres/writer/queries.sql:
-- name: SaveProduct :exec
INSERT INTO products (id, name, price_cents, stock)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
price_cents = EXCLUDED.price_cents,
stock = EXCLUDED.stock,
updated_at = NOW();ON CONFLICT ... DO UPDATE is the upsert that lets one SaveProduct method serve both create and update - matching the port's single Save.
4. The order domain needs its own two files, since sqlc.yaml lists all four. order/infrastructure/postgres/reader/queries.sql:
-- name: GetOrder :one
SELECT id, user_id, total_cents, created_at
FROM orders
WHERE id = $1;
-- name: ListOrderItems :many
SELECT product_id, name, unit_price_cents, quantity, line_total_cents
FROM order_items
WHERE order_id = $1
ORDER BY id;And order/infrastructure/postgres/writer/queries.sql:
-- name: CreateOrder :exec
INSERT INTO orders (id, user_id, total_cents)
VALUES ($1, $2, $3);
-- name: CreateOrderItem :exec
INSERT INTO order_items (order_id, product_id, name, unit_price_cents, quantity, line_total_cents)
VALUES ($1, $2, $3, $4, $5, $6);5. Generate:
sqlc generateThis writes three files into each of the four out directories - db.go, models.go, and queries.sql.go - the packages the adapter imports. They do not exist until you run this; that is why a fresh checkout must sqlc generate before it builds.
In production: the exact sqlc version is pinned in the Makefile and CI, so every developer regenerates byte-identical code. The generated directories are committed, so a normal build needs no code-gen - only a query change does.
Verify
ls catalog/infrastructure/postgres/readerdb.go models.go queries.sql.goqueries.sql.go holds a GetProduct method and a ProductsRow-style struct you did not write. Open it if you like - then close it and never edit it.
Exercise
Why should generated sqlc files be read when curious, but not hand-edited?
Answer
They are outputs, not source. The source of truth is the migration schema and the annotated SQL. If generated code is wrong, fix the SQL or schema and regenerate so the next sqlc generate does not erase a manual patch.
The Postgres adapter
Goal: hand-write the repositories that satisfy the same port, over the generated code.
Learn
sqlc gives you type-safe queries; the adapter is the thin layer that maps between the generated row types and your domain entities, and that satisfies the domain's Repository port. This is the only new code you write for storage - the port is unchanged, so nothing above it moves.
Generated rows are not domain entities. They are database shapes: column names, nullable fields, integer widths, and join results. Mapping them by hand is the same boundary discipline as DTO mapping in stage 05 - a little duplication that keeps a schema change from becoming a domain change.
Two adapter jobs are worth naming:
- Error translation. The driver returns
pgx.ErrNoRowsfor a missing row; the adapter turns that into the domain'sErrProductNotFound, so the application layer never learns which database is underneath. - Atomic writes. An order is a row in
ordersplus rows inorder_items. They must all land or none - a transaction, entirely inside the adapter.
Each adapter opens with var _ domain.Repository = (*T)(nil) - the compile-time proof that it fits the port. That single line is why the swap in the Swap the engine section is safe.
Build
1. Create catalog/infrastructure/postgres/catalog_repository.go:
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/catalog/domain"
"minimart/catalog/infrastructure/postgres/reader"
"minimart/catalog/infrastructure/postgres/writer"
)
// CatalogRepository implements the catalog domain.Repository port over Postgres.
// It holds sqlc query objects bound to a read pool and a write pool.
type CatalogRepository struct {
reader *reader.Queries
writer *writer.Queries
}
// Compile-time proof the Postgres adapter satisfies the same port as the
// in-memory one. This is why swapping storage touches no business code.
var _ domain.Repository = (*CatalogRepository)(nil)
func NewCatalogRepository(readPool, writePool *pgxpool.Pool) *CatalogRepository {
return &CatalogRepository{
reader: reader.New(readPool),
writer: writer.New(writePool),
}
}
func (r *CatalogRepository) Save(ctx context.Context, p domain.Product) error {
return r.writer.SaveProduct(ctx, writer.SaveProductParams{
ID: p.ID,
Name: p.Name,
PriceCents: p.PriceCents,
Stock: int32(p.Stock),
})
}
func (r *CatalogRepository) GetByID(ctx context.Context, id string) (domain.Product, error) {
row, err := r.reader.GetProduct(ctx, id)
if err != nil {
// Translate the driver's "no rows" into the domain's not-found error, so
// the application layer never learns which database we use.
if errors.Is(err, pgx.ErrNoRows) {
return domain.Product{}, domain.ErrProductNotFound
}
return domain.Product{}, err
}
return domain.Product{
ID: row.ID,
Name: row.Name,
PriceCents: row.PriceCents,
Stock: int(row.Stock),
}, nil
}
func (r *CatalogRepository) List(ctx context.Context) ([]domain.Product, error) {
rows, err := r.reader.ListProducts(ctx)
if err != nil {
return nil, err
}
out := make([]domain.Product, 0, len(rows))
for _, row := range rows {
out = append(out, domain.Product{
ID: row.ID,
Name: row.Name,
PriceCents: row.PriceCents,
Stock: int(row.Stock),
})
}
return out, nil
}Reads use r.reader (the replica pool), writes use r.writer (the primary). The int32(p.Stock) / int(row.Stock) conversions bridge the domain's int and Postgres's 32-bit INT - the adapter's job, kept out of the domain.
2. Create order/infrastructure/postgres/order_repository.go - the same shape, but Save is a transaction:
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/order/domain"
"minimart/order/infrastructure/postgres/reader"
"minimart/order/infrastructure/postgres/writer"
)
type OrderRepository struct {
writePool *pgxpool.Pool
reader *reader.Queries
writer *writer.Queries
}
var _ domain.Repository = (*OrderRepository)(nil)
func NewOrderRepository(readPool, writePool *pgxpool.Pool) *OrderRepository {
return &OrderRepository{
writePool: writePool,
reader: reader.New(readPool),
writer: writer.New(writePool),
}
}
// Save writes the order and all its items in one transaction. If any item insert
// fails, the deferred Rollback undoes the whole thing.
func (r *OrderRepository) Save(ctx context.Context, o domain.Order) error {
tx, err := r.writePool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) // no-op after a successful Commit
q := r.writer.WithTx(tx)
if err := q.CreateOrder(ctx, writer.CreateOrderParams{
ID: o.ID,
UserID: o.UserID,
TotalCents: o.TotalCents,
}); err != nil {
return err
}
for _, it := range o.Items {
if err := q.CreateOrderItem(ctx, writer.CreateOrderItemParams{
OrderID: o.ID,
ProductID: it.ProductID,
Name: it.Name,
UnitPriceCents: it.UnitPriceCents,
Quantity: int32(it.Quantity),
LineTotalCents: it.LineTotalCents,
}); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *OrderRepository) GetByID(ctx context.Context, id string) (domain.Order, error) {
row, err := r.reader.GetOrder(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Order{}, domain.ErrOrderNotFound
}
return domain.Order{}, err
}
items, err := r.reader.ListOrderItems(ctx, id)
if err != nil {
return domain.Order{}, err
}
domItems := make([]domain.Item, 0, len(items))
for _, it := range items {
domItems = append(domItems, domain.Item{
ProductID: it.ProductID,
Name: it.Name,
UnitPriceCents: it.UnitPriceCents,
Quantity: int(it.Quantity),
LineTotalCents: it.LineTotalCents,
})
}
return domain.Order{
ID: row.ID,
UserID: row.UserID,
TotalCents: row.TotalCents,
Items: domItems,
CreatedAt: row.CreatedAt,
}, nil
}r.writer.WithTx(tx) returns a query set bound to the transaction, so both inserts run inside it. defer tx.Rollback(ctx) is the safety net: it is a no-op once Commit succeeds, and undoes everything if any insert returns early.
In production: a repeated multi-table write like this is factored into aWithinTransaction(ctx, func(ctx) error) errorport method, so a use case can declare "these writes succeed together" while the adapter supplies theBEGIN/COMMIT. For read-modify-write rules, another common shape isUpdateProduct(ctx, id, func(Product) (Product, error)): the use case names the change, and the adapter owns locking, retry, and commit. The decision crosses the port; the SQL never does.
Checkpoint
go build ./...Compiles clean. The var _ domain.Repository lines are doing the real checking here - if a method signature drifts from the port, the build fails at exactly that line, naming the missing method.
Exercise
Why map sqlc row structs into domain entities instead of returning the generated types directly?
Answer
Generated row structs describe the database shape. Domain entities describe business validity. Mapping at the adapter boundary keeps database choices out of the domain and gives the adapter one place to translate driver errors and storage types.
Swap the engine
Goal: point the modules at Postgres, run the pools from the composition root, and measure the change surface.
Learn
Everything is in place: the schema, the generated queries, the adapters. The swap itself is one line in each module's Init - the single place that names a concrete adapter - plus wiring the connection pools at the composition root and handing them in.
Count what does not change: domain/, application/, presentation/. The port never moved, so nothing that depends on the port moved. That is the guarantee this whole architecture exists to deliver, and you are about to measure it.
Everything in this section is storage only - no Kafka. The composition root gains its event wiring in stage 12; here it stays exactly as small as stage 09 left it.
Build
1. In catalog/module.go, Init builds the Postgres adapter instead of memory.New(), and NewModule now receives the two pools:
// Package catalog wires the catalog domain into the modular monolith. It is the
// domain's composition root: the one place that names concrete adapters.
package catalog
import (
"context"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/catalog/application"
"minimart/catalog/infrastructure/postgres"
cataloghttp "minimart/catalog/presentation/http"
catalogmodule "minimart/catalog/presentation/module"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
)
// Module is the catalog domain's self-wiring unit.
type Module struct {
readPool *pgxpool.Pool
writePool *pgxpool.Pool
svc *application.Service
handlers *cataloghttp.Handlers
}
var _ module.Module = (*Module)(nil)
func NewModule(readPool, writePool *pgxpool.Pool) *Module {
return &Module{readPool: readPool, writePool: writePool}
}
func (m *Module) Name() string { return "catalog" }
// Init chooses the concrete storage adapter. Swapping storage is this one line;
// no other layer is touched. (Earlier stages used memory.New() here.)
func (m *Module) Init(ctx context.Context) error {
repo := postgres.NewCatalogRepository(m.readPool, m.writePool)
m.svc = application.NewService(repo)
m.handlers = cataloghttp.NewHandlers(m.svc)
return nil
}
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error {
c.Catalog = catalogmodule.New(m.svc)
return nil
}
func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error {
m.handlers.Register(mux)
return nil
}2. The same swap in order/module.go - Postgres repo, pools in the constructor. Its service still takes two arguments (repository + registry); the publisher joins in stage 12:
package order
import (
"context"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/order/application"
"minimart/order/infrastructure/postgres"
orderhttp "minimart/order/presentation/http"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
)
type Module struct {
registry *contracts.Contracts
readPool *pgxpool.Pool
writePool *pgxpool.Pool
svc *application.Service
handlers *orderhttp.Handlers
}
var _ module.Module = (*Module)(nil)
func NewModule(registry *contracts.Contracts, readPool, writePool *pgxpool.Pool) *Module {
return &Module{registry: registry, readPool: readPool, writePool: writePool}
}
func (m *Module) Name() string { return "order" }
func (m *Module) Init(ctx context.Context) error {
repo := postgres.NewOrderRepository(m.readPool, m.writePool)
m.svc = application.NewService(repo, m.registry)
m.handlers = orderhttp.NewHandlers(m.svc)
return nil
}
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error { return nil }
func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error {
m.handlers.Register(mux)
return nil
}3. The pools are shared infrastructure, so the composition root builds them. Create cmd/deps.go:
package cmd
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/conf"
)
// newPools builds a read pool (replica) and a write pool (primary). Locally both
// DSNs resolve to the same database; in production they point at different hosts.
func newPools(ctx context.Context) (readPool, writePool *pgxpool.Pool, err error) {
writePool, err = pgxpool.New(ctx, conf.DatabaseURL())
if err != nil {
return nil, nil, fmt.Errorf("connect primary: %w", err)
}
readPool, err = pgxpool.New(ctx, conf.DatabaseReplicaURL())
if err != nil {
writePool.Close()
return nil, nil, fmt.Errorf("connect replica: %w", err)
}
return readPool, writePool, nil
}4. Wire it in cmd/server.go - build the pools, then pass them to the modules:
package cmd
import (
"context"
"log"
"net/http"
"minimart/catalog"
"minimart/conf"
"minimart/order"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
)
// StartServer runs the HTTP API. It builds the DB pools, then runs the four-phase
// module boot and serves.
func StartServer() error {
ctx := context.Background()
readPool, writePool, err := newPools(ctx)
if err != nil {
return err
}
defer writePool.Close()
defer readPool.Close()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
registry := &contracts.Contracts{}
modules := []module.Module{
catalog.NewModule(readPool, writePool),
order.NewModule(registry, readPool, writePool),
}
for _, m := range modules {
if err := m.Init(ctx); err != nil {
return err
}
}
for _, m := range modules {
if err := m.RegisterContracts(ctx, registry); err != nil {
return err
}
}
if err := registry.Verify(); err != nil {
return err
}
for _, m := range modules {
if err := m.RegisterHTTP(ctx, mux); err != nil {
return err
}
}
log.Printf("minimart server listening on %s", conf.HTTPAddr())
return http.ListenAndServe(conf.HTTPAddr(), mux)
}The boot loop is unchanged from stage 09. The only new lines are the two newPools calls and the pools passed into each NewModule.
Grows in stage 12: thisserver.goand bothmodule.gofiles reach their final form in stage 12, which adds the Kafka producer (server.go), the publisher (order module + service), and aRegisterWorkersmethod (theModuleinterface and every module). Stage 11 is storage only.
Checkpoint
The subcommand dispatcher (minimart server) arrives in stage 13. Until then, run the server from a one-line temporary main.go:
package main
import "minimart/cmd"
func main() {
if err := cmd.StartServer(); err != nil {
panic(err)
}
}With Postgres up and migrated (from the Postgres in Docker section and the Migrations section):
go run .In a second terminal, create a product and read it back (note the id):
curl -s -X POST localhost:8080/v1/products \
-d '{"name":"Widget","price_cents":1299,"stock":10}'
curl -s localhost:8080/v1/productsNow the proof memory could never give you. Stop the server (Ctrl+C), start it again, and list products:
go run .
# second terminal:
curl -s localhost:8080/v1/productsThe product is still there. The process lost its memory; the data was never in the process.
The change surface. Files touched to swap the entire storage engine:
domain/- 0application/- 0presentation/- 0catalog/module.goandorder/module.go- one function (Init) and the constructor- new adapters + generated code under
infrastructure/postgres/
The Dependency Rule was never about folder names. This is the number it buys.
What you learned
- A new storage engine is a new adapter plus one line at each swap point - the port never moves, so nothing above it does.
- Migrations (dbmate) own the schema; sqlc generates type-safe queries from it; the adapter maps generated rows to domain entities and translates driver errors.
- The composition root owns shared infrastructure (connection pools) and injects it; the module only assembles.
Exercise
What proves the storage boundary held during the swap from memory to Postgres?
Answer
The changed surface stays at the adapters, module wiring, and shared connection pools. The domain rules, use cases, handlers, and contracts keep their shape because they depended on ports, not on the memory implementation.