MiniCart

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):

YAML
# 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:

Go
// 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):

Shell
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:

Shell
docker compose up -d
In production: every configurable value is one env var with a documented line in sample.env; adding a knob means adding it to both conf and sample.env. No setting is ever a string literal buried in code - that is the twelve-factor rule stage 13 leans on for deploys.

Verify

Shell
docker compose exec db psql -U minimart -c '\dt'
Code
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:

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:

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 run dbmate new create_widgets, which stamps the current UTC time - never invent a timestamp by hand.

3. Create the migration runner db/db.go:

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:

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:

Go
package main

import "minimart/cmd"

func main() {
	if err := cmd.RunMigrate(); err != nil {
		panic(err)
	}
}
Shell
go mod tidy          # pulls dbmate; writes go.sum
go run .
docker compose exec db psql -U minimart -c '\dt'
Code
                List of relations
 Schema |       Name        | Type  |  Owner
--------+-------------------+-------+----------
 public | order_items       | table | minimart
 public | orders            | table | minimart
 public | products          | table | minimart
 public | schema_migrations | table | minimart

schema_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 next sqlc generate changes 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:

YAML
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:

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:

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:

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:

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:

Shell
sqlc generate

This 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

Shell
ls catalog/infrastructure/postgres/reader
Code
db.go  models.go  queries.sql.go

queries.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.ErrNoRows for a missing row; the adapter turns that into the domain's ErrProductNotFound, so the application layer never learns which database is underneath.
  • Atomic writes. An order is a row in orders plus rows in order_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:

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:

Go
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 a WithinTransaction(ctx, func(ctx) error) error port method, so a use case can declare "these writes succeed together" while the adapter supplies the BEGIN/COMMIT. For read-modify-write rules, another common shape is UpdateProduct(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

Shell
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:

Go
// 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:

Go
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:

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:

Go
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: this server.go and both module.go files reach their final form in stage 12, which adds the Kafka producer (server.go), the publisher (order module + service), and a RegisterWorkers method (the Module interface 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:

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):

Shell
go run .

In a second terminal, create a product and read it back (note the id):

Shell
curl -s -X POST localhost:8080/v1/products \
  -d '{"name":"Widget","price_cents":1299,"stock":10}'

curl -s localhost:8080/v1/products

Now the proof memory could never give you. Stop the server (Ctrl+C), start it again, and list products:

Shell
go run .
# second terminal:
curl -s localhost:8080/v1/products

The 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/ - 0
  • application/ - 0
  • presentation/ - 0
  • catalog/module.go and order/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.