← All projects

ecommerce · intermediate · ~12-26h

Aurora Commerce

An e-commerce backend where checkout is one ACID transaction — overselling becomes impossible.

You'll build the order pipeline for a small store: a catalog with categories and images, a server-side cart created anonymously and claimed at checkout, and a checkout that reads the cart, decrements inventory, and creates an order atomically. The whole project is an argument for a relational database — money and stock have invariants, and PostgreSQL transactions enforce them for you, whichever language you write the API in.

backend Godatabase PostgreSQLmobile Jetpack Composeai Gemini APIcloud Google Cloud
The one idea that makes this project click

Checkout is ONE atomic transaction — a conditional `UPDATE … WHERE stock >= qty RETURNING unit_price` per cart line that can't drive stock negative, with the cart claimed and the idempotency key written in the same commit — so overselling is structurally impossible and a retried request is a safe no-op, not a second order.

Production spec — the contract you're building toward

The runnable result is a Postgres-backed order pipeline whose checkout reads a server-side, token-identified cart and runs as one ACID transaction, served identically by a Go and a Spring Boot/Kotlin backend. The contract is small — `/products`, four `/cart` endpoints, `/checkout`, and `/orders/{id}` over eight tables (categories, products, product_images, customers, carts, cart_items, orders, order_items) — with both backends at byte-for-byte parity.

Read the full production spec →

Why this stack

Commerce is the canonical relational problem: orders reference customers and products, inventory must never go negative, and totals must be exact. PostgreSQL gives you ACID transactions, foreign keys, and CHECK constraints that turn those rules into guarantees — and crucially, that's true whether the API is Go or Spring Boot. The language is a shell around the database; the database is the lesson.

What you'll be able to do

  • Model orders, items, and inventory with foreign keys and CHECK constraints
  • Build a server-side anonymous cart — a DB-minted token in a header, an ON CONFLICT merge — that checkout claims atomically
  • Write a checkout that is atomic — cart claim, stock decrement, and order creation succeed or fail together
  • Implement the same transaction in two backends (Go/pgx and Spring Boot/Kotlin) and see the DB carry the weight
  • Prevent overselling under concurrency with row locks and serializable retries
  • Build the storefront in Jetpack Compose, Flutter, or SwiftUI against one API
  • Optionally turn a recipe into a filled cart with Gemini structured output

TechFit — which tools actually suit this build

TechFit — how well each technology suits this project (editorial 1–5).
Technology Fit Role Why
PostgreSQL spotlight 5/5 System of record; checkout runs as a single ACID transaction. Transactions + constraints make overselling and money drift structurally impossible.
Go 5/5 Default API in front of Postgres. Fast, simple service with excellent pgx tooling and one-binary deploys.
Kotlin 4/5 Alternative API via Spring Boot (Kotlin) with @Transactional. Mature, declarative transaction management; a classic enterprise-commerce fit.
Jetpack Compose 4/5 Android storefront — catalog and checkout. Declarative UI ships the list/detail/checkout flow quickly.
Google Cloud 4/5 Cloud SQL (Postgres) + Cloud Run host. Managed Postgres plus serverless containers fit the API cleanly.
Gemini API 3/5 Optional — turn a recipe into a filled cart. A delightful add-on, but not load-bearing for commerce correctness.
MongoDB 2/5 Alternative primary store (not chosen). Cross-document money + inventory invariants are awkward without default multi-document ACID.

The build

Your path filters the steps below — pick a backend, a frontend, and any optional modules.

Build it your way — steps below adapt to your choices.

Backend
Frontend
Optional modules — off by default
  • AI Recipe → Cart: Paste a dish recipe; Gemini extracts the ingredients, matches them to catalog products, and adds them to the cart.
  • Order Support Agent: A Gemini function-calling endpoint with typed read-only tools (order status, stock, restock); writes like cancel/refund need explicit human confirmation.
  • Logging & Observability: Structured logs, a request/trace id propagated through checkout, per-phase log lines, and an optional local OpenTelemetry + Grafana/Tempo trace view.
  • Storefront & Flash Sales: A denormalized /storefront home page (banners, featured, categories, live deals) plus temporal flash-sale deals whose global-allocation and per-customer caps extend the guarded checkout inside one transaction — zero oversell.
  • Order Lifecycle & Inventory: Cart holds with a TTL and a SKIP LOCKED expiry sweep, an authorize→capture→fulfil→refund order/payment state machine enforced by guarded status transitions, an append-only stock ledger, and a transactional outbox — every stock and state change inside the one checkout transaction, zero oversell.
  • Search & Recommendations: Faceted full-text product search (a generated tsvector queried with websearch_to_tsquery, price/in-stock/category facets, keyset pagination, and a pg_trgm typo-tolerant fallback) plus SQL-only recommendations — customers-also-bought, frequently-bought-together, and trending — computed straight from the order data, no new datastore.
  • Redis Read-Cache & Rate-Limiting: A Redis read-through cache in front of GET /products (a TTL plus explicit key invalidation on catalog-visibility writes) and a per-customer atomic rate-limiter on POST /checkout returning 429; reads and the limiter fail open to Postgres, so correctness never depends on the cache — stock and the checkout decision are never cached and the guarded UPDATE stays the uncached source of truth.
  • Commerce Analytics: SQL-only analytics over the order data — revenue over time with a running total, average order value, and top products, plus RFM customer segmentation with ntile(5) and market-basket association rules (support/confidence/lift, ranked by lift rather than raw co-count) straight from the same database. An optional Gemini constrained-JSON board report narrates the metrics and degrades to null without ever taking the metrics down; runs for $0 with no API key.

Learn the language itself: Go · Spring Boot (Kotlin)

Pick your backend (Go or Spring Boot/Kotlin) and frontend (Compose, Flutter, or SwiftUI) above — the steps below adapt. Watch the spotlight: by the checkout step, a single BEGIN … COMMIT makes overselling, partial orders, and wrong totals impossible rather than merely unlikely — and it reads the same lesson in either language. Long before you write a line of Go or Kotlin, you’ll race two psql sessions and watch Postgres refuse the double-sell with your own eyes.

Stand up Postgres locally

Beginner

Start a Postgres container with Docker Compose and export a DATABASE_URL your API will read — so every learner gets the same database with one command and the API knows where to find it.

New in this step
Docker Compose

A YAML file that defines and runs containers (here, one Postgres) so the whole team gets an identical, throwaway database.

docker compose quickstart
DATABASE_URL

An environment variable holding the connection string; reading it from the env means the same build runs locally and in the cloud.

twelve-factor config environment
postgres:// connection string (DSN)

The single-line address of a database: user:password@host:port/dbname plus options.

postgres connection string format docs ↗
sslmode=disable

Turns off TLS for the local container (fine for localhost; never for a real server).

libpq sslmode
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why Compose for local dev

A throwaway Postgres in Docker gives every learner the same version and a clean reset (docker compose down -v). The API reads DATABASE_URL from the environment so the exact same build runs locally, in CI, and on Cloud Run — only the connection string changes.

First, verify your toolchain (cold machine → first observable)

You only need Docker for this whole first step — the primary check runs psql inside the container. Confirm the two tools you do need are present:

docker --version         # https://docs.docker.com/get-docker/
docker compose version   # bundled with Docker Desktop / the compose plugin

The host psql (libpq client) is needed only for the optional variant below — it is not bundled with Docker, and a Go or Spring learner has no reason to have it. If you want that variant, check it too and install the psql client / libpq if it is missing:

psql --version   # optional — only for the host-psql variant
docker-compose.yml
Run these in your terminal / editor
# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: aurora
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5
volumes: { pgdata: {} }
Run it
Run these in your terminal / editor
docker compose up -d
export DATABASE_URL="postgres://postgres:dev@localhost:5432/aurora?sslmode=disable"
# Primary check — Docker only, no host psql needed (runs psql INSIDE the container):
docker compose exec db psql -U postgres -d aurora -c "select version();"
# Optional variant, only if you have the host psql client (libpq):
# psql "$DATABASE_URL" -c "select version();"
What success looks like

docker compose ps shows the db service running (healthy), and the Docker-only docker compose exec db psql … query connects and prints a PostgreSQL 16.x … banner — proof the container is up (and, if you set the optional host psql, that DATABASE_URL reaches it).

Design the schema around invariants

Beginner

Create the store’s eight tables — the catalog (categories, products, product_images), customers, the server-side cart (carts, cart_items), and the order pair (orders, order_items) — with foreign keys and the constraints that encode your business rules, so impossible data (negative stock, an orphan order, a duplicate cart line) is rejected by the database, not just by app code.

New in this step
CHECK constraint

A rule the row must satisfy or the write is rejected, e.g. CHECK (stock >= 0) makes negative stock impossible.

postgres check constraint docs ↗
FOREIGN KEY / REFERENCES

Forces a column to point at a real row in another table, so an order can’t reference a customer who doesn’t exist — and a product can’t exist without its category.

postgres foreign key references
partial unique index

A uniqueness rule that applies only to rows matching a WHERE clause; here it makes set idempotency keys unique while allowing many NULLs.

postgres partial index
BIGINT GENERATED ALWAYS AS IDENTITY

The modern auto-incrementing 64-bit primary key (the successor to serial).

postgres identity column
TIMESTAMPTZ

A timestamp that stores the instant in UTC, so created_at is unambiguous across time zones.

postgres timestamptz vs timestamp
money as BIGINT cents

Integers can’t drift the way floats do; store 1499, not 14.99.

why store money as integer cents
gen_random_uuid()

Generates a random UUID as a column default — core PostgreSQL since 13, no extension needed. The database, never app code, mints the cart’s token.

postgres gen_random_uuid docs ↗
composite primary key

A primary key over two columns; PRIMARY KEY (cart_id, product_id) means a cart holds at most one line per product, so a re-add must merge, not duplicate.

postgres composite primary key
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Let the schema be the rulebook

The most durable validation lives in the database, not the app. NOT NULL, CHECK (stock >= 0), and FOREIGN KEY mean a bug in any client — Go, Kotlin, a stray psql session — still can’t write impossible data. Money is BIGINT cents (never float). order_items captures the price at purchase time, so later price changes don’t rewrite history. The foreign keys also dictate creation order: products.category_id is NOT NULL REFERENCES categories(id), so categories must exist before products — the same reason the seed inserts categories before the catalog. Author this file straight into the place the migration runner expects — it is the init migration (0001_init.up.sql for golang-migrate, V1__init.sql for Flyway), not throwaway SQL you retype later. The migrations step wires the runner to the files you write here and in the next step.

The cart lives in the database — anonymous now, claimed at checkout

carts + cart_items are the server-side cart the API steps build on. Three design choices carry the whole cart contract. The token: token UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE — the cart’s identity on the wire (the X-Cart-Token header), minted by the database default, unique so a token lookup is exact. The nullable owner: customer_id BIGINT NULL — a cart starts anonymous (no login in this build) and checkout claims it by setting customer_id and flipping status to 'converted'; the status CHECK admits 'active', 'converted', and 'abandoned' ('abandoned' is reserved — nothing in the base build produces it, exactly like the orders’ 'shipped'). The line PK: PRIMARY KEY (cart_id, product_id) plus CHECK (qty > 0) — one row per product per cart, so adding the same product twice must merge quantities (the cart step does this in one ON CONFLICT statement), and a zero-quantity line simply cannot exist.

0001_init.up.sql (Go) / V1__init.sql (Spring) — the init migration
Run these in your terminal / editor
CREATE TABLE categories (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name      TEXT NOT NULL,
  slug      TEXT NOT NULL UNIQUE,                          -- UNIQUE so the seed's ON CONFLICT (slug) is idempotent
  image_url TEXT                                           -- the category's single image; seeded with the placeholder
);

CREATE TABLE products (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        TEXT    NOT NULL UNIQUE,                     -- UNIQUE so the seed's ON CONFLICT (name) is idempotent
  unit_price  BIGINT  NOT NULL CHECK (unit_price >= 0),    -- cents
  stock       INTEGER NOT NULL CHECK (stock >= 0),
  category_id BIGINT  NOT NULL REFERENCES categories(id)   -- every product has a category -> categories are created first
);

CREATE TABLE product_images (
  id         BIGINT  GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  product_id BIGINT  NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  url        TEXT    NOT NULL,                             -- a reference the client fetches; the API never serves image bytes
  position   INTEGER NOT NULL DEFAULT 0,                   -- gallery order (GET /products sorts ascending)
  UNIQUE (product_id, position)                            -- also the seed's ON CONFLICT guard
);

CREATE TABLE customers (
  id    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT UNIQUE NOT NULL
);

CREATE TABLE carts (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  token       UUID   NOT NULL DEFAULT gen_random_uuid() UNIQUE,  -- the X-Cart-Token identity; minted by the DB, core PostgreSQL (13+)
  customer_id BIGINT NULL REFERENCES customers(id),        -- NULL while anonymous; set when checkout claims the cart
  status      TEXT   NOT NULL DEFAULT 'active'
                CHECK (status IN ('active','converted','abandoned')),  -- 'abandoned' is reserved: no producer, like orders' 'shipped'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()           -- touched by every cart write
);

CREATE TABLE cart_items (
  cart_id    BIGINT  NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
  product_id BIGINT  NOT NULL REFERENCES products(id),     -- FK: a cart can only ever hold real products
  qty        INTEGER NOT NULL CHECK (qty > 0),
  PRIMARY KEY (cart_id, product_id)                        -- one line per product; POST /cart/items merges via ON CONFLICT
);

CREATE TABLE orders (
  id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id     BIGINT NOT NULL REFERENCES customers(id),
  total           BIGINT NOT NULL CHECK (total >= 0),
  status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending','shipped','cancelled')),
  idempotency_key TEXT,                                    -- safe-retry key; see the idempotency step
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Partial unique index: many NULLs allowed, but a set key is unique.
CREATE UNIQUE INDEX orders_idem_key ON orders (idempotency_key)
  WHERE idempotency_key IS NOT NULL;

CREATE TABLE order_items (
  order_id    BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id  BIGINT NOT NULL REFERENCES products(id),
  quantity    INTEGER NOT NULL CHECK (quantity > 0),
  unit_price  BIGINT  NOT NULL CHECK (unit_price >= 0),   -- price at purchase
  PRIMARY KEY (order_id, product_id)
);
Apply it
Run these in your terminal / editor
# Docker only (no host psql needed) — apply the init migration file directly to check it now:
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0001_init.up.sql
# Optional variant, if you have the host psql client (libpq):
# psql "$DATABASE_URL" -f db/migrations/0001_init.up.sql
What success looks like

Open an interactive session (docker compose exec db psql -U postgres -d aurora, or psql "$DATABASE_URL" with the host client) and run the \d checks: \dt lists all eight tables — cart_items, carts, categories, customers, order_items, orders, product_images, products. \d carts shows token | uuid | not null | gen_random_uuid() with a UNIQUE constraint (no extension was needed), and \d orders shows the partial unique index orders_idem_key on idempotency_key WHERE idempotency_key IS NOT NULL. Inserting impossible data is rejected by the schema itself (real output from postgres:16):

=# INSERT INTO products (name, unit_price, stock, category_id) VALUES ('x', 1, -1, 1);
ERROR:  new row for relation "products" violates check constraint "products_stock_check"

Seed the categories, the catalog, and a customer

Beginner

Insert the three category tiles, a few products to list and buy (each with one placeholder image), and one customer — the categories before the products because the foreign key demands it, and the customer because the first checkout fails without one.

New in this step
INSERT … ON CONFLICT … DO NOTHING

Postgres’s upsert: if the row already exists (a duplicate email, slug, or name), skip it instead of erroring, so re-running the seed is safe.

postgres on conflict do nothing docs ↗
resolve an FK by slug (INSERT … SELECT … JOIN)

Instead of hardcoding category_id = 1, the insert joins categories on its stable slug — identity values are assigned by the database and must never be assumed.

postgres insert select join foreign key
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the order of inserts matters — and why nothing is hardcoded

Two foreign keys drive this seed. orders.customer_id is NOT NULL REFERENCES customers(id), so the first checkout fails with a foreign-key violation unless a real customer already exists — seed one now; on a fresh database its id is 1, and that’s the customerId the checkout body sends (the cart stays anonymous until checkout claims it). And products.category_id is NOT NULL REFERENCES categories(id), so the categories are inserted before the products — and the products resolve their category by slug with a JOIN, never a hardcoded id, because identity values are the database’s to assign (a re-run, or a Spring CommandLineRunner on every boot, must not depend on them). All images share one placeholder URL — a plain migration file has no variables, so the literal repeats. Idempotency now spans all four statements: re-running the file keeps exactly 3 categories, 3 products, 3 images, and 1 customer. See the foreign-key treatment in the PostgreSQL track. Like the schema, author this straight into the runner’s directory — it is the seed migration (0002_seed.up.sql for golang-migrate, V2__seed.sql for Flyway), applied right after the init migration.

0002_seed.up.sql (Go) / V2__seed.sql (Spring) — the seed migration
Run these in your terminal / editor
-- one customer first: orders.customer_id NOT NULL REFERENCES customers(id)
INSERT INTO customers (email) VALUES ('demo@aurora.test')
  ON CONFLICT (email) DO NOTHING;

-- categories BEFORE products: products.category_id is NOT NULL. All images share the one
-- placeholder URL — a plain migration file has no variables, so the literal repeats.
INSERT INTO categories (name, slug, image_url) VALUES
  ('Drinkware',   'drinkware',   'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg'),
  ('Apparel',     'apparel',     'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg'),
  ('Accessories', 'accessories', 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg')
  ON CONFLICT (slug) DO NOTHING;

-- products resolve their category BY SLUG (never a hardcoded id); a re-run keeps exactly 3 products (DoD #3)
INSERT INTO products (name, unit_price, stock, category_id)
SELECT v.name, v.unit_price, v.stock, c.id
FROM (VALUES
  ('Aurora Mug',          1499,  50, 'drinkware'),
  ('Aurora Tee',          2999,  12, 'apparel'),
  ('Aurora Sticker Pack',  499, 200, 'accessories')
) AS v(name, unit_price, stock, slug)
JOIN categories c ON c.slug = v.slug
ON CONFLICT (name) DO NOTHING;

-- one placeholder image per product at position 0 (the GET /products images list)
INSERT INTO product_images (product_id, url, position)
SELECT p.id, 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg', 0
FROM products p
ON CONFLICT (product_id, position) DO NOTHING;
Apply it
Run these in your terminal / editor
# Docker only (no host psql needed) — apply the seed migration file directly to check it now:
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0002_seed.up.sql
# Optional variant, if you have the host psql client (libpq):
# psql "$DATABASE_URL" -f db/migrations/0002_seed.up.sql
What success looks like

The four counts land at 3/3/3/1, and a second apply inserts nothing (INSERT 0 0 four times) — the seed is idempotent end to end. On a fresh DB the demo customer is id = 1 — the canonical customerId the checkout body sends. Real output from postgres:16:

=# SELECT (SELECT count(*) FROM categories) AS categories, (SELECT count(*) FROM products) AS products,
          (SELECT count(*) FROM product_images) AS images,  (SELECT count(*) FROM customers) AS customers;
 categories | products | images | customers
------------+----------+--------+-----------
          3 |        3 |      3 |         1

Scaffold the Go API: composed main + GET /products

Go Beginner

Create the module, open a pgxpool behind a Store, and compose a main that wires the router and shuts down cleanly — then serve GET /products, each product carrying its category object and its images list, so the API has a first working endpoint.

New in this step
pgx / pgxpool

The leading Postgres driver for Go; pgxpool is its connection pool, opened once and shared so requests reuse connections instead of dialing the DB each time.

pgx pgxpool
connection pool

A fixed set of live DB connections handed out and returned per query; the pool, not your code, manages reuse and limits.

database connection pool why
Go module (go mod init)

The versioned root every package in your API imports from; its path (e.g. github.com/you/aurora-api) is the import prefix.

go modules tutorial
http.ServeMux

Go’s standard request router; on Go 1.22+ it matches method + path patterns like GET /products.

go servemux method routing
context (ctx)

A value threaded through every call that carries cancellation and deadlines, so a dropped request can abort its DB query.

go context package
graceful shutdown / SIGTERM

On a stop signal, finish in-flight requests before exiting; signal.NotifyContext cancels a context on SIGINT/SIGTERM (Cloud Run sends SIGTERM).

go http server graceful shutdown
PORT env var

The port to listen on, injected by the host (Cloud Run sets it); default to 8080 locally.

cloud run PORT environment
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why pgx, and why compose the whole server now

pgx is the most widely used PostgreSQL driver for Go; its native pool is fast and exposes Postgres features the generic database/sql hides. Always pass a context and always use parameters ($1) — never string-concatenate SQL. Stand up the real entrypoint up front: cmd/api/main.go opens the pool once, builds the router, and serves — so later steps (checkout, order read, the trace-id middleware) plug into a server that already exists rather than living as loose fragments. Graceful shutdown matters because Cloud Run sends SIGTERM when it replaces your instance; draining beats dropping in-flight checkouts. The deeper treatment is in the Go track.

Set up the module
Run these in your terminal / editor
go mod init github.com/you/aurora-api
go get github.com/jackc/pgx/v5
Go version floor: pgx/v5 needs Go 1.25+

pgx/v5 (>= v5.10.0) declares go 1.25.0 in its own go.mod, so go get on a module with an older go directive auto-bumps you to 1.25 — and a learner on Go older than 1.25 gets a confusing build failure instead of a clear message. Check your toolchain and upgrade if needed:

go version   # want go1.25+ ; upgrade, or set GOTOOLCHAIN=auto to let Go fetch it

(The base scaffold otherwise only needs Go 1.22+ for http.ServeMux method routing; the floor here is pgx, not the standard library.)

Composed entrypoint + GET /products (essentials)
Run these in your terminal / editor
// cmd/api/main.go
package main

import (
	"bytes"
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	// The cart + checkout steps add "fmt", "github.com/jackc/pgx/v5", and ".../pgx/v5/pgconn" here —
	// see their "add these imports" notes.
)

type Category struct {
	ID       int64  `json:"id"`
	Slug     string `json:"slug"`
	Name     string `json:"name"`
	ImageURL string `json:"imageUrl"`
}

type Image struct {
	URL string `json:"url"`
}

type Product struct {
	ID        int64    `json:"id"`
	Name      string   `json:"name"`
	UnitPrice int64    `json:"unitPrice"` // cents; camelCase on the wire
	Stock     int      `json:"stock"`
	Category  Category `json:"category"`  // always present: products.category_id is NOT NULL
	Images    []Image  `json:"images"`    // ordered by position; [] when a product has no image rows
}

// Store is the contract the handlers consume; the cart and checkout land on it next.
type Store struct{ pool *pgxpool.Pool }

func (s *Store) Products(ctx context.Context) ([]Product, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT p.id, p.name, p.unit_price, p.stock,
		       c.id, c.slug, c.name, COALESCE(c.image_url, '')
		FROM products p
		JOIN categories c ON c.id = p.category_id
		ORDER BY p.id`)
	if err != nil { return nil, err }
	defer rows.Close()
	out := []Product{}
	idx := map[int64]int{}
	for rows.Next() {
		p := Product{Images: []Image{}} // non-nil: no image rows serializes as [], never null
		if err := rows.Scan(&p.ID, &p.Name, &p.UnitPrice, &p.Stock,
			&p.Category.ID, &p.Category.Slug, &p.Category.Name, &p.Category.ImageURL); err != nil { return nil, err }
		idx[p.ID] = len(out)
		out = append(out, p)
	}
	if err := rows.Err(); err != nil { return nil, err }
	imgs, err := s.pool.Query(ctx,
		`SELECT product_id, url FROM product_images ORDER BY product_id, position`)
	if err != nil { return nil, err }
	defer imgs.Close()
	for imgs.Next() {
		var pid int64
		var u string
		if err := imgs.Scan(&pid, &u); err != nil { return nil, err }
		if i, ok := idx[pid]; ok { out[i].Images = append(out[i].Images, Image{URL: u}) }
	}
	return out, imgs.Err()
}

func newRouter(s *Store) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })
	mux.HandleFunc("GET /products", func(w http.ResponseWriter, r *http.Request) {
		ps, err := s.Products(r.Context())
		if err != nil { slog.Error("products", "err", err); writeJSON(w, 500, map[string]string{"error": "internal"}); return }
		writeJSON(w, 200, ps)
	})
	return mux // cart + checkout + orders routes register here in later steps
}

// writeJSON is the ONE writer every response goes through. It disables HTML escaping and trims the
// encoder's trailing newline so the bytes match Spring/Jackson exactly: the default json.Encoder would
// turn the & in a real image URL's query params (or a name like "Tees & Mugs") into \u0026 and
// append a newline — Jackson emits the literal byte and no trailing newline.
func writeJSON(w http.ResponseWriter, status int, v any) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	_ = enc.Encode(v)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write(bytes.TrimRight(buf.Bytes(), "\n"))
}

func main() { if err := run(); err != nil { slog.Error("fatal", "err", err); os.Exit(1) } }

func run() error {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil { return err }
	defer pool.Close()

	// The observability step wraps this Handler with withTrace(...) — a bare newRouter here means
	// the trace id never reaches Checkout. See "Structured logging in Go".
	srv := &http.Server{Addr: ":" + cmp.Or(os.Getenv("PORT"), "8080"), Handler: newRouter(&Store{pool})}
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("listen", "err", err)
		}
	}()
	<-ctx.Done()
	sc, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(sc)
}
Optional: serving the images yourself (not a base step, like the Cloud Run deploy)

The base only stores and returns the image reference (url); the seeded placeholder points at an existing public image, so nothing more is required to reach the definition of done. As a clearly-marked extension, the backend can itself be the image origin — a GET /images/{key} endpoint serving a file bundled with the app — with a CDN in front, so clients fetch the CDN URL (that is what goes in product_images.url) and the origin is hit only on a CDN miss; swapping the bundled file for an object-storage read is the scale variant. None of this changes the contract: clients always just fetch url.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: Postgres reachable via env DATABASE_URL; schema + seed already applied (categories, products, product_images); module github.com/you/aurora-api; github.com/jackc/pgx/v5.
Task: Scaffold cmd/api/main.go with a pgxpool behind a Store, a newRouter(*Store) the handlers register on, GET /products, and a run() with graceful shutdown.
Requirements:
- Pool created once in run(); closed on shutdown; every query takes a context.
- run() uses signal.NotifyContext (SIGINT/SIGTERM) and http.Server.Shutdown with a 10s deadline; ErrServerClosed is a clean stop.
- Listen on PORT (default 8080) — Cloud Run injects it. Money is int64 cents; parameterised queries only.
- /products returns 200 with a JSON array ordered by id; each product carries category {id, slug, name, imageUrl} (join categories; COALESCE a NULL image_url to "") and images [{url}] ordered by position ascending.
- Initialise each product's Images as a non-nil empty slice so a product with no image rows serializes as [], never null.
- writeJSON is the single response writer: json.Encoder with SetEscapeHTML(false) and the trailing newline trimmed, so URL/name bytes match Spring/Jackson exactly.
- /healthz returns 200 "ok".
Tests / acceptance:
- `go build ./...` and `go vet ./...` pass.
- After seeding, `curl -s localhost:8080/products | jq length` returns 3, and `.[0].category.slug` is "drinkware" with `.[0].images | length` = 1.
Output: a unified diff plus a note on pool sizing.
What success looks like

The server starts and curl -s localhost:8080/products returns 200 with the 3 seeded products as a JSON array (unitPrice in cents, camelCase), each carrying its category object and one-element images list. curl -s localhost:8080/products | jq length prints 3. Real response (first product; compiled with pgx v5.10.0 / Go 1.26 against postgres:16, pretty-printed here):

[ { "id": 1, "name": "Aurora Mug", "unitPrice": 1499, "stock": 50,
    "category": { "id": 1, "slug": "drinkware", "name": "Drinkware",
                  "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg" },
    "images": [ { "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg" } ] } ]

Scaffold the Spring Boot (Kotlin) API + GET /products

Spring Boot (Kotlin) Beginner

Generate a Kotlin Spring Boot app, wire the datasource from the environment, and expose GET /products with JdbcTemplate — each product carrying its category object and images list — so the API has a first working endpoint. Export JDBC_DATABASE_URL (the JDBC URL shape) for the same local Postgres the Go path uses.

New in this step
Spring Boot

An opinionated Java/Kotlin framework that auto-configures and boots an embedded web server, so a tiny main gives you a running API.

spring boot getting started
@SpringBootApplication

The annotation on the main class that turns on auto-configuration and component scanning; it composes the server, controllers, and filters for you.

springbootapplication annotation
JdbcTemplate

Spring’s thin SQL helper: you write the SQL, it runs it and maps rows. We use it (not JPA) to keep the database lesson visible.

spring jdbctemplate query
JDBC URL vs libpq URL

The JDBC driver needs jdbc:postgresql://host:port/db with user/password set separately; it will not accept the Go path’s postgres://user:pass@host/db.

postgresql jdbc url format
@RestController / @GetMapping

Annotations that mark a class as a JSON endpoint and map GET /products to a method, with the return value serialized to JSON.

spring restcontroller getmapping
application.properties

Spring’s config file; ${ENV_VAR:default} reads the datasource from the environment with a local fallback.

spring application.properties datasource
server.shutdown=graceful

Drains in-flight requests on stop (on by default in Boot 3).

spring boot graceful shutdown
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why Spring Boot here, in Kotlin — and what composes the server

Spring Boot is the classic enterprise-commerce backend, and its declarative @Transactional is the perfect vehicle for the checkout you’ll write next. We use Kotlin (not Java) — same JVM, less ceremony. JdbcTemplate keeps the SQL explicit so the database lesson stays front-and-centre; Spring Data JPA would hide it. The @SpringBootApplication main is your composed entrypoint: it boots the embedded server, auto-configures the JdbcTemplate from the datasource properties, registers controllers and filters, and — on Boot 3 — drains in-flight requests on shutdown (server.shutdown=graceful). Read the language depth in the Kotlin track.

Set the Spring datasource env (same local Postgres)
Run these in your terminal / editor
export JDBC_DATABASE_URL="jdbc:postgresql://localhost:5432/aurora"
export DB_USER=postgres
export DB_PASSWORD=dev
build.gradle.kts (key deps)
Run these in your terminal / editor
// build.gradle.kts (key deps)
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-jdbc")
    runtimeOnly("org.postgresql:postgresql")
    // Flyway 10+ (versions from the Boot BOM): Postgres support is a SEPARATE module —
    // flyway-core alone throws "Unsupported Database: PostgreSQL 16" and the app won't start.
    implementation("org.flywaydb:flyway-core")
    runtimeOnly("org.flywaydb:flyway-database-postgresql")
}
src/main/resources/application.properties
Run these in your terminal / editor
# JDBC URL shape (NOT the Go path's libpq DATABASE_URL):
spring.datasource.url=${JDBC_DATABASE_URL:jdbc:postgresql://localhost:5432/aurora}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:dev}
server.shutdown=graceful
Generate the app + verify your JDK (cold start)

The Go path shows go mod init + go get; here is the Spring equivalent. Check a JDK first (Spring Boot 3 needs Java 17+), then generate the project with the Initializr:

java -version   # want 17+ ; install from https://adoptium.net if missing
curl -s https://start.spring.io/starter.zip \
  -d type=gradle-kotlin -d language=kotlin -d bootVersion=3.4.0 \
  -d dependencies=web,jdbc,postgresql \
  -d groupId=com.aurora -d artifactId=aurora-api \
  -d packageName=com.aurora -o aurora-api.zip && unzip aurora-api.zip

The generated @SpringBootApplication entrypoint is your composed server:

// src/main/kotlin/com/aurora/AuroraApplication.kt
package com.aurora

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class AuroraApplication

fun main(args: Array<String>) {
    runApplication<AuroraApplication>(*args)
}
List products (with category + images)
Run these in your terminal / editor
// web/ProductController.kt
@RestController
class ProductController(private val jdbc: JdbcTemplate) {
    data class Category(val id: Long, val slug: String, val name: String, val imageUrl: String)
    data class Image(val url: String)
    data class Product(val id: Long, val name: String, val unitPrice: Long, val stock: Int,
                       val category: Category, val images: List<Image>)

    @GetMapping("/products")
    fun list(): List<Product> {
        // one read for all image rows, grouped per product (ordered by position)
        val images = jdbc.query(
            "SELECT product_id, url FROM product_images ORDER BY product_id, position",
        ) { rs, _ -> rs.getLong("product_id") to Image(rs.getString("url")) }
            .groupBy({ it.first }, { it.second })
        return jdbc.query(
            """SELECT p.id, p.name, p.unit_price, p.stock,
                      c.id AS cat_id, c.slug, c.name AS cat_name, COALESCE(c.image_url, '') AS cat_image
               FROM products p JOIN categories c ON c.id = p.category_id
               ORDER BY p.id""",
        ) { rs, _ ->
            Product(
                rs.getLong("id"), rs.getString("name"), rs.getLong("unit_price"), rs.getInt("stock"),
                Category(rs.getLong("cat_id"), rs.getString("slug"), rs.getString("cat_name"), rs.getString("cat_image")),
                images[rs.getLong("id")] ?: emptyList(),   // [] on the wire, never null
            )
        }
    }
}
Optional: serving the images yourself (not a base step, like the Cloud Run deploy)

The base only stores and returns the image reference (url); the seeded placeholder points at an existing public image, so nothing more is required to reach the definition of done. As a clearly-marked extension, the backend can itself be the image origin — a GET /images/{key} endpoint serving a file bundled with the app — with a CDN in front, so clients fetch the CDN URL (that is what goes in product_images.url) and the origin is hit only on a CDN miss; swapping the bundled file for an object-storage read is the scale variant. None of this changes the contract: clients always just fetch url.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3 (Kotlin), Postgres via spring.datasource.* from env (JDBC_DATABASE_URL must be a jdbc:postgresql:// URL — NOT the Go path's libpq DATABASE_URL — plus DB_USER/DB_PASSWORD), spring-boot-starter-jdbc; schema + seed already applied (categories, products, product_images).
Task: Create the @SpringBootApplication entrypoint and a ProductController exposing GET /products via JdbcTemplate.
Requirements:
- Kotlin data classes: Product(id, name, unitPrice: Long, stock, category: Category, images: List<Image>); Category(id, slug, name, imageUrl); Image(url). No JPA, use JdbcTemplate.
- Join categories for the nested category object (COALESCE a NULL image_url to ''); read product_images ordered by (product_id, position) and group per product; a product with no image rows gets emptyList() so the wire shows [], never null.
- Parameterised queries only; configure the datasource from the environment; set server.shutdown=graceful.
- Money is Long cents; field names camelCase on the wire (unitPrice, imageUrl).
Tests / acceptance:
- `./gradlew bootRun` starts; `curl -s localhost:8080/products | jq length` returns 3 after seeding; `.[0].category.slug` is "drinkware" and `.[0].images | length` is 1.
Output: a unified diff plus the application.properties datasource lines.
What success looks like

./gradlew bootRun starts on :8080; curl -s localhost:8080/products returns the same 200 JSON array as the Go path — identical shape, unitPrice cents, the nested category object, and the images list (the parity invariant; Jackson emits the URL bytes literally, matching the Go writer). … | jq length prints 3.

[ { "id": 1, "name": "Aurora Mug", "unitPrice": 1499, "stock": 50,
    "category": { "id": 1, "slug": "drinkware", "name": "Drinkware",
                  "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg" },
    "images": [ { "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg" } ] } ]

Build the server-side cart (Go)

Go Intermediate

Build the four cart endpoints on the carts/cart_items tables — POST /cart/items creates an anonymous cart on demand and merges re-adds, GET /cart prices it live, PUT/DELETE /cart/items/{productId} edit a line — with the cart’s token travelling only in the X-Cart-Token headers.

New in this step
X-Cart-Token (opaque header token)

The cart’s identity on the wire: requests send it, every cart response repeats it in a response header — never in the JSON body. The client just stores and echoes it.

opaque token custom http header api
ON CONFLICT … DO UPDATE / EXCLUDED

The merging upsert: EXCLUDED names the row that failed to insert, so SET qty = cart_items.qty + EXCLUDED.qty sums a re-add with the existing line in one race-free statement.

postgres on conflict do update excluded docs ↗
advisory read

GET /cart re-resolves prices live from the catalog, but they may go stale a moment later — fine for display; the checkout transaction is where truth is fixed.

advisory read stale price cart
idempotent absolute set (PUT)

PUT {qty: 3} means “make it 3” — a retry converges on the same state. The additive POST deliberately does not: a re-add means “one more”.

http put idempotent semantics
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Anonymous now, claimed at checkout — and why the token rides in headers

There is no login in this build, so a cart starts anonymous: POST /cart/items with no X-Cart-Token creates a carts row whose token the database default gen_random_uuid() mints (never app code), adds the line, and returns the token in the X-Cart-Token response header — that response header is where the client first learns its token; it persists it and sends it on every later cart and checkout call. The token resolves only an active cart: once checkout claims the cart (customer_id set, status = 'converted' — the ★ step), the same token stops resolving and returns 404 {"error":"cart_not_found"}. Keeping the token out of the JSON body keeps the cart representation uniform — {total, updatedAt, items: [...]} on every endpoint, lineTotal = unitPrice * qty and total = sum(lineTotal) server-computed in integer cents, updatedAt in the RFC3339-UTC-no-sub-second wire format the order read pins, moving on every cart write. An empty cart is a valid 200 with "items": [].

Why POST merges, PUT sets, and no cart endpoint takes an Idempotency-Key

The cart_items primary key (cart_id, product_id) allows one line per product, so a re-add must merge — and the ON CONFLICT … DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty upsert does it in one statement, no read-modify-write race. PUT /cart/items/{productId} is the idempotent absolute set (SET qty = EXCLUDED.qty), which is exactly why the cart needs no Idempotency-Key: a retried PUT converges on the same state, and the additive POST is deliberate (a re-add means “one more”). DELETE is naturally idempotent — removing an absent line is a no-op 200, since the requested state already holds. A line leaves the cart only through DELETE: PUT with qty: 0 is a 422, matching the qty > 0 CHECK. Every write also touches carts.updated_at in the same transaction, so updatedAt moves on every mutation. Errors are shared by all four endpoints: an unknown or non-active token (or a missing one anywhere except POST /cart/items) is 404 cart_not_found; an unknown productId trips the cart_items.product_id FK (SQLSTATE 23503) and maps to 404 not_found; a missing body, missing productId, or qty below 1 is 422 invalid_request.

The cart store (pgx) — create-on-demand + the merging upsert + the live read
Run these in your terminal / editor
// The cart representation: the 200 body of EVERY cart endpoint. The token is NOT here —
// it travels only in the X-Cart-Token headers, so the body stays uniform.
type CartItem struct {
	ProductID int64  `json:"productId"`
	Name      string `json:"name"`
	Qty       int    `json:"qty"`
	UnitPrice int64  `json:"unitPrice"`
	LineTotal int64  `json:"lineTotal"`
}
type Cart struct {
	Total     int64      `json:"total"`
	UpdatedAt string     `json:"updatedAt"` // RFC3339 UTC Z, no sub-second (the shared wire rule)
	Items     []CartItem `json:"items"`
}

var ErrCartNotFound = errors.New("cart not found")

// isBadUUID: a malformed X-Cart-Token can never match a cart — fold the uuid cast
// error (SQLSTATE 22P02) into "unknown token" instead of surfacing a 500.
func isBadUUID(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == "22P02"
}

// AddCartItem adds a line; "" token -> create an anonymous cart first. Returns the cart + its token.
func (s *Store) AddCartItem(ctx context.Context, token string, productID int64, qty int) (Cart, string, error) {
	tx, err := s.pool.Begin(ctx)
	if err != nil { return Cart{}, "", err }
	defer tx.Rollback(ctx)
	var cartID int64
	if token == "" {
		// the token is minted by the column default gen_random_uuid() — never by app code
		if err := tx.QueryRow(ctx,
			`INSERT INTO carts DEFAULT VALUES RETURNING id, token::text`).Scan(&cartID, &token); err != nil {
			return Cart{}, "", err
		}
	} else if err := tx.QueryRow(ctx,
		`SELECT id FROM carts WHERE token = $1 AND status = 'active'`, token).Scan(&cartID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) || isBadUUID(err) { return Cart{}, "", ErrCartNotFound }
		return Cart{}, "", err
	}
	// merge, not overwrite: a re-add of the same product sums quantities in ONE statement
	if _, err := tx.Exec(ctx,
		`INSERT INTO cart_items (cart_id, product_id, qty) VALUES ($1, $2, $3)
		 ON CONFLICT (cart_id, product_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty`,
		cartID, productID, qty); err != nil {
		return Cart{}, "", err // FK 23503 (unknown productId) -> the handler maps 404 not_found
	}
	if _, err := tx.Exec(ctx,
		`UPDATE carts SET updated_at = now() WHERE id = $1`, cartID); err != nil { return Cart{}, "", err }
	if err := tx.Commit(ctx); err != nil { return Cart{}, "", err }
	cart, err := s.CartByToken(ctx, token)
	return cart, token, err
}

// CartByToken is GET /cart: live advisory prices + server-computed integer-cent totals.
func (s *Store) CartByToken(ctx context.Context, token string) (Cart, error) {
	cart := Cart{Items: []CartItem{}} // non-nil: an empty cart serializes as {"items":[]}
	var updatedAt time.Time
	if err := s.pool.QueryRow(ctx,
		`SELECT updated_at FROM carts WHERE token = $1 AND status = 'active'`, token).Scan(&updatedAt); err != nil {
		if errors.Is(err, pgx.ErrNoRows) || isBadUUID(err) { return Cart{}, ErrCartNotFound }
		return Cart{}, err
	}
	cart.UpdatedAt = updatedAt.UTC().Format(time.RFC3339) // trailing Z, no sub-second
	rows, err := s.pool.Query(ctx, `
		SELECT ci.product_id, p.name, ci.qty, p.unit_price, ci.qty * p.unit_price AS line_total
		FROM carts c
		JOIN cart_items ci ON ci.cart_id = c.id
		JOIN products   p  ON p.id = ci.product_id
		WHERE c.token = $1 AND c.status = 'active'
		ORDER BY ci.product_id`, token)
	if err != nil { return Cart{}, err }
	defer rows.Close()
	for rows.Next() {
		var it CartItem
		if err := rows.Scan(&it.ProductID, &it.Name, &it.Qty, &it.UnitPrice, &it.LineTotal); err != nil {
			return Cart{}, err
		}
		cart.Total += it.LineTotal
		cart.Items = append(cart.Items, it)
	}
	return cart, rows.Err()
}
The handlers + the PUT/DELETE SQL (drive the rest via the prompt)
Run these in your terminal / editor
// in newRouter(s) — every cart response repeats the token in the X-Cart-Token response header:
mux.HandleFunc("POST /cart/items", func(w http.ResponseWriter, r *http.Request) {
	var req struct {
		ProductID int64 `json:"productId"`
		Qty       int   `json:"qty"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil ||
		req.ProductID == 0 || req.Qty < 1 {
		writeJSON(w, 422, map[string]string{"error": "invalid_request"}); return
	}
	// the header is OPTIONAL here (only here): absent -> the server creates an anonymous cart
	cart, token, err := s.AddCartItem(r.Context(), r.Header.Get("X-Cart-Token"), req.ProductID, req.Qty)
	if err != nil { writeCartErr(w, err); return }
	w.Header().Set("X-Cart-Token", token) // on creation, this is where the client learns its token
	writeJSON(w, 200, cart)
})
mux.HandleFunc("GET /cart", func(w http.ResponseWriter, r *http.Request) {
	token := r.Header.Get("X-Cart-Token") // required from here on: absent/unknown -> 404 cart_not_found
	cart, err := s.CartByToken(r.Context(), token)
	if err != nil { writeCartErr(w, err); return }
	w.Header().Set("X-Cart-Token", token)
	writeJSON(w, 200, cart)
})

// writeCartErr maps the shared cart error table (all four endpoints).
func writeCartErr(w http.ResponseWriter, err error) {
	var pgErr *pgconn.PgError
	switch {
	case errors.Is(err, ErrCartNotFound):
		writeJSON(w, 404, map[string]string{"error": "cart_not_found"})
	case errors.As(err, &pgErr) && pgErr.Code == "23503": // unknown productId (cart_items FK)
		writeJSON(w, 404, map[string]string{"error": "not_found"})
	default:
		writeJSON(w, 500, map[string]string{"error": "internal"})
	}
}

// The SQL for the two line-edit endpoints (wire them via the prompt):
//   PUT /cart/items/{productId} {qty}  — the idempotent ABSOLUTE SET (works for a new line too):
//     INSERT INTO cart_items (cart_id, product_id, qty) VALUES ($1, $2, $3)
//     ON CONFLICT (cart_id, product_id) DO UPDATE SET qty = EXCLUDED.qty;
//   DELETE /cart/items/{productId}     — naturally idempotent (an absent line is a no-op 200):
//     DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2;
// Both also touch carts.updated_at in the same tx; a non-numeric {productId} is 404 not_found.
Agent prompt — paste into an agent with repo access

Before you run this: you POST productId 1 with qty 2, then POST the same productId with qty 1 using the returned token. What qty does GET /cart show — 1, 2, or 3 — and why?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: pgxpool is s.pool; the 8-table schema + seed are applied (carts.token UUID DEFAULT gen_random_uuid() UNIQUE; cart_items PK (cart_id, product_id), qty > 0); newRouter(*Store) and the byte-parity writeJSON exist; github.com/jackc/pgx/v5 + pgconn.
Task: Implement the four cart endpoints on Store + newRouter: POST /cart/items, GET /cart, PUT /cart/items/{productId}, DELETE /cart/items/{productId}.
Requirements:
- POST /cart/items {productId, qty}: X-Cart-Token header OPTIONAL (only here). Absent -> INSERT INTO carts DEFAULT VALUES RETURNING id, token::text (the DB mints the token). Present -> resolve WHERE token=$1 AND status='active'. Add the line with the MERGE upsert: ON CONFLICT (cart_id, product_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty.
- GET /cart: the §5.2 representation {total, updatedAt, items:[{productId,name,qty,unitPrice,lineTotal}]} — one join, prices re-resolved LIVE from the catalog; totals server-computed in integer cents; an empty cart is 200 {"items":[]} (non-nil slice). updatedAt = t.UTC().Format(time.RFC3339) (no sub-second).
- PUT /cart/items/{productId} {qty}: the idempotent ABSOLUTE SET upsert (DO UPDATE SET qty = EXCLUDED.qty); qty >= 1 (qty 0 -> 422). DELETE /cart/items/{productId}: plain DELETE; an absent line is a no-op 200.
- EVERY cart response sets the X-Cart-Token response header (on creation it is how the client learns the token); the token NEVER appears in a JSON body. Every write also touches carts.updated_at in the same tx.
- Errors (all four): unknown/non-active token, missing token (except POST /cart/items), or a malformed token (fold the 22P02 uuid cast error) -> 404 {"error":"cart_not_found"}; unknown productId -> pgconn.PgError 23503 -> 404 {"error":"not_found"}; non-numeric {productId} -> 404 {"error":"not_found"}; bad body / qty < 1 -> 422 {"error":"invalid_request"}.
- NO Idempotency-Key on any cart endpoint: PUT is the idempotent op, POST is deliberately additive.
Tests / acceptance:
- POST without a token returns the cart AND an X-Cart-Token response header; re-POSTing the same productId with that token sums quantities (2 then 1 -> qty 3, lineTotal 4497 for the seeded Mug).
- PUT {qty:2} after the merge yields qty 2 (absolute, not 5); a repeated PUT is a no-op; DELETE of an absent line returns 200 with the cart unchanged.
- GET /cart with an unknown or malformed token -> 404 cart_not_found; POST with productId 999 -> 404 not_found; qty 0 -> 422.
Output: a unified diff plus a one-line note on why the token lives in headers, not the body.
What success looks like

The token round-trip, the merge, and the live totals — real output from the compiled pgx v5.10.0 / Go 1.26 handlers against postgres:16 (DoD #10):

POST /cart/items (no token) -> 200, X-Cart-Token response header: 86d92123-ab06-4ac8-b90f-5187380012af
  body: {"total":2998,"updatedAt":"2026-07-14T22:10:40Z","items":[{"productId":1,"name":"Aurora Mug","qty":2,"unitPrice":1499,"lineTotal":2998}]}
POST /cart/items (same productId, qty 1) -> 200 (merged, not duplicated)
  body: {"total":4497,"updatedAt":"2026-07-14T22:10:40Z","items":[{"productId":1,"name":"Aurora Mug","qty":3,"unitPrice":1499,"lineTotal":4497}]}
GET /cart -> 200, header echoed: true
unknown productId -> 404 {"error":"not_found"}
qty 0             -> 422 {"error":"invalid_request"}
malformed token   -> 404 {"error":"cart_not_found"}
unknown token     -> 404 {"error":"cart_not_found"}
PUT qty=2 after add qty=5 -> qty 2 (absolute set, not 7); PUT qty=2 again -> qty 2 (idempotent)
DELETE an absent line     -> 200, cart unchanged (no-op)

updatedAt ends in Z with no sub-second digits and moves on every write; the items array is [] (never null) for an empty cart.

Build the server-side cart (Spring/Kotlin)

Spring Boot (Kotlin) Intermediate

Build the same four cart endpoints with JdbcTemplate — the SQL is byte-for-byte the Go path’s — binding the optional X-Cart-Token request header and repeating it on every ResponseEntity.

New in this step
@RequestHeader(required = false)

Binds the X-Cart-Token header as nullable — absent means “create an anonymous cart” on POST /cart/items, and means 404 cart_not_found everywhere else.

spring requestheader optional
response header on ResponseEntity

ResponseEntity.ok().header("X-Cart-Token", token).body(cart) — how every cart response repeats the token; on creation it is where the client learns it.

spring responseentity header
ON CONFLICT … DO UPDATE / EXCLUDED

The merging upsert, identical SQL to the Go path: EXCLUDED names the row that failed to insert, so a re-add sums quantities in one race-free statement.

postgres on conflict do update excluded docs ↗
DataIntegrityViolationException

Spring’s wrapper for integrity errors like the cart_items.product_id FK violation (SQLSTATE 23503) — the advice maps it to 404 not_found, exactly like the checkout’s unknown customer.

spring dataintegrityviolationexception
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same contract, Spring wiring — and the same anonymous-then-claimed story

The mechanics mirror the Go step exactly (read that step’s two Detail boxes for the why): a cart starts anonymous — POST /cart/items with no header runs INSERT INTO carts DEFAULT VALUES RETURNING id, token::text, the token minted by the database default — and checkout later claims it. The token resolves only active carts, travels only in the X-Cart-Token headers (never the JSON body), and every cart response repeats it on the ResponseEntity. Postgres renders the UUID in canonical lowercase text on both backends, so the header value is byte-identical to the Go path’s. The upsert SQL — merge on POST, absolute set on PUT — is shared verbatim; JdbcTemplate binds with ? instead of $1. Cart reads are advisory (prices re-resolved live); updatedAt follows the RFC3339-no-sub-second rule via instant.truncatedTo(ChronoUnit.SECONDS). Two mapping notes: a missing cart throws CartNotFoundException404 {"error":"cart_not_found"} via the advice, and a malformed token’s uuid cast error (SQLSTATE 22P02, surfaced as a DataAccessException) is caught in the service and rethrown as CartNotFoundException too — a garbage token is just an unknown token, not a 500. The unknown-productId FK (23503) stays with the global DataIntegrityViolationException404 not_found advice mapping the checkout step already establishes.

CartService + CartController (essentials; JdbcTemplate, reasoned)
Run these in your terminal / editor
class CartNotFoundException(token: String?) : RuntimeException("cart $token not found")

data class CartItem(val productId: Long, val name: String, val qty: Int,
                    val unitPrice: Long, val lineTotal: Long)
data class Cart(val total: Long, val updatedAt: String, val items: List<CartItem>)

@Service
class CartService(private val jdbc: JdbcTemplate) {

    private fun resolveActiveCart(token: String): Long = try {
        jdbc.queryForObject(
            "SELECT id FROM carts WHERE token = ?::uuid AND status = 'active'", Long::class.java, token)!!
    } catch (e: EmptyResultDataAccessException) {
        throw CartNotFoundException(token)          // unknown or already-converted token
    } catch (e: DataAccessException) {
        throw CartNotFoundException(token)          // malformed token: the uuid cast (22P02) can't match any cart
    }

    @Transactional
    fun addItem(token: String?, productId: Long, qty: Int): Pair<Cart, String> {
        val (cartId, t) = if (token == null) {
            // the token is minted by the column default gen_random_uuid() — never by app code
            val row = jdbc.queryForMap("INSERT INTO carts DEFAULT VALUES RETURNING id, token::text")
            (row["id"] as Long) to (row["token"] as String)
        } else resolveActiveCart(token) to token
        // merge, not overwrite — identical SQL to the Go path
        jdbc.update(
            """INSERT INTO cart_items (cart_id, product_id, qty) VALUES (?, ?, ?)
               ON CONFLICT (cart_id, product_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty""",
            cartId, productId, qty)                 // FK 23503 -> the advice maps 404 not_found
        jdbc.update("UPDATE carts SET updated_at = now() WHERE id = ?", cartId)
        return cartByToken(t) to t
    }

    fun cartByToken(token: String): Cart {
        val updatedAt = try {
            jdbc.queryForObject(
                "SELECT updated_at FROM carts WHERE token = ?::uuid AND status = 'active'",
                java.time.OffsetDateTime::class.java, token)!!
        } catch (e: EmptyResultDataAccessException) { throw CartNotFoundException(token) }
          catch (e: DataAccessException)            { throw CartNotFoundException(token) }
        val items = jdbc.query(
            """SELECT ci.product_id, p.name, ci.qty, p.unit_price, ci.qty * p.unit_price AS line_total
               FROM carts c
               JOIN cart_items ci ON ci.cart_id = c.id
               JOIN products   p  ON p.id = ci.product_id
               WHERE c.token = ?::uuid AND c.status = 'active'
               ORDER BY ci.product_id""",
            { rs, _ -> CartItem(rs.getLong("product_id"), rs.getString("name"), rs.getInt("qty"),
                                rs.getLong("unit_price"), rs.getLong("line_total")) }, token)
        return Cart(
            total = items.sumOf { it.lineTotal },   // server-computed integer cents
            updatedAt = updatedAt.toInstant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString(),
            items = items,                          // a non-null List: an empty cart is {"items":[]}
        )
    }
}

data class AddItemReq(val productId: Long?, val qty: Int?)

@RestController
class CartController(private val svc: CartService) {
    @PostMapping("/cart/items")
    fun add(
        @RequestBody req: AddItemReq,               // {productId, qty} — validate before touching the DB
        @RequestHeader(value = "X-Cart-Token", required = false) token: String?,
    ): ResponseEntity<Any> {
        val productId = req.productId ?: 0L
        val qty = req.qty ?: 0
        if (productId == 0L || qty < 1)
            return ResponseEntity.unprocessableEntity().body(mapOf("error" to "invalid_request"))
        val (cart, t) = svc.addItem(token, productId, qty)
        return ResponseEntity.ok().header("X-Cart-Token", t).body(cart) // the client learns its token here
    }

    @GetMapping("/cart")
    fun read(@RequestHeader(value = "X-Cart-Token", required = false) token: String?): ResponseEntity<Any> {
        if (token == null) return ResponseEntity.status(404).body(mapOf("error" to "cart_not_found"))
        return ResponseEntity.ok().header("X-Cart-Token", token).body(svc.cartByToken(token))
    }
    // PUT /cart/items/{productId} (absolute-set upsert) + DELETE /cart/items/{productId} via the prompt;
    // bind {productId} as String + toLongOrNull() ?: 404 (the non-numeric-id pin).
}
Agent prompt — paste into an agent with repo access

Before you run this: you POST productId 1 with qty 2, then POST the same productId with qty 1 using the returned token. What qty does GET /cart show — 1, 2, or 3 — and why?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3.4 (Kotlin), JdbcTemplate over Postgres; the 8-table schema + seed applied (carts.token UUID DEFAULT gen_random_uuid() UNIQUE; cart_items PK (cart_id, product_id), qty > 0); the ApiExceptionHandler advice exists.
Task: Implement the four cart endpoints: POST /cart/items, GET /cart, PUT /cart/items/{productId}, DELETE /cart/items/{productId} — byte-for-byte the Go contract.
Requirements:
- POST /cart/items {productId, qty}: @RequestHeader X-Cart-Token required=false (optional ONLY here). Absent -> INSERT INTO carts DEFAULT VALUES RETURNING id, token::text. Present -> resolve WHERE token = ?::uuid AND status = 'active'. Add the line with the MERGE upsert (DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty).
- GET /cart: {total, updatedAt, items:[{productId,name,qty,unitPrice,lineTotal}]} — one join, live prices, server-computed integer cents; empty cart -> 200 {"items":[]} (non-null List). updatedAt = OffsetDateTime -> toInstant().truncatedTo(ChronoUnit.SECONDS), UTC Z.
- PUT /cart/items/{productId} {qty}: the ABSOLUTE-SET upsert (DO UPDATE SET qty = EXCLUDED.qty), qty >= 1 (0 -> 422). DELETE: plain DELETE; absent line is a no-op 200. Bind {productId} as String + toLongOrNull() ?: 404 not_found.
- EVERY cart response repeats the token via ResponseEntity.header("X-Cart-Token", token); the token never appears in a body. Every write touches carts.updated_at in the same @Transactional method.
- Errors: CartNotFoundException -> 404 {"error":"cart_not_found"} in the advice (unknown/non-active/missing token; also CATCH the malformed-token uuid-cast DataAccessException in the service and rethrow CartNotFoundException); unknown productId FK 23503 -> DataIntegrityViolationException -> 404 {"error":"not_found"} (the existing advice arm); bad body / qty < 1 -> 422 {"error":"invalid_request"}.
- NO Idempotency-Key on any cart endpoint: PUT is the idempotent op, POST is deliberately additive.
Tests / acceptance (@SpringBootTest + Testcontainers postgres:16):
- POST without a token returns the cart AND an X-Cart-Token response header; re-POSTing the same productId with that token sums quantities (2 then 1 -> qty 3, lineTotal 4497 for the seeded Mug).
- PUT {qty:2} after the merge yields qty 2; DELETE of an absent line returns 200 unchanged.
- GET /cart with an unknown or malformed token -> 404 cart_not_found; POST with productId 999 -> 404 not_found; qty 0 -> 422.
Output: a unified diff plus a one-line note on why the token lives in headers, not the body.
What success looks like

Reasoned parity with the compiled Go run: POST /cart/items without a header returns the cart body plus an X-Cart-Token response header (a canonical lowercase UUID, byte-identical to what pgx sees); a re-add of the same productId merges (qty 2 then 1 → 3, lineTotal 4497); GET /cart prices live and returns {"items":[]} for an empty cart; an unknown or malformed token is 404 {"error":"cart_not_found"}; an unknown productId is 404 {"error":"not_found"} via the 23503 advice arm; qty 0 is 422. updatedAt ends in Z with no sub-second digits — truncatedTo(ChronoUnit.SECONDS) does what the Go t.UTC().Format(time.RFC3339) does.

Watch overselling become impossible — by hand

Intermediate

Before any application code, open two psql sessions and race the conditional UPDATE on the last unit — watch the database refuse the double-sell.

See the guarantee before you trust it

The whole project rests on one claim: a conditional UPDATE … WHERE stock >= qty cannot oversell, because Postgres row locks serialise the two writers and the loser sees 0 rows affected. Don’t take that on faith — make the database prove it in front of you. Set one product to stock = 1, then in two sessions both BEGIN and both run the same guarded decrement. The first to run holds the row lock; the second blocks until the first commits, then sees UPDATE 0 and must abort. That blocked-then-zero moment is exactly what the Go transaction and the Spring @Transactional method lean on next — the safety lives in the DB, not the language. The PostgreSQL track drills this in Step 8 (SELECT … FOR UPDATE) and Step 9 (SERIALIZABLE + 40001 retry).

New here? Two terms this race turns on
transaction (BEGIN / COMMIT / ROLLBACK)

A group of statements that succeed or fail as one unit; COMMIT makes them permanent, ROLLBACK undoes all of them. Until a session commits, its uncommitted changes are invisible to — and hold locks against — the other session.

postgres transaction begin commit docs ↗
row lock

An UPDATE locks each row it touches until the transaction ends; a second writer aiming at the same row blocks until the first commits, then re-evaluates its WHERE. This blocked-then-zero moment is what serialises the two checkouts.

postgres row level lock docs ↗
Open two psql sessions
Run these in your terminal / editor
# Terminal 1 — Session A:
docker compose exec db psql -U postgres -d aurora
# Terminal 2 — Session B:
docker compose exec db psql -U postgres -d aurora
# (host-psql variant: psql "$DATABASE_URL" in each terminal)
Setup (either session): one last unit
Run these in your terminal / editor
UPDATE products SET stock = 1 WHERE id = 1;
Session A (run first)
Run these in your terminal / editor
BEGIN;
UPDATE products
  SET stock = stock - 1
  WHERE id = 1 AND stock >= 1;   -- A sees: UPDATE 1
-- ...now switch to Session B and run its UPDATE (it will block) ...
COMMIT;                          -- releases the row lock; B unblocks
Session B (run after A's UPDATE, before A commits)
Run these in your terminal / editor
BEGIN;
UPDATE products
  SET stock = stock - 1
  WHERE id = 1 AND stock >= 1;   -- BLOCKS on A's row lock, then sees: UPDATE 0 (the loser)
ROLLBACK;                        -- nothing to commit; stock stayed at 0
-- final: SELECT stock FROM products WHERE id = 1;  -> 0, sold exactly once
What success looks like

Session A’s guarded UPDATE prints UPDATE 1. Session B blocks on the row lock until A commits, then unblocks and prints UPDATE 0 — the loser. SELECT stock FROM products WHERE id = 1; is 0: the last unit sold exactly once, no application code involved.

A: UPDATE 1
B: UPDATE 0   -- after A commits

★ Checkout as a single transaction (Go)

Go Intermediate

Wrap the whole checkout — read the cart’s lines, guard-and-decrement stock per line, claim the cart, insert the order and items, persist the idempotency key — in one pgx transaction, so the entire purchase either commits together or rolls back together.

New in this step
transaction (BEGIN / COMMIT / ROLLBACK)

A group of statements that succeed or fail as one unit; COMMIT makes them permanent, ROLLBACK undoes all of them.

postgres transaction begin commit docs ↗
ACID

The four guarantees a transaction gives: Atomicity, Consistency, Isolation, Durability — the reason the database, not your code, keeps money and stock correct.

ACID properties database
atomicity

The all-or-nothing part of ACID: there is no state where stock dropped but the order wasn’t created.

database atomicity
RETURNING

A clause that gives back values from the rows an INSERT/UPDATE touched, so you capture the price in the same statement that decrements — no second read.

postgres returning clause
conditional UPDATE … WHERE stock >= qty

The oversell guard: it decrements only if enough stock remains; otherwise it touches zero rows.

conditional update where stock
pgx.ErrNoRows

pgx’s signal that a query returned no row; here it means the guard matched nothing, i.e. out of stock.

pgx ErrNoRows
READ COMMITTED

Postgres’s default isolation level; a plain second SELECT could see a concurrent price change, which is why we capture price via RETURNING instead.

postgres read committed isolation
defer (Go)

Schedules a call to run when the function returns; defer tx.Rollback(ctx) guarantees cleanup, and is a no-op after a successful commit.

go defer statement
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
This is the spotlight: atomicity is the feature

Either every change lands or none does. The lines come from the cart — the server resolves the active cart by its token and reads its cart_items; the client sends no lines. Per line, the conditional UPDATE … WHERE stock >= qty refuses to oversell atomically; if it affects zero rows, you abort. Capture the price with RETURNING unit_price in that same statement — read it once, never twice — so the total and the recorded order_items.unit_price are guaranteed to be the value you actually decremented against (under READ COMMITTED a separate SELECT could see a concurrent price change and drift). Then the transaction claims the cartUPDATE carts SET customer_id = $1, status = 'converted' WHERE id = $2 AND status = 'active' — so the anonymous cart becomes the customer’s at the same instant the stock moves, and its token stops resolving. If any later insert fails, Rollback restores stock and the claim: the cart stays active, so the shopper can adjust and retry. There is no window where money moved but inventory didn’t. You witnessed this exact guard win the race by hand in the previous step; this is that same UPDATE, now inside one transaction. The PostgreSQL track covers the locking (Step 8) and isolation (Step 9) underneath it.

The transaction (pgx) — cart lines in, guarded UPDATE per line, cart claimed
Run these in your terminal / editor
// A cart line as read INSIDE the checkout tx. Internal only — the client sends no lines (§5.3),
// so no json tags are needed.
type Line struct {
	ProductID int64
	Qty       int
}

// ErrOutOfStock: a line's qty exceeds available stock, so the guarded UPDATE matches zero rows.
// POST /checkout maps it to 409. ErrEmptyCart maps to 422 — nothing has been written when it fires.
var ErrOutOfStock = errors.New("out of stock")
var ErrEmptyCart = errors.New("empty cart")

// Checkout is ONE transaction: read the cart's lines, run the guarded UPDATE per line, claim the
// cart, insert order + items, persist the idempotency key. idemKey may be ""; a KNOWN key never
// reaches this method — the handler replays it first (§5.3 replay ordering).
func (s *Store) Checkout(ctx context.Context, customerID int64, cartToken, idemKey string) (int64, error) {
	tx, err := s.pool.Begin(ctx)
	if err != nil { return 0, err }
	defer tx.Rollback(ctx) // no-op after a successful Commit

	// resolve the ACTIVE cart — a converted or unknown token is 404 cart_not_found
	var cartID int64
	if err := tx.QueryRow(ctx,
		`SELECT id FROM carts WHERE token = $1 AND status = 'active'`, cartToken).Scan(&cartID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) || isBadUUID(err) { return 0, ErrCartNotFound }
		return 0, err
	}
	// the server reads the cart's lines — the client sent none. One row per product
	// (the cart_items PK): duplicate lines cannot occur, so no aggregation step is needed.
	rows, err := tx.Query(ctx,
		`SELECT product_id, qty FROM cart_items WHERE cart_id = $1 ORDER BY product_id`, cartID)
	if err != nil { return 0, err }
	var lines []Line
	for rows.Next() {
		var l Line
		if err := rows.Scan(&l.ProductID, &l.Qty); err != nil { rows.Close(); return 0, err }
		lines = append(lines, l)
	}
	rows.Close()
	if err := rows.Err(); err != nil { return 0, err }
	if len(lines) == 0 { return 0, ErrEmptyCart } // 422; nothing has been written

	var total int64
	prices := make([]int64, len(lines))
	for i, l := range lines {
		// THE SPOTLIGHT, unchanged: guard + decrement + capture price in ONE statement
		err := tx.QueryRow(ctx,
			`UPDATE products SET stock = stock - $1
			   WHERE id = $2 AND stock >= $1
			 RETURNING unit_price`, l.Qty, l.ProductID).Scan(&prices[i])
		if errors.Is(err, pgx.ErrNoRows) { // 0 rows updated == insufficient stock
			return 0, fmt.Errorf("product %d: %w", l.ProductID, ErrOutOfStock)
		}
		if err != nil { return 0, err }
		total += prices[i] * int64(l.Qty)
	}

	// claim the cart: the anonymous cart becomes the customer's at the instant the stock moves
	claim, err := tx.Exec(ctx,
		`UPDATE carts SET customer_id = $1, status = 'converted', updated_at = now()
		 WHERE id = $2 AND status = 'active'`, customerID, cartID)
	if err != nil { return 0, err }
	if claim.RowsAffected() == 0 { // raced: a concurrent request converted it first
		return 0, ErrCartNotFound
	}

	var key any
	if idemKey != "" { key = idemKey } // NULL when empty so the partial unique index stays happy
	var orderID int64
	if err := tx.QueryRow(ctx,
		`INSERT INTO orders (customer_id, total, idempotency_key) VALUES ($1, $2, $3) RETURNING id`,
		customerID, total, key).Scan(&orderID); err != nil { return 0, err }

	for i, l := range lines {
		if _, err := tx.Exec(ctx,
			`INSERT INTO order_items (order_id, product_id, quantity, unit_price)
			   VALUES ($1, $2, $3, $4)`,
			orderID, l.ProductID, l.Qty, prices[i]); err != nil { return 0, err }
	}
	return orderID, tx.Commit(ctx)
}
Why duplicate lines cannot occur — and why the claim is guarded

The old worry — a client sending the same product on two lines and tripping the order_items (order_id, product_id) primary key — is gone by construction: the checkout lines come from cart_items, whose own (cart_id, product_id) primary key holds one row per product, because merging already happened at add time (the cart step’s ON CONFLICT upsert). No aggregation step, no 23505 from order_items_pkey. The claim carries its own guard: WHERE id = $2 AND status = 'active' means that if two requests race the same cart, the second one’s claim matches zero rows and the whole transaction rolls back — the same conditional-UPDATE discipline as the stock guard, applied to the cart. An unknown productId also cannot reach this method: cart_items.product_id is itself a foreign key, so the missing-product case was caught where the client supplied the id — POST /cart/items, as a 404.

Agent prompt — paste into an agent with repo access

Before you run this: a 2-line cart has enough stock for line 1 but line 2 exceeds stock. After Checkout returns, what are the two products' stocks, and what status is the cart in?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: pgxpool is s.pool; the 8-table schema + seed applied; the cart step's ErrCartNotFound + isBadUUID exist; ErrOutOfStock + ErrEmptyCart defined. Internal Line struct {ProductID int64; Qty int} — no json tags, the client sends no lines.
Task: Implement Store.Checkout(ctx, customerID, cartToken, idemKey) as ONE transaction.
Requirements:
- Begin a tx; defer Rollback; Commit only at the end.
- Resolve the ACTIVE cart: SELECT id FROM carts WHERE token=$1 AND status='active'; pgx.ErrNoRows (or the 22P02 uuid-cast error) -> ErrCartNotFound.
- Read the cart's lines from cart_items (ORDER BY product_id); close rows before issuing more statements on the tx. Zero lines -> ErrEmptyCart (the handler maps 422) — nothing written yet.
- Per line, guard+decrement+capture price in ONE statement: `UPDATE products SET stock=stock-$1 WHERE id=$2 AND stock>=$1 RETURNING unit_price`; pgx.ErrNoRows -> ErrOutOfStock. Do NOT read unit_price a second time. (No aggregation: the cart_items PK guarantees one line per product.)
- CLAIM the cart in the same tx: `UPDATE carts SET customer_id=$1, status='converted', updated_at=now() WHERE id=$2 AND status='active'`; zero rows affected -> ErrCartNotFound (a concurrent request won the claim).
- total = sum(captured price * qty); insert orders(customer_id,total,idempotency_key) RETURNING id (key NULL when idemKey==""); insert order_items with the captured price. int64 cents; params only.
- The idempotency key is written INSIDE this transaction (so a crash can't leave a key without an order).
Tests / acceptance:
- Integration test against the Compose DB: a cart whose line exceeds stock returns ErrOutOfStock, leaves EVERY product's stock unchanged, and leaves the cart 'active' (the claim rolled back).
- A success reduces stock by exactly each line's qty, sets the cart to 'converted' with customer_id set, and total equals sum(captured price*qty).
- Checkout of an emptied cart returns ErrEmptyCart; a second checkout of the SAME token returns ErrCartNotFound (already converted).
Output: a unified diff plus a short proof there is no oversell window and no double price read.
What success looks like

The claim and the rollback, observed for real (compiled pgx v5.10.0 / Go 1.26 against postgres:16): a 2-line cart (3 Mugs + 1 Sticker Pack) checks out as one order with total = 4996 (3×1499 + 499), both stocks drop by exactly the line quantities, and the cart flips to converted with customer_id = 1 — its token stops resolving. A cart whose line exceeds stock rolls the whole transaction back: stock unchanged, cart still active.

POST /checkout -> 200 {"orderId":1}
order total=4996; cart after: status=converted customer_id=1; mug stock: 50 -> 47, sticker stock: 200 -> 199
GET /cart with the converted token -> 404 {"error":"cart_not_found"}

oversell (qty 9999 vs stock 12) -> 409 {"error":"out_of_stock"}; stock unchanged: 12; cart stays "active"

★ Checkout as a single transaction (Spring/Kotlin)

Spring Boot (Kotlin) Intermediate

Put the whole checkout in one @Transactional method — read the cart’s lines, run the same guard-and-capture UPDATE … RETURNING per line, claim the cart, and persist the idempotency key in-transaction — so the entire purchase commits together or rolls back together.

New in this step
transaction

A group of statements that succeed or fail as one unit; either all become permanent or all are undone.

postgres transaction begin commit docs ↗
ACID

The four transaction guarantees (Atomicity, Consistency, Isolation, Durability) — why the database, not your code, keeps money and stock correct.

ACID properties database
atomicity

The all-or-nothing part: there is no state where stock dropped but the order wasn’t created.

database atomicity
@Transactional

Spring’s declarative transaction boundary: opens a tx when the method is entered, commits on normal return, rolls back when it throws.

spring transactional annotation docs ↗
RETURNING

A clause that gives back values from the affected rows, so you capture the price in the same statement that decrements — no second read.

postgres returning clause
conditional UPDATE … WHERE stock >= qty

The oversell guard: it decrements only if enough stock remains; otherwise it touches zero rows.

conditional update where stock
queryForObject

JdbcTemplate’s call for a query expected to return exactly one value/row.

jdbctemplate queryforobject
EmptyResultDataAccessException

What queryForObject throws when the query returns no row; here it means the guard matched nothing, i.e. out of stock, so the tx rolls back.

EmptyResultDataAccessException spring
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same transaction, declarative boundary

Spring’s @Transactional opens a transaction when the method is entered and commits when it returns normally (rolling back on a thrown exception). The database does the heavy lifting — the SQL is identical to the Go version: resolve the active cart by its token, read its cart_items as the lines (the client sends none), run the guarded UPDATE … WHERE stock >= ? RETURNING unit_price per line so price is read once in the same statement you decrement against — no second SELECT, no money drift — then claim the cart (customer_id set, status = 'converted', guarded by WHERE status = 'active') and insert the order. Throwing on a zero-row update both signals out-of-stock and triggers the rollback — so a failed line also un-claims the cart, which stays active for the shopper to adjust. queryForObject throws EmptyResultDataAccessException when the guard matches 0 rows; treat that as out-of-stock inside the method (never let the global 404 advice see it). You saw this guard win by hand earlier; this is that UPDATE inside one declarative transaction. The locking and isolation underneath are in the PostgreSQL track (Steps 8–9).

The transaction (JdbcTemplate + @Transactional) — cart lines in, price captured once
Run these in your terminal / editor
// A cart line as read INSIDE the checkout tx. Internal only — the client sends no lines (§5.3).
data class Line(val productId: Long, val qty: Int)

class OutOfStockException(productId: Long) : RuntimeException("product $productId out of stock")
class EmptyCartException : RuntimeException("cart is empty")           // -> 422; nothing written yet
// CartNotFoundException comes from the cart step.

@Service
class CheckoutService(private val jdbc: JdbcTemplate) {

    // idemKey may be null; a KNOWN key never reaches this method — the controller replays it
    // first (§5.3 replay ordering).
    @Transactional
    fun checkout(customerId: Long, cartToken: String, idemKey: String?): Long {
        // resolve the ACTIVE cart — a converted/unknown token is 404 cart_not_found
        val cartId = try {
            jdbc.queryForObject(
                "SELECT id FROM carts WHERE token = ?::uuid AND status = 'active'",
                Long::class.java, cartToken,
            )!!
        } catch (e: EmptyResultDataAccessException) {
            throw CartNotFoundException(cartToken)
        } catch (e: DataAccessException) {
            throw CartNotFoundException(cartToken) // malformed token: the uuid cast can't match any cart
        }
        // the server reads the cart's lines — one row per product (the cart_items PK), so
        // duplicate lines cannot occur and no aggregation step is needed.
        val lines = jdbc.query(
            "SELECT product_id, qty FROM cart_items WHERE cart_id = ? ORDER BY product_id",
            { rs, _ -> Line(rs.getLong("product_id"), rs.getInt("qty")) }, cartId,
        )
        if (lines.isEmpty()) throw EmptyCartException()

        var total = 0L
        val prices = LongArray(lines.size)
        for ((i, l) in lines.withIndex()) {
            // THE SPOTLIGHT, unchanged: guard + decrement + capture price in ONE statement
            prices[i] = try {
                jdbc.queryForObject(
                    "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ? RETURNING unit_price",
                    Long::class.java, l.qty, l.productId, l.qty,
                )!!
            } catch (e: EmptyResultDataAccessException) {
                throw OutOfStockException(l.productId) // 0 rows updated -> rolls the whole tx back
            }
            total += prices[i] * l.qty
        }
        // claim the cart: anonymous -> the customer's, at the instant the stock moves
        val claimed = jdbc.update(
            "UPDATE carts SET customer_id = ?, status = 'converted', updated_at = now() " +
                "WHERE id = ? AND status = 'active'", customerId, cartId,
        )
        if (claimed == 0) throw CartNotFoundException(cartToken) // raced: converted concurrently
        val orderId = jdbc.queryForObject(
            "INSERT INTO orders (customer_id, total, idempotency_key) VALUES (?, ?, ?) RETURNING id",
            Long::class.java, customerId, total, idemKey,
        )!!
        for ((i, l) in lines.withIndex()) {
            jdbc.update(
                "INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)",
                orderId, l.productId, l.qty, prices[i],
            )
        }
        return orderId
    }
}
Why duplicate lines cannot occur — and why the claim is guarded

The old worry — a client sending the same product on two lines and tripping the order_items (order_id, product_id) primary key — is gone by construction: the checkout lines come from cart_items, whose own (cart_id, product_id) primary key holds one row per product, because merging already happened at add time (the cart step’s ON CONFLICT upsert). No aggregation step, no 23505 from order_items_pkey. The claim carries its own guard: WHERE id = ? AND status = 'active' returns an update count of 0 if a concurrent request converted the cart first — throw CartNotFoundException and @Transactional rolls everything back, exactly like the Go path’s zero-row claim. An unknown productId also cannot reach this method: cart_items.product_id is itself a foreign key, so the missing-product case was caught at POST /cart/items as a 404.

Agent prompt — paste into an agent with repo access

Before you run this: when the guarded UPDATE matches 0 rows and the method throws OutOfStockException, what does @Transactional do to the earlier lines' decrements — and to the cart claim?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3.4 (Kotlin), JdbcTemplate, Postgres; the 8-table schema applied; the cart step's CartNotFoundException exists. Internal data class Line(productId: Long, qty: Int) — no wire binding, the client sends no lines.
Task: Implement CheckoutService.checkout(customerId, cartToken, idemKey) as one @Transactional method.
Requirements:
- Resolve the ACTIVE cart: SELECT id FROM carts WHERE token = ?::uuid AND status='active'; catch EmptyResultDataAccessException -> CartNotFoundException, and catch the malformed-token DataAccessException (uuid cast, 22P02) -> CartNotFoundException too.
- Read the cart's lines from cart_items (ORDER BY product_id); empty -> EmptyCartException (mapped to 422) — nothing written yet. No aggregation: the cart_items PK guarantees one line per product.
- Per line, guard+decrement+capture price in ONE statement: `UPDATE products SET stock=stock-? WHERE id=? AND stock>=? RETURNING unit_price`; catch EmptyResultDataAccessException INSIDE checkout() and rethrow OutOfStockException (mandatory: the global 404 advice must never see the out-of-stock case). Do NOT read unit_price a second time.
- CLAIM the cart in the same method: jdbc.update UPDATE carts SET customer_id=?, status='converted', updated_at=now() WHERE id=? AND status='active'; an update count of 0 -> CartNotFoundException (a concurrent request won the claim).
- total = sum(captured price * qty); insert orders(customer_id,total,idempotency_key) RETURNING id (idemKey may be null); insert order_items with the captured price. Long cents.
- Advice mappings: OutOfStockException -> 409 {"error":"out_of_stock"}; CartNotFoundException -> 404 {"error":"cart_not_found"}; EmptyCartException -> 422 {"error":"invalid_request"}.
Tests / acceptance:
- @SpringBootTest + Testcontainers postgres:16: a cart line over stock throws OutOfStockException, leaves EVERY stock unchanged, and leaves the cart 'active' (the claim rolled back with the tx).
- A success decrements exactly, sets the cart to 'converted' with customer_id set, and total == sum(captured price*qty).
- Checkout of an emptied cart throws EmptyCartException; a second checkout of the SAME token throws CartNotFoundException (already converted).
Output: a unified diff plus the @RestControllerAdvice mappings.
What success looks like

Reasoned parity with the compiled Go run (which shows total=4996, both stocks down by their line quantities, the cart converted, and the oversell rollback leaving the cart active): a satisfiable cart returns a new orderId, each products.stock drops by exactly its qty, orders.total equals sum(captured unit_price * qty), and the cart is claimed — customer_id set, status = 'converted', its token no longer resolving. When any guarded UPDATE matches 0 rows the method throws OutOfStockException; @Transactional rolls back the whole call, so earlier decrements and the cart claim are undone — stock unchanged everywhere, cart still active.

Build POST /checkout (Go)

Go Intermediate

Add the HTTP endpoint — decode {customerId}, read the X-Cart-Token and Idempotency-Key headers, replay a known key first, then call Store.Checkout and map the result to 200 {orderId} / 409 / 422 / 404 — so a client has a real URL to POST to and a retry can’t create a second order.

New in this step
idempotency

A request you can safely send more than once with the same effect as sending it once; vital because networks retry after timeouts.

idempotency api design
Idempotency-Key header

A client-generated id (a UUID) sent with the request; the server uses it to recognise a retry and return the original result.

idempotency key http header
replay-first ordering

Check the key before touching any other state — here, before resolving the cart, because a successful checkout converted it and a cart-first handler would 404 every honest retry.

idempotency key lookup before processing
SQLSTATE 23505 (unique_violation)

Postgres’s error code when a row breaks a unique index; here, two concurrent first attempts with the same idempotency key — the loser is caught and replays the winner’s order.

postgres sqlstate 23505 docs ↗
pgconn.PgError

pgx’s typed error that exposes the SQLSTATE Code, so you can branch on 23505 specifically.

pgx pgconn PgError code
http.MaxBytesReader

Caps the request body size so a giant or malformed payload can’t exhaust memory.

go http MaxBytesReader
status codes 200/409/422/404

The contract: success / out-of-stock / invalid body or empty cart / missing cart or unknown customer — distinct so the client can react correctly.

http status 409 422 difference
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The endpoint is the contract every client and test shares

The transaction is a method; the endpoint is what the mobile app POSTs to. The body is just {customerId} — the server reads the cart’s lines itself; in production the id would come from the authenticated session, and since this course builds no auth, the seeded demo customer’s id is sent explicitly. The cart travels as the required X-Cart-Token header (absent, unknown, or already-converted → 404 {"error":"cart_not_found"}), the Idempotency-Key header stays optional, and a missing customerId or an empty cart is a 422. Map ErrOutOfStock to 409 {"error":"out_of_stock"}. An unknown customerId trips the orders.customer_id foreign key (SQLSTATE 23503, orders_customer_id_fkey) — map that to 404 {"error":"not_found"}, checked before the default 500. (An unknown productId can no longer reach checkout at all: cart lines are FK-guaranteed at add time.) Register the route on the same newRouter from the scaffold. This is the exact §5 contract the frontend’s checkout button calls.

Replay ordering is load-bearing: look the key up BEFORE resolving the cart

Here is the subtlety the cart introduced (DoD #6). A successful checkout converts the cart — so when a client times out and retries the same request, its X-Cart-Token no longer resolves an active cart. If the handler resolved the cart first, every honest retry would die as 404 cart_not_found instead of replaying the original order. So when an Idempotency-Key is present, the handler looks it up first and returns the original orderId before touching the cart; only a key miss proceeds to Checkout and the transaction. The in-tx key persist stays the race backstop: two concurrent first attempts both miss the lookup and both reach the insert — the loser hits orders_idem_key (SQLSTATE 23505), its whole transaction rolls back (releasing its stock decrement and its cart claim), and the handler replays the winner’s order. Gate that arm on the constraint name so only orders_idem_key is ever treated as a replay.

POST /checkout handler (pgx) — replay-first, then the error map
Run these in your terminal / editor
type checkoutReq struct {
	CustomerID int64 `json:"customerId"`
}

func (s *Store) orderIDByKey(ctx context.Context, key string) (int64, error) {
	var id int64
	err := s.pool.QueryRow(ctx, `SELECT id FROM orders WHERE idempotency_key = $1`, key).Scan(&id)
	return id, err
}

// in newRouter(s):
mux.HandleFunc("POST /checkout", func(w http.ResponseWriter, r *http.Request) {
	var req checkoutReq
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil ||
		req.CustomerID == 0 {
		writeJSON(w, 422, map[string]string{"error": "invalid_request"}); return
	}
	idem := r.Header.Get("Idempotency-Key")

	// REPLAY FIRST (load-bearing): a successful checkout CONVERTED the cart, so a retry's token
	// no longer resolves. Look the key up BEFORE the cart, or every honest retry 404s.
	if idem != "" {
		if id, err := s.orderIDByKey(r.Context(), idem); err == nil {
			writeJSON(w, 200, map[string]int64{"orderId": id}); return
		}
	}
	token := r.Header.Get("X-Cart-Token")
	if token == "" { // required here (only POST /cart/items may omit it)
		writeJSON(w, 404, map[string]string{"error": "cart_not_found"}); return
	}

	orderID, err := s.Checkout(r.Context(), req.CustomerID, token, idem)
	switch {
	case err == nil:
		writeJSON(w, 200, map[string]int64{"orderId": orderID})
	case errors.Is(err, ErrOutOfStock):
		writeJSON(w, 409, map[string]string{"error": "out_of_stock"})
	case errors.Is(err, ErrCartNotFound):
		writeJSON(w, 404, map[string]string{"error": "cart_not_found"})
	case errors.Is(err, ErrEmptyCart):
		writeJSON(w, 422, map[string]string{"error": "invalid_request"})
	default:
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23503" { // FK violation: unknown customerId
			writeJSON(w, 404, map[string]string{"error": "not_found"}); return
		}
		// race backstop: a concurrent duplicate hit orders_idem_key (23505); its tx rolled back
		// (stock decrement + cart claim released) — replay the winner's order. Gate on the
		// constraint NAME so no other unique index is ever misread as a replay.
		if idem != "" && errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "orders_idem_key" {
			if id, e := s.orderIDByKey(r.Context(), idem); e == nil {
				writeJSON(w, 200, map[string]int64{"orderId": id}); return
			}
		}
		writeJSON(w, 500, map[string]string{"error": "internal"})
	}
})
Agent prompt — paste into an agent with repo access

Before you run this: a checkout succeeds and converts the cart. The client times out and retries with the SAME Idempotency-Key and the same token — what status and body come back, and why not 404 cart_not_found?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: Store.Checkout(ctx, customerID, cartToken, idemKey) exists; ErrOutOfStock, ErrCartNotFound, ErrEmptyCart defined; newRouter(*Store); github.com/jackc/pgx/v5 + pgconn. Contract: POST /checkout {customerId} + required X-Cart-Token header + optional Idempotency-Key -> 200 {orderId} | 409 {error:"out_of_stock"} | 422 {error:"invalid_request"} | 404 {error:"cart_not_found"} | 404 {error:"not_found"}.
Task: Register POST /checkout on newRouter and add a Store.orderIDByKey(ctx, key) lookup.
Requirements:
- Decode with a 1MB MaxBytesReader; missing customerId -> 422.
- REPLAY FIRST: when Idempotency-Key is present, look it up via orderIDByKey BEFORE resolving the cart; on a hit return the ORIGINAL orderId with 200. This ordering is load-bearing: a successful checkout converts the cart, so a retry's token no longer resolves — resolving the cart first would 404 every honest retry.
- Missing X-Cart-Token header -> 404 {"error":"cart_not_found"} (only POST /cart/items may omit the token).
- Map ErrOutOfStock -> 409 {"error":"out_of_stock"}; ErrCartNotFound -> 404 {"error":"cart_not_found"}; ErrEmptyCart -> 422 {"error":"invalid_request"}.
- On a pgconn.PgError SQLSTATE 23503 (unknown customerId FK, orders_customer_id_fkey), return 404 {"error":"not_found"} — checked BEFORE the 23505 arm and the default 500.
- Race backstop: on SQLSTATE 23505 with ConstraintName == "orders_idem_key" and a non-empty key, SELECT the existing order (on the pool — the tx is already rolled back) and return its id with 200. Gate on the constraint NAME.
Tests / acceptance:
- POST without a token returns 404 cart_not_found; with an empty cart returns 422; over-stock returns 409, stock unchanged, cart still active.
- POSTing twice with the SAME Idempotency-Key returns the SAME orderId both times — the 2nd via the replay-first lookup, even though the cart is now converted — and stock decrements exactly once.
- POST with an unknown customerId returns 404 {"error":"not_found"}, stock unchanged, cart still active (the claim rolled back).
Output: a unified diff plus one sentence on why the replay lookup precedes cart resolution.
What success looks like

The retry survives the cart’s conversion — that is the whole point of the replay-first ordering. Real output (compiled pgx v5.10.0 / Go 1.26 against postgres:16):

POST /checkout (token + Idempotency-Key) -> 200 {"orderId":1}
cart after: status=converted customer_id=1
RETRY same Idempotency-Key (cart now converted) -> 200 {"orderId":1}   (replay-first, no 404)
mug stock after retry: 47 (decremented once)

empty cart          -> 422 {"error":"invalid_request"}
missing X-Cart-Token -> 404 {"error":"cart_not_found"}

One order, one decrement, no matter how many times the client retries — and the same request replayed against the now-converted cart still answers 200 with the original order.

Build POST /checkout (Spring/Kotlin)

Spring Boot (Kotlin) Intermediate

Add the controller — bind {customerId} plus the X-Cart-Token and Idempotency-Key headers, replay a known key first, then call CheckoutService and let the advice map errors to 200/409/422/404 — so a client has a real URL to POST to and a retry can’t create a second order.

New in this step
idempotency

A request you can safely send more than once with the same effect as once; vital because networks retry after timeouts.

idempotency api design
Idempotency-Key header

A client-generated UUID sent with the request; the server uses it to recognise a retry and return the original result.

idempotency key http header
replay-first ordering

Check the key before touching any other state — here, before resolving the cart, because a successful checkout converted it and a cart-first controller would 404 every honest retry.

idempotency key lookup before processing
@RequestBody

Binds the JSON request body to a Kotlin data class.

spring requestbody docs ↗
@RequestHeader

Reads a header (here X-Cart-Token and Idempotency-Key, both required = false so the controller decides what absence means).

spring requestheader optional
ResponseEntity

Lets you set the status code and body explicitly (e.g. 422 with an error map).

spring responseentity status
DuplicateKeyException

Spring’s wrapper around the Postgres unique-violation (SQLSTATE 23505); catching it means a concurrent attempt with the same idempotency key already produced the order, so return that original.

spring duplicatekeyexception
@RestControllerAdvice / @ExceptionHandler

One place that maps thrown exceptions to HTTP responses (e.g. OutOfStockException to 409), keeping controllers clean.

spring restcontrolleradvice exceptionhandler
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same contract, Spring wiring — and the same load-bearing replay ordering

The @RestController binds {customerId} plus two headers: the required X-Cart-Token (absent → 404 {"error":"cart_not_found"}; only POST /cart/items may omit it) and the optional Idempotency-Key. The replay ordering matters exactly as on the Go path: a successful checkout converts the cart, so a retry’s token no longer resolves — when a key is present, the controller looks it up first and returns the original orderId before the service ever touches the cart; only a key miss calls checkout(...). The advice maps OutOfStockException409, CartNotFoundException404 {"error":"cart_not_found"}, and EmptyCartException422. An unknown customerId trips orders_customer_id_fkey (23503), which Spring surfaces as DataIntegrityViolationException — the advice maps that to 404 {"error":"not_found"} (safe because the race-backstop DuplicateKeyException, a subtype, is caught in the controller first). That backstop covers two concurrent first attempts: the loser’s insert hits orders_idem_key (23505), its @Transactional rolls back — releasing its stock decrement and cart claim — and the controller’s catch replays the winner’s order with a lookup that runs outside the rolled-back transaction. A catch-all @ExceptionHandler(Exception::class) returns 500 {"error":"internal"} for anything unmapped, so the error frame matches the Go path instead of Spring’s whitelabel body; the specific handlers still win. This is the same §5 contract the Go path serves, so one mobile client works against either backend.

CheckoutController + advice (essentials) — replay-first, then the error map
Run these in your terminal / editor
data class CheckoutReq(val customerId: Long)

@RestController
class CheckoutController(private val svc: CheckoutService, private val jdbc: JdbcTemplate) {
    @PostMapping("/checkout")
    fun checkout(
        @RequestBody req: CheckoutReq,
        @RequestHeader(value = "X-Cart-Token", required = false) cartToken: String?,
        @RequestHeader(value = "Idempotency-Key", required = false) idemKey: String?,
    ): ResponseEntity<Map<String, Any>> {
        if (req.customerId == 0L)
            return ResponseEntity.unprocessableEntity().body(mapOf("error" to "invalid_request"))
        // REPLAY FIRST (load-bearing): a successful checkout CONVERTED the cart, so a retry's
        // token no longer resolves. Look the key up BEFORE the cart, or every honest retry 404s.
        if (idemKey != null) {
            jdbc.query("SELECT id FROM orders WHERE idempotency_key = ?",
                { rs, _ -> rs.getLong("id") }, idemKey)
                .firstOrNull()?.let { return ResponseEntity.ok(mapOf("orderId" to it)) }
        }
        if (cartToken == null) // required here (only POST /cart/items may omit it)
            return ResponseEntity.status(404).body(mapOf("error" to "cart_not_found"))
        return try {
            ResponseEntity.ok(mapOf("orderId" to svc.checkout(req.customerId, cartToken, idemKey)))
        } catch (e: DuplicateKeyException) {
            // race backstop: a concurrent duplicate hit orders_idem_key (23505); the @Transactional
            // rolled back (stock + cart claim released) — this lookup runs OUTSIDE that dead tx.
            val id = jdbc.queryForObject(
                "SELECT id FROM orders WHERE idempotency_key = ?", Long::class.java, idemKey)!!
            ResponseEntity.ok(mapOf("orderId" to id))
        }
    }
}

@RestControllerAdvice
class ApiExceptionHandler {
    @ExceptionHandler(OutOfStockException::class)
    fun outOfStock(e: OutOfStockException) =
        ResponseEntity.status(409).body(mapOf("error" to "out_of_stock"))
    @ExceptionHandler(CartNotFoundException::class)
    fun cartNotFound(e: CartNotFoundException) =
        ResponseEntity.status(404).body(mapOf("error" to "cart_not_found"))
    @ExceptionHandler(EmptyCartException::class)
    fun emptyCart(e: EmptyCartException) =
        ResponseEntity.unprocessableEntity().body(mapOf("error" to "invalid_request"))
    @ExceptionHandler(EmptyResultDataAccessException::class)
    fun notFound(e: EmptyResultDataAccessException) =
        ResponseEntity.status(404).body(mapOf("error" to "not_found"))
    // Unknown customerId (checkout) or unknown productId (cart writes) -> FK 23503 ->
    // DataIntegrityViolationException. Safe: the race-backstop DuplicateKeyException (a DIVE
    // subtype) is caught in the controller FIRST.
    @ExceptionHandler(org.springframework.dao.DataIntegrityViolationException::class)
    fun notFoundFk(e: org.springframework.dao.DataIntegrityViolationException) =
        ResponseEntity.status(404).body(mapOf("error" to "not_found"))
    // Catch-all: any unmapped error is 500 {"error":"internal"}, matching the Go frame — never
    // Spring's whitelabel {timestamp,status,error,path}. The specific handlers above still win.
    @ExceptionHandler(Exception::class)
    fun internal(e: Exception) =
        ResponseEntity.status(500).body(mapOf("error" to "internal"))
}
Agent prompt — paste into an agent with repo access

Before you run this: a checkout succeeds and converts the cart. The client retries with the SAME Idempotency-Key and token — what does the controller return, and which lookup answers before the service could throw CartNotFoundException?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: CheckoutService.checkout(customerId, cartToken, idemKey) + OutOfStockException + CartNotFoundException + EmptyCartException exist; JdbcTemplate over Postgres with orders_idem_key. Contract: POST /checkout {customerId} + required X-Cart-Token header + optional Idempotency-Key -> 200 {orderId} | 409 {error:"out_of_stock"} | 422 {error:"invalid_request"} | 404 {error:"cart_not_found"} | 404 {error:"not_found"}.
Task: Add a CheckoutController and a @RestControllerAdvice mapping the errors.
Requirements:
- Bind {customerId} and both headers (required=false); missing customerId -> 422 {"error":"invalid_request"}.
- REPLAY FIRST: when Idempotency-Key is present, look it up BEFORE resolving the cart; on a hit return the ORIGINAL orderId with 200. Load-bearing: a successful checkout converts the cart, so a retry's token no longer resolves — resolving the cart first would 404 every honest retry.
- Missing X-Cart-Token -> 404 {"error":"cart_not_found"} (only POST /cart/items may omit the token).
- Advice: OutOfStockException -> 409 {"error":"out_of_stock"}; CartNotFoundException -> 404 {"error":"cart_not_found"}; EmptyCartException -> 422 {"error":"invalid_request"}; DataIntegrityViolationException -> 404 {"error":"not_found"} (unknown customerId FK 23503; safe because DuplicateKeyException, a subtype, is caught in the controller first).
- Race backstop: catch DuplicateKeyException in the controller and SELECT the existing order in a lookup that runs OUTSIDE the rolled-back transaction (the controller is outside the service's @Transactional, so a plain JdbcTemplate read there is safe) — never inside the same poisoned tx.
- Catch-all @ExceptionHandler(Exception::class) -> 500 {"error":"internal"}, matching the Go frame, not Spring's whitelabel body. The specific handlers still win.
Tests / acceptance:
- POST without a token returns 404 cart_not_found; an empty cart returns 422; over-stock returns 409 with stock unchanged and the cart still active.
- POSTing twice with the SAME Idempotency-Key returns the SAME orderId both times — the 2nd via the replay-first lookup, even though the cart is now converted — and exactly one order exists.
- POST with an unknown customerId returns 404 {"error":"not_found"}, stock unchanged, cart still active.
Output: a unified diff plus the advice class.
What success looks like

Byte-for-byte the same as the Go endpoint (whose compiled run shows the retry returning the original orderId after the cart converted): 200 {"orderId": N} on success; the retry with the same Idempotency-Key answers from the replay-first lookup200, same orderId, no cart_not_found — with stock decremented exactly once. A missing token → 404 {"error":"cart_not_found"}; an empty cart → 422 {"error":"invalid_request"}; over-stock → 409 {"error":"out_of_stock"} with the cart left active; an unknown customerId404 {"error":"not_found"} (FK 23503 via DataIntegrityViolationException); any unmapped error → 500 {"error":"internal"} (not Spring’s whitelabel body). One mobile client works against either backend.

Read an order back: GET /orders/{id}

Intermediate

Expose GET /orders/{id} returning the order with its line items — the confirmation screen and the support feature both read it, so checkout isn’t a write-only dead end.

New in this step
path parameter

A value taken from the URL itself (the {id} in /orders/{id}), used to fetch one specific resource.

rest path parameter
r.PathValue(id) (Go)

Go 1.22+ reads a {id} pattern segment as a string; convert it with strconv.Atoi before querying.

go r.PathValue docs ↗
@PathVariable (Spring)

Binds the {id} segment to a method parameter, already typed as Long.

spring pathvariable docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Close the loop the frontend (and features) depend on

Checkout creates orders; something has to read them back. The frontend’s checkout flow uses the orderId to display a confirmation — that screen needs a data source. The optional ai-support feature’s get_order_status tool and the idempotency replay (“return the original order”) also assume an order can be fetched. One small endpoint closes the hole and makes the base path a complete build. It is mostly identical across backends — the SQL is the same; only the handler wiring differs — so drive it via the prompt and reuse your existing access layer.

The order-read contract (shared)
Run these in your terminal / editor
GET /orders/{id}
200: { "id":4821, "customerId":1, "total":5998, "status":"pending",
       "createdAt":"2026-01-01T00:00:00Z",
       "items":[ { "productId":2, "quantity":2, "unitPrice":2999 } ] }
404: { "error":"not_found" }   // no such order, OR a non-numeric {id} (e.g. /orders/abc)
createdAt is a parity trap

Spec §5.4/§7 require createdAt as an RFC3339 / ISO-8601 UTC instant with a trailing Z and no sub-second digits (2026-01-01T00:00:00Z). The defaults on both backends keep the fraction and disagree, so pin it explicitly:

  • Go — scan created_at into a time.Time, then emit it as a string field via t.UTC().Format(time.RFC3339). Plain .UTC() + default time.Time marshal is RFC3339Nano and keeps the fraction (…:00.216339Z), breaking the rule.
  • Spring — read the column as OffsetDateTime/Instant, truncatedTo(java.time.temporal.ChronoUnit.SECONDS), and serialize in UTC with the Z designator. A Jackson-UTC config alone still keeps the fraction.
A non-numeric {id} is a 404, never a 400/500

§5.4 requires /orders/abc to be 404 {"error":"not_found"}, and both backends’ matchers must agree.

  • Gor.PathValue("id") is a string; on a strconv.Atoi error return 404 (don’t 500).
  • Spring@PathVariable id: Long raises MethodArgumentTypeMismatchException400 by default. Either bind it as String and toLongOrNull() ?: return 404, or add @ExceptionHandler(org.springframework.web.method.annotation.MethodArgumentTypeMismatchException::class) returning 404 {"error":"not_found"}.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: orders + order_items tables exist; the API already serves GET /products on the same router/controller. orders.status is NOT NULL ('pending' by default).
Task: Add GET /orders/{id} returning the order with its items, or 404 if absent.
Requirements:
- Read the order row, then its order_items; assemble {id, customerId, total, status, createdAt, items:[{productId, quantity, unitPrice}]} (camelCase, integer cents). status is a string between total and createdAt; SELECT + scan/bind it.
- createdAt wire format: RFC3339 UTC, trailing Z, NO sub-second digits (§5.4/§7). Go: emit t.UTC().Format(time.RFC3339) as a STRING (plain time.Time marshal keeps the fraction). Spring: read OffsetDateTime/Instant, truncatedTo(ChronoUnit.SECONDS), serialize UTC with Z (UTC config alone keeps the fraction).
- A missing id returns 404 {"error":"not_found"} (Go: map pgx.ErrNoRows; Spring: the advice handles EmptyResultDataAccessException).
- A non-numeric {id} (e.g. /orders/abc) is 404, never 400/500, and both matchers must agree. Go: on strconv.Atoi error return 404. Spring: bind id as String + toLongOrNull() ?: 404, or add an @ExceptionHandler for MethodArgumentTypeMismatchException -> 404.
- Parameterised query only.
Tests / acceptance:
- After a checkout, GET /orders/{returnedId} returns the order with its exact items, total, and status.
- createdAt on the wire ends in Z with no sub-second digits (e.g. 2026-01-01T00:00:00Z), byte-identical across backends.
- GET /orders/999999 returns 404; GET /orders/abc returns 404 (not 400/500).
Output: a unified diff plus the response shape.
What success looks like

GET /orders/{returnedId} returns 200 with the order and its line items — total in cents, status (a string, "pending" for a fresh order), createdAt as an RFC3339 UTC instant ending in Z with no sub-second digits (byte-identical across backends), and items[] carrying the price captured at purchase. A missing id returns 404 {"error":"not_found"}, and so does a non-numeric {id} like /orders/abc (never 400/500 — both matchers agree).

{ "id": 4821, "customerId": 1, "total": 5998, "status": "pending",
  "createdAt": "2026-01-01T00:00:00Z",
  "items": [ { "productId": 2, "quantity": 2, "unitPrice": 2999 } ] }

Beat the concurrency: when one UPDATE is enough — and when it isn't

Advanced

The single conditional UPDATE is race-safe for one-row stock; learn the precise case (a multi-row read-decide-write) that needs SERIALIZABLE + a 40001 retry — so you reach for the heavier tool only when you actually need it.

New in this step
row lock

Postgres locks a row an UPDATE touches until the transaction ends, so a second writer blocks then sees the committed result; this is what serialises two checkouts of the last unit.

postgres row level lock docs ↗
transaction isolation level

How much one in-flight transaction can see of another’s uncommitted work; Postgres defaults to READ COMMITTED.

postgres transaction isolation levels
SERIALIZABLE

The strictest level: transactions behave as if run one-at-a-time, catching anomalies a per-row guard can’t.

postgres serializable isolation
write skew

Two transactions each read, each decide independently, each write a different row; both are legal alone but jointly violate a rule (e.g. a shared budget).

write skew anomaly
SQLSTATE 40001 (serialization_failure)

What SERIALIZABLE raises to abort one conflicting transaction; the app retries the whole transaction (nothing committed, so it’s safe).

postgres 40001 serialization failure retry
SELECT … FOR UPDATE

The alternative pessimistic pattern: lock rows as you read them, then decide and write; a different mechanism from the single conditional UPDATE used here.

postgres select for update
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
One decrement vs write-skew across rows

For the checkout you built — one conditional decrement per product — the UPDATE … WHERE stock >= qty is already correct under concurrency: Postgres row locks serialise the two writers and the loser sees zero rows (you watched this by hand). Single-row guards don’t need a higher isolation level. The case that breaks is a read-decide-write across multiple rows — e.g. “only allow checkout if the whole cart stays within a per-customer spend budget.” Two transactions each read the budget, each see room, each commit their own line: separately legal, jointly over budget. That’s write-skew, and READ COMMITTED + per-row UPDATEs cannot catch it because neither transaction’s write conflicts with the other’s. SERIALIZABLE does catch it — it aborts one with SQLSTATE 40001, and you retry the whole transaction. So: keep the cheap single-statement guard for the current checkout; reach for SERIALIZABLE the moment a decision spans rows. Note: this project’s guard is a single conditional UPDATE … WHERE stock >= qty — the UPDATE itself acquires the row lock implicitly, so you never need an explicit SELECT … FOR UPDATE here. FOR UPDATE is the alternative pessimistic-lock pattern (read first, decide, then write) and is a different mechanism. The PostgreSQL track walks both — the explicit SELECT … FOR UPDATE (Step 8) and SERIALIZABLE + 40001 retry (Step 9) — so you can see where each applies.

Serializable + retry (pseudocode, same in any backend)
Run these in your terminal / editor
# Needed only for a read-decide-write that spans multiple rows (e.g. a cart-wide budget check):
for attempt in 1..3:
  BEGIN ISOLATION LEVEL SERIALIZABLE
  ... read the budget, decide, write the lines ...
  COMMIT
  on SQLSTATE 40001 (serialization_failure): retry   # nothing committed, safe to repeat
  on other error: abort
Agent prompt — paste into an agent with repo access

Before you run this: 20 buyers race for a product with stock = 1, each checking out their OWN single-line cart, using only the single conditional UPDATE (no SERIALIZABLE). How many succeed, how many get out-of-stock, and what happens to the 19 losers' carts?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: The cart endpoints + Checkout exist (checkout reads the cart's lines and claims the cart). Prove the single-row guard cannot oversell under concurrency, and demonstrate the write-skew case that needs SERIALIZABLE.
Task: (1) Add a concurrency test firing N parallel buyers for the last unit. (2) Add a short note/test showing a cart-wide budget check that write-skews under READ COMMITTED and is fixed by SERIALIZABLE + a 40001 retry.
Requirements:
- Seed one product with stock = 1; launch 20 concurrent buyers, EACH doing the full path with its own cart: POST /cart/items with no token (a fresh anonymous cart, one line, quantity 1), then POST /checkout with its own X-Cart-Token and a distinct Idempotency-Key.
- Assert: exactly 1 success, 19 out-of-stock, final stock = 0 (the single-statement guard, no SERIALIZABLE needed).
- Assert the cart states: the 19 losers' carts are still 'active' (their transactions rolled back, claim included) and exactly 1 cart is 'converted'.
- For the budget scenario, wrap the multi-row decision in BEGIN ISOLATION LEVEL SERIALIZABLE with a bounded retry on SQLSTATE 40001.
Tests / acceptance:
- The 20-buyer concurrency test passes reliably across 10 runs.
- The budget test shows the anomaly under READ COMMITTED and its absence under SERIALIZABLE.
Output: a unified diff plus one paragraph on when single-row guard suffices vs when SERIALIZABLE is required.
What success looks like

With stock = 1 and 20 concurrent buyers — each creating its own single-line cart and checking out with its own token — exactly 1 succeeds, 19 get out-of-stock, stock ends at 0, and the cart states tell the rollback story: the 19 losers’ carts are still active (each failed transaction released its claim), one cart is converted. The single conditional UPDATE never oversells, no SERIALIZABLE needed. Real output (compiled pgx v5.10.0 / Go 1.26 against postgres:16):

results: map[out_of_stock:19 success:1]
final stock=0; loser carts still active=19; winner carts converted=1

The budget scenario, by contrast, write-skews under READ COMMITTED and is caught only by SERIALIZABLE (one tx aborts with 40001, then retries).

Make retries safe with idempotency keys

Intermediate

Trace the Idempotency-Key end-to-end: written inside the checkout transaction, enforced by the partial unique index, and a duplicate returns the original order.

Networks retry; your order pipeline must not duplicate

Clients resend after timeouts, and a naive server turns one buy into two orders. The fix is already wired through your build, in two layers. The fast path: the handler looks a known Idempotency-Key up before resolving the cart and replays the original orderId — the ordering is load-bearing, because a successful checkout converted the cart, so a retry’s X-Cart-Token no longer resolves and a cart-first handler would 404 every honest retry. The race backstop: only a key miss proceeds to Checkout, which inserts the key into orders.idempotency_key inside the same transaction as the order — so a crash can never leave a key without its order. If two concurrent first attempts both miss the lookup, the partial unique index orders_idem_key (from the schema step) makes the loser’s insert fail with SQLSTATE 23505; its whole transaction rolls back — releasing its stock decrement and its cart claim — and the handler replays the winner’s order. The uniqueness guarantee comes from the database, so it holds no matter which backend writes it. Three rules make this airtight: (1) replay a known key before touching the cart; (2) write the key in the same tx as the order; (3) on conflict, return the existing order rather than erroring — even if you want its current status, read it back, don’t recompute it.

The column and index (already in the schema; here for reference)
Run these in your terminal / editor
-- shipped in the schema step:
--   orders.idempotency_key TEXT
--   CREATE UNIQUE INDEX orders_idem_key ON orders (idempotency_key) WHERE idempotency_key IS NOT NULL;
-- The flow: header -> handler replays a KNOWN key first (before the cart) -> a miss reaches Checkout,
-- which inserts the key in-tx -> a concurrent duplicate hits 23505 -> handler replays the winner's order.
SELECT id, total, created_at FROM orders WHERE idempotency_key = $1;  -- the replay lookup (both arms)
What success looks like

After a successful checkout, SELECT count(*) FROM orders WHERE idempotency_key = $1; is 1. Re-POSTing with that key and the same X-Cart-Token adds no row: the replay-first lookup answers with the original orderId before the (now-converted) cart is even consulted. The count stays 1 no matter how many times the client retries — and SELECT status FROM carts … still shows exactly one converted cart for the purchase.

Version the schema with migrations

Intermediate

Wire the migration files you already authored (the init + seed steps) to a runner — golang-migrate on the Go path, Flyway on the JVM — so a fresh clone reaches the exact same schema, repeatably and reviewably.

New in this step
migration

A numbered, immutable SQL file that evolves the schema one ordered step at a time, giving a repeatable, reviewable history.

database schema migration
up vs down

up applies a change (create the tables); down reverses it (drop them) so you can roll back.

migration up down
golang-migrate

The common Go migration runner; applies db/migrations/000N_*.up.sql in order against DATABASE_URL.

golang-migrate cli docs ↗
Flyway

The JVM equivalent; Spring Boot auto-applies db/migration/V*.sql on startup.

flyway spring boot
never edit an applied migration

Once a file has run somewhere, add a new migration instead; editing it desyncs environments.

flyway immutable migrations
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why migrations, not a living schema.sql

Production schemas change over time and across environments. Numbered, immutable migration files give a repeatable, reviewable history and a safe path forward (and back). Never edit an applied migration — add a new one. Go projects often use golang-migrate; Spring projects commonly use Flyway, which applies db/migration/V*.sql on startup. Nothing to rewrite here: you already authored the schema and seed as migration files (0001_init.up.sql / V1__init.sql, 0002_seed.up.sql / V2__seed.sql) — this step just points the runner at them and adds the down files so a fresh clone comes up identically.

The migration set (matches the schema + seed you wrote)
Run these in your terminal / editor
# Go (golang-migrate):              # Spring (Flyway, src/main/resources):
db/migrations/                      db/migration/
  0001_init.up.sql   # 8 tables +     V1__init.sql        # 8 tables + orders_idem_key
  0001_init.down.sql #   idem index   V2__seed.sql        # customer + categories + products + images
  0002_seed.up.sql   # customer +    # (Flyway runs these automatically on boot)
  0002_seed.down.sql #   catalog
# apply: migrate -path db/migrations -database "$DATABASE_URL" up
Keep the two copies from drifting: V1/V2 are byte-identical to 0001/0002

The two runners read two copies of the same DDL, so the §7 byte-for-byte parity invariant demands they stay identical: V1__init.sql is a byte-for-byte copy of 0001_init.up.sql, and V2__seed.sql of 0002_seed.up.sql. Same DDL, both files — the UNIQUE on products.name and categories.slug, the carts.token default and status CHECKs, the orders_idem_key partial unique index, and the seed’s four ON CONFLICT guards — so a change to one side must be copied to the other. If you built both backends, diff the two copies (e.g. in CI) to guarantee they never drift:

Prove the copies match
Run these in your terminal / editor
diff db/migrations/0001_init.up.sql src/main/resources/db/migration/V1__init.sql
diff db/migrations/0002_seed.up.sql src/main/resources/db/migration/V2__seed.sql
# both print nothing (exit 0) — identical bytes, so the schemas cannot silently drift

Integration-test the money path (Go)

Go Intermediate

Run the checkout against a real Postgres in a Go test, asserting totals and stock to the cent — because transaction behaviour only exists in a real database, never in a mock.

New in this step
integration test

A test that exercises real components wired together (here, your code against a live Postgres), as opposed to a unit test that isolates one function with fakes.

integration test vs unit test
why mocks can't prove this

A mock doesn’t enforce constraints, locks, or rollback, so only a real DB can prove the transaction is atomic.

mock database limitations transaction
t.Skip

Go’s way to skip a test at runtime (here, when DATABASE_URL is unset) instead of failing it.

go testing t.Skip docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Mocks lie about transactions

Transaction semantics, constraints, and serialization failures only exist in a real database. Point the test at the Compose DB (or a disposable container), and assert exact integer totals.

Package layout: single-package here, spec's internal/ split is optional

This course keeps everything in package main under cmd/api, so the test lives beside the code it exercises and needs no exported accessors. That is a deliberate simplification: the spec (§3.1) instead splits Store/httpapi into internal/ with a root-level store_integration_test.go and an exported Store.Pool. Both are valid — if you take the spec’s split, export the accessors the test reaches across packages. Either way the assembled build passes go test ./....

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: The cart ops (AddCartItem) + Checkout(ctx, customerID, cartToken, idemKey) + schema exist; Postgres via DATABASE_URL.
Task: Add integration tests for the checkout money + stock path, driven through the cart.
Requirements:
- Seed known products; build a multi-line cart through AddCartItem (fresh anonymous cart, two products), then Checkout with its token; assert total == sum(unit_price*qty) exactly.
- Assert stock decremented by exact quantities and the cart 'converted' with customer_id set; an over-quantity line rolls ALL changes back — stock unchanged AND the cart still 'active'.
- Include the 20-buyer concurrency case (each buyer its own single-line cart + distinct Idempotency-Key): exactly 1 success, 19 out-of-stock, final stock 0.
- t.Skip cleanly if DATABASE_URL is unset.
Tests / acceptance:
- `go test ./... -run TestCheckoutIntegration` passes against the Compose DB.
Output: a unified diff plus how the test isolates itself between runs.
What success looks like

Against the Compose Postgres, go test ./... -run TestCheckoutIntegration reports ok (PASS): a cart built through AddCartItem checks out with total == sum(unit_price*qty) to the cent, stock down by exact quantities, and the cart converted; an over-quantity line rolls all changes back (stock unchanged, cart still active); the 20-buyer race — each with its own single-line cart — lands exactly 1 success + 19 out-of-stock. With DATABASE_URL unset the test t.Skips rather than failing.

ok  	github.com/you/aurora-api	0.42s

Integration-test the money path (Spring/Kotlin)

Spring Boot (Kotlin) Intermediate

Use @SpringBootTest with Testcontainers to run the checkout against a real Postgres and assert exact money + stock — because transaction behaviour only exists in a real database, never in a mock.

New in this step
integration test

Exercises real components wired together (your service against a live Postgres), unlike a unit test that isolates one function with fakes; only a real DB proves the transaction is atomic.

integration test vs unit test
@SpringBootTest

Boots the full Spring application context for a test, so controllers, services, and the datasource are all real.

springboottest annotation docs ↗
Testcontainers / @Container

Spins up a throwaway postgres:16 Docker container for the test and tears it down after, so the test owns a clean real database.

testcontainers postgresql junit
@DynamicPropertySource

Feeds the container’s generated JDBC URL into spring.datasource.* at runtime, so the app connects to the test container.

spring dynamicpropertysource testcontainers
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: CartService + CheckoutService.checkout(customerId, cartToken, idemKey) exist; Testcontainers + JUnit 5 available.
Task: Add a @SpringBootTest integration test backed by a Postgres Testcontainer, driven through the cart.
Requirements:
- Spin up postgres:16 via @Container; apply schema/migrations on start.
- Build a multi-line cart through CartService.addItem (fresh anonymous cart), then checkout with its token; assert total == sum(unitPrice*qty) and exact stock decrements; assert the cart is 'converted' with customer_id set.
- An over-quantity checkout throws OutOfStockException and leaves stock unchanged AND the cart 'active' (rolled back, claim included).
- Include the 20-buyer concurrency case (each thread its own single-line cart + distinct Idempotency-Key): exactly 1 success, 19 out-of-stock, final stock 0.
Tests / acceptance:
- `./gradlew test` passes; the container is reused per class.
Output: a unified diff plus the @DynamicPropertySource wiring of the datasource URL.
What success looks like

./gradlew test reports BUILD SUCCESSFUL: the @SpringBootTest boots against a postgres:16 Testcontainer, builds the cart through CartService, and asserts total == sum(unitPrice*qty), exact stock decrements, and the cart converted; an over-quantity checkout throws OutOfStockException and leaves stock unchanged with the cart still active (rolled back); the 20-thread race lands exactly 1 success + 19 out-of-stock — the same money path the Go test proves.

BUILD SUCCESSFUL in 24s

Show the catalog (Jetpack Compose)

Jetpack Compose Beginner

Build an Android list screen that fetches GET /products and renders name, price, and stock — the storefront the shopper actually sees.

New in this step
Ktor client + ContentNegotiation

A Kotlin HTTP client; the plugin auto-decodes JSON responses into your data classes.

ktor client content negotiation
@Serializable (kotlinx.serialization)

Marks a data class so the JSON (unitPrice) maps straight onto its fields.

kotlinx serialization serializable
@Composable

A function that describes a piece of UI; Compose redraws it when its inputs change.

jetpack compose composable
LazyColumn

A scrolling list that only composes the rows on screen, efficient for a catalog.

jetpack compose lazycolumn
10.0.2.2

The special address the Android emulator uses to reach the host machine’s localhost (where your API runs).

android emulator 10.0.2.2 localhost
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Format money only at the edge

The API sends integer cents; the client formats to a display string for display only. Keep every layer honest about the canonical integer. formatCents pins Locale.US so the format is stable regardless of device locale (a comma-decimal device would otherwise print $14,99); real localization would use NumberFormat.getCurrencyInstance(), out of scope here. The fetch uses the Ktor client with content negotiation, so the camelCase JSON (unitPrice) deserializes straight into the data class.

Client model + fetch (Ktor)
Run these in your terminal / editor
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable

// Model ALL the payload's fields: kotlinx.serialization rejects unknown JSON keys by default,
// so leaving category/images unmodelled would fail the decode, not just skip them.
@Serializable
data class Category(val id: Long, val slug: String, val name: String, val imageUrl: String)

@Serializable
data class Image(val url: String)

@Serializable
data class Product(val id: Long, val name: String, val unitPrice: Long, val stock: Int,
                   val category: Category, val images: List<Image>)

// baseUrl: from the Android emulator, "http://10.0.2.2:8080" reaches the host's localhost.
val api = HttpClient { install(ContentNegotiation) { json() } }

suspend fun fetchProducts(baseUrl: String): List<Product> =
    api.get("$baseUrl/products").body()

// Money is integer cents end to end; format only for display.
// Pin the locale so a comma-decimal device can't render "$14,99": Locale.US -> "$14.99".
fun formatCents(cents: Long): String = "$%,.2f".format(java.util.Locale.US, cents / 100.0)
Catalog screen (Compose)
Run these in your terminal / editor
// A failed fetch throws, so model the screen as one-of loading/ready/error — never a silent blank list.
sealed interface UiState {
    data object Loading : UiState
    data class Ready(val products: List<Product>) : UiState
    data class Error(val message: String) : UiState
}

@Composable
fun CatalogScreen(baseUrl: String) {
    var state by remember { mutableStateOf<UiState>(UiState.Loading) }
    LaunchedEffect(Unit) {
        state = try { UiState.Ready(fetchProducts(baseUrl)) }
                catch (e: Exception) { UiState.Error(e.message ?: "Failed to load catalog") }
    }
    when (val s = state) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Error   -> Text(s.message, color = MaterialTheme.colorScheme.error)
        is UiState.Ready   -> LazyColumn { items(s.products) { p -> ProductRow(p) } }
    }
}

@Composable
fun ProductRow(p: Product) {
    Row(Modifier.fillMaxWidth().padding(16.dp), Arrangement.SpaceBetween) {
        Column {
            Text(p.name, style = MaterialTheme.typography.titleMedium)
            Text(p.category.name, style = MaterialTheme.typography.labelSmall)
            Text(if (p.stock > 0) "In stock" else "Sold out",
                 color = if (p.stock > 0) Color.Unspecified else MaterialTheme.colorScheme.error)
        }
        Text(formatCents(p.unitPrice)) // e.g. "$14.99"
    }
    // p.images carries the gallery URLs (position-ordered) for a product-detail screen;
    // rendering them (e.g. with Coil's AsyncImage) is a natural extension, not required here.
}
What success looks like

The 3 seeded products render as rows showing name, category (e.g. “Drinkware”), and formatted price (e.g. “$14.99”); “Sold out” appears in the error color for zero-stock items; a failed fetch shows the UiState.Error message (not a silent blank list), while an empty catalog renders an empty list. The decode proves the full contract landed: category and images are modelled, so kotlinx.serialization accepts the payload.

Show the catalog (Flutter)

Flutter Beginner

Fetch GET /products with the http package and render a ListView of products — the storefront the shopper actually sees.

New in this step
http package

Dart’s simple HTTP client for calling your API.

flutter http package get docs ↗
jsonDecode + a fromJson factory

Parse the response string into a map, then build a typed Product from it.

flutter json parsing fromjson
FutureBuilder

A widget that rebuilds when an async call (the fetch) completes, so you can show a spinner then the list.

flutter futurebuilder
ListView

Flutter’s scrolling list of widgets for the catalog rows.

flutter listview
10.0.2.2

The address the Android emulator uses to reach the host machine’s localhost (where your API runs).

android emulator 10.0.2.2 localhost
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Fetch + list (Flutter)
Run these in your terminal / editor
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';

// From the Android emulator, "http://10.0.2.2:8080" reaches the host's localhost.
const baseUrl = 'http://10.0.2.2:8080';

class Category {
  final int id;
  final String slug;
  final String name;
  final String imageUrl;
  const Category({required this.id, required this.slug, required this.name, required this.imageUrl});
  factory Category.fromJson(Map<String, dynamic> j) => Category(
      id: j['id'] as int, slug: j['slug'] as String, name: j['name'] as String, imageUrl: j['imageUrl'] as String);
}

class Product {
  final int id;
  final String name;
  final int unitPrice; // integer cents
  final int stock;
  final Category category;
  final List<String> imageUrls; // from images: [{url}], position-ordered
  const Product({required this.id, required this.name, required this.unitPrice, required this.stock,
      required this.category, required this.imageUrls});
  factory Product.fromJson(Map<String, dynamic> j) => Product(
      id: j['id'] as int, name: j['name'] as String, unitPrice: j['unitPrice'] as int, stock: j['stock'] as int,
      category: Category.fromJson(j['category'] as Map<String, dynamic>),
      imageUrls: [for (final i in j['images'] as List) (i as Map<String, dynamic>)['url'] as String]);
}

// Money is integer cents end to end; format only for display.
String formatCents(int cents) => '\$${(cents / 100.0).toStringAsFixed(2)}';

Future<List<Product>> fetchProducts() async {
  final res = await http.get(Uri.parse('$baseUrl/products'));
  // Guard the status BEFORE decoding: a 500 returns a non-array body, so jsonDecode/`as List` would throw.
  if (res.statusCode != 200) {
    throw Exception('products failed: ${res.statusCode}');
  }
  final data = jsonDecode(res.body) as List;
  return data.map((j) => Product.fromJson(j as Map<String, dynamic>)).toList();
}

// in the widget tree:
FutureBuilder<List<Product>>(
  future: fetchProducts(),
  builder: (context, snap) {
    if (snap.hasError) return Text('Failed to load catalog: ${snap.error}');
    if (!snap.hasData) return const CircularProgressIndicator();
    return ListView(children: [
      for (final p in snap.data!)
        ListTile(
          title: Text(p.name),
          subtitle: Text('${p.category.name} · ${p.stock > 0 ? 'In stock' : 'Sold out'}'),
          trailing: Text(formatCents(p.unitPrice)),
          // p.imageUrls feeds a detail screen's gallery (e.g. Image.network) — optional here.
        ),
    ]);
  },
);
Add and pin the Flutter deps (http now, uuid at checkout)

The backend steps pin their versions (Go’s pgx, the spec’s pgx v5.10.0 / jackson-databind 2.18.2); the Flutter app should too. It uses two pub.dev packages — http (this catalog fetch and the checkout POST) and uuid (the checkout Idempotency-Key). Add both with flutter pub add, which writes the resolved caret constraint into pubspec.yaml so the pin is explicit and reproducible:

Add the Flutter deps
Run these in your terminal / editor
flutter pub add http    # -> pubspec.yaml: http: ^1.2.0  (use whatever pub add resolves)
flutter pub add uuid    # -> pubspec.yaml: uuid: ^4.0.0  (used by the checkout step)
What success looks like

The 3 seeded products render as ListTile rows — name, category plus “In stock”/“Sold out” (e.g. “Drinkware · In stock”), and the formatted price (e.g. “$14.99”); the category object and images list decode into the typed model. A failed fetch (non-200 status) surfaces via snapshot.hasError as a “Failed to load catalog” message rather than a spinner that never resolves; an empty catalog renders an empty list.

Show the catalog (SwiftUI)

SwiftUI Beginner

Fetch GET /products with URLSession and render a List, decoding integer cents with Codable — the storefront the shopper actually sees.

New in this step
URLSession

Apple’s built-in HTTP client; its async data(from:) fetches the products.

urlsession async await docs ↗
Codable

The protocol that auto-decodes matching JSON keys (unitPrice) into a Swift struct.

swift codable json
Identifiable

Lets List track each row by its id.

swiftui identifiable list
List

SwiftUI’s scrolling list view for the catalog rows.

swiftui list
.task

Runs an async job (the fetch) when the view appears.

swiftui task modifier
127.0.0.1

The iOS simulator shares the Mac’s network, so localhost/127.0.0.1 reaches your API directly (unlike the Android emulator’s 10.0.2.2).

ios simulator localhost
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Fetch + list (SwiftUI)
Run these in your terminal / editor
// baseURL: from the iOS simulator, "http://127.0.0.1:8080" reaches the host.
let baseURL = "http://127.0.0.1:8080"

// Money is integer cents end to end; format only for display.
func formatCents(_ cents: Int) -> String {
    return String(format: "$%.2f", Double(cents) / 100.0)
}

struct Category: Codable {
    let id: Int; let slug: String; let name: String; let imageUrl: String
}

struct ProductImage: Codable {
    let url: String
}

struct Product: Codable, Identifiable {
    let id: Int; let name: String; let unitPrice: Int; let stock: Int
    let category: Category; let images: [ProductImage] // position-ordered gallery references
}

func fetchProducts() async throws -> [Product] {
    let (data, _) = try await URLSession.shared.data(from: URL(string: "\(baseURL)/products")!)
    return try JSONDecoder().decode([Product].self, from: data)
}

struct CatalogView: View {
    @State private var products: [Product] = []
    @State private var errorMessage: String?
    var body: some View {
        List {
            if let errorMessage { Text(errorMessage).foregroundStyle(.red) }
            ForEach(products) { p in
                HStack {
                    VStack(alignment: .leading) {
                        Text(p.name)
                        Text("\(p.category.name) · \(p.stock > 0 ? "In stock" : "Sold out")").foregroundStyle(.secondary)
                    }
                    Spacer(); Text(formatCents(p.unitPrice))
                }
                // p.images feeds a detail screen's gallery (AsyncImage) — optional here.
            }
        }
        // Surface the error instead of swallowing it with `try?`-to-empty (with ATS fixed, a real failure shows here).
        .task {
            do { products = try await fetchProducts() }
            catch { errorMessage = "Failed to load catalog: \(error.localizedDescription)" }
        }
    }
}
Allow cleartext to your local API (ATS)

iOS App Transport Security blocks cleartext http by default, and unlike a browser it does not auto-exempt loopback — so a fetch to http://127.0.0.1:8080 throws NSURLErrorAppTransportSecurityRequiresSecureConnection. Add Apple’s documented local-networking exception to the app’s Info.plist so the simulator can reach your local API (this is a dev-only exception — a shipping app talks to your API over HTTPS):

Info.plist — allow local networking
Run these in your terminal / editor
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsLocalNetworking</key>
  <true/>
</dict>
Format money only at the edge

The API sends integer cents; formatCents renders a display string for display only, so every layer stays honest about the canonical integer (the same rule the Compose path follows). Note the .task here uses a do/catch rather than try?-to-empty: try? would collapse a real failure into an empty list, hiding the error — catch it and show it instead.

What success looks like

The 3 seeded products render as rows — name, category plus “In stock”/“Sold out” (e.g. “Drinkware · In stock”), and the formatted price (e.g. “$14.99”); the nested category and images decode via Codable. With the NSAllowsLocalNetworking exception in place, a failed fetch surfaces the error message row rather than silently emptying the list; an empty catalog renders no product rows.

Wire the cart and the checkout button (Jetpack Compose)

Jetpack Compose Intermediate

Add lines with POST /cart/items, persisting the token the server returns in the X-Cart-Token response header — then let the checkout button POST {customerId} with that token and an Idempotency-Key, so the shopper can actually buy and a retry is safe.

New in this step
persist the X-Cart-Token response header

The first POST /cart/items (sent without a token) returns the cart’s token in a response header — response.headers["X-Cart-Token"] in Ktor. Store it (in-memory + DataStore) and send it on every later cart/checkout call.

ktor client read response header
UUID for the Idempotency-Key

Generate one random id per checkout attempt and reuse it on retry, so a resend returns the original order instead of a duplicate (java.util.UUID.randomUUID()).

kotlin uuid randomUUID
ViewModel

The Android class that holds screen state and survives configuration changes (e.g. rotation), keeping the token and the in-flight checkout out of the composable.

android viewmodel docs ↗
UI state machine

Model the screen as one-of {idle, loading, success(orderId), outOfStock} so a 409 can’t be mistaken for success.

compose ui state sealed class
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Android engineer (Kotlin, Jetpack Compose) in this repo.
Context: The cart is SERVER-side. POST /cart/items {productId, qty} — the X-Cart-Token request header is optional on the FIRST add (the server creates an anonymous cart and returns its token in the X-Cart-Token RESPONSE header); every later cart/checkout call sends the token. GET /cart returns {total, updatedAt, items:[...]}. POST /checkout takes {customerId} + the X-Cart-Token header + an optional Idempotency-Key; returns 200 {orderId}, 409 {error:"out_of_stock"}, 404 {error:"cart_not_found"}, or 422. GET /orders/{id} reads the confirmation.
Task: Add add-to-cart and checkout actions to the catalog/cart screens.
Requirements:
- "Add to cart": POST /cart/items; on the first add (no stored token) read response.headers["X-Cart-Token"] and persist it (ViewModel + DataStore); send it on every later call. Render the returned cart body (total, items) as the cart screen.
- Checkout button: POST /checkout with {customerId}, the stored token, and one UUID Idempotency-Key per attempt, reused across retries.
- Loading state; on 200 navigate to a confirmation screen that reads GET /orders/{orderId}; on 409 show "out of stock" in place and refresh the cart; on 404 cart_not_found (e.g. the order already went through on a lost response) clear the stored token and refresh.
Tests / acceptance:
- ViewModel unit test: the first add stores the token from the response header and the second add sends it as a request header.
- A 409 sets an OutOfStock UI state and does not transition to success.
Output: a unified diff plus the ViewModel state machine.
What success looks like

The first “Add to cart” creates the server-side cart and the app stores the token from the X-Cart-Token response header; later adds send it back and the cart screen renders the server-computed total and items live. Tapping Checkout posts {customerId} with the token and navigates to a confirmation screen showing the orderId (read back from GET /orders/{id}); a 409 shows the out-of-stock message in place and does not navigate — re-sending with the same Idempotency-Key returns the original order, not a duplicate, even though the successful checkout converted the cart.

Wire the cart and the checkout button (Flutter)

Flutter Intermediate

Add lines with POST /cart/items, persisting the token from the X-Cart-Token response header — then let the checkout button POST {customerId} with that token and an Idempotency-Key, so the shopper can actually buy and a retry is safe.

New in this step
persist the X-Cart-Token response header

The first POST /cart/items (sent without a token) returns the cart’s token in a response header — response.headers['x-cart-token'] with package:http (it lowercases header names). Store it and send it on every later cart/checkout call.

dart http response headers
UUID for the Idempotency-Key

Generate one random id per checkout attempt and reuse it on retry (the uuid package added at the catalog step), so a resend returns the original order, not a duplicate.

flutter uuid package
state notifier (Riverpod / Bloc)

A class that holds the token and checkout state and notifies the UI on change, modelled as one-of {idle, loading, success(orderId), outOfStock} so a 409 can’t read as success.

flutter riverpod state notifier docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Flutter engineer (Dart) in this repo.
Context: The cart is SERVER-side. POST /cart/items {productId, qty} — the X-Cart-Token request header is optional on the FIRST add (the server creates an anonymous cart and returns its token in the X-Cart-Token RESPONSE header; package:http exposes it as response.headers['x-cart-token']); every later cart/checkout call sends the token. GET /cart returns {total, updatedAt, items:[...]}. POST /checkout takes {customerId} + the X-Cart-Token header + an optional Idempotency-Key; returns 200 {orderId}, 409 {error:"out_of_stock"}, 404 {error:"cart_not_found"}, or 422. GET /orders/{id} reads the confirmation.
Task: Add add-to-cart and checkout actions with proper state handling (e.g. a Riverpod/Bloc notifier).
Requirements:
- "Add to cart": POST /cart/items; on the first add (no stored token) read and persist the response header; send it on every later call. Render the returned cart body (total, items) as the cart screen.
- Checkout: POST /checkout with {customerId}, the stored token, and one UUID Idempotency-Key per attempt, reused on retry.
- Loading/success/error states; on 200 navigate to a confirmation that reads GET /orders/{orderId}; on 409 show an out-of-stock message and refresh the cart; on 404 cart_not_found clear the stored token and refresh.
Tests / acceptance:
- A unit test on the notifier: the first add stores the token from the response header and the second add sends it as a request header.
- A 409 response yields an OutOfStock state and no success state transition.
Output: a unified diff plus the state model.

Wire the cart and the checkout button (SwiftUI)

SwiftUI Intermediate

Add lines with POST /cart/items, persisting the token from the X-Cart-Token response header — then let the checkout button POST {customerId} with that token and an Idempotency-Key, updating an @Observable model with the result, so the shopper can actually buy and a retry is safe.

New in this step
persist the X-Cart-Token response header

The first POST /cart/items (sent without a token) returns the cart’s token in a response header — (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "X-Cart-Token"). Store it and send it on every later cart/checkout call.

urlsession httpurlresponse value forHTTPHeaderField
UUID for the Idempotency-Key

Generate one UUID() per checkout attempt and reuse it on retry, so a resend returns the original order, not a duplicate.

swift uuid
@Observable

The Swift macro that makes a model class publish changes to SwiftUI, so the view updates when the cart or checkout result lands.

swift observable macro docs ↗
@MainActor

Guarantees UI state updates run on the main thread, avoiding races when the async POST returns.

swift mainactor
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: iOS engineer (Swift, SwiftUI, Swift Concurrency) in this repo.
Context: The cart is SERVER-side. POST /cart/items {productId, qty} — the X-Cart-Token request header is optional on the FIRST add (the server creates an anonymous cart and returns its token in the X-Cart-Token RESPONSE header; read it via HTTPURLResponse.value(forHTTPHeaderField:)); every later cart/checkout call sends the token. GET /cart returns {total, updatedAt, items:[...]}. POST /checkout takes {customerId} + the X-Cart-Token header + an optional Idempotency-Key; returns 200 {orderId}, 409 {error:"out_of_stock"}, 404 {error:"cart_not_found"}, or 422. GET /orders/{id} reads the confirmation.
Task: Add async add-to-cart and checkout to an @Observable CartModel.
Requirements:
- "Add to cart": POST /cart/items; on the first add (no stored token) read and persist the response header; send it on every later call. Render the returned cart body (total, items).
- Checkout: POST /checkout with {customerId}, the stored token, and one UUID Idempotency-Key per attempt, reused on retry; @MainActor state updates.
- Loading/success/error; on 200 expose orderId and show the confirmation (read GET /orders/{id}); on 409 set an outOfStock flag and refresh the cart; on 404 cart_not_found clear the stored token and refresh.
Tests / acceptance:
- A unit test: the first add stores the token from the stubbed response header and the second add sends it as a request header.
- A stubbed 409 sets outOfStock and does not set orderId.
Output: a unified diff plus the CartModel definition.

Deploy to Cloud Run + Cloud SQL

Advanced

Push the container to Cloud Run and connect it to a managed Cloud SQL Postgres instance — the same build you ran locally, now hosted with backups and TLS handled for you. (Optional; the only step that can cost money.)

New in this step
Cloud Run

Runs your container as a serverless HTTP service; it injects PORT and you don’t manage servers.

cloud run quickstart docs ↗
scale-to-zero

Cloud Run drops to zero instances when idle, so the container costs nothing when parked — but Cloud SQL does not scale to zero and bills continuously, so a parked deploy is not free.

cloud run scale to zero
Cloud SQL

Managed Postgres with patching, backups, and TLS handled for you.

cloud sql postgres
gcloud run deploy --source . (Cloud Buildpacks)

Builds the container image from your source automatically, so no Dockerfile is required.

cloud run deploy source buildpacks
Cloud SQL connector / SocketFactory

Connects over an authenticated socket instead of a raw password URL; the Spring path adds the postgres-socket-factory dependency.

cloud sql connector socketfactory
db-f1-micro

The smallest (shared-core) Cloud SQL tier and the only part of this course that can incur cost. Cloud SQL bills continuously (~$7–10/mo — $300 trial credits cover it) and must be deleted when you’re done. Note PG16+ defaults to the Enterprise Plus edition, which has no shared-core tier, so pass --edition=ENTERPRISE to keep db-f1-micro.

cloud sql machine type db-f1-micro
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Managed Postgres, serverless API

Cloud SQL runs Postgres with backups and patching handled; Cloud Run scales the container to zero when idle and connects over the Cloud SQL connector (no password in the image). Only the connection env differs by path: the Go binary reads the libpq DATABASE_URL (from Secret Manager), while the Spring jar reads its own JDBC_DATABASE_URL plus DB_USER/DB_PASSWORD — pointed at Cloud SQL through the Cloud SQL JDBC SocketFactory (add the postgres-socket-factory dependency; see the Cloud SQL connector docs).

A few things the raw run deploy line hides: the instance ships with neither your aurora database nor a DB user, so you create both, then migrate the provisioned DB before it serves traffic. Pass the DSN via --set-secrets (Secret Manager), never inline --set-env-vars plaintext. The connector DSN omits sslmode because host=/cloudsql/... is an authenticated unix socket, not a TCP connection.

Cost + edition caveats: Cloud SQL does not scale to zero — it bills continuously (~$7–10/mo, covered by the $300 trial credits) and must be deleted (gcloud sql instances delete) when you’re done, or a parked demo keeps charging. And as of 2026, PostgreSQL 16+ defaults to the Enterprise Plus edition, which has no shared-core tier, so --tier=db-f1-micro is rejected unless you also pass --edition=ENTERPRISE.

Deploy
Run these in your terminal / editor
# 1) Instance. PG16+ defaults to Enterprise Plus (no shared-core tier), so pin --edition=ENTERPRISE
#    to keep the db-f1-micro shared-core tier — otherwise the create is rejected.
gcloud sql instances create aurora-pg --database-version=POSTGRES_16 \
  --edition=ENTERPRISE --tier=db-f1-micro --region=us-central1

# 2) Database + a DB user (the instance ships with neither for your app).
gcloud sql databases create aurora --instance=aurora-pg
gcloud sql users create aurora_app --instance=aurora-pg
gcloud sql users set-password aurora_app --instance=aurora-pg --prompt-for-password

# 3) Keep secrets out of plaintext: store them in Secret Manager, not --set-env-vars.
#    Go path: the whole libpq DSN (user:password embedded). The connector DSN omits sslmode
#    (host=/cloudsql/... is an authenticated unix socket).
printf 'postgres://aurora_app:PW@/aurora?host=/cloudsql/PROJECT:us-central1:aurora-pg' \
  | gcloud secrets create aurora-dsn --data-file=-
#    Spring path: just the password (DB_USER/DB_NAME travel as env; see the Spring line below).
printf 'PW' | gcloud secrets create aurora-db-password --data-file=-

# 4) Migrate the provisioned DB before it serves traffic (run against the same secret DSN):
migrate -path db/migrations -database "$(gcloud secrets versions access latest --secret=aurora-dsn)" up

# 5) Deploy, injecting the DSN from Secret Manager (NOT inline plaintext).
gcloud run deploy aurora-api \
  --source . \
  --add-cloudsql-instances PROJECT:us-central1:aurora-pg \
  --set-secrets DATABASE_URL=aurora-dsn:latest \
  --region us-central1 --allow-unauthenticated

# Spring path instead: keep JDBC_DATABASE_URL + DB_USER/DB_NAME as env, but source the password from Secret Manager:
#   --set-env-vars JDBC_DATABASE_URL="jdbc:postgresql:///aurora?cloudSqlInstance=PROJECT:us-central1:aurora-pg&socketFactory=com.google.cloud.sql.postgres.SocketFactory",DB_USER=aurora_app,DB_NAME=aurora --set-secrets DB_PASSWORD=aurora-db-password:latest

# 6) STOP BILLING when you're done — Cloud SQL does not scale to zero:
gcloud sql instances delete aurora-pg

Parse a recipe into ingredients with Gemini

Optional add-on Intermediate

Send a free-text recipe to Gemini and get back a structured JSON list of ingredients using a response schema — so the AI’s output is typed data your code can trust, not prose you have to parse.

New in this step
Gemini

Google’s family of LLMs; you call it over an HTTP API from your server.

gemini api overview docs ↗
generateContent

The core Gemini call: send a prompt, get a response.

gemini generatecontent
structured output (responseMimeType + responseSchema)

Constrain the model to emit JSON matching a schema, turning a fuzzy task into a typed array — no brittle string parsing.

gemini structured output responseschema
GOOGLE_API_KEY

The env var the genai SDKs read for your free AI Studio key; keep it server-side — the mobile app calls your endpoint, never Gemini directly.

google ai studio api key
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Structured output makes the AI safe to consume

Gemini can be constrained to emit JSON matching a schema (responseMimeType: "application/json" + responseSchema). That turns a fuzzy “read this recipe” task into a typed array your code can trust — no brittle string parsing. Keep the API key server-side; the mobile app calls your endpoint, never Gemini directly.

The amount comes back as a string — normalise it to an integer before the cart

Gemini types the parsed quantity as a string (the schema above), because a recipe line reads “2 eggs”, “200g flour”, or “a pinch of salt”. But everything downstream is integer-typed — the cart’s POST /cart/items decoder and cart_items.qty (INTEGER NOT NULL CHECK (qty > 0)), order_items.quantity (INTEGER NOT NULL CHECK (quantity > 0)) — so a string quantity fails to unmarshal and never reaches the cart or an order. Bridge the gap before the item enters the cart (this is built in the /cart/from-recipe step): round a leading numeric amount to its integer count, and default a non-numeric amount (“a pinch of salt”) to 1 for the shopper to adjust. Emit quantity as a JSON number, never a string, so "a pinch of salt" becomes 1. The parse step keeps the string amount as-is; the normalisation lives on the recipe → cart path.

Ask Gemini for structured ingredients
Run these in your terminal / editor
System: Extract the shopping ingredients from a recipe. Return ONLY JSON.
User: <the recipe text>

responseSchema (conceptual):
{ "type": "array", "items": {
    "type": "object",
    "properties": { "name": {"type":"string"}, "quantity": {"type":"string"} },
    "required": ["name"] } }
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Gemini integration engineer. The reader has no repo here — return complete code.
Context: Server-side handler in the user's selected backend; GOOGLE_API_KEY in env (the genai SDKs read this).
Task: Implement parseRecipe(recipeText) that calls Gemini's generateContent with a JSON response schema and
returns a typed list of {name, quantity?}.
Requirements:
- Use responseMimeType="application/json" + a responseSchema for an array of {name, quantity?}.
- Keep the key server-side; time out after 20s; validate the JSON before returning.
- Link to the official schema docs rather than hardcoding a model name that may change.
Tests / acceptance (describe):
- "2 eggs, 200g flour, a pinch of salt" yields >= 3 ingredients with names eggs/flour/salt.
- Malformed model output is rejected, not returned raw.
Output: the complete handler, no commentary.

Match ingredients to catalog products

Optional add-on Intermediate

Resolve each ingredient name to a real product with a case-insensitive search, ranking by best match — so a noisy “2 eggs” maps to the right catalog row, with a confidence the shopper can confirm.

New in this step
ILIKE

A case-insensitive LIKE for simple substring matching — the honest first pass.

postgres ilike docs ↗
pg_trgm extension

Adds trigram matching to Postgres; enable it once with CREATE EXTENSION IF NOT EXISTS pg_trgm.

postgres pg_trgm extension
trigram similarity

Compares two strings by their 3-character chunks to score fuzzy matches (handles typos and plurals); similarity(name, $1) returns that score.

postgres trigram similarity
% operator

pg_trgm’s is-similar-to test, used in the WHERE to keep only plausible matches.

pg_trgm percent operator
confidenceX1000 (scaled-integer confidence)

The trigram similarity of the match, scaled ×1000 to an integer (round(similarity(...) * 1000)::bigint) so no raw float reaches the wire; the client reads it to confirm a fuzzy hit instead of silently adding the wrong product. A match keeps confidenceX1000 >= 300 (similarity ≥ 0.3).

fuzzy match confidence threshold
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Keep matching in Postgres, where the catalog lives — with pg_trgm

Match in Postgres, where the catalog lives. This step teaches pg_trgm trigram similarity — the spec defines confidenceX1000 as the trigram similarity score scaled ×1000 to an integer, backed by a GIN index — so the query below requires the extension (similarity() / %). ILIKE/full-text search is the honest first mention and embeddings a later alternative, but the chosen approach here is trigram. Return the top match per ingredient plus a scaled-integer confidenceX1000 so the UI can let the shopper confirm fuzzy hits — never silently add the wrong product. The SQL uses Postgres positional placeholders ($1); on the Spring path, JdbcTemplate binds with ? instead, so swap the placeholder when you wire it.

Score -> confidenceX1000 -> threshold: how the number flows

similarity(name, $1) returns a score in 0..1; the % operator in the WHERE keeps only rows above pg_trgm.similarity_threshold (default 0.3, tunable with SET pg_trgm.similarity_threshold = ...). The endpoint scales that score ×1000 to an integer — confidenceX1000 = round(similarity(name, $1) * 1000)::bigint — so no raw float reaches the wire (a Go/Jackson byte-parity hazard, the same scaling the analytics ratios use): Go marshals the int64, Spring/Kotlin the Long, byte-identical by construction. An ingredient whose best score is below the threshold (confidenceX1000 < 300) goes into unmatched[] instead of being guessed — the rule is born here, where the score exists.

Enable the extension (once, before the query)
Run these in your terminal / editor
CREATE EXTENSION IF NOT EXISTS pg_trgm;   -- required: without it, similarity()/% raise "function similarity(text, unknown) does not exist"
Fuzzy match in SQL
Run these in your terminal / editor
SELECT id, name, round(similarity(name, $1) * 1000)::bigint AS confidence_x1000
FROM products
WHERE name % $1            -- trigram "is similar": keeps rows above pg_trgm.similarity_threshold (default 0.3)
ORDER BY confidence_x1000 DESC
LIMIT 3;

Add matched products to the cart

Optional add-on Intermediate

Expose an endpoint that takes a recipe, runs parse → match, and returns a proposed cart for the shopper to confirm — never auto-filling it, so a wrong match is always a human decision.

New in this step
proposal (no auto-mutation)

The endpoint returns a suggested cart the client confirms, rather than changing server state; the platform’s no-auto-actions rule, so a fuzzy match can’t silently add the wrong product.

human in the loop confirmation api
unmatched[]

Ingredients with no confident product match are returned in their own list, not silently dropped, so the shopper sees what was skipped.

partial match results api design
confidence threshold

The minimum match strength to count as a hit — compared as the scaled integer confidenceX1000 >= 300 (similarity ≥ 0.3); below it, the ingredient goes to unmatched instead of being guessed.

similarity score threshold
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The seeded catalog is swag — pick a matching phrase for the demo

The seed is store swag — Aurora Mug, Aurora Tee, Aurora Sticker Pack — so a literal food ingredient (“eggs”, “flour”) matches nothing and lands in unmatched[]. That is correct behaviour, but it makes for a dull demo: to see a match, feed the recipe a phrase that trigram-matches a catalog name (e.g. a line mentioning “mug” scores against “Aurora Mug”). In a real store the catalog would hold groceries; here, use a catalog-matching phrase so the proposal comes back non-empty.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: parseRecipe (Gemini) and the SQL fuzzy-match query both exist.
Task: Add POST /cart/from-recipe { recipe: string } -> { items: [{productId, name, quantity, confidenceX1000}], unmatched: [string] }.
Requirements:
- Call parseRecipe, then match each ingredient via the trigram query (top 1). confidenceX1000 = round(similarity(name, $1) * 1000)::bigint — the trigram similarity scaled x1000 to an INTEGER (Go int64 / Kotlin Long), so no raw float reaches the wire and both backends are byte-identical.
- Normalise each parsed string amount to an integer quantity >= 1 BEFORE the item enters the cart / proposal: round a leading numeric amount to its integer count; default a non-numeric amount ("a pinch of salt") to 1. Emit quantity as a JSON number, never a string (the int-typed downstream decoders + order_items.quantity INTEGER CHECK(quantity>0) reject a string).
- Ingredients with no match (score below a threshold) go in `unmatched`, NOT silently dropped.
- Return 422 {error:"invalid_request"} for an empty/blank recipe (no Gemini call); 502 {error:"upstream_unavailable"} when Gemini fails or times out (map the 20s timeout to 502).
- Do NOT mutate the cart server-side; return a PROPOSAL the client confirms (respects "no auto-actions"). Confirmed items enter the cart through the normal POST /cart/items (§5.2); this endpoint never writes.
Tests / acceptance:
- A recipe ingredient that matches the seeded catalog (e.g. a catalog-matching phrase) returns it with confidenceX1000 >= 300 (similarity >= 0.3), an integer.
- "a pinch of salt" normalises to quantity 1 (a JSON number), not the raw string.
- A nonsense ingredient appears in `unmatched`.
- An empty recipe -> 422; a simulated Gemini timeout -> 502.
Output: a unified diff plus the matching threshold and why.
What success looks like

POST /cart/from-recipe { "recipe": "<a catalog-matching line + a pinch of salt>" } returns 200 with items[] (each {productId, name, quantity, confidenceX1000}) and unmatched[]: every quantity is an integer >= 1 (a JSON number — “a pinch of salt” -> 1), every confidenceX1000 is an integer in [0, 1000] (the trigram similarity scaled ×1000 — no raw float on the wire, byte-identical on Go and Spring), and the cart is not mutated (it stays a proposal the client confirms). An empty/blank recipe returns 422 {"error":"invalid_request"} with no Gemini call; a Gemini failure or 20s timeout returns 502 {"error":"upstream_unavailable"}.

Declare read-only support tools for Gemini

Optional add-on Intermediate

Give Gemini three typed, read-only function declarations — get_order_status, check_stock, estimate_restock — and let the model pick which to call for a shopper’s question, so the model reasons over your real data without ever touching it directly.

New in this step
function calling

The model chooses a tool and arguments but runs nothing; your server executes the matching query and feeds the result back, then the model writes a grounded answer.

gemini function calling docs ↗
FunctionDeclaration

How you describe a tool to the model: a name, a description the model reads to decide, and typed parameters.

gemini functiondeclaration
JSON-Schema parameters

The typed shape of a tool’s inputs (e.g. order_id: integer, required), so the model returns well-formed arguments.

json schema object properties required
read-only tools

Every tool here only SELECTs; the worst case is a wrong read, never a wrong write — writes are gated separately later.

llm tool use safety read only
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Function calling: declare, model picks, you execute, feed back, grounded answer

Function calling lets the model choose a tool, not run it. You send Gemini the question plus typed function declarations (name, description, JSON-Schema parameters). The model replies with a function call — a tool name and arguments — but executes nothing. Your server runs the matching query against Postgres and sends the result back as a function response; the model then writes a grounded, natural-language answer. Every tool here is read-only, so the worst case is a wrong read, never a wrong write. Keep the loop and the key server-side. Costs nothing — a free Google AI Studio key (free tier) is all you need. Official guide: https://ai.google.dev/gemini-api/docs/function-calling

The three read-only tool declarations (conceptual JSON-Schema)
Run these in your terminal / editor
get_order_status(order_id: integer)    -> { status, created_at, total }
check_stock(product_id: integer)       -> { name, stock }
estimate_restock(product_id: integer)  -> { eta_days }   # heuristic from history

# Each is a FunctionDeclaration:
{ "name": "get_order_status",
  "description": "Look up the current status of an order by its id.",
  "parameters": { "type": "object",
    "properties": { "order_id": { "type": "integer" } },
    "required": ["order_id"] } }
Model-facing snake_case vs the camelCase HTTP wire — a deliberate carve-out

status is one of the §4.1 enumerated order states (e.g. pending / shipped / cancelled), so the model gets a closed value set to reason over, not free text. The tool param and return names here are model-facing snake_case (order_id, product_id, created_at, eta_days) — deliberately distinct from the camelCase public HTTP wire the mobile app sees. That is intentional: function declarations read best in snake_case for the model, and they never leave the server. (This return uses created_at for the same field the wire calls createdAt — one name for the concept on the model side, not a third alias.)

Build the support endpoint (Go function calling)

Optional add-on Intermediate

Implement POST /support in Go — declare the tools, let Gemini choose one, execute the read-only query, and return the model’s grounded answer — driving the loop yourself so the server, not the model, decides what runs.

New in this step
google.golang.org/genai

The current unified Go SDK for Gemini (the older generative-ai-go is deprecated); it reads GOOGLE_API_KEY.

google.golang.org/genai go sdk docs ↗
manual function-calling loop

You read the model’s chosen call, run it, send the result back, and ask again — versus auto-execution; manual is what keeps writes impossible.

gemini manual function calling loop
FunctionCall / FunctionResponse

The model returns a FunctionCall (tool + args); you reply with a FunctionResponse (the query result) so it can answer with real data.

gemini functioncall functionresponse
don't hardcode the model id

Ids change; read it from config (e.g. SUPPORT_MODEL, default gemini-2.5-flash) and check the official model list before deploying.

gemini models list
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The manual loop with google.golang.org/genai

Use the current unified SDK, google.golang.org/genai (the old generative-ai-go module is deprecated). You drive the loop yourself, which is what keeps writes impossible: you only ever dispatch the three read-only functions. The SDK reads GOOGLE_API_KEY (a free AI Studio key — costs nothing); keep it server-side. Don’t hardcode a model id — ids change; read the model name from an env var (e.g. SUPPORT_MODEL, default gemini-2.5-flash) and verify against the official list before deploying. Models: https://ai.google.dev/gemini-api/docs/models

Function-calling essentials (pgx + genai)
Run these in your terminal / editor
// support/agent.go (essentials) — google.golang.org/genai
client, _ := genai.NewClient(ctx, &genai.ClientConfig{
	APIKey:  os.Getenv("GOOGLE_API_KEY"), // free AI Studio key, server-side only
	Backend: genai.BackendGeminiAPI,
})

tools := []*genai.Tool{{FunctionDeclarations: []*genai.FunctionDeclaration{{
	Name:        "get_order_status",
	Description: "Look up the current status of an order by its id.",
	Parameters: &genai.Schema{
		Type:       genai.TypeObject,
		Properties: map[string]*genai.Schema{"order_id": {Type: genai.TypeInteger}},
		Required:   []string{"order_id"},
	},
}}}} // ...plus check_stock and estimate_restock, declared the same way

cfg := &genai.GenerateContentConfig{Tools: tools}
resp, _ := client.Models.GenerateContent(ctx, model, contents, cfg) // contents = the user's question

for _, fc := range resp.FunctionCalls() { // the model chose a tool; we execute it
	result := dispatchReadOnly(ctx, pool, fc.Name, fc.Args)          // SELECT-only, against Postgres
	part := genai.NewPartFromFunctionResponse(fc.Name, result)
	// Ground the next turn: append BOTH the model's call turn AND our function response,
	// then re-call. Re-calling with the unchanged `contents` would leave the model unable to
	// see its own call or the result — it compiles but silently fails to ground the answer.
	contents = append(contents,
		resp.Candidates[0].Content,                                    // the model's function-call turn
		genai.NewContentFromParts([]*genai.Part{part}, genai.RoleUser), // our FunctionResponse
	)
	resp, _ = client.Models.GenerateContent(ctx, model, contents, cfg)
}
// resp now holds the grounded, natural-language answer
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Senior Go engineer integrating Gemini function calling. The reader has no repo here — return complete code.
Context: net/http API over Postgres (pgxpool as pool); module google.golang.org/genai; free Google AI Studio key in GOOGLE_API_KEY (server-side).
Task: Implement POST /support {question:string} that runs ONE function-calling loop and returns a 200 UNION discriminated by `requiresConfirmation`.
Requirements:
- Declare exactly three READ-ONLY tools: get_order_status(order_id), check_stock(product_id), estimate_restock(product_id); each runs a parameterised SELECT against Postgres.
- Send the question + tools via client.Models.GenerateContent; for each returned FunctionCall, append BOTH the model's call turn AND your FunctionResponse to `contents`, then call GenerateContent again for the grounded answer (re-calling with the unchanged `contents` leaves the model unable to ground its answer — a silent failure).
- 200 is a union of two shapes: {answer:string} (requiresConfirmation absent) for a plain reply, OR {action, orderId, summary, requiresConfirmation:true} when the shopper asks to cancel/refund. Build the proposal arm here (or leave it to the cancel-gate step — see that step).
- 422 {error:"invalid_request"} for a blank question; 502 {error:"upstream_unavailable"} when Gemini fails or times out (map the 20s timeout to 502).
- NEVER declare or execute a write (cancel/refund) here — reads only; cancel/refund is proposed, not called.
- Key stays server-side; 20s timeout; do NOT hardcode a model id — read it from config and link the official model list.
- Follow the official function-calling guide for exact Content/Part construction: https://ai.google.dev/gemini-api/docs/function-calling
Tests / acceptance:
- "where is order 4821?" triggers get_order_status and the answer reflects the real DB row.
- "is the Aurora Tee in stock?" triggers check_stock; an unknown product yields a graceful "not found", not a crash.
- A blank question -> 422; a simulated Gemini timeout -> 502.
- No code path mutates the database.
Output: the complete handler, no commentary.
What success looks like

POST /support {"question":"where is order 4821?"} returns 200 with an answer body ({answer:string}, no requiresConfirmation) grounded in the real DB row — the loop appended the model’s call turn and the function response, so the answer reflects live data, not trace_id:""-style emptiness. A cancel/refund question returns the proposal arm ({action, orderId, summary, requiresConfirmation:true}) instead, and mutates nothing. A blank question returns 422 {"error":"invalid_request"}; a Gemini failure or 20s timeout returns 502 {"error":"upstream_unavailable"}. No code path writes to the database.

Build the support endpoint (Spring/Kotlin function calling)

Optional add-on Intermediate

Implement the same POST /support in Spring Boot (Kotlin) with the official com.google.genai Java SDK, driving the loop manually so the server, not the model, stays in control of what runs.

New in this step
com.google.genai (google-genai)

The official Java/Kotlin Gemini SDK; add com.google.genai:google-genai (latest from Maven Central); it reads GOOGLE_API_KEY.

google-genai java sdk docs ↗
Automatic Function Calling

The SDK can auto-execute a method it picks; avoid it here so your server decides what runs and can refuse writes.

java genai automatic function calling
manual loop

Read response.functionCalls(), run the query yourself, send a FunctionResponse, then call generateContent again for the grounded answer.

gemini manual function calling
FunctionCall / FunctionResponse

The model returns the chosen tool + args; you reply with the query result so it answers with real data.

gemini functioncall functionresponse
don't hardcode the model id

Ids change; read it from config and check the official model list before deploying.

gemini models list
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Manual loop, not automatic function calling

The Java SDK can auto-execute a method it picks (Automatic Function Calling). Don’t use that here — drive the loop manually so your server decides what runs and can refuse writes. Add com.google.genai:google-genai (take the latest from Maven Central); the SDK reads GOOGLE_API_KEY (free AI Studio key — costs nothing), kept server-side. Same model-id rule: don’t pin it. Javadoc: https://googleapis.github.io/java-genai/javadoc/

Function-calling essentials (JdbcTemplate + google-genai)
Run these in your terminal / editor
// SupportService.kt (essentials) — com.google.genai
val client = Client.builder().apiKey(System.getenv("GOOGLE_API_KEY")).build()

val tools = Tool.builder().functionDeclarations(
    FunctionDeclaration.builder()
        .name("get_order_status")
        .description("Look up the current status of an order by its id.")
        .parameters(orderIdSchema) // object { order_id: integer } — build with Schema.builder()
        .build(),
    // ...check_stock, estimate_restock declared the same way
).build()

val cfg = GenerateContentConfig.builder().tools(tools).build()
var resp = client.models.generateContent(model, question, cfg)

for (call in resp.functionCalls()) {                               // the model chose a tool
    val result = dispatchReadOnly(jdbc, call.name().get(), call.args().get()) // SELECT-only
    val fr = FunctionResponse.builder()
        .name(call.name().get())
        .response(mapOf("output" to result))
        .build()
    // append the model's function-call turn AND this FunctionResponse, then ask again
    // (appending only fr leaves the model unable to see its own call — it can't ground the answer)
    resp = client.models.generateContent(model, contentsWith(resp, fr), cfg)
}
// resp.text() now holds the grounded answer
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Senior Kotlin/Spring engineer integrating Gemini function calling. The reader has no repo here — return complete code.
Context: Spring Boot 3 (Kotlin), JdbcTemplate over Postgres; dependency com.google.genai:google-genai (latest from Maven Central); free Google AI Studio key in GOOGLE_API_KEY (server-side).
Task: Implement POST /support {question:string} that runs ONE manual function-calling loop and returns a 200 UNION discriminated by `requiresConfirmation`.
Requirements:
- Declare exactly three READ-ONLY tools: get_order_status(order_id), check_stock(product_id), estimate_restock(product_id); each runs a parameterised SELECT via JdbcTemplate.
- Use the MANUAL loop (not Automatic Function Calling): read response.functionCalls(), execute, append BOTH the model's function-call turn AND your FunctionResponse to the contents, then call generateContent again for the grounded answer.
- 200 is a union of two shapes: {answer:string} (requiresConfirmation absent) for a plain reply, OR {action, orderId, summary, requiresConfirmation:true} when the shopper asks to cancel/refund. Build the proposal arm here (or leave it to the cancel-gate step — see that step). Keep this at parity with the Go /support endpoint.
- 422 {error:"invalid_request"} for a blank question; 502 {error:"upstream_unavailable"} when Gemini fails or times out (map the 20s timeout to 502).
- NEVER declare or execute a write (cancel/refund) here — reads only; cancel/refund is proposed, not called.
- Key stays server-side; do NOT hardcode a model id — read it from config and link the official model list.
- Build the parameter schemas and Content per the official Javadoc: https://googleapis.github.io/java-genai/javadoc/
Tests / acceptance:
- "where is order 4821?" triggers get_order_status; the answer reflects the real row.
- An unknown product id yields a graceful "not found", not an exception to the caller.
- A blank question -> 422; a simulated Gemini timeout -> 502.
- No code path mutates the database.
Output: the complete service + controller, no commentary.
What success looks like

Parity with the Go /support: POST /support {"question":"where is order 4821?"} returns 200 with an answer body ({answer:string}, no requiresConfirmation) grounded in the real row — the manual loop appended the model’s function-call turn and the FunctionResponse before re-asking. A cancel/refund question returns the proposal arm ({action, orderId, summary, requiresConfirmation:true}) and mutates nothing. A blank question returns 422 {"error":"invalid_request"}; a Gemini failure or 20s timeout returns 502 {"error":"upstream_unavailable"}. No code path writes to the database.

Gate writes behind explicit human confirmation

Optional add-on Intermediate

When a shopper asks to cancel or refund, the agent must return a confirmation proposal, never perform the mutation — so an AI can never change order state on its own; only a human-confirmed call can.

New in this step
read-vs-write boundary

Reads can run automatically, but a model that can cancel orders on its own is a liability; so cancel_order/refund_order are not callable tools.

llm tool use write safety
proposal with requiresConfirmation: true

Instead of acting, the agent returns { action, orderId, summary, requiresConfirmation: true } and the UI shows a confirm button.

human in the loop confirmation
separate confirmed-only endpoint

The real mutation lives behind a distinct POST /orders/{id}/cancel that runs only after the human confirms — the only place state changes.

confirmation endpoint mutation api
replay-safety by order state

A double-tapped confirm is safe because a second cancel finds the order already cancelled and returns the same result — replay-safety comes from the order’s state, not the Idempotency-Key. The key is accepted for symmetry with checkout but is not the safety mechanism and is not stored.

idempotent cancel endpoint
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The read-vs-write safety boundary

The three tools are reads, so they can run automatically. Writes are different: a model that can cancel orders on its own is a liability. So cancel_order / refund_order are not callable tools. If the conversation implies one, the endpoint returns a structured proposal{ action, orderId, summary, requiresConfirmation: true } — and the UI shows a confirm button. The actual mutation lives behind a separate, human-confirmed endpoint (POST /orders/{id}/cancel) that runs only after the human taps confirm. This is the platform’s no-auto-actions rule — the same boundary the recipe feature respects by proposing a cart.

Cancel is replay-safe by order state, not by the Idempotency-Key: a second cancel finds the order already cancelled and returns the same 200, restocking nothing. The cancel accepts an Idempotency-Key for symmetry with checkout, but it is not the safety mechanism and must not be stored — the single orders.idempotency_key column holds the checkout key, and writing a cancel key there would overwrite it. Only /cancel (action cancel) is in scope here; a refund would follow the same propose-then-confirm pattern but there is no /refund endpoint in this build.

The proposal and the confirm-only cancel contract (shared)
Run these in your terminal / editor
# Agent proposes (from POST /support) — NO mutation:
{ "action": "cancel", "orderId": 4821, "summary": "Cancel order 4821 (Aurora Tee ×2)", "requiresConfirmation": true }

# Human confirms -> the ONLY endpoint that mutates:
POST /orders/{id}/cancel        Header: Idempotency-Key: <uuid>
Body:    { "action": "cancel", "orderId": 4821, "summary": "..." }   # the confirmed proposal; path {id} is authoritative

200:     { "orderId": 4821, "status": "cancelled" }   # only from status 'pending'; replay returns the same result
409:     { "error": "not_cancellable" }               # order is 'shipped' (or any non-'pending' state)
404:     { "error": "not_found" }                      # no such order
422:     { "error": "invalid_request" }               # action != "cancel", or body orderId != path {id} (path wins)

# In the SAME transaction as status='cancelled', restock every line:
#   products.stock += order_items.quantity   -- for each order_items row of the order
# Replay (order already 'cancelled'): return the same 200, restock NOTHING, store NO cancel key.
# Idempotency-Key is accepted for symmetry with /checkout but is NOT the safety mechanism (state is) and is NOT stored.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend). Identical contract on Go and Spring (§7 parity).
Context: The /support function-calling endpoint exists with read-only tools and already returns a 200 UNION. Checkout already supports Idempotency-Key.
Task: Add the write-confirmation boundary so cancel is proposed by /support, never auto-executed, and mutated only by a separate human-confirmed endpoint.
Requirements:
- Do NOT add cancel/refund as callable Gemini tools. If the shopper asks to cancel, return {action, orderId, summary, requiresConfirmation: true} — no DB change.
- MODIFY the /support handler from the Go/Spring build steps to return the proposal arm of its union when the conversation implies a cancel (it currently returns answer-only) — this is what wires the union's proposal branch into the handler.
- Implement the real mutation as a SEPARATE endpoint POST /orders/{id}/cancel that requires the confirmed proposal + an Idempotency-Key and is the ONLY place the write happens:
  - RESTOCK in-tx: products.stock += order_items.quantity per line, in the SAME transaction that sets status='cancelled', only from state 'pending'.
  - STATE: 200 only from 'pending' (status -> 'cancelled'); 409 not_cancellable from 'shipped' (or any non-'pending' state); 404 not_found for no such order.
  - 422 invalid_request when action != "cancel" or body orderId != path {id} (the path {id} is authoritative — reconcile before any state change).
  - REPLAY-BY-STATE: a second cancel finds the order already 'cancelled', returns the same 200, restocks NOTHING, and stores NO cancel key. Replay-safety comes from order STATE, not the Idempotency-Key; the key is accepted for symmetry with /checkout but is NOT stored (storing it would overwrite the checkout key in orders.idempotency_key).
- Scope: only /cancel with action 'cancel' is in scope (there is no /refund endpoint). Refund would follow the same human-confirmed proposal->confirm pattern but is out of scope here.
- Log the proposal and the later confirmed action (ties into the observability module).
Tests / acceptance:
- Asking the agent to "cancel order 4821" returns a proposal with requiresConfirmation=true from /support and performs NO mutation (order + stock unchanged).
- /support now returns the proposal arm for a cancel request (was answer-only before this step).
- Cancel from 'pending' -> 200 'cancelled' AND each line's products.stock increased by order_items.quantity, in one transaction.
- Cancel of a 'shipped' order -> 409 not_cancellable; unknown order -> 404; action != "cancel" or body orderId != path {id} -> 422.
- A second cancel (same or new Idempotency-Key) -> same 200, no further restock, no cancel key stored.
Output: a unified diff plus where the read-vs-write boundary is enforced and where the /support handler now returns the proposal.
What success looks like

Asking the agent to cancel an order returns a proposal (requiresConfirmation: true) from /support and mutates nothing. POST /orders/{id}/cancel with the confirmed body + an Idempotency-Key: from pending -> 200 {"orderId":N,"status":"cancelled"} and every line’s products.stock rises by its order_items.quantity, in one transaction. A shipped order -> 409 not_cancellable; an unknown order -> 404 not_found; action != "cancel" or body orderId != path {id} -> 422 invalid_request (path wins, reconciled before any write). A second cancel returns the same 200, restocks nothing, and stores no cancel key — orders.idempotency_key still holds only the checkout key.

Make logging first-class: structured logs + a trace id

Optional add-on Intermediate

Decide up front that the API emits structured (JSON) logs and that every request carries a trace id, generated at the edge and threaded all the way into the checkout transaction — so you can later answer “show every line for order 4821” with a query, not a grep.

New in this step
structured (JSON) logging

One JSON object per line with typed fields, instead of free-form prose, so logs are queryable at volume (every out-of-stock in the last hour).

structured logging json
trace id (request id)

One id generated per request that tags every log line for that request, so you can follow a single checkout end to end.

trace id correlation id logging
propagation through context

Carry the trace id on the request context so handlers and the checkout transaction all log the same id without passing it by hand.

propagate request id context
per-phase log lines

Log at each checkout phase (decrement, total, order insert, commit/rollback, out-of-stock, idempotency hit) so a failure points to exactly where it happened.

log per phase transaction
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why structured logging is a skill, not an afterthought

Prose logs (println-style) are unsearchable at volume. Structured logs are one JSON object per line with typed fields, so “show every line for order 4821” or “every out-of-stock in the last hour” is a query, not a grep. A trace id — one id per request, generated at the edge and propagated through the request context — ties a request’s lines together, including each phase of the checkout transaction. Log a line at every phase: stock decrement, total computed, order insert, commit or rollback, out-of-stock, idempotency hit. The concept is identical across backends; only the wiring differs. Costs nothing — this is plain stdout.

What one checkout looks like in the logs (illustrative)
Run these in your terminal / editor
{"time":"...","level":"INFO","msg":"checkout.stock_decremented","trace_id":"a1b2","product_id":7,"qty":2}
{"time":"...","level":"INFO","msg":"checkout.total_computed","trace_id":"a1b2","total_cents":5998}
{"time":"...","level":"INFO","msg":"checkout.order_inserted","trace_id":"a1b2","order_id":4821}
{"time":"...","level":"INFO","msg":"checkout.committed","trace_id":"a1b2","order_id":4821}

Structured logging in Go: slog + a trace-id middleware

Optional add-on Intermediate

Configure log/slog to write JSON to stdout, add middleware that puts a trace id on the request context, and log each checkout phase with it — so every line of a request shares one id you can filter on.

New in this step
log/slog

Go’s standard-library structured logger; replaces log/fmt.Println for typed, queryable output.

go log/slog docs ↗
slog.NewJSONHandler

A handler that writes one JSON object per log line to a writer (here os.Stdout).

slog json handler
HTTP middleware

A wrapper around your handler that runs on every request (here, to generate and attach the trace id) before passing control on.

go http middleware pattern
unexported context key

Store the trace id on r.Context() under a private key type so other packages can’t collide with it.

go context value key type
InfoContext / ErrorContext

slog calls that take the context, so a custom handler can pull the trace id from it automatically on every line.

slog infocontext
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
slog JSON handler + a context-carried trace id

log/slog is the standard library’s structured logger; slog.NewJSONHandler(os.Stdout, …) writes one JSON object per line. Generate a trace id in middleware (uuid.NewString(), or stdlib crypto/rand), store it on the request context under an unexported key, and read it back inside Checkout via the context-aware methods (logger.InfoContext(ctx, …)), so every phase line carries the same trace_id. The key detail: a plain JSONHandler does not read the context, so InfoContext alone would emit no trace_id. Wrap it in a small custom slog.Handler whose Handle pulls the id from the context and adds it to the record — then InfoContext(ctx, …) carries trace_id on every line automatically and you never thread it by hand (no slog.With per call). Costs nothing — stdout only. Guide: https://go.dev/blog/slog

One key type, shared across packages. The context key must be reachable from both where you set it (the middleware) and where you read it (Checkout). If those live in different packages — as the spec’s internal/httpapi and internal/store split does — an unexported ctxKey in one package is invisible to the other (undefined: ctxKey). Put the key plus exported accessors (trace.WithID(ctx, id) / trace.ID(ctx)) in a neutral internal/trace package both import; the key type itself stays unexported inside it.

Wire the middleware explicitly. Unlike Spring, where a @Component OncePerRequestFilter is auto-registered into the filter chain, http.ServeMux does not auto-apply middleware — you must wrap the router where you build the server. Update the scaffold’s Handler: newRouter(&Store{pool}) to Handler: withTrace(newRouter(&Store{pool})); skip this and withTrace never runs, so every phase line emits trace_id:"" — a silent failure that compiles and serves fine.

Install uuid (or use stdlib crypto/rand instead)
Run these in your terminal / editor
go get github.com/google/uuid   # for uuid.NewString(); skip if you use the crypto/rand variant below
slog JSON + trace-id middleware (essentials)
Run these in your terminal / editor
// internal/trace/trace.go — shared key + exported accessors so httpapi and store can both use it
package trace
type ctxKey struct{}
func WithID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, ctxKey{}, id) }
func ID(ctx context.Context) string { s, _ := ctx.Value(ctxKey{}).(string); return s }

// logging setup — a custom handler makes InfoContext self-sufficient (no per-call slog.With)
type traceHandler struct{ slog.Handler }
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
	if tid := trace.ID(ctx); tid != "" {
		r.AddAttrs(slog.String("trace_id", tid))
	}
	return h.Handler.Handle(ctx, r)
}
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.SetDefault(slog.New(traceHandler{base}))

// trace-id middleware — stamp the id via the trace package
func withTrace(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r.WithContext(trace.WithID(r.Context(), uuid.NewString())))
	})
}

// WIRE IT into the server — http.ServeMux does NOT auto-apply middleware, so update
// the scaffold's Handler to wrap the router (otherwise trace_id is always "" in Checkout):
srv := &http.Server{Addr: ":" + cmp.Or(os.Getenv("PORT"), "8080"), Handler: withTrace(newRouter(&Store{pool}))}

// inside Checkout, just pass ctx — the handler adds trace_id automatically, no slog.With:
slog.InfoContext(ctx, "checkout.stock_decremented", "product_id", l.ProductID, "qty", l.Qty)
// ...total_computed, order_inserted, committed / rolled_back, out_of_stock, idempotency_hit
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: net/http API; Store.Checkout exists; module github.com/google/uuid available (or use crypto/rand).
Task: Add slog JSON logging, a trace-id middleware, and per-phase checkout logs.
Requirements:
- slog.NewJSONHandler to os.Stdout; set as default; one JSON object per line.
- Middleware generates a trace id, stores it on r.Context() via a shared internal/trace package (unexported key type, exported WithID/ID accessors) so both httpapi and store can read it across packages. A custom slog.Handler reads the id from ctx and adds trace_id, so InfoContext carries it automatically (no per-call slog.With).
- WIRE the middleware into the server: change the scaffold's Handler to withTrace(newRouter(&Store{pool})). http.ServeMux does NOT auto-apply middleware (unlike Spring's auto-registered @Component filter), so without this wrap trace_id is always "".
- Log each checkout phase (stock decrement, total computed, order insert, commit, rollback, out-of-stock, idempotency hit) via InfoContext/ErrorContext, all sharing the request's trace_id.
- No swallowed errors: log rollback causes with context.
Tests / acceptance:
- A single checkout emits one JSON line per phase, all with the same NON-EMPTY trace_id (proves withTrace is wired into the http.Server Handler).
- An out-of-stock checkout logs at the decrement phase and returns the existing 409.
Output: a unified diff plus where the trace id enters the context and where the http.Server Handler is wrapped.

Structured logging in Spring: JSON console + an MDC trace-id filter

Optional add-on Intermediate

Turn on Spring Boot’s built-in JSON console logging, add a filter that puts a trace id into the MDC at the edge, and log each checkout phase through SLF4J — so every line of a request shares one id you can filter on.

New in this step
logging.structured.format.console=ecs

Turns on Spring Boot 3.4+‘s built-in JSON console logging (here in ECS format), no extra dependency.

spring boot structured logging docs ↗
SLF4J

The logging facade you code against (LoggerFactory.getLogger(...)); Spring Boot wires the JSON output behind it.

slf4j logger usage
MDC (Mapped Diagnostic Context)

A per-thread key/value bag; anything you put in it (the traceId) is added to every log line in that request automatically.

slf4j mdc
OncePerRequestFilter

A Spring filter that runs once per request at the edge; put the trace id in the MDC at the start and clear it in a finally.

spring onceperrequestfilter
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Built-in structured logging + MDC

Spring Boot 3.4+ ships built-in structured logging — set logging.structured.format.console=ecs (or logstash/gelf) and the console emits JSON with no extra dependency. Anything you put in the MDC is added to every JSON line automatically, so a OncePerRequestFilter that calls MDC.put("traceId", …) at the start (and MDC.clear() in a finally) stamps every line in that request — including the checkout phase logs you emit with an SLF4J logger. On older Spring Boot, add net.logstash.logback:logstash-logback-encoder (latest 9.x) and a logback-spring.xml instead. Costs nothing — stdout only. Docs: https://docs.spring.io/spring-boot/reference/features/logging.html

Structured console + MDC trace-id filter (essentials)
Run these in your terminal / editor
// application.properties:
//   logging.structured.format.console=ecs

import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.stereotype.Component
import jakarta.servlet.FilterChain            // jakarta, NOT javax — Boot 3 uses jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.slf4j.LoggerFactory
import java.util.UUID

@Component
class TraceIdFilter : OncePerRequestFilter() {
    override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
        MDC.put("traceId", UUID.randomUUID().toString())
        try { chain.doFilter(req, res) } finally { MDC.clear() }
    }
}

// inside the @Transactional checkout — MDC traceId rides along on every line:
private val log = LoggerFactory.getLogger(CheckoutService::class.java)
log.info("checkout.stock_decremented product_id={} qty={}", l.productId, l.qty)
// ...total_computed, order_inserted, committed / rolled_back (on OutOfStockException), idempotency_hit
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3.4+ (Kotlin), JdbcTemplate; CheckoutService exists.
Task: Add structured console logging, an MDC trace-id filter, and per-phase checkout logs.
Requirements:
- Set logging.structured.format.console=ecs (built-in; on older Spring Boot use logstash-logback-encoder + logback-spring.xml).
- A OncePerRequestFilter puts a generated traceId into the MDC at the edge and clears it in a finally block.
- Log each checkout phase (stock decrement, total computed, order insert, commit, rollback on OutOfStockException, out-of-stock, idempotency hit) via an SLF4J logger; MDC adds traceId to each line.
- No swallowed exceptions: log the rollback cause with context.
Tests / acceptance:
- A single checkout's log lines all carry the same traceId.
- An over-quantity checkout logs at the decrement/rollback phase and still maps to HTTP 409.
Output: a unified diff plus the property and the filter.

Log errors with context — never swallow them

Optional add-on Intermediate

Every caught error gets logged once, with the trace id and enough context to act on it — then it’s handled, not silently discarded.

One log, with context, at the boundary that has it

A swallowed error (catch {} with nothing logged) is a debugging dead end. Log where you actually have context — which order, which product, which checkout phase — attach the trace id, and include the error itself (slog’s error attr; SLF4J’s last-arg Throwable). Then handle it once: don’t log-and-rethrow at every layer, or one failure becomes ten noisy lines. The checkout rollback path is the prime example — log why it rolled back (out-of-stock vs serialization failure) before returning the clean client error. And never silence an error to keep the logs quiet: a rollback you can’t explain later is worse than a noisy one.

Optional: distributed tracing with OpenTelemetry + a local Tempo

Optional add-on Advanced

For a visual trace across a request, add OpenTelemetry spans and view them in a Grafana + Tempo you run locally with Docker — so you can see a checkout’s phases on a timeline. Entirely optional — structured stdout already gives you most of the value.

New in this step
OpenTelemetry (OTel)

A vendor-neutral standard and SDK for emitting traces (and metrics/logs) from your app.

opentelemetry overview
span

One timed, named operation (e.g. checkout) that can nest child spans, so a request becomes a timeline you can read.

opentelemetry span
OTLP / OTEL_EXPORTER_OTLP_ENDPOINT

The wire protocol OTel exports over; the env var points your app at the local collector (:4317 gRPC / :4318 HTTP).

opentelemetry otlp exporter endpoint
Tempo

Grafana’s trace backend; it receives the OTLP spans.

grafana tempo getting started
Grafana

The UI where you explore the traces (the Tempo datasource), on :3000, all local Docker, free.

grafana explore traces
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Spans on top of logs — local and free

OpenTelemetry adds spans (timed, nested operations) on top of your logs, so you can see a checkout’s phases on a timeline. Wrap the checkout in a span (Go: tracer.Start(ctx, "checkout") then span.End(); Spring: the zero-code Java agent or the opentelemetry-spring-boot-starter instruments it for you) and export over OTLP to a local Tempo, viewed in Grafana. Tempo receives OTLP on 4317 (gRPC) / 4318 (HTTP); Grafana is on 3000. This stays free — it’s all local Docker. Pin nothing: the OTel modules move fast, so go get …@latest and copy the current official compose. Go: https://opentelemetry.io/docs/languages/go/ · Java: https://opentelemetry.io/docs/zero-code/java/ · Tempo: https://grafana.com/docs/tempo/latest/getting-started/

Run a local Tempo + Grafana (optional, costs nothing)
Run these in your terminal / editor
# Use Grafana's official example stack (Tempo + Grafana) — copy the CURRENT compose from:
#   https://github.com/grafana/tempo/tree/main/example/docker-compose/local
docker compose up -d   # Grafana on :3000, Tempo OTLP on :4317 (gRPC) / :4318 (HTTP)
Point your app at the local collector
Run these in your terminal / editor
# Both SDKs/agents honour the standard OTLP env vars:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
export OTEL_SERVICE_NAME="aurora-api"
# Go:    go get go.opentelemetry.io/otel@latest  (plus the sdk/trace + otlptracehttp exporter modules)
# Spring: attach the agent with -javaagent:opentelemetry-javaagent.jar  (or add the spring-boot-starter)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: Structured logging + trace-id already in place. A local Tempo+Grafana runs via Docker (OTLP on 4317/4318). This step is OPTIONAL.
Task: Add OpenTelemetry tracing and wrap checkout in a span exported to the local Tempo.
Requirements:
- Initialise a TracerProvider with an OTLP exporter to OTEL_EXPORTER_OTLP_ENDPOINT; set OTEL_SERVICE_NAME.
- Go: start a "checkout" span (tracer.Start) and defer span.End(); attach order_id once known. Spring: the Java agent or opentelemetry-spring-boot-starter auto-instruments HTTP + JDBC.
- Do NOT pin module versions in committed docs — use @latest / the current BOM and link the official guide.
- Everything stays local and free; no hosted collector.
Tests / acceptance:
- After a checkout, the request's trace appears in Grafana Explore (Tempo datasource) with the checkout span.
Output: a unified diff plus the (unpinned) OTel modules/agent used and why.

Add the storefront tables and a live deal

Optional add-on Intermediate

Add the module’s own tables — banners, featured products, a deals table with a start/end window, and a per-customer purchase ledger — as one additive, idempotent migration, then seed a banner and a live flash deal. The module owns this schema and touches no base table (the home page’s category tiles are the base categories), so it drops onto the base build without a rewrite.

New in this step
CREATE TABLE IF NOT EXISTS

Creates the table only when it is absent, so re-running the module’s migration is a safe no-op — the mark of an additive, optional module.

postgres create table if not exists docs ↗
denormalized read model

Tables shaped for one screen’s read (the home page), assembled in a single request rather than joined together by every client.

denormalized read model home page
temporal window (starts_at / ends_at)

Two timestamptz columns that bound when a deal is live, so now() BETWEEN starts_at AND ends_at decides validity in SQL.

postgres time range validity
allocation cap vs per-customer cap

The global cap is the total units the drop may sell; the per-customer cap is how many one buyer may take — two independent limits a flash sale enforces.

flash sale purchase limit design
CHECK as a backstop

CHECK (sold <= allocation_cap) makes an over-allocated drop as impossible as negative stock — the database refuses it even if app code is wrong.

postgres check constraint docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the module owns its own additive tables — and seeds no categories

An optional module must stand alone on the base build and assume nothing else. So it creates its own tables and never rewrites the core schema: storefront_featured and deals reference products(id) with a foreign key, but the products, categories, orders, and order_items tables are untouched. The module seeds no categories — the home page’s category tiles are the three base categories rows the base seed created (image_url included), so there is no second category table to drift out of sync; banners keep their own image_url because banner art is module-owned marketing, not catalog data. The deals table carries the flash-sale state — the sale price, the window, the global allocation_cap, and a running sold counter — and deal_purchases is the per-customer ledger the checkout writes to. Two constraints turn the caps into guarantees: CHECK (sold <= allocation_cap) backstops the global cap (a stray UPDATE can’t oversell the drop), and the deal_purchases (deal_id, customer_id) primary key keeps one ledger row per buyer. This is the same lesson as the base schema step — let the schema be the rulebook — applied to a module’s own tables. The deeper treatment is in the PostgreSQL track.

0003_storefront.up.sql (Go) / V3__storefront.sql (Spring) — the module tables
Run these in your terminal / editor
-- OPTIONAL MODULE: storefront. Additive, self-contained, idempotent. Touches no base table.

-- Marketing banners for the home page. Banners keep their own image_url (module-owned banner art);
-- the category tiles need no module table — they are the BASE categories, image_url included.
CREATE TABLE IF NOT EXISTS storefront_banners (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  headline   TEXT    NOT NULL,
  subhead    TEXT    NOT NULL DEFAULT '',
  cta_href   TEXT    NOT NULL DEFAULT '',
  image_url  TEXT    NOT NULL DEFAULT '',
  active     BOOLEAN NOT NULL DEFAULT true,
  sort_order INTEGER NOT NULL DEFAULT 0
);

-- Curated featured products (a thin join onto the base catalog).
CREATE TABLE IF NOT EXISTS storefront_featured (
  product_id BIGINT PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE,
  sort_order INTEGER NOT NULL DEFAULT 0
);

-- Temporal deal: a time-boxed sale price + a global allocation cap for a flash drop.
CREATE TABLE IF NOT EXISTS deals (
  id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  product_id       BIGINT      NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  sale_price_cents BIGINT      NOT NULL CHECK (sale_price_cents >= 0),  -- cents
  starts_at        TIMESTAMPTZ NOT NULL,
  ends_at          TIMESTAMPTZ NOT NULL,
  allocation_cap   INTEGER     NOT NULL CHECK (allocation_cap >= 0),    -- global units the drop may sell
  per_customer_cap INTEGER     NOT NULL CHECK (per_customer_cap >= 1),  -- units one customer may buy
  sold             INTEGER     NOT NULL DEFAULT 0 CHECK (sold >= 0),    -- running allocation counter
  CHECK (ends_at > starts_at),
  CHECK (sold <= allocation_cap)                                        -- oversell of the drop is impossible
);
CREATE INDEX IF NOT EXISTS deals_product_window ON deals (product_id, starts_at, ends_at);

-- Per-customer purchase ledger for a deal (enforces per_customer_cap in-transaction).
CREATE TABLE IF NOT EXISTS deal_purchases (
  deal_id     BIGINT  NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
  customer_id BIGINT  NOT NULL REFERENCES customers(id),
  qty         INTEGER NOT NULL CHECK (qty > 0),
  PRIMARY KEY (deal_id, customer_id)
);
0004_storefront_seed.up.sql (Go) / V4__storefront_seed.sql (Spring) — demo data
Run these in your terminal / editor
-- Assumes the base seed (3 categories + 3 products + 1 customer) exists. Idempotent on re-run.

-- (a) One active marketing banner (module-owned image_url = the placeholder).
--     No natural unique key -> guard on the headline with NOT EXISTS.
INSERT INTO storefront_banners (headline, subhead, cta_href, image_url, active, sort_order)
SELECT 'Aurora Summer Drop', 'Flash deals live now — limited units', '/deals',
       'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg',
       true, 0
WHERE NOT EXISTS (SELECT 1 FROM storefront_banners WHERE headline = 'Aurora Summer Drop');

-- (b) Feature the base 'Aurora Sticker Pack' (by name, never a hardcoded id). product_id is the PK.
INSERT INTO storefront_featured (product_id, sort_order)
SELECT p.id, 0 FROM products p WHERE p.name = 'Aurora Sticker Pack'
ON CONFLICT (product_id) DO NOTHING;

-- (c) One LIVE flash deal on the featured product: sale 299 (base 499), cap 50, open now.
--     No natural unique key -> guard on product_id with NOT EXISTS.
INSERT INTO deals (product_id, sale_price_cents, starts_at, ends_at, allocation_cap, per_customer_cap, sold)
SELECT p.id, 299, now() - INTERVAL '1 hour', now() + INTERVAL '7 days', 50, 2, 0
FROM products p
WHERE p.name = 'Aurora Sticker Pack'
  AND NOT EXISTS (SELECT 1 FROM deals d WHERE d.product_id = p.id);
Apply it
Run these in your terminal / editor
# Docker only (no host psql needed) — apply the module migration + seed to check them now:
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0003_storefront.up.sql
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0004_storefront_seed.up.sql
What success looks like

\dt now lists four new tables — storefront_banners, storefront_featured, deals, deal_purchases — beside the base eight (and no module category table: the tiles are the base categories). Re-running the migration is a no-op (NOTICE: relation "deals" already exists, skipping) and re-running the seed inserts 0 rows, so the module is idempotent. The CHECK (sold <= allocation_cap) makes over-allocating the drop impossible with no application code involved — real output from postgres:16 on the seeded deal (cap 50):

=# UPDATE deals SET sold = allocation_cap + 1 WHERE product_id = 3;
ERROR:  new row for relation "deals" violates check constraint "deals_check1"
DETAIL:  Failing row contains (1, 3, 299, ..., 50, 2, 51).

Serve GET /storefront: the home page in one read

Optional add-on Intermediate

Expose GET /storefront returning banners, the base category tiles, featured products, and live deals in one response, resolving each product’s effective display price in SQL with now() BETWEEN starts_at AND ends_at. This is the denormalized home page a real store serves — one read, everything the shopper’s landing screen needs.

New in this step
now() BETWEEN starts_at AND ends_at

The temporal predicate that decides, at read time, whether a deal is live — the sale turns itself on and off with the clock, no cron job.

postgres now between timestamp docs ↗
LEFT JOIN + COALESCE

Join the live deal if one exists and COALESCE(sale_price_cents, unit_price) picks the sale price, otherwise falls back to the base price — one expression for the effective price.

postgres left join coalesce default docs ↗
advisory vs authoritative price

The displayed price is a read and may be slightly stale (like the cart read, GET /cart); the price that counts is fixed atomically at checkout, so the home page never becomes the source of truth for money.

read model stale price checkout truth
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
One read for the whole home page — and why the sale price here is advisory

A home page needs four things at once: what’s promoted (banners), how to browse (the base category tiles — the same {id, slug, name, imageUrl} object shape GET /products nests, read straight from the base categories table), what to show off (featured), and what’s on sale right now (deals). Serving them in one GET /storefront keeps the landing screen a single round-trip. The effective display price is resolved in SQL — COALESCE(sale_price_cents, unit_price) under a LEFT JOIN on the live-deal predicate — so the shopper sees the sale price the instant a deal opens and the base price the instant it closes. Crucially this price is advisory, exactly like the cart read (GET /cart re-resolves prices live, and truth is fixed at checkout): it can be slightly stale between the read and the tap on Checkout. The checkout re-resolves the true price atomically inside its transaction (the next steps), so a deal that expires in that gap never charges the stale sale price. The home page is a view, not the money authority.

Byte-parity on free-text: the base writeJSON already covers it

The storefront carries marketing free-text — a headline, a subhead, names, image URLs — where Go’s default JSON encoder would escape the ampersand and angle-bracket characters to their unicode-escape forms while Jackson (Spring) leaves them literal, so a banner reading “Tees & Mugs” would differ byte-for-byte across backends. This is exactly why the base scaffold routed every response through the no-HTML-escape, no-trailing-newline writeJSON. The storefront introduces no new writer — it reuses that identical base helper:

Go: the base writer (unchanged) — literal free-text, like Jackson
Run these in your terminal / editor
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)                        // literal & and angle brackets, like Jackson
_ = enc.Encode(v)
w.Header().Set("Content-Type", "application/json")
w.Write(bytes.TrimRight(buf.Bytes(), "\n"))     // no trailing newline, like Jackson
The /storefront reads (SQL, shared by both backends)
Run these in your terminal / editor
-- banners (module-owned, incl. the banner art image_url)
SELECT id, headline, subhead, cta_href, image_url FROM storefront_banners
WHERE active ORDER BY sort_order, id;

-- categories: a plain read of the BASE categories table — the same {id, slug, name, imageUrl}
-- shape GET /products nests; ordered by id (base categories carry no sort_order)
SELECT id, slug, name, image_url FROM categories ORDER BY id;

-- featured, with the effective DISPLAY price resolved in SQL
SELECT p.id, p.name, p.unit_price,
       COALESCE(d.sale_price_cents, p.unit_price) AS display_price,
       (d.id IS NOT NULL)                          AS on_sale,
       p.stock
FROM storefront_featured f
JOIN products p ON p.id = f.product_id
LEFT JOIN deals d ON d.product_id = p.id AND now() BETWEEN d.starts_at AND d.ends_at
ORDER BY f.sort_order, p.id;

-- deals live now, with countdown target + remaining allocation
SELECT p.id AS product_id, p.name, p.unit_price, d.sale_price_cents AS sale_price,
       d.ends_at,                                  -- backend formats per the base createdAt rule (§5.4)
       (d.allocation_cap - d.sold) AS remaining,
       (d.sold >= d.allocation_cap) AS sold_out
FROM deals d JOIN products p ON p.id = d.product_id
WHERE now() BETWEEN d.starts_at AND d.ends_at
ORDER BY d.ends_at;
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: base build complete (categories, products, orders, GET /products); the storefront module tables exist and are seeded (storefront_banners, storefront_featured, deals, deal_purchases). Money is integer cents; field names camelCase on the wire.
Task: Add GET /storefront returning {banners, categories, featured, deals} in one response.
Requirements:
- banners: {id, headline, subhead, ctaHref, imageUrl} for active banners, ordered by sort_order.
- categories: the BASE categories table — {id, slug, name, imageUrl} (the same object shape GET /products nests), SELECT id, slug, name, image_url FROM categories ORDER BY id. The module has no category table of its own.
- featured: {id, name, unitPrice, displayPrice, onSale, stock}; displayPrice = COALESCE(the live deal's sale_price_cents, unit_price) via a LEFT JOIN on now() BETWEEN starts_at AND ends_at; onSale = a live deal exists.
- deals: {productId, name, unitPrice, salePrice, endsAt, remaining, soldOut} for deals live now; remaining = allocation_cap - sold; soldOut = sold >= allocation_cap.
- endsAt wire format = the base createdAt rule (§5.4): RFC3339 UTC, trailing Z, NO sub-second digits.
- The display/effective price here is ADVISORY (it may be stale); it is NOT authoritative — checkout re-resolves the true price. Do not cache stock/price outside a tx.
- All four sections serialize as [] when empty (the base non-nil-slice rule), written through the base writeJSON (no HTML escaping, no trailing newline), so a headline with & serializes identically to Spring/Jackson.
- 503 {"error":"unavailable"} if the DB read fails (retryable); 500 {"error":"internal"} otherwise.
Tests / acceptance:
- GET /storefront returns 200 with the four sections; categories carries the three BASE rows (drinkware/apparel/accessories) with their imageUrl; a product with a live deal shows displayPrice < unitPrice and onSale true; when the deal window is closed it shows displayPrice == unitPrice and onSale false.
- endsAt ends in Z with no sub-second digits, byte-identical across backends.
- A banner headline containing & serializes byte-identically on Go and Spring.
Output: a unified diff plus the effective-price SQL.
What success looks like

GET /storefront returns 200 with the four sections in one payload — the categories section carrying the three base rows, imageUrl included. The featured section resolves the effective display price in SQL — the Sticker Pack, under a live deal, shows displayPrice = 299 against its unitPrice = 499 with onSale = true. The deals section carries the countdown target endsAt (RFC3339 UTC, trailing Z, no sub-second) and remaining = allocation_cap - sold. Real output from postgres:16 (URL column trimmed for width):

--- categories (the BASE table) ---
 id |    slug     |    name     | image_url
----+-------------+-------------+--------------------------------------
  1 | drinkware   | Drinkware   | https://upload.wikimedia.org/...jpg
  2 | apparel     | Apparel     | https://upload.wikimedia.org/...jpg
  3 | accessories | Accessories | https://upload.wikimedia.org/...jpg
--- featured (effective display price) ---
 id |        name         | unit_price | display_price | on_sale | stock
----+---------------------+------------+---------------+---------+-------
  3 | Aurora Sticker Pack |        499 |           299 | t       |   200
--- deals (live now) ---
 product_id |        name         | sale_price |       ends_at        | remaining | sold_out
------------+---------------------+------------+----------------------+-----------+----------
          3 | Aurora Sticker Pack |        299 | 2026-07-21T22:13:22Z |        50 | f

★ Extend checkout with the flash-sale caps (Go)

Optional add-on Advanced

In your Go checkout transaction, reserve the global allocation cap and the per-customer cap as guarded statements inside the one existing transaction, and capture the effective sale-aware price via RETURNING — so a flash drop can’t oversell, a buyer can’t exceed their limit, and the price charged is the true price at the transaction instant.

New in this step
cap in the WHERE clause

UPDATE deals SET sold = sold + $1 WHERE ... AND sold + $1 <= allocation_cap reserves only if the cap allows; the row lock serialises concurrent buyers, so the guard is race-safe like the base stock guard.

conditional update where guard docs ↗
capture the sale price via RETURNING

The same reservation statement returns sale_price_cents, so the price is captured atomically in-transaction — never read from the stale storefront display.

postgres update returning docs ↗
guarded upsert

INSERT ... ON CONFLICT (deal_id, customer_id) DO UPDATE SET qty = qty + excluded.qty WHERE qty + excluded.qty <= cap RETURNING qty — inserts or increments the ledger only within the per-customer cap; zero rows back means the limit was hit.

postgres insert on conflict do update where
SELECT EXISTS classification probe

When the reservation matches zero rows, a cheap SELECT EXISTS(a live deal) tells “sold out” apart from “not on sale” — a routing decision, not a price or stock read.

postgres select exists
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
This is the spotlight, at maximum stakes

A time-boxed drop is the base oversell race with thousands racing for N units — so the caps must live where the stock guard lives: inside the one transaction, enforced by the database. For each line, the base guard runs unchanged (decrement stock, capture the base price via RETURNING). Then a second guarded UPDATE reserves the global allocation on the live deal, with sold + qty <= allocation_cap in the WHERE, and returns sale_price_cents — the row lock serialises concurrent buyers exactly as the stock guard does, and the CHECK (sold <= allocation_cap) backstops it. If that reservation matches zero rows it means one of two things, and a cheap SELECT EXISTS probe distinguishes them: a live deal exists (the drop is exhausted, so out) versus no live deal (not on sale, keep the base price). Finally a guarded upsert into deal_purchases enforces the per-customer cap. The effective price is the sale price when a deal was reserved, otherwise the base price — and that is what the order total and each order_items.unit_price capture, mirroring the base “price at purchase time” invariant. Because every guard is inside the one transaction, a rejected line rolls the whole checkout back: the stock decrement, the allocation reservation, and the ledger increment all undo together. The PostgreSQL track covers the locking underneath.

The extended per-line guards (pgx) — added inside the existing Checkout loop
Run these in your terminal / editor
// after the base guard captured basePrice via RETURNING unit_price:
effective := basePrice

// (2) reserve the global allocation on a LIVE deal and capture the sale price in ONE statement.
var dealID, salePrice int64
var perCap int
err = tx.QueryRow(ctx,
	`UPDATE deals SET sold = sold + $1
	   WHERE product_id = $2
	     AND now() BETWEEN starts_at AND ends_at
	     AND sold + $1 <= allocation_cap
	 RETURNING id, sale_price_cents, per_customer_cap`,
	l.Qty, l.ProductID).Scan(&dealID, &salePrice, &perCap)
switch {
case err == nil:
	effective = salePrice // a live deal was reserved -> the sale price is the true price
	// (3) per-customer cap: guarded upsert; the cap lives in the WHERE.
	var newQty int
	e := tx.QueryRow(ctx,
		`INSERT INTO deal_purchases (deal_id, customer_id, qty)
		   SELECT $1, $2, $3::int WHERE $3::int <= $4::int
		 ON CONFLICT (deal_id, customer_id)
		   DO UPDATE SET qty = deal_purchases.qty + EXCLUDED.qty
		   WHERE deal_purchases.qty + EXCLUDED.qty <= $4::int
		 RETURNING qty`, dealID, customerID, l.Qty, perCap).Scan(&newQty)
	if errors.Is(e, pgx.ErrNoRows) {
		return 0, fmt.Errorf("deal %d: %w", dealID, ErrPurchaseLimit)
	}
	if e != nil { return 0, e }
case errors.Is(err, pgx.ErrNoRows):
	// 0 rows: EITHER no live deal (charge base price) OR the drop is exhausted. Classify cheaply.
	var liveExists bool
	if e := tx.QueryRow(ctx,
		`SELECT EXISTS(SELECT 1 FROM deals
		   WHERE product_id = $1 AND now() BETWEEN starts_at AND ends_at)`,
		l.ProductID).Scan(&liveExists); e != nil { return 0, e }
	if liveExists { return 0, fmt.Errorf("product %d: %w", l.ProductID, ErrDealSoldOut) }
	// else: not on sale -> effective stays basePrice
default:
	return 0, err
}
// use `effective` for total and order_items.unit_price (the price-at-purchase-time capture)
Agent prompt — paste into an agent with repo access

Before you run this: 20 buyers race a deal with allocation_cap = 1 but plenty of stock — how many succeed, what error do the rest get, and does products.stock ever drop below the winner's one unit?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: Store.Checkout(ctx, customerID, cartToken, idemKey) exists as ONE pgx transaction (the base ★ step: it reads the cart's lines and claims the cart); ErrOutOfStock defined; the storefront module tables exist. github.com/jackc/pgx/v5. A deal applies automatically to any CART line whose product has a live deal — the POST /checkout request contract ({customerId} + X-Cart-Token) is unchanged.
Task: Extend Checkout so a cart line whose product has a LIVE deal reserves the flash-sale caps and captures the sale price, all inside the existing transaction.
Requirements:
- Keep the base guard first and unchanged: UPDATE products ... RETURNING unit_price (base price); 0 rows -> ErrOutOfStock. Keep the cart claim exactly where it is.
- Global allocation: UPDATE deals SET sold = sold + $qty WHERE product_id=$p AND now() BETWEEN starts_at AND ends_at AND sold + $qty <= allocation_cap RETURNING id, sale_price_cents, per_customer_cap. On 0 rows, SELECT EXISTS(a live deal for $p): true -> ErrDealSoldOut; false -> not on sale, keep the base price.
- Per-customer: guarded upsert into deal_purchases (INSERT ... SELECT WHERE qty<=cap ON CONFLICT DO UPDATE ... WHERE sum<=cap RETURNING qty); 0 rows -> ErrPurchaseLimit.
- Effective price = sale price iff a deal was reserved, else base price; the order total and each order_items.unit_price use the effective price (price at purchase time).
- All guards inside the ONE transaction; a rejected line rolls the whole checkout back (stock, reservation, ledger, cart claim — the cart stays active). Never read stock/price outside the tx.
- Map ErrDealSoldOut -> 409 {"error":"deal_sold_out"} and ErrPurchaseLimit -> 409 {"error":"purchase_limit"} in POST /checkout, before the default 500.
Tests / acceptance:
- A buy under a live deal records order_items.unit_price = the sale price; an expired deal records the base price.
- 20 buyers vs allocation_cap=1, each with its OWN single-line cart (POST /cart/items then checkout with its token + a distinct Idempotency-Key) -> exactly 1 success + 19 deal_sold_out; products.stock down by exactly 1; deals.sold=1.
- One customer, per_customer_cap=2, 5 concurrent buys (each its own single-line cart) -> exactly 2 success + 3 purchase_limit.
Output: a unified diff plus a proof the caps live inside the guarded statements.
What success looks like

A checkout of 2 Sticker Packs while the deal is live captures the sale price atomically — order_items.unit_price = 299 and orders.total = 598 (2 × 299), not the base 499. Close the window (set ends_at in the past) and the same checkout captures the base price — unit_price = 499, total = 998 — because the price is re-resolved inside the transaction, never read from the stale storefront display. (Verified with pgx v5.10.0 / Go 1.26 against postgres:16.)

live deal:    order_items.unit_price=299  orders.total=598
expired deal: order_items.unit_price=499  orders.total=998

★ Extend checkout with the flash-sale caps (Spring/Kotlin)

Optional add-on Advanced

Add the same two guards to your @Transactional checkout via JdbcTemplate — reserve the global allocation and the per-customer cap inside the one method, capturing the sale price with RETURNING — so the Spring path enforces the drop byte-for-byte like the Go path.

New in this step
queryForMap on UPDATE ... RETURNING

Runs the guarded UPDATE ... RETURNING id, sale_price_cents, per_customer_cap and maps the one returned row to a Map; the reservation and the price capture are one statement.

jdbctemplate queryformap docs ↗
EmptyResultDataAccessException

What queryForMap/queryForObject throws when the guarded statement matches zero rows — here it means the cap blocked the reservation or the upsert.

EmptyResultDataAccessException spring
in-method catch-and-rethrow (parity)

Catch the zero-row exception inside checkout() and throw DealSoldOutException/PurchaseLimitException so the global 404 advice never turns a cap conflict into a 404 — the same mandatory move the base out-of-stock path makes.

spring restcontrolleradvice exception mapping
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same guards, declarative boundary — and the mandatory in-method catch

The mechanic is identical to Go; only the JDBC surface differs, and it is reasoned here, not compiled (there is no JVM toolchain in this authoring environment) — written against Spring 6 / Boot 3.4 / JdbcTemplate. Inside the existing @Transactional checkout(), after the base stock guard, run the deal reservation with jdbc.queryForMap(...) on the guarded UPDATE ... RETURNING. On zero rows it throws EmptyResultDataAccessException; catch it inside the method and either run the SELECT EXISTS probe (jdbc.queryForObject(..., Boolean::class.java)) to decide DealSoldOutException versus not-on-sale, or fall through to the base price. The per-customer upsert uses jdbc.queryForObject(the RETURNING qty, Long::class.java); a zero-row EmptyResultDataAccessException becomes PurchaseLimitException. This in-method catch is mandatory for parity — exactly as the base out-of-stock path must catch its zero-row exception so the global EmptyResultDataAccessException → 404 advice never sees it (spec §7). The advice then maps both new exceptions to 409. Language depth is in the Kotlin track.

The extended guards (JdbcTemplate + @Transactional) — reasoned parity
Run these in your terminal / editor
class DealSoldOutException(productId: Long) : RuntimeException("product $productId deal sold out")
class PurchaseLimitException(dealId: Long) : RuntimeException("deal $dealId purchase limit")

// inside the existing @Transactional checkout(), per line, after the base stock guard:
var effective = basePrice
val deal: Map<String, Any>? = try {
    jdbc.queryForMap(
        """UPDATE deals SET sold = sold + ?
             WHERE product_id = ? AND now() BETWEEN starts_at AND ends_at
               AND sold + ? <= allocation_cap
           RETURNING id, sale_price_cents, per_customer_cap""",
        l.qty, l.productId, l.qty,
    )
} catch (e: EmptyResultDataAccessException) {
    // 0 rows: sold out (a live deal exists) vs not on sale — classify with a cheap probe.
    val live = jdbc.queryForObject(
        "SELECT EXISTS(SELECT 1 FROM deals WHERE product_id = ? AND now() BETWEEN starts_at AND ends_at)",
        Boolean::class.java, l.productId,
    )!!
    if (live) throw DealSoldOutException(l.productId)
    null // not on sale -> keep basePrice
}
if (deal != null) {
    effective = deal["sale_price_cents"] as Long
    val dealId = deal["id"] as Long
    val perCap = deal["per_customer_cap"] as Int
    try {
        jdbc.queryForObject(
            """INSERT INTO deal_purchases (deal_id, customer_id, qty)
                 SELECT ?, ?, ?::int WHERE ?::int <= ?::int
               ON CONFLICT (deal_id, customer_id)
                 DO UPDATE SET qty = deal_purchases.qty + EXCLUDED.qty
                 WHERE deal_purchases.qty + EXCLUDED.qty <= ?::int
               RETURNING qty""",
            Long::class.java, dealId, customerId, l.qty, l.qty, perCap, perCap,
        )
    } catch (e: EmptyResultDataAccessException) {
        throw PurchaseLimitException(dealId)
    }
}
// use `effective` for total and order_items.unit_price
Agent prompt — paste into an agent with repo access

Before you run this: when the per-customer upsert matches zero rows and checkout() throws PurchaseLimitException, what does @Transactional do to the stock decrement and the allocation reservation already applied on that line?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: CheckoutService.checkout(customerId, cartToken, idemKey) exists as one @Transactional method over JdbcTemplate (the base ★ step: it reads the cart's lines and claims the cart); OutOfStockException + a @RestControllerAdvice already map errors. The storefront module tables exist. A deal applies automatically to any CART line whose product has a live deal — the POST /checkout request contract ({customerId} + X-Cart-Token) is unchanged.
Task: Extend checkout() so a cart line whose product has a LIVE deal reserves the flash-sale caps and captures the sale price, all inside the one @Transactional method. Keep it byte-for-byte with the Go path.
Requirements:
- Keep the base stock guard first and unchanged; keep the cart claim exactly where it is.
- Reserve global allocation with jdbc.queryForMap on UPDATE deals ... AND sold + ? <= allocation_cap RETURNING id, sale_price_cents, per_customer_cap. Catch EmptyResultDataAccessException INSIDE checkout(): run SELECT EXISTS(a live deal); true -> throw DealSoldOutException; false -> not on sale, keep the base price.
- Per-customer: guarded upsert into deal_purchases RETURNING qty; EmptyResultDataAccessException -> throw PurchaseLimitException.
- The in-method catch is MANDATORY so the global EmptyResultDataAccessException -> 404 advice never turns a cap conflict into a 404 (§7 parity).
- Effective price = sale price iff reserved, else base price; total and order_items.unit_price use it.
- @RestControllerAdvice maps DealSoldOutException -> 409 {"error":"deal_sold_out"} and PurchaseLimitException -> 409 {"error":"purchase_limit"}.
Tests / acceptance (Testcontainers postgres:16):
- A buy under a live deal records the sale price; an expired deal records the base price.
- 20 threads vs allocation_cap=1, each with its OWN single-line cart (addItem then checkout with its token + a distinct Idempotency-Key) -> 1 success + 19 deal_sold_out, stock down by 1, deals.sold=1.
- One customer, per_customer_cap=2, 5 concurrent buys (each its own single-line cart) -> 2 success + 3 purchase_limit.
Output: a unified diff plus the advice mappings.
What success looks like

Reasoned, not compiled — the Spring path mirrors the Go path the compiled run proves: a buy under a live deal captures the sale price into order_items.unit_price; an expired deal captures the base price. A zero-row reservation with a live deal throws DealSoldOutException (→ 409 {"error":"deal_sold_out"}); a zero-row per-customer upsert throws PurchaseLimitException (→ 409 {"error":"purchase_limit"}); @Transactional rolls back the whole call, so a rejected line leaves stock, deals.sold, and the ledger unchanged. The two new 409 bodies are byte-identical to the Go path.

Prove the drop can't oversell — race the caps

Optional add-on Advanced

Fire many concurrent buyers at a capped deal and assert both caps hold with zero oversell — the base 20-buyer race, now at flash-sale stakes: a global allocation of one unit, and a single customer hitting their personal limit.

Two caps, one transaction, zero oversell

The base concurrency test proved a single conditional UPDATE can’t oversell one unit of stock. The flash sale raises the stakes to two shared counters, and the proof is the same: the guarded reservation lives inside the transaction, so row locks serialise the racers and the losers see zero rows. Test the global cap by setting a deal to allocation_cap = 1 with plenty of stock, then launch 20 concurrent buyers — each with its own single-line cart, exactly like the base concurrency test — and exactly one wins the allocation, the other 19 get deal_sold_out; because each loser’s reservation (with its stock decrement and cart claim) rolls back, products.stock drops by exactly one, not nineteen, and the losers’ carts stay active. Test the per-customer cap by setting per_customer_cap = 2 with a generous allocation, then have one customer fire five concurrent buys — exactly two succeed and three get purchase_limit. Neither counter can be pushed past its cap, and the CHECK (sold <= allocation_cap) backstop stands guard behind the reservation. Reuse the base 20-buyer harness (goroutines plus a wait group, or threads plus a latch). The PostgreSQL track has the isolation details.

Agent prompt — paste into an agent with repo access

Before you run this: with allocation_cap = 1 and 20 concurrent buyers of a well-stocked product, what is the final deals.sold, and how far does products.stock fall?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: the flash-sale checkout extension exists (deal reservation + per-customer ledger inside the one transaction).
Task: Add a concurrency test that fires parallel buyers at a capped deal and asserts the caps hold with zero oversell. Reuse the base 20-buyer harness pattern (each buyer creates its own cart).
Requirements:
- Global cap: set a deal to allocation_cap=1 with plenty of stock (e.g. 200); launch 20 concurrent buyers, EACH creating its own single-line cart (POST /cart/items, quantity 1) and checking out with its own X-Cart-Token + a distinct Idempotency-Key.
- Assert exactly 1 success + 19 deal_sold_out; final products.stock down by exactly 1; final deals.sold=1; the losers' carts stay active.
- Per-customer cap: set allocation_cap high, per_customer_cap=2; one customer fires 5 concurrent quantity-1 checkouts (each its own single-line cart).
- Assert exactly 2 success + 3 purchase_limit; final deals.sold=2; deal_purchases.qty=2.
Tests / acceptance:
- Both assertions hold reliably across repeated runs (the caps are enforced by the guarded statements + row locks, so the outcome is deterministic).
Output: a unified diff plus the final counts.
What success looks like

Both caps hold, deterministically. With allocation_cap = 1, stock 200, and 20 concurrent buyers, exactly 1 succeeds and 19 get deal_sold_out; products.stock ends at 199 (down by one, not nineteen) and deals.sold = 1. With per_customer_cap = 2 and one customer firing 5 concurrent buys, exactly 2 succeed and 3 get purchase_limit; deals.sold = 2 and the ledger holds qty = 2. Repeated runs give the same counts every time. (Real output from the pgx v5.10.0 / Go 1.26 harness against postgres:16, stable across 8 consecutive runs.)

TEST 1 (allocation_cap=1, stock=200, 20 buyers):
  results: map[deal_sold_out:19 success:1]
  final products.stock=199  deals.sold=1

TEST 2 (per_customer_cap=2, one customer, 5 concurrent buys):
  results: map[purchase_limit:3 success:2]
  final products.stock=198  deals.sold=2  deal_purchases.qty=2

Build the flash-sale home screen (Jetpack Compose)

Optional add-on Intermediate

Render GET /storefront in Compose — a banner, category chips, deal cards with a live countdown and a sold-out surface, and the featured grid — so the shopper sees the drop, its ticking timer, and when it runs out.

New in this step
countdown via LaunchedEffect delay loop

A coroutine in LaunchedEffect that updates a remaining-time state every second, so the deal card’s timer ticks down without blocking the UI.

jetpack compose countdown timer docs ↗
parse endsAt (RFC3339 instant)

Instant.parse(endsAt) reads the Z-suffixed wire time; diff it against Clock.System.now() for the seconds remaining.

kotlin instant parse iso8601
struck-through price (textDecoration)

Text(..., textDecoration = TextDecoration.LineThrough) shows the original unitPrice crossed out beside the salePrice, the familiar sale affordance.

compose text linethrough
sold-out surface

When remaining reaches zero (or the timer hits zero), the card swaps its buy affordance for a “Sold out”/“Deal ended” state, so the UI never lets a shopper tap a dead deal.

ui empty sold out state
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The countdown and the sold-out surface — reasoned, not compiled

This screen is reasoned against Compose APIs (no Android toolchain here). It reads the GET /storefront payload into a UI model and renders four regions: the banner (headline + subhead), category chips (name), deal cards, and the featured grid. A deal card shows the salePrice beside a struck-through unitPrice, plus a countdown: a LaunchedEffect ticks every second, parsing endsAt once and recomputing the seconds left, and when that reaches zero it shows “Deal ended”. The sold-out surface is driven by the payload’s remaining (allocation_cap - sold) and soldOut fields — at zero the card disables its buy affordance and shows “Sold out”. Both are display-only: the client shows the advisory sale price and countdown, but the authoritative price and the real caps are enforced at checkout (the previous steps), so a card that looks live can still return deal_sold_out on tap — surface that 409 as the sold-out state rather than a generic error. Money stays integer cents; format only at the edge, exactly as the base catalog screen does.

Deal card with countdown + sold-out (Compose)
Run these in your terminal / editor
@Serializable
data class Deal(
    val productId: Long, val name: String,
    val unitPrice: Long, val salePrice: Long,
    val endsAt: String, val remaining: Int, val soldOut: Boolean,
)

@Composable
fun DealCard(deal: Deal, onBuy: (Long) -> Unit) {
    // Tick the countdown once per second from the RFC3339 endsAt instant.
    var secondsLeft by remember(deal.endsAt) {
        mutableStateOf((Instant.parse(deal.endsAt) - Clock.System.now()).inWholeSeconds)
    }
    LaunchedEffect(deal.endsAt) {
        while (secondsLeft > 0) { delay(1000); secondsLeft -= 1 }
    }
    val ended = secondsLeft <= 0
    val soldOut = deal.soldOut || deal.remaining <= 0

    Card(Modifier.fillMaxWidth().padding(12.dp)) {
        Column(Modifier.padding(16.dp)) {
            Text(deal.name, style = MaterialTheme.typography.titleMedium)
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                Text(formatCents(deal.salePrice), color = MaterialTheme.colorScheme.primary)
                Text(formatCents(deal.unitPrice), textDecoration = TextDecoration.LineThrough,
                     color = MaterialTheme.colorScheme.onSurfaceVariant)
            }
            when {
                soldOut -> Text("Sold out", color = MaterialTheme.colorScheme.error)
                ended   -> Text("Deal ended", color = MaterialTheme.colorScheme.error)
                else    -> {
                    Text("Ends in ${secondsLeft / 60}m ${secondsLeft % 60}s")
                    Text("${deal.remaining} left")
                    Button(onClick = { onBuy(deal.productId) }) { Text("Buy") }
                }
            }
        }
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Android engineer (Kotlin, Jetpack Compose) in this repo.
Context: GET /storefront returns {banners, categories, featured, deals}; a deal is {productId, name, unitPrice, salePrice, endsAt (RFC3339 Z), remaining, soldOut}; categories are the base {id, slug, name, imageUrl} tiles. Buying goes through the server-side cart: POST /cart/items {productId, qty} (persist the X-Cart-Token from the response header, as the base cart step does) then POST /checkout {customerId} with the token + an Idempotency-Key. A checkout of a sold-out deal returns 409 {"error":"deal_sold_out"}; over a per-customer cap returns 409 {"error":"purchase_limit"}.
Task: Build a home screen that fetches GET /storefront and renders the banner, category chips, deal cards with a live countdown + sold-out surface, and the featured grid.
Requirements:
- Ktor client + kotlinx.serialization; model the screen as loading/ready/error (no silent blank state).
- Deal card: salePrice beside a struck-through unitPrice; a countdown ticking each second from endsAt (parse the RFC3339 instant once); at zero show "Deal ended".
- Sold-out surface driven by remaining/soldOut: disable Buy and show "Sold out" when remaining <= 0.
- Money is integer cents; format only for display with Locale.US.
- Buy = add the deal product to the cart (reusing the stored token, or storing the new one from the response header) then checkout with the token; on a 409 deal_sold_out or purchase_limit, show the sold-out / limit state in place, not a generic error.
Tests / acceptance:
- A live, well-stocked deal renders the sale price, the struck-through original, and a ticking timer; a Buy navigates to confirmation on 200.
- A deal with remaining 0 renders "Sold out" with Buy disabled.
- A 409 deal_sold_out on Buy flips the card to the sold-out state.
Output: a unified diff plus the UI state model.
What success looks like

The home screen renders the “Aurora Summer Drop” banner, the three base category chips, and a deal card for the Sticker Pack showing $2.99 beside a struck-through $4.99, with a timer counting down to endsAt. When the payload’s remaining is zero — or the timer reaches zero — the card shows “Sold out”/“Deal ended” with Buy disabled. Tapping Buy on a live card adds the product to the server-side cart and checks out with the stored X-Cart-Token; if the drop was exhausted between load and tap, the 409 deal_sold_out flips the card to “Sold out” in place rather than a generic error, because the caps are enforced at checkout, not trusted from the advisory home-page read.

Add the lifecycle tables and widen the order status

Optional add-on Intermediate

Add the module’s own tables — reservations, payments, an append-only stock ledger, and a transactional outbox — as one additive migration, and widen the base orders.status enum to admit the payment lifecycle. The module owns this schema and only ever touches one base object (the status CHECK), so it drops onto the base build without a rewrite.

New in this step
append-only ledger

A table you only ever INSERT into (never update or delete), so it is a permanent audit of what changed and why — here, every movement of products.stock.

append only ledger table audit
transactional outbox

A table you write an event into inside the same transaction as a state change, so a later poller can deliver it reliably — the event and the change commit or roll back together.

transactional outbox pattern
widen a CHECK enum (DROP then ADD CONSTRAINT)

Postgres has no ALTER … MODIFY for a CHECK; you DROP CONSTRAINT IF EXISTS the old rule and ADD CONSTRAINT a wider one, keeping every value the old rule allowed so no existing row breaks.

postgres alter table drop add check constraint docs ↗
JSONB

A binary JSON column type; the outbox stores each event’s payload as JSONB so the shape can vary per topic without a schema change.

postgres jsonb column docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the module owns its own additive tables — and its one edit to a base object

An optional module must stand alone on the base build and assume nothing else. So it creates its own tables and never rewrites the core schema: reservations, payments, stock_movements, and outbox reference the base products/orders/customers with foreign keys, but those base tables are untouched — with one unavoidable exception. The base declares orders.status (§4.1) with CHECK (status IN ('pending','shipped','cancelled')), and the new lifecycle states (authorized, paid, fulfilled, refunded) would be rejected by that CHECK as SQLSTATE 23514. Postgres has no in-place enum-CHECK edit, so the module drops and re-adds the constraint, keeping every base value (shipped, cancelled) so no existing row is invalidated. The ADD COLUMN IF NOT EXISTS status line is a no-op on the real base (the column already exists) and keeps the migration correct on a base variant that somehow lacks it. This is the same lesson as the base schema step — let the schema be the rulebook — applied to a module’s own tables. The deeper treatment is in the PostgreSQL track.

0005_order_lifecycle.up.sql (Go) / V5__order_lifecycle.sql (Spring) — the module tables
Run these in your terminal / editor
-- OPTIONAL MODULE: order-lifecycle. Additive, self-contained, idempotent. Assumes ONLY the base.

-- (1) Widen the base orders.status enum to admit the payment/fulfilment lifecycle (the module's ONE
--     edit to a base object). Keep every base value so no existing row is invalidated.
ALTER TABLE orders ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_check;
ALTER TABLE orders ADD CONSTRAINT orders_status_check
  CHECK (status IN ('pending','authorized','paid','fulfilled','refunded','cancelled','shipped'));

-- (2) Reservations: a cart hold with a TTL. A hold is a GUARDED decrement of products.stock into the
--     reserved bucket (this row); it never bypasses the base oversell guard.
CREATE TABLE IF NOT EXISTS reservations (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  product_id  BIGINT      NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  customer_id BIGINT      NOT NULL REFERENCES customers(id),
  qty         INTEGER     NOT NULL CHECK (qty > 0),
  status      TEXT        NOT NULL DEFAULT 'held'
                CHECK (status IN ('held','released','committed')),
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS reservations_sweep ON reservations (expires_at) WHERE status = 'held';

-- (3) Payments: a mock provider's authorize -> capture -> refund records.
CREATE TABLE IF NOT EXISTS payments (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id   BIGINT      NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  kind       TEXT        NOT NULL CHECK (kind IN ('authorize','capture','refund')),
  amount     BIGINT      NOT NULL CHECK (amount >= 0),                          -- cents
  status     TEXT        NOT NULL CHECK (status IN ('authorized','captured','refunded','voided')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS payments_order ON payments (order_id);

-- (4) Stock ledger: an append-only audit of EVERY change to products.stock. It AUGMENTS the base
--     products.stock >= 0 guard (records why stock moved); it never replaces it.
CREATE TABLE IF NOT EXISTS stock_movements (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  product_id BIGINT      NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  delta      INTEGER     NOT NULL CHECK (delta <> 0),  -- signed: - = out of available, + = into available
  reason     TEXT        NOT NULL CHECK (reason IN ('sale','reserve','reserve_expire','refund','restock')),
  ref        TEXT,                                     -- provenance, e.g. 'order:4821' or 'reservation:12'
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS stock_movements_product ON stock_movements (product_id, created_at);

-- (5) Transactional outbox: events written in the SAME commit as a state transition, drained later.
CREATE TABLE IF NOT EXISTS outbox (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  topic      TEXT        NOT NULL,   -- 'order_authorized' | 'order_paid' | 'order_fulfilled' | 'order_refunded'
  payload    JSONB       NOT NULL,
  sent_at    TIMESTAMPTZ,            -- NULL until the drain poller marks it sent
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS outbox_unsent ON outbox (id) WHERE sent_at IS NULL;
0006_order_lifecycle_seed.up.sql (Go) / V6__order_lifecycle_seed.sql (Spring) — reconcile the ledger
Run these in your terminal / editor
-- Backfill the ledger with the seeded stock so sum(delta) = products.stock from day one. Idempotent:
-- guarded by ref='seed', a re-run inserts nothing.
INSERT INTO stock_movements (product_id, delta, reason, ref)
  SELECT p.id, p.stock, 'restock', 'seed'
  FROM products p
  WHERE NOT EXISTS (
    SELECT 1 FROM stock_movements m WHERE m.product_id = p.id AND m.ref = 'seed');
Apply it
Run these in your terminal / editor
# Docker only (no host psql needed) — apply the module migration + seed to check them now:
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0005_order_lifecycle.up.sql
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0006_order_lifecycle_seed.up.sql
What success looks like

\dt now lists four new tables — reservations, payments, stock_movements, outbox — beside the base four, and re-running the migration is a no-op (NOTICE: relation "reservations" already exists, skipping). The widened enum admits a lifecycle state and still rejects anything outside it, and the ledger reconciles with live stock. (Real output against postgres:16.)

=# SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname='orders_status_check';
 CHECK ((status = ANY (ARRAY['pending'::text, 'authorized'::text, 'paid'::text, 'fulfilled'::text,
                             'refunded'::text, 'cancelled'::text, 'shipped'::text])))

=# INSERT INTO orders (customer_id, total, status) VALUES (1, 100, 'fulfilled');   -- INSERT 0 1
=# INSERT INTO orders (customer_id, total, status) VALUES (1, 100, 'bogus');
ERROR:  new row for relation "orders" violates check constraint "orders_status_check"

Place and commit a cart hold

Optional add-on Intermediate

Add POST /reservations to place a time-boxed hold — a guarded decrement of products.stock into the reserved bucket plus a ledger row, all in one transaction — and POST /reservations/{id}/commit to turn a live hold into a confirmed sale. The hold reuses the exact base oversell guard, so a reservation can never oversell any more than a checkout can.

New in this step
inventory reservation (cart hold)

Units moved out of available stock and set aside for one shopper for a short window, so two shoppers can’t both buy the last unit while one is still deciding.

inventory reservation cart hold pattern
TTL / expiry

A hold carries an expires_at; if the shopper doesn’t buy in time it lapses and the units return to stock, so abandoned carts don’t strand inventory forever.

reservation time to live expiry
now() + interval

Postgres date math: now() + '300 seconds'::interval computes the expiry instant in SQL, so the TTL is set by the database clock, not the app’s.

postgres interval type now docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
A hold is a guarded decrement — it never bypasses the base guard

The whole project rests on one guard: UPDATE products SET stock = stock - qty WHERE id = ? AND stock >= qty cannot drive stock negative. A hold is that same statement — it decrements available stock into the reserved bucket (the reservations row), and 0 rows means out of stock exactly as a checkout would. So a reservation can’t oversell any more than a checkout can; the reserved units simply live in a reservations row instead of an order until the shopper commits or the hold lapses. products.stock stays the source of truth — it is available stock, and a hold moves units out of it under the guard. In the same transaction the hold writes one stock_movements('reserve', -qty) row so the ledger records why stock dropped. Commit (reserved → sold) is a guarded transition on the reservation (WHERE status = 'held'); because the units already left products.stock at hold time, commit creates the order and authorizes payment but does not decrement stock again and writes no new ledger row — the earlier reserve row is the stock-out record. The PostgreSQL track covers the row locking underneath.

Place a hold and commit it (SQL, shared by both backends)
Run these in your terminal / editor
-- POST /reservations: guarded decrement + reservation row + ledger, in ONE tx.
UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1 RETURNING stock;  -- 0 rows -> out_of_stock (409)
INSERT INTO reservations (product_id, customer_id, qty, expires_at)
  VALUES ($2, $3, $1, now() + ($4 || ' seconds')::interval) RETURNING id, expires_at;
INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES ($2, -$1, 'reserve', 'reservation:'||$id);

-- POST /reservations/{id}/commit: guarded held -> committed; NO second stock decrement.
UPDATE reservations SET status = 'committed' WHERE id = $1 AND status = 'held'
  RETURNING product_id, customer_id, qty;                       -- 0 rows -> classify: 409 illegal / 404 missing
-- then, in the SAME tx: INSERT the order (status 'authorized') + order_items + payments('authorize') + outbox.
Agent prompt — paste into an agent with repo access

Before you run this: after a hold of 5 units on a product with 50 in stock, what does GET /products report as its stock — 50, 45, or 55 — and where did the other units go?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: base checkout exists; the order-lifecycle tables exist (reservations, payments, stock_movements, outbox). Money is integer cents; timestamps follow the base createdAt rule (§5.4: RFC3339 UTC, trailing Z, no sub-second).
Task: Add POST /reservations (place a hold) and POST /reservations/{id}/commit (hold -> sale), each as ONE transaction.
Requirements:
- POST /reservations body {customerId, productId, quantity, ttlSeconds?} (ttlSeconds default 300). Run the base guarded decrement UPDATE products SET stock=stock-$1 WHERE id=$2 AND stock>=$1; 0 rows -> 409 {"error":"out_of_stock"} (an unknown productId folds here too — the guard can't tell them apart, and unlike base checkout, whose cart lines are FK-guaranteed, the id here is client-supplied, so the case is live). Insert the reservations row with expires_at = now() + ttl and a stock_movements('reserve', -qty) row. Unknown customerId (FK 23503) -> 404 {"error":"not_found"}. Empty/malformed body, quantity<=0, ttlSeconds<0 -> 422 {"error":"invalid_request"}.
- 200 body {reservationId, expiresAt} (expiresAt per the base createdAt wire rule).
- POST /reservations/{id}/commit: guarded UPDATE reservations SET status='committed' WHERE id=$1 AND status='held' RETURNING product_id, customer_id, qty. On 0 rows, SELECT EXISTS to classify: reservation exists -> 409 {"error":"illegal_transition"}; missing (or non-numeric {id}) -> 404 {"error":"not_found"}. On success, insert the order (status 'authorized') + order_items + payments('authorize', total, 'authorized') + outbox('order_authorized') in the SAME tx. Do NOT decrement stock again and write NO new ledger row.
- All JSON application/json, no trailing newline; both backends byte-identical.
Tests / acceptance:
- A hold of 5 on a stock-50 product returns 200 and GET /products shows stock 45.
- An over-quantity hold returns 409 out_of_stock and leaves stock unchanged.
- Commit returns 200 {orderId, status:"authorized"}; a second commit of the same reservation returns 409 illegal_transition; GET /products shows stock unchanged by the commit.
Output: a unified diff plus a note on why commit must not decrement stock twice.
What success looks like

A hold decrements available stock under the base guard, an over-quantity hold is refused with stock unchanged, and committing a live hold creates an authorized order without touching stock a second time. A second commit of the same reservation is an illegal transition. (Real output from the compiled pgx v5.10.0 / Go 1.26 run against postgres:16.)

held reservation #1 qty=5 expiresAt=2026-07-14T00:19:36Z -> Aurora Mug stock now 45
over-quantity hold -> err=product 1: out of stock  (stock unchanged: 45)
held #2 qty=3 -> Mug stock now 47 (reserved out of available)
commit #2 -> reservation "committed", order #1 "authorized"; Mug stock still 47 (NOT decremented again)
re-commit #2 -> err=reservation 2 not held: illegal transition   => 409 illegal_transition

Return expired holds to stock with SELECT … FOR UPDATE SKIP LOCKED

Optional add-on Advanced

Add an expiry sweep that finds lapsed holds and returns their units to stock, draining the held queue with SELECT … FOR UPDATE SKIP LOCKED so many sweepers can run at once without ever fighting over the same row. Each release is a guarded increment plus a ledger row, all in one transaction — the mirror image of the hold.

New in this step
SELECT … FOR UPDATE SKIP LOCKED

FOR UPDATE row-locks the selected rows for the transaction; SKIP LOCKED makes a concurrent query skip rows another transaction already holds instead of blocking — so two sweepers grab disjoint batches.

postgres select for update skip locked docs ↗
queue-drain pattern

The canonical way to use a table as a work queue: each worker claims and processes a distinct batch of rows, and locked rows are invisible to the others.

postgres skip locked job queue
scheduled sweep (ticker / @Scheduled)

Run the sweep periodically — Go with a time.Ticker goroutine, Spring with @Scheduled(fixedDelay = …) — so expired holds are reclaimed without a manual trigger.

go time ticker background job
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why SKIP LOCKED is the right tool for reclaiming holds

An expired hold is stranded inventory: units that left available stock but were never bought. The sweep must return them — and in production several instances of your service run at once, so several sweepers race over the same expired rows. A plain SELECT … FOR UPDATE would make the second sweeper block on the first’s locks, serialising the work and risking timeouts. SKIP LOCKED instead makes each sweeper skip rows another already holds and claim the next free batch, so they drain the queue in parallel with zero contention and never process a row twice. Per claimed row the sweep does the mirror of the hold — UPDATE products SET stock = stock + qty (units back into available), status = 'released', and a stock_movements('reserve_expire', +qty) row — all inside one transaction, so a crash mid-sweep reclaims either all of a batch or none. The partial index reservations_sweep (on expires_at WHERE status = 'held', from the tables step) keeps the scan cheap even with millions of settled rows. This is the same queue-drain the outbox uses later. The PostgreSQL track drills the locking in depth.

The expiry sweep (SQL, shared) + a Go poller
Run these in your terminal / editor
// SweepExpired drains the held queue with FOR UPDATE SKIP LOCKED, so concurrent sweepers never fight.
// Claim a batch, then per row restock + release + ledger — all in ONE tx. (Spring: the same SQL in a
// @Scheduled method over JdbcTemplate.)
func SweepExpired(ctx context.Context, tx pgx.Tx, limit int) (int, error) {
	rows, _ := tx.Query(ctx,
		`SELECT id, product_id, qty FROM reservations
		   WHERE status = 'held' AND expires_at < now()
		 ORDER BY expires_at
		 FOR UPDATE SKIP LOCKED
		 LIMIT $1`, limit)
	type held struct{ id, productID int64; qty int }
	var batch []held
	for rows.Next() { var h held; _ = rows.Scan(&h.id, &h.productID, &h.qty); batch = append(batch, h) }
	rows.Close() // close before issuing more queries on this tx's connection
	for _, h := range batch {
		tx.Exec(ctx, `UPDATE products SET stock = stock + $1 WHERE id = $2`, h.qty, h.productID)
		tx.Exec(ctx, `UPDATE reservations SET status = 'released' WHERE id = $1`, h.id)
		tx.Exec(ctx, `INSERT INTO stock_movements (product_id, delta, reason, ref)
		                VALUES ($1, $2, 'reserve_expire', 'reservation:'||$3)`, h.productID, h.qty, h.id)
	}
	return len(batch), nil
}
Agent prompt — paste into an agent with repo access

Before you run this: two sweepers start at the same instant against 4 expired holds, each with LIMIT 2. How many holds does each claim, and how many holds get released twice?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: reservations exist with a partial index reservations_sweep on (expires_at) WHERE status='held'. The hold path writes stock_movements('reserve', -qty).
Task: Add an expiry sweep that reclaims expired held reservations, and run it on a schedule.
Requirements:
- Claim work with SELECT id, product_id, qty FROM reservations WHERE status='held' AND expires_at < now() ORDER BY expires_at FOR UPDATE SKIP LOCKED LIMIT $n.
- Per claimed row, in the SAME tx: UPDATE products SET stock = stock + qty (back into available); UPDATE reservations SET status='released'; INSERT stock_movements('reserve_expire', +qty, 'reservation:'||id).
- Go: scan the batch, close rows, then update per row; run from a time.Ticker goroutine. Spring: the same SQL in a @Scheduled(fixedDelay=...) method over JdbcTemplate.
- The sweep is safe to run in many instances at once: SKIP LOCKED guarantees disjoint batches, so no hold is released twice.
Tests / acceptance:
- An expired hold of qty N restores products.stock by exactly N and flips the reservation to 'released'; a second sweep with nothing expired reclaims 0.
- Two concurrent sweepers over 4 expired holds (LIMIT 2 each) claim disjoint pairs; no row is released twice; total stock returned equals the sum of the 4 holds.
Output: a unified diff plus the SKIP LOCKED claim query.
What success looks like

The sweep returns an expired hold’s units to available stock and marks it released; a second sweep with nothing expired reclaims 0. Under two concurrent sweepers, SKIP LOCKED hands each a disjoint batch — no hold is ever released twice. (Real output against postgres:16.)

swept 1 expired hold(s); reservation #1 now "released"; Aurora Mug stock restored to 50
second sweep (nothing expired) -> swept 0

-- two concurrent transactions, same FOR UPDATE SKIP LOCKED query, 4 expired holds:
Session A (holds its locks):  id = 2, 3
Session B (runs concurrently): id = 4, 5     -- skipped A's locked rows, claimed the rest

★ Authorize payment and write the stock ledger inside checkout (Go)

Optional add-on Advanced

Extend your Go checkout transaction so that, inside the one existing transaction, each line writes an append-only stock_movements('sale') row and the order authorizes its mock payment (pending → authorized) with a payment record and an outbox event. The ledger records why stock moved without ever replacing the guard that moved it.

New in this step
authorize (vs capture)

authorize reserves the funds without taking them; capture (a later step) actually charges. Checkout authorizes; fulfilment captures — the standard two-phase card flow.

payment authorize vs capture
ledger row in the same tx as the guarded UPDATE

The stock_movements INSERT rides inside the very transaction that decrements stock, so the audit trail can never disagree with a committed stock change (both commit or both roll back).

append only ledger same transaction
guarded status transition

UPDATE orders SET status='authorized' WHERE id=? AND status='pending' flips the state only from the expected one; it is the same conditional-UPDATE discipline as the oversell guard, applied to the lifecycle.

conditional update where status
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The ledger augments the spotlight guard — it never replaces it

This is the base ★ checkout, extended — exactly as the storefront module extends it. The base guard is unchanged: UPDATE products SET stock = stock - qty WHERE id = ? AND stock >= qty RETURNING unit_price still decrements and captures price in one statement, still 0 rows means out-of-stock. products.stock stays the single source of truth the guard protects. The module adds two things inside the same transaction. First, per line, a stock_movements('sale', -qty, 'order:'||id) row — an append-only audit of why stock dropped, written in the same commit as the guarded UPDATE so the two can never diverge. Second, after the order is inserted, the payment is authorized: a guarded transition UPDATE orders SET status='authorized' WHERE id=? AND status='pending', a payments('authorize') row, and an outbox('order_authorized') event — all in the one tx. So a rolled-back checkout leaves no ledger row, no payment, and no event; a committed one leaves all three atomically. A freshly-checked-out order now reads back with status: "authorized" (not pending) — an additive change to the existing GET /orders/{id} field. The PostgreSQL track covers the transaction internals.

The checkout extension (pgx) — added inside the existing Checkout tx
Run these in your terminal / editor
// after the base guard captured the price via RETURNING unit_price, per line, in the SAME tx:
if _, err := tx.Exec(ctx,
	`INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4)`,
	orderID, l.ProductID, l.Qty, prices[i]); err != nil { return 0, err }
// MODULE: the append-only ledger records WHY stock moved, in the same tx as the guarded UPDATE.
if _, err := tx.Exec(ctx,
	`INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES ($1, $2, 'sale', $3)`,
	l.ProductID, -l.Qty, fmt.Sprintf("order:%d", orderID)); err != nil { return 0, err }

// ...once, after all order_items are inserted, still inside the ONE checkout tx:
// MODULE: authorize the mock payment as a guarded transition (pending -> authorized) + outbox event.
var newStatus string
if err := tx.QueryRow(ctx,
	`UPDATE orders SET status = 'authorized' WHERE id = $1 AND status = 'pending' RETURNING status`,
	orderID).Scan(&newStatus); err != nil { return 0, err }
if _, err := tx.Exec(ctx,
	`INSERT INTO payments (order_id, kind, amount, status) VALUES ($1, 'authorize', $2, 'authorized')`,
	orderID, total); err != nil { return 0, err }
if _, err := tx.Exec(ctx,
	`INSERT INTO outbox (topic, payload) VALUES ('order_authorized', $1::jsonb)`,
	fmt.Sprintf(`{"orderId":%d}`, orderID)); err != nil { return 0, err }
Agent prompt — paste into an agent with repo access

Before you run this: if the payments INSERT fails after the stock was decremented and the order row inserted, what does the learner see in products.stock and in orders after the request returns?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: Store.Checkout(ctx, customerID, cartToken, idemKey) exists as ONE pgx transaction (the base ★ step: it reads the cart's lines and claims the cart); the order-lifecycle tables exist. github.com/jackc/pgx/v5.
Task: Extend Checkout so, inside the existing transaction, each cart line writes a stock ledger row and the order authorizes its mock payment.
Requirements:
- Keep the base guard first and unchanged: UPDATE products ... RETURNING unit_price; 0 rows -> ErrOutOfStock. products.stock stays the source of truth.
- Per line, in the SAME tx: INSERT stock_movements(product_id, -qty, 'sale', 'order:'||orderID) — an append-only audit, NOT a replacement for the guard.
- After the order + items insert, in the SAME tx: guarded UPDATE orders SET status='authorized' WHERE id=$1 AND status='pending' RETURNING status; INSERT payments(order_id,'authorize',total,'authorized'); INSERT outbox('order_authorized', {"orderId":N}::jsonb).
- The checkout RESPONSE contract is unchanged ({orderId}); only the order's status (now 'authorized') and the new rows change. A rollback must leave no ledger row, no payment, no outbox event.
Tests / acceptance:
- A successful checkout of qty 2 leaves one stock_movements('sale', delta=-2) row, one payments('authorize') row, one outbox('order_authorized') row, and orders.status='authorized'.
- sum(stock_movements.delta) for the product still equals products.stock (ledger reconciles).
- An out-of-stock line rolls the whole tx back: no ledger row, no payment, no event, stock unchanged.
Output: a unified diff plus a one-line proof the ledger can't diverge from a committed stock change.
What success looks like

A checkout of 2 Sticker Packs (base 499 each) commits the order as authorized with total = 998, and writes exactly one sale ledger row of delta = -2 in the same transaction. (Verified with pgx v5.10.0 / Go 1.26 against postgres:16.)

order #2 created: status="authorized" total=998
ledger 'sale' row for order #2: delta=-2

★ Authorize payment and write the stock ledger inside checkout (Spring/Kotlin)

Optional add-on Advanced

Add the same two extensions to your @Transactional checkout via JdbcTemplate — a stock_movements('sale') row per line and the guarded pending → authorized payment authorize with a payment record and an outbox event — so the Spring path records payment and inventory history byte-for-byte like the Go path.

New in this step
jdbc.update inside @Transactional

Each jdbc.update(INSERT …) runs on the transaction Spring opened for the method, so the ledger row, the payment, and the order all commit or roll back together.

jdbctemplate update transactional docs ↗
?::jsonb parameter cast

The outbox payload is bound as a JSON string and cast in SQL with ?::jsonb, so the driver sends text and Postgres stores it as JSONB.

postgres jsonb parameter cast jdbc
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same extension, declarative boundary — reasoned, not compiled

The mechanic is identical to Go; only the JDBC surface differs, and it is reasoned here, not compiled (there is no JVM toolchain in this authoring environment) — written against Spring 6 / Boot 3.4 / JdbcTemplate. Inside the existing @Transactional checkout(), after the base stock guard and the order_items insert, add jdbc.update("INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES (?, ?, 'sale', ?)", productId, -qty, "order:$orderId") per line. Then, once, authorize the payment: the guarded jdbc.update("UPDATE orders SET status='authorized' WHERE id=? AND status='pending'", orderId), a payments('authorize') insert, and an outbox('order_authorized', payload::jsonb) insert. Because @Transactional opened the transaction for the whole method, all of it commits on normal return and rolls back on any throw — so a rolled-back checkout leaves no ledger row, no payment, and no event, exactly like the Go path. products.stock stays the source of truth the base guard protects; the ledger only augments it. Language depth is in the Kotlin track.

The checkout extension (JdbcTemplate + @Transactional) — reasoned parity
Run these in your terminal / editor
// inside the existing @Transactional checkout(), after the base stock guard + order_items insert, per line:
jdbc.update(
    "INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES (?, ?, 'sale', ?)",
    l.productId, -l.qty, "order:$orderId",
)

// ...once, after all order_items are inserted, still inside the ONE @Transactional method:
val authorized = jdbc.update(
    "UPDATE orders SET status = 'authorized' WHERE id = ? AND status = 'pending'", orderId,
)   // authorized == 1 on the happy path (a fresh order is 'pending')
jdbc.update(
    "INSERT INTO payments (order_id, kind, amount, status) VALUES (?, 'authorize', ?, 'authorized')",
    orderId, total,
)
jdbc.update(
    "INSERT INTO outbox (topic, payload) VALUES ('order_authorized', ?::jsonb)",
    """{"orderId":$orderId}""",
)
Agent prompt — paste into an agent with repo access

Before you run this: the payments INSERT throws after the stock UPDATE and the order insert succeeded. What does @Transactional do to the decrement, the order row, and the ledger row already written in this method?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: CheckoutService.checkout(customerId, cartToken, idemKey) exists as one @Transactional method over JdbcTemplate (the base ★ step: it reads the cart's lines and claims the cart); the order-lifecycle tables exist. Keep it byte-for-byte with the Go path.
Task: Extend checkout() so each cart line writes a stock ledger row and the order authorizes its mock payment, all inside the one @Transactional method.
Requirements:
- Keep the base stock guard first and unchanged; products.stock stays the source of truth.
- Per line: jdbc.update INSERT stock_movements(product_id, -qty, 'sale', 'order:'+orderId) — an append-only audit, not a replacement for the guard.
- After the order + items insert: guarded jdbc.update UPDATE orders SET status='authorized' WHERE id=? AND status='pending'; INSERT payments(order_id,'authorize',total,'authorized'); INSERT outbox('order_authorized', ?::jsonb) with the payload {"orderId":N}.
- The checkout RESPONSE contract is unchanged ({orderId}); a thrown exception rolls back the ledger row, the payment, and the event together.
Tests / acceptance (Testcontainers postgres:16):
- A successful checkout leaves one stock_movements('sale') row, one payments('authorize') row, one outbox('order_authorized') row, and orders.status='authorized'.
- An out-of-stock line throws and rolls back: no ledger row, no payment, no event, stock unchanged.
- sum(stock_movements.delta) reconciles with products.stock.
Output: a unified diff plus the three added INSERT/UPDATE statements.
What success looks like

Reasoned, not compiled — the Spring path mirrors the Go path the compiled run proves: a successful checkout commits the order as authorized, one payments('authorize') row, one outbox('order_authorized') event, and one stock_movements('sale') row per line, all in the one @Transactional method. An out-of-stock line throws, @Transactional rolls back the whole method, and no ledger row, payment, or event survives. The total and the ledger reconcile byte-identically to the Go path.

Drive the state machine: capture and fulfil as guarded transitions

Optional add-on Intermediate

Add POST /orders/{id}/capture (authorized → paid) and POST /orders/{id}/fulfil (paid → fulfilled), each a guarded UPDATE … WHERE status = <expected> RETURNING. An illegal transition matches zero rows and returns 409 — the same conditional-UPDATE discipline as the oversell guard, now driving the order’s lifecycle.

New in this step
order state machine

The order moves through a fixed set of states along allowed edges only; the database, not the client, decides which hop is legal from the current state.

order status state machine transitions
guarded transition (UPDATE … WHERE status = expected)

UPDATE orders SET status=$next WHERE id=? AND status=$expected RETURNING flips the state only from the expected one; a race or a repeat sees 0 rows, so illegal hops are refused atomically — no read-then-write gap.

conditional update where status returning docs ↗
classify 0 rows: 409 vs 404

A 0-row transition is either the wrong state (the order exists) or a missing order; a cheap SELECT EXISTS probe tells them apart, so a wrong-state hop is 409 and a missing order is 404.

postgres select exists probe
capture (mock provider)

capture charges the funds authorize reserved; here capture is the authorized → paid hop, recorded as a payments('capture') row.

payment capture after authorize
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The same guard, now on the lifecycle — and why transitions are strict

You proved the conditional UPDATE … WHERE stock >= qty can’t oversell because a race sees 0 rows. The lifecycle uses the exact same trick: UPDATE orders SET status = $next WHERE id = ? AND status = $expected RETURNING flips the state only from the expected one, atomically, with no read-then-write gap. A 0-row result means the hop was illegal — and a cheap SELECT EXISTS(SELECT 1 FROM orders WHERE id = ?) probe classifies it: the order exists (wrong state) is a 409 {"error":"illegal_transition"}; it doesn’t exist (or a non-numeric {id}) is a 404 {"error":"not_found"}. Transitions are strict — a single expected state — so repeating a hop is itself illegal (capturing an already-paid order sees 0 rows). That strictness is the lesson: the state machine, enforced by the database, makes double-charging or shipping-before-paying structurally impossible, not merely discouraged. capture records a payments('capture','captured') row and an outbox('order_paid') event; fulfil records an outbox('order_fulfilled') event — each in the same transaction as the status flip. The PostgreSQL track covers the row locking that serialises two racers on the same order.

A guarded transition (SQL, shared by both backends)
Run these in your terminal / editor
-- capture: authorized -> paid. RETURNING lets the app read back the amount for the payments row.
UPDATE orders SET status = 'paid'
  WHERE id = $1 AND status = 'authorized'
RETURNING total;                       -- 0 rows -> classify (409 illegal_transition / 404 not_found)

-- then, in the SAME tx: INSERT payments(order_id,'capture',total,'captured') + outbox('order_paid').
-- fulfil is the same shape: SET status='fulfilled' WHERE status='paid'; then outbox('order_fulfilled').

-- classification probe when the guarded UPDATE returns 0 rows:
SELECT EXISTS(SELECT 1 FROM orders WHERE id = $1);   -- true -> 409 illegal_transition; false -> 404 not_found
Agent prompt — paste into an agent with repo access

Before you run this: an order is 'authorized'. You POST /capture twice in quick succession. What status does each call return, and how many payments('capture') rows exist afterward?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: orders.status now runs pending -> authorized -> paid -> fulfilled -> refunded (order-lifecycle migration applied); GET /orders/{id} returns status. Money is integer cents.
Task: Add POST /orders/{id}/capture (authorized -> paid) and POST /orders/{id}/fulfil (paid -> fulfilled), each ONE transaction.
Requirements:
- Each hop: UPDATE orders SET status=$next WHERE id=$1 AND status=$expected RETURNING total. On success (200) return {"orderId":N,"status":"<next>"}.
- On 0 rows, run SELECT EXISTS(SELECT 1 FROM orders WHERE id=$1): true -> 409 {"error":"illegal_transition"}; false -> 404 {"error":"not_found"}. A non-numeric {id} is 404 (Go: strconv.Atoi error -> 404; Spring: bind String + toLongOrNull() ?: 404, or an @ExceptionHandler for MethodArgumentTypeMismatchException).
- capture: in the SAME tx, INSERT payments(order_id,'capture',total,'captured') + outbox('order_paid'). fulfil: INSERT outbox('order_fulfilled').
- Transitions are STRICT (single expected state), so a repeat is an illegal transition (409).
- Spring: catch the 0-row EmptyResultDataAccessException INSIDE the service and rethrow IllegalTransitionException, mapped to 409 by @RestControllerAdvice — so the global 404 advice never sees a wrong-state conflict (§7 parity).
Tests / acceptance:
- Capture an authorized order -> 200 {status:"paid"}; a SECOND capture -> 409 illegal_transition; exactly one payments('capture') row exists.
- Fulfil a paid order -> 200 {status:"fulfilled"}; fulfilling an authorized (not paid) order -> 409.
- capture/fulfil on a missing id -> 404; on a non-numeric id -> 404 (never 400/500).
Output: a unified diff plus the advice mapping (Spring) or the switch (Go).
What success looks like

Each legal hop returns 200 with the new status; a repeat or an out-of-order hop matches 0 rows and returns 409 illegal_transition, and a missing order returns 404. The raw guarded UPDATE shows the mechanic — UPDATE 1 the first time, UPDATE 0 on the illegal repeat — and the EXISTS probe confirms 409 (order present) over 404. (Real output against postgres:16; Go transition runs shown below.)

=# UPDATE orders SET status='paid' WHERE id=2 AND status='authorized' RETURNING id, status;
 id | status
----+--------
  2 | paid            -- UPDATE 1  (legal)
=# UPDATE orders SET status='paid' WHERE id=2 AND status='authorized' RETURNING id, status;
(0 rows)              -- UPDATE 0  (illegal repeat)
=# SELECT EXISTS(SELECT 1 FROM orders WHERE id=2);  -> t   (order exists => 409, not 404)

capture authorized->paid: paid
SECOND capture (already paid) -> illegal transition   => 409 illegal_transition
fulfil paid->fulfilled: fulfilled
fulfil unknown order 999999 -> not found              => 404 not_found

Refund as a compensating transaction

Optional add-on Advanced

Add POST /orders/{id}/refund — an idempotent, atomic compensation that transitions a paid or fulfilled order to refunded, restocks the refunded lines (positive ledger rows plus guarded increments), records a refund payment, and emits an event, all in one transaction. A second refund is a safe no-op, so a double-tapped button can’t refund twice.

New in this step
compensating transaction

You can’t un-commit a committed transaction, so you reverse its effects with a new one — restock the units, record a refund, move the order to refunded.

compensating transaction saga
idempotency from state

Instead of storing a key, the refund reads the order’s state: a second refund finds it already refunded and returns the same result without restocking again — the same replay-safety the base cancel uses (§5.6).

idempotent api derived from state
restock via positive ledger rows

Each refunded line writes a stock_movements('refund', +qty) row and products.stock += qty in the same tx, so the audit shows the reversal and available stock rises by exactly what came back.

inventory restock ledger entry
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Reverse a committed sale — atomically, and exactly once

A committed checkout can’t be un-committed, so a refund compensates: a new transaction that reverses the effects. In one tx it runs the guarded terminal transition UPDATE orders SET status='refunded' WHERE id=? AND status IN ('paid','fulfilled') RETURNING, then for each refunded line writes a positive stock_movements('refund', +qty) row and products.stock += qty (the units come back to available), records one payments('refund','refunded') row for the refunded amount, and emits an outbox('order_refunded') event. Optional body {"lines":[{"productId":3,"quantity":2}]} refunds specific lines; omit it to refund all. Idempotency comes from state, not a stored key (matching the base cancel, §5.6): a 0-row transition is classified by a SELECT status probe — already refunded returns the same {orderId, status, refunded} 200 and restocks nothing (a double-tapped confirm can’t double-restock); a not-yet-captured order (pending/authorized) or a cancelled one returns 409 {"error":"not_refundable"}; a missing order returns 404. An Idempotency-Key header is accepted for symmetry with /checkout but is not the safety mechanism, and no refund key is stored. A partial refund still moves the order to the terminal refunded state — this course models refunded as “a refund has been issued”. The PostgreSQL track covers the isolation that keeps two concurrent refunds from both restocking.

The refund compensation (SQL, shared by both backends)
Run these in your terminal / editor
-- Guarded terminal transition; refundable ONLY from paid or fulfilled.
UPDATE orders SET status = 'refunded'
  WHERE id = $1 AND status IN ('paid','fulfilled')
RETURNING status;                     -- 0 rows -> probe below

-- On 0 rows: SELECT status FROM orders WHERE id = $1
--   'refunded'      -> idempotent replay: recompute SUM(amount) of payments('refund') and return 200
--   other / found   -> 409 not_refundable
--   no row          -> 404 not_found

-- On success, per refunded line, in the SAME tx (default: every order_items line):
UPDATE products SET stock = stock + $qty WHERE id = $product_id;                       -- back into available
INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES ($product_id, $qty, 'refund', 'order:'||$1);
-- then once: INSERT payments($1,'refund',$refunded,'refunded') + outbox('order_refunded').
Agent prompt — paste into an agent with repo access

Before you run this: you POST /refund on a fulfilled order, then POST the identical /refund again. What does each call return, and how many units are added back to stock in total?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: orders run ... paid -> fulfilled -> refunded; order_items hold the purchased quantity + unit_price; stock_movements is the append-only ledger. Money is integer cents.
Task: Add POST /orders/{id}/refund as an idempotent, atomic compensating transaction.
Requirements:
- Guarded terminal transition: UPDATE orders SET status='refunded' WHERE id=$1 AND status IN ('paid','fulfilled') RETURNING status.
- On 0 rows, SELECT status FROM orders WHERE id=$1: 'refunded' -> idempotent replay, recompute refunded = SUM(payments.amount WHERE kind='refund') and return 200 {"orderId":N,"status":"refunded","refunded":C}, restocking NOTHING; a found non-refundable state -> 409 {"error":"not_refundable"}; no row -> 404 {"error":"not_found"}. Non-numeric {id} -> 404.
- Optional body {lines:[{productId,quantity}]} (default: all order_items lines). Per refunded line: UPDATE products SET stock=stock+qty; INSERT stock_movements(product_id, +qty, 'refund', 'order:'||id). A line not in the order, or quantity above what was purchased -> 422 {"error":"invalid_request"}.
- Once: INSERT payments(order_id,'refund',refunded,'refunded') + outbox('order_refunded'). All in ONE tx.
- Accept an Idempotency-Key header for symmetry but do NOT store it; the safety mechanism is order state.
Tests / acceptance:
- Refund a fulfilled order -> 200 {status:"refunded", refunded:<sum>} and products.stock rises by exactly the refunded quantities.
- A SECOND identical refund -> the SAME 200 body and stock UNCHANGED (no double restock).
- Refund a pending/authorized order -> 409 not_refundable; a missing id -> 404.
Output: a unified diff plus a one-line proof a double-tapped refund can't double-restock.
What success looks like

A refund reverses the sale atomically — the order becomes refunded, the refunded amount is recorded, and stock rises by exactly the refunded quantity. The identical refund replayed returns the same body and leaves stock unchanged: no double-restock. (Real output from the compiled pgx v5.10.0 / Go 1.26 run against postgres:16.)

Aurora Sticker stock before refund: 198
refund order #2 -> refunded=998 cents; status="refunded"; Sticker stock restored to 200
REPLAY refund   -> refunded=998 cents; Sticker stock still 200 (no double restock)

Drain a transactional outbox without dual-write

Optional add-on Advanced

Add a poller that delivers unsent outbox rows and marks them sent, draining the queue with FOR UPDATE SKIP LOCKED. Because each event was written in the same commit as its state transition, this is reliable side-effects without the dual-write bug — no event is ever lost or sent for a rolled-back order.

New in this step
dual-write problem

Writing to the database and then to a message broker are two separate commits; a crash between them either loses the event or sends one for a change that rolled back.

dual write problem outbox
transactional outbox (the fix)

Write the event into an outbox table in the same transaction as the state change, so they commit together; a separate poller delivers it afterward — never lossy, never premature.

transactional outbox pattern
at-least-once delivery

The poller may deliver a row more than once if it crashes before marking it sent, so consumers must be idempotent; you never lose an event, which is the guarantee that matters here.

at least once delivery idempotent consumer
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the outbox is the only shape that is neither lossy nor premature

Ship a side-effect for a checkout — email a receipt, notify a warehouse — and the naive options both break. Send after COMMIT and a crash in the gap loses the event: the order is paid but nothing fires. Send before COMMIT and a rollback sends an event for an order that never existed. The transactional outbox fixes both: the state transition writes an outbox row in the same commit (you did this in the checkout, capture, fulfil, and refund steps), so the event and the change are inseparable — either both are durable or neither is. A separate poller then reads unsent rows, delivers them (here a stub consumer that just logs, so the course stays at $0), and marks them sent_at. It drains with the same FOR UPDATE SKIP LOCKED queue pattern as the reservation sweep, so many pollers run without double-claiming a row; the outbox_unsent partial index keeps the scan cheap. Delivery is at-least-once — a poller that crashes after delivering but before marking sent will redeliver — so a real consumer must be idempotent; but nothing is ever lost. The PostgreSQL track covers SKIP LOCKED in depth.

Drain the outbox (SQL, shared) — the same SKIP LOCKED queue pattern
Run these in your terminal / editor
-- Claim a batch of unsent events; concurrent pollers get disjoint batches.
SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT $1;

-- Per delivered row (stub consumer = log/no-op), in the SAME tx:
UPDATE outbox SET sent_at = now() WHERE id = $id;
Agent prompt — paste into an agent with repo access

Before you run this: an order is checked out, captured, fulfilled, then refunded. How many outbox rows are unsent before the first drain, and which topics are they?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: checkout/capture/fulfil/refund each write an outbox row in the same commit as their state change (topics: order_authorized, order_paid, order_fulfilled, order_refunded). The outbox table has a partial index outbox_unsent on (id) WHERE sent_at IS NULL.
Task: Add an outbox drain poller with a STUB consumer, run on a schedule.
Requirements:
- Claim work: SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id FOR UPDATE SKIP LOCKED LIMIT $n.
- Per row: deliver via a stub consumer (log the topic/id; no external call, no cost), then UPDATE outbox SET sent_at=now() WHERE id=$id — in the SAME tx.
- Go: scan the batch, close rows, update per row; run from a time.Ticker goroutine. Spring: the same SQL in a @Scheduled method over JdbcTemplate.
- Delivery is at-least-once (a crash before marking sent redelivers); document that consumers must be idempotent. Many pollers must be safe to run at once (SKIP LOCKED -> disjoint batches).
Tests / acceptance:
- After a checkout+capture+fulfil+refund, the drain delivers exactly 4 rows (order_authorized, order_paid, order_fulfilled, order_refunded) and leaves 0 unsent.
- A second drain with nothing unsent delivers 0.
- Two concurrent pollers never mark the same row sent twice.
Output: a unified diff plus a one-line note on why send-after-commit and send-before-commit both fail.
What success looks like

After a full lifecycle (checkout → capture → fulfil → refund plus a committed reservation), the drain delivers every unsent event exactly once and leaves the queue empty; a second drain finds nothing. (Real output from the compiled pgx v5.10.0 / Go 1.26 run against postgres:16.)

unsent before drain: 5
    [outbox] delivering #1 topic=order_authorized (stub, no-op)
    [outbox] delivering #2 topic=order_authorized (stub, no-op)
    [outbox] delivering #3 topic=order_paid (stub, no-op)
    [outbox] delivering #4 topic=order_fulfilled (stub, no-op)
    [outbox] delivering #5 topic=order_refunded (stub, no-op)
drained 5; unsent after drain: 0

Read inventory from the ledger: restock and low-stock

Optional add-on Intermediate

Use the append-only stock_movements ledger to restock (a guarded increment plus a restock row) and to read low-stock levels, and confirm the ledger reconciles with live stock — sum(delta) equals products.stock for every product. The ledger is the audit that explains every move the guard made.

New in this step
restock (guarded increment + ledger row)

Restocking is products.stock += qty plus a stock_movements('restock', +qty) row in one tx, so the source of truth rises and the audit records the reason.

inventory restock ledger entry
read state from an append-only ledger

Because every move is a row, you can answer “how much did this product sell?” by summing the ledger — the history is queryable, not lost in overwrites.

event sourcing read model aggregate
reconcile invariant (sum(delta) = stock)

If the ledger recorded every move faithfully, its running total equals live stock; a mismatch flags a bug where some change skipped the ledger.

ledger reconciliation invariant
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The ledger is the audit — the guard is still the source of truth

products.stock remains the single value the base guarded UPDATE protects; stock_movements is the append-only record of why it moved. Every change in this module wrote a ledger row in the same transaction as the stock change — sale/reserve (-qty), reserve_expire/refund/restock (+qty) — so the two can never drift. That gives you two capabilities. Restock is a guarded increment plus a restock row: UPDATE products SET stock = stock + qty and INSERT stock_movements('restock', +qty), in one tx, so replenishment is audited like everything else. Low-stock is a read from the ledger joined to the catalog — which products are at or below a threshold, and (via a GROUP BY) how their movements sum. The reconcile invariant is the proof the discipline held: sum(stock_movements.delta) = products.stock for every product, because the seed backfilled the starting stock as a restock row and every move since wrote one. A mismatch would mean some code path changed stock without recording it — a bug the reconcile query surfaces immediately. This is the audit trail that makes the whole lifecycle explainable. The PostgreSQL track covers aggregate reads and indexing for them.

Restock and read low-stock from the ledger (SQL, shared)
Run these in your terminal / editor
-- restock (in ONE tx): guarded increment + an audited ledger row.
UPDATE products SET stock = stock + $1 WHERE id = $2;
INSERT INTO stock_movements (product_id, delta, reason, ref) VALUES ($2, $1, 'restock', $3);

-- low stock + reconcile: products at or below a threshold, with the ledger sum beside live stock.
SELECT p.id, p.name, p.stock, COALESCE(SUM(m.delta), 0) AS ledger_sum
  FROM products p LEFT JOIN stock_movements m ON m.product_id = p.id
 GROUP BY p.id
HAVING p.stock <= $1                                   -- $1 = the low-stock threshold
 ORDER BY p.stock;
Agent prompt — paste into an agent with repo access

Before you run this: after a sale of 2 units, a reservation of 5 that later expired and was swept, and a refund of the 2 sold, what does sum(stock_movements.delta) equal for that product relative to its live stock?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: stock_movements is the append-only ledger; every stock change in the module wrote a row in the same tx (seed backfilled starting stock as a 'restock' row per product).
Task: Add a restock operation and a low-stock read, both from the ledger, and a reconcile check.
Requirements:
- Restock (one tx): UPDATE products SET stock=stock+qty WHERE id=?; INSERT stock_movements(product_id, +qty, 'restock', ref). Money/quantities are integers.
- Low-stock read: SELECT id, name, stock, SUM(delta) grouped per product, filtered to stock <= threshold, ordered by stock ascending.
- Reconcile: for every product, sum(stock_movements.delta) must equal products.stock; expose it as a check (a row per product with a boolean reconciles).
Tests / acceptance:
- A restock of N raises products.stock by N and appends exactly one 'restock' ledger row.
- The low-stock query returns only products at/below the threshold, ascending by stock.
- sum(delta) == stock for every product after a mixed run (sale, reserve+expire+sweep, refund, restock).
Output: a unified diff plus the reconcile query.
What success looks like

After a mixed run — a sale, a reservation that expired and was swept back, a committed reservation, and a refund — the ledger reconciles exactly with live stock for every product: sum(delta) equals products.stock. (Real output from the compiled pgx v5.10.0 / Go 1.26 run against postgres:16.)

Aurora Mug           stock=47   ledger_sum=47   reconciles=true
Aurora Tee           stock=12   ledger_sum=12   reconciles=true
Aurora Sticker Pack  stock=200  ledger_sum=200  reconciles=true

Index the catalog for search: a generated tsvector, GIN, and trigram

Optional add-on Intermediate

Add the discovery module’s own additive schema to the base products table — a searchable description and a generated tsvector over name || description — then index the vector with GIN, the name with pg_trgm, and category_id for the category facet. The module owns this schema and touches no base table’s meaning (the category itself is base data), so it drops onto the base build without a rewrite.

New in this step
tsvector

Postgres’ pre-parsed full-text representation of a document (the lexemes and their positions) — searching it with an index is far faster than scanning raw text.

postgres tsvector full text search docs ↗
generated STORED column

A column whose value is computed from other columns and stored automatically, so the search vector recomputes itself whenever the name or description changes — the app never maintains it.

postgres generated column stored docs ↗
immutable to_tsvector

The two-argument to_tsvector('english', ...) is immutable (a fixed language), which a generated column requires; the one-argument form depends on a session setting and is rejected here.

postgres to_tsvector immutable regconfig
GIN index

The inverted index that makes tsvector @@ tsquery fast — it maps each lexeme to the rows that contain it.

postgres GIN index full text docs ↗
pg_trgm extension

Adds trigram matching for fuzzy/typo search; enable it once with CREATE EXTENSION IF NOT EXISTS pg_trgm (the same extension the ai-recipe module uses).

postgres pg_trgm extension docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the module adds only a description — and why the tsvector is generated

The base products table is (id, name, unit_price, stock, category_id) — categorised via the base categories table, but with no description. So the module adds description as its one additive column with ALTER TABLE products ADD COLUMN IF NOT EXISTS …, backfills it in the next step, and builds the search vector over name || ' ' || description. The category needs no module column: the browse facet joins the base products.category_id → categories, so search hits carry the base slug and there is no second category value to drift. What the base doesn’t declare is an index on products.category_id (pointless on a 3-row catalog), so the module adds one for its facet join. Making the vector a generated STORED column means Postgres keeps it in sync for you: change a product’s description and its search_tsv updates in the same write, no trigger to hand-write. The generated column must use the two-argument to_tsvector('english', …) — pinning the language makes the expression immutable, which a stored generated column requires; the one-argument form reads a session setting and Postgres refuses it. pg_trgm is enabled with CREATE EXTENSION IF NOT EXISTS so the module stands alone whether or not ai-recipe already turned it on, and the down migration leaves the extension in place (another module may need it). The deeper treatment of full-text and trigram indexing is in the PostgreSQL track.

0007_discovery.up.sql (Go) / V7__discovery.sql (Spring) — the module schema
Run these in your terminal / editor
-- OPTIONAL MODULE: discovery. Additive, self-contained, idempotent. Stands alone on the BASE build.

-- Typo-tolerance leans on pg_trgm. IF NOT EXISTS is a no-op if ai-recipe already enabled it.
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Module-owned column on the base catalog (additive; NOT NULL with a default so existing rows fill in).
-- The category needs NO module column: the base products.category_id -> categories join covers it.
ALTER TABLE products ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';

-- Generated full-text vector over name + description. The 2-arg to_tsvector('english', ...) is IMMUTABLE
-- (a fixed regconfig), which is what a STORED generated column requires — the 1-arg form is only STABLE.
ALTER TABLE products ADD COLUMN IF NOT EXISTS search_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || description)) STORED;

CREATE INDEX IF NOT EXISTS products_search_tsv ON products USING GIN (search_tsv);         -- full-text
CREATE INDEX IF NOT EXISTS products_name_trgm  ON products USING GIN (name gin_trgm_ops);  -- typo-tolerance
CREATE INDEX IF NOT EXISTS products_category   ON products (category_id);                  -- category facet join (the base declares no FK index)
Apply it (Docker only, no host psql needed)
Run these in your terminal / editor
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0007_discovery.up.sql
What success looks like

\d products now shows the two module-owned columns — description and the generated search_tsv tsvector — and three new indexes beside the base ones (note products_category is on the base category_id). Re-running the migration is a clean no-op (each object reports already exists, skipping), so the module is idempotent. Real output from postgres:16:

              Table "public.products"
   Column    |  Type    | ...
-------------+----------+-----
 id          | bigint   | ...
 name        | text     | ...
 unit_price  | bigint   | ...
 stock       | integer  | ...
 category_id | bigint   | not null
 description | text     | not null default ''::text
 search_tsv  | tsvector | generated always as (to_tsvector('english'::regconfig, (name || ' '::text) || description)) stored
Indexes:
    "products_category" btree (category_id)
    "products_name_trgm" gin (name gin_trgm_ops)
    "products_search_tsv" gin (search_tsv)

Seed searchable copy and a demo order graph

Optional add-on Intermediate

Backfill the base catalog with descriptions, add the one category the base seed lacks plus seven more products (each resolving its base category_id by slug), and insert a small historical order graph — the co-purchase data your recommendations read. There is no new datastore: the orders live in the same SQL database as the catalog, which is the whole point of this module.

New in this step
co-purchase order graph

A set of orders where products appear together; querying which products share orders is the raw signal behind “customers also bought”.

market basket co-purchase recommendation
idempotent seed via a unique key

Keying each demo order on the base orders.idempotency_key (a partial-unique index) with ON CONFLICT DO NOTHING means re-running the seed inserts nothing — the mark of a safe, repeatable migration.

postgres on conflict do nothing idempotent seed
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why the module seeds its own orders, dates some in the past — and coexists with sales-insights

Recommendations are computed from real order history, but the base seed creates only three products and one customer — no orders. So the module seeds its own demo order graph: ten historical orders across an expanded catalog and three demo customers, inserted directly (not through the checkout guard, since they are recommendation data and must not decrement live stock). Idempotency is layered: order keys are seed:disc:* guarded by the base orders.idempotency_key partial-unique index, customers by ON CONFLICT (email), products by ON CONFLICT (name). The one category the base seed lacks (Stationery/stationery) is inserted first with ON CONFLICT (slug) — the identical row the sales-insights module also seeds, so either module can run first — and the seven new products resolve their base category_id by slug, never a hardcoded id. Three of the product names (Aurora Hoodie, Aurora Water Bottle, Aurora Notebook) are deliberately the same rows sales-insights seeds, so its ON CONFLICT (name) inserts silently defer to these when both modules are on. Two orders are dated 30+ days back so they fall outside the default 7-day trending window while still counting for all-time co-purchase — that gap is what makes the trending window observable later.

0008_discovery_seed.up.sql (Go) / V8__discovery_seed.sql (Spring) — demo data
Run these in your terminal / editor
-- Assumes the base seed (3 categories + 3 products + 1 customer) exists. Idempotent on re-run.
-- (a) Backfill description for the base three products (name-matched; the STORED search_tsv
--     recomputes automatically; their category_id already comes from the base seed).
UPDATE products SET description = 'Ceramic coffee mug with the Aurora logo' WHERE name = 'Aurora Mug';
UPDATE products SET description = 'Soft cotton t-shirt in Aurora colours'   WHERE name = 'Aurora Tee';
UPDATE products SET description = 'Weatherproof vinyl sticker pack'         WHERE name = 'Aurora Sticker Pack';

-- (b) The one category the base seed lacks. Identical row in the sales-insights seed;
--     ON CONFLICT (slug) lets whichever module runs first win.
INSERT INTO categories (name, slug, image_url) VALUES
  ('Stationery', 'stationery', 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg')
ON CONFLICT (slug) DO NOTHING;

-- (c) Seven more module-owned products, category_id resolved BY SLUG (never a hardcoded id).
--     ON CONFLICT (name) DO NOTHING makes this idempotent AND lets sales-insights' same-named
--     rows (Aurora Hoodie / Water Bottle / Notebook) defer to these.
INSERT INTO products (name, unit_price, stock, description, category_id)
SELECT v.name, v.unit_price, v.stock, v.description, c.id
FROM (VALUES
  ('Aurora Hoodie',       5999, 30, 'Heavyweight hoodie with embroidered logo', 'apparel'),
  ('Aurora Water Bottle', 1899, 40, 'Insulated stainless-steel bottle',         'drinkware'),
  ('Aurora Notebook',      899, 60, 'Dot-grid notebook, 120 pages',             'stationery'),
  ('Aurora Tote Bag',     1299, 45, 'Canvas tote with Aurora print',            'accessories'),
  ('Aurora Cap',          1799, 35, 'Adjustable cap, embroidered logo',         'apparel'),
  ('Aurora Coaster Set',   999, 55, 'Set of four cork-backed coasters',         'drinkware'),
  ('Aurora Enamel Pin',    699, 80, 'Hard-enamel lapel pin',                    'accessories')
) AS v(name, unit_price, stock, description, slug)
JOIN categories c ON c.slug = v.slug
ON CONFLICT (name) DO NOTHING;

-- (d) Three module-owned demo customers so "customers also bought" spans real buyers.
INSERT INTO customers (email) VALUES
  ('disc1@aurora.test'),('disc2@aurora.test'),('disc3@aurora.test')
ON CONFLICT (email) DO NOTHING;

-- (e) A small historical order graph (keys seed:disc:*), inserted DIRECTLY (recommendation data
--     must not decrement live stock). Orders 30+ days back fall OUTSIDE a 7-day trending window
--     while still counting for all-time co-purchase.
WITH demo(tag, cust, product, qty, days_ago) AS (
  VALUES
    ('disc-o1','disc1@aurora.test','Aurora Mug',1,2),          ('disc-o1','disc1@aurora.test','Aurora Tee',1,2),
    ('disc-o2','disc1@aurora.test','Aurora Mug',1,4),          ('disc-o2','disc1@aurora.test','Aurora Sticker Pack',2,4),
    ('disc-o3','disc2@aurora.test','Aurora Mug',1,1),          ('disc-o3','disc2@aurora.test','Aurora Tee',1,1),          ('disc-o3','disc2@aurora.test','Aurora Enamel Pin',1,1),
    ('disc-o4','disc2@aurora.test','Aurora Hoodie',1,3),       ('disc-o4','disc2@aurora.test','Aurora Cap',1,3),
    ('disc-o5','disc3@aurora.test','Aurora Water Bottle',1,5), ('disc-o5','disc3@aurora.test','Aurora Coaster Set',1,5),
    ('disc-o6','disc3@aurora.test','Aurora Mug',1,6),          ('disc-o6','disc3@aurora.test','Aurora Tee',1,6),
    ('disc-o7','disc1@aurora.test','Aurora Notebook',1,2),     ('disc-o7','disc1@aurora.test','Aurora Enamel Pin',1,2),
    ('disc-o8','disc2@aurora.test','Aurora Tote Bag',1,4),     ('disc-o8','disc2@aurora.test','Aurora Sticker Pack',1,4),
    ('disc-o9','disc1@aurora.test','Aurora Mug',1,30),         ('disc-o9','disc1@aurora.test','Aurora Tee',1,30),
    ('disc-o10','disc3@aurora.test','Aurora Hoodie',1,32),     ('disc-o10','disc3@aurora.test','Aurora Water Bottle',1,32)
),
ins_orders AS (
  INSERT INTO orders (customer_id, total, status, idempotency_key, created_at)
  SELECT c.id, 0, 'pending', 'seed:disc:' || d.tag, now() - make_interval(days => d.days_ago)
  FROM (SELECT DISTINCT tag, cust, days_ago FROM demo) d
  JOIN customers c ON c.email = d.cust
  ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
  RETURNING id, idempotency_key
)
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT o.id, p.id, d.qty, p.unit_price
FROM demo d
JOIN products p   ON p.name = d.product
JOIN ins_orders o ON o.idempotency_key = 'seed:disc:' || d.tag
ON CONFLICT (order_id, product_id) DO NOTHING;

UPDATE orders o
SET total = (SELECT COALESCE(SUM(oi.quantity * oi.unit_price),0) FROM order_items oi WHERE oi.order_id = o.id)
WHERE o.idempotency_key LIKE 'seed:disc:%';
What success looks like

The catalog is now ten products across four base categories (the stationery tile joined the base three), and the demo order graph is in place — ten orders, twenty-one line items. Re-running the seed inserts 0 rows (idempotent). Real output from postgres:16 — note the module products’ ids come from the insert’s join order, so refer to products by name, never a hardcoded id:

 id |        name         | unit_price | stock |    slug
----+---------------------+------------+-------+-------------
  1 | Aurora Mug          |       1499 |    50 | drinkware
  2 | Aurora Tee          |       2999 |    12 | apparel
  3 | Aurora Sticker Pack |        499 |   200 | accessories
  4 | Aurora Coaster Set  |        999 |    55 | drinkware
  5 | Aurora Water Bottle |       1899 |    40 | drinkware
  6 | Aurora Cap          |       1799 |    35 | apparel
  7 | Aurora Hoodie       |       5999 |    30 | apparel
  8 | Aurora Enamel Pin   |        699 |    80 | accessories
  9 | Aurora Tote Bag     |       1299 |    45 | accessories
 10 | Aurora Notebook     |        899 |    60 | stationery
(10 rows)

 orders | items
--------+-------
     10 |    21

Serve GET /search: full-text, facets, and keyset pagination (Go)

Optional add-on Advanced

Build GET /search in Go: match with websearch_to_tsquery, filter by the price / in-stock / category facets, and page with the keyset seek method — never OFFSET. Return ranked hits, category facet counts, and an opaque cursor the client passes back as after.

New in this step
websearch_to_tsquery

Turns a user’s search box string (with quotes and or) into a tsquery safely — no syntax errors from raw input — which you match against the tsvector with @@.

postgres websearch_to_tsquery docs ↗
ts_rank

Scores how well a row matches the query so you can order by relevance; here it is scaled to an integer so both backends serialize it identically.

postgres ts_rank relevance docs ↗
keyset (seek) pagination

Instead of OFFSET N (which rescans and skips N rows and can drift as data changes), you carry the last row’s sort key and ask for rows “after” it — constant-cost, stable pages.

keyset pagination seek method sql
row-value comparison

(score, id) < (afterScore, afterId) compares the pair lexicographically, so a single predicate seeks past the cursor correctly even when many rows share the same score.

postgres row value comparison tuple
base64url cursor

The cursor is base64url (URL-safe, no padding) of score:id — two integers — so it is opaque to the client and byte-identical across Go and Spring.

base64url RFC 4648 no padding
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Ranking, facets, the seek method, and the cursor's exact encoding

Match search_tsv @@ websearch_to_tsquery('english', q) and order by relevance: score = (ts_rank(...) * 1000000)::bigint, scaled to an integer so a raw float never reaches the wire or the cursor (that would be a Go/Jackson byte-parity hazard). Each hit joins the base categories and carries the category slug; the facets are two ideas at once — the price / in-stock / category values filter the hits (the category query param is a slug, matched on c.slug), and a second query returns the category counts for the same query with the category filter deliberately omitted, so the shopper always sees every category still reachable. Facet rows are {slug, name, count}: the slug is what a client feeds back as the filter, the name is for display. For paging, use the seek method: order by (score DESC, id DESC) and seek past the cursor with the row-value comparison (score, id) < (afterScore, afterId). Because id is unique, (score, id) is a total order — the page never skips or repeats a hit even though most hits tie on score. The cursor is base64url (no padding) of the last hit’s score:id, e.g. (75991, 1) becomes NzU5OTE6MQ; decoding splits on : and parses two integers, and a malformed cursor is a 422. Search hits carry the free-text product name, so the writer is the same base no-HTML-escape encoder (SetEscapeHTML(false) + trimmed trailing newline) every response already goes through — byte-identical with Spring/Jackson.

The full-text + facet + keyset read (pgx; identical SQL on both backends)
Run these in your terminal / editor
WITH q AS (SELECT websearch_to_tsquery('english', $1) AS query)
SELECT p.id, p.name, p.unit_price, p.stock, c.slug AS category,
       (ts_rank(p.search_tsv, q.query) * 1000000)::bigint AS score
FROM products p
JOIN categories c ON c.id = p.category_id
CROSS JOIN q
WHERE p.search_tsv @@ q.query
  AND p.unit_price >= $2 AND p.unit_price <= $3
  AND (NOT $4 OR p.stock > 0)                                                          -- inStock facet
  AND ($5 = '' OR c.slug = $5)                                                         -- category facet (slug)
  AND (NOT $6 OR ((ts_rank(p.search_tsv, q.query) * 1000000)::bigint, p.id) < ($7, $8)) -- keyset seek
ORDER BY score DESC, p.id DESC
LIMIT $9;

-- facet counts: same q + price/stock, category filter deliberately omitted; rows are {slug, name, count}
WITH q AS (SELECT websearch_to_tsquery('english', $1) AS query)
SELECT c.slug, c.name, count(*)
FROM products p
JOIN categories c ON c.id = p.category_id
CROSS JOIN q
WHERE p.search_tsv @@ q.query AND p.unit_price >= $2 AND p.unit_price <= $3 AND (NOT $4 OR p.stock > 0)
GROUP BY c.slug, c.name ORDER BY count(*) DESC, c.slug;
Agent prompt — paste into an agent with repo access

Before you fetch page 2, why will the seek method never skip or repeat a hit even though most hits in this seed tie on the same score?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo (Go 1.26, net/http, pgx v5.10.0).
Context: base build complete (categories, products, orders, GET /products); the discovery module schema + seed exist (products.search_tsv generated tsvector, GIN + pg_trgm indexes, description column, a demo order graph). Money is integer cents; field names camelCase on the wire.
Task: Add GET /search?q=&minPrice=&maxPrice=&inStock=&category=&after=&limit= returning {hits, facets, nextCursor}.
Requirements:
- Match search_tsv @@ websearch_to_tsquery('english', q). score = (ts_rank(...) * 1000000)::bigint (integer). Order by score DESC, id DESC. JOIN the base categories so each hit carries the category SLUG.
- hits: {id, name, unitPrice, stock, category, score} — category is the base slug.
- Facets: filter by minPrice/maxPrice (integer cents), inStock (stock > 0), and category (a SLUG, matched on c.slug). facets.categories = rows of {slug, name, count} for the same q + price/stock but IGNORING the category filter.
- Keyset pagination (NOT OFFSET): seek with the row-value comparison (score, id) < (afterScore, afterId). nextCursor = base64url (no padding) of "score:id" of the last hit when the page is full; null on the last page. Decode after by base64url + split on ':' + parse two int64.
- q is required (blank q -> 422). Bad minPrice/maxPrice (non-integer/negative), limit not in 1..100, inStock not true/false, or malformed after cursor -> 422 {"error":"invalid_request"}. Retryable DB failure -> 503 {"error":"unavailable"}; otherwise 500 {"error":"internal"}. No 400, no 404 on this endpoint.
- JSON byte-parity: the base writeJSON (SetEscapeHTML(false), trailing newline trimmed), so a product name with & or angle brackets serializes identically to Spring/Jackson.
Tests / acceptance:
- Page 1 (limit 3) then page 2 via nextCursor returns disjoint hits with no gap, even though hits tie on score.
- facets.categories rows are {slug, name, count} and sum to the price/stock-filtered match count regardless of the category filter; category=apparel filters hits to the apparel slug.
- limit=0 and a garbled after both return 422; a name containing & serializes literally.
Output: a unified diff plus the search SQL.
What success looks like

Against the seed, GET /search?q=aurora&limit=3 returns three ranked hits and the category facet counts; passing the returned nextCursor (NzU5OTE6MQ, the base64url of 75991:1) back as after yields the next three with no overlap — the score drops from 75991 to 60793 exactly where the “aurora appears twice” hits (name + description both mention it) give way to “aurora once”. Real output from postgres:16; go build ./... and go vet ./... pass:

--- page 1 (limit 3) ---
 id |      name       | unit_price | stock |  category   | score
----+-----------------+------------+-------+-------------+-------
  9 | Aurora Tote Bag |       1299 |    45 | accessories | 75991
  2 | Aurora Tee      |       2999 |    12 | apparel     | 75991
  1 | Aurora Mug      |       1499 |    50 | drinkware   | 75991
facets.categories: {accessories, Accessories, 3}, {apparel, Apparel, 3}, {drinkware, Drinkware, 3}, {stationery, Stationery, 1}
nextCursor: NzU5OTE6MQ

--- page 2 (after = NzU5OTE6MQ, i.e. seek (score,id) < (75991,1)) ---
 id |       name        | unit_price | stock |  category   | score
----+-------------------+------------+-------+-------------+-------
 10 | Aurora Notebook   |        899 |    60 | stationery  | 60793
  8 | Aurora Enamel Pin |        699 |    80 | accessories | 60793
  7 | Aurora Hoodie     |       5999 |    30 | apparel     | 60793

Serve GET /search: the same contract (Spring/Kotlin)

Optional add-on Advanced

Implement the identical GET /search in Spring Boot with JdbcTemplate: the SQL is byte-for-byte the same as the Go path, and Jackson already emits the literal free-text and no trailing newline the Go encoder had to be tuned to match.

New in this step
JdbcTemplate + RowMapper

Runs the same explicit SQL and maps each row to a small DTO — no JPA, so the query stays exactly the Go query.

spring jdbctemplate rowmapper docs ↗
base64url in Java

java.util.Base64.getUrlEncoder().withoutPadding() produces the identical cursor bytes as Go’s base64.RawURLEncoding, so the cursor is portable across backends.

java Base64 getUrlEncoder withoutPadding
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Parity: same SQL, same integers, and Jackson matches the Go encoder for free

Nothing about the contract changes — the same websearch_to_tsquery match joined to the base categories (hits carry the slug), the same integer score = (ts_rank(...) * 1000000)::bigint, the same (score, id) row-value seek, and the same facet-count query returning {slug, name, count} rows with the category filter omitted. JdbcTemplate.query(sql, rowMapper, args...) runs the identical SQL and maps rows to a Hit record; the []int64 in the recommendation queries becomes a java.sql.Array. The cursor is Base64.getUrlEncoder().withoutPadding() of "score:id", byte-identical to Go’s base64.RawURLEncoding. And the byte-parity trap the Go path fought — HTML-escaped &/angle brackets and a trailing newline — does not exist on Spring: Jackson emits literal characters and no trailing newline by default, so the free-text name serializes the same on both backends with no special code. Bad query params throw and are mapped to 422 {"error":"invalid_request"} by the @RestControllerAdvice; a retryable data-access failure maps to 503 {"error":"unavailable"}.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Spring Boot / Kotlin engineer in this repo (Spring 6 / Boot 3.4, JdbcTemplate, no JPA).
Context: base build complete; the discovery module schema + seed exist (products.search_tsv, GIN + pg_trgm indexes, description/category, a demo order graph). Must match the Go GET /search byte-for-byte.
Task: Add GET /search?q=&minPrice=&maxPrice=&inStock=&category=&after=&limit= returning {hits, facets, nextCursor}, identical to the Go contract.
Requirements:
- Reuse the EXACT search SQL and facet SQL from the Go step (websearch_to_tsquery, the JOIN onto the base categories, ts_rank scaled to a bigint, (score, id) row-value seek). Map rows with a RowMapper to Hit(id, name, unitPrice, stock, category, score:Long) — category is the base slug.
- Facets: rows of {slug, name, count} for the same q + price/stock, ignoring the category filter; the category query param is a slug matched on c.slug.
- Cursor: java.util.Base64.getUrlEncoder().withoutPadding() of "score:id"; decode symmetrically; malformed -> 422.
- Validate params exactly as Go: blank q, bad minPrice/maxPrice, limit not in 1..100, inStock not true/false, malformed after -> 422 {"error":"invalid_request"} via @RestControllerAdvice. Retryable data-access failure -> 503 {"error":"unavailable"}; otherwise 500 {"error":"internal"}.
- Rely on Jackson defaults for free-text (literal & and angle brackets, no trailing newline) — do NOT add an escaping workaround; confirm the bytes match Go.
Tests / acceptance:
- The same page-1/page-2-by-cursor sequence returns the same hits, the same nextCursor "NzU5OTE6MQ", and the same {slug, name, count} facet rows as Go.
- The 422 bodies for blank q / bad limit / garbled cursor are byte-identical to Go.
Output: a unified diff plus a one-paragraph note on where Jackson matches the Go encoder without extra code.
What success looks like

The Spring GET /search returns the same hits in the same order, the same nextCursor value NzU5OTE6MQ, the same {slug, name, count} facet rows, and the same 422/503 bodies as the Go path — verified by diffing the response bytes for the page-1 and page-2 requests. The only backend difference is invisible on the wire: Spring needed no escaping workaround because Jackson already emits the literal free-text and no trailing newline.

Add typo-tolerance with a pg_trgm fallback

Optional add-on Advanced

When full-text finds nothing — a shopper typed hoddie — fall back to a pg_trgm word-similarity match on the product name so the misspelling still lands. Lower the word-similarity threshold so the index-backed <% operator keeps only plausible matches, then page the fallback with the same keyset machinery.

New in this step
why full-text misses typos

Full-text matches normalized words (lexemes), so hoddie never equals the lexeme hoodie — a misspelling yields zero hits and needs a fuzzy fallback.

postgres full text search stemming lexemes
word_similarity

Scores how similar a search term is to the closest word inside a longer string by comparing 3-character chunks, so hoddie scores high against “Aurora Hoodie”.

postgres pg_trgm word_similarity
word-similarity operator

pg_trgm’s word-similarity test written q <% name: true when the term is similar enough to a word in the name; it is backed by the products_name_trgm GIN index.

postgres pg_trgm word similarity operator docs ↗
word_similarity_threshold

The cutoff the <% operator uses (default 0.6); lower it per session so a plausible typo like hoddie (0.4) still matches while noise is rejected.

postgres pg_trgm word_similarity_threshold
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
A fallback that fires only when full-text is empty — so the two score scales never mix

websearch_to_tsquery matches lexemes, not spellings, so hoddie returns nothing. When the full-text query returns no rows on the first page (after absent), the server re-runs a trigram fallback: q <% p.name, ordered by word_similarity(q, name), scaled to the same integer score, joined to the base categories for the slug, and paged with the same (score, id) seek. The fallback fires only on an empty full-text page 1, so a paginated session is entirely full-text or entirely trigram — the two score scales never mix inside one result set. hoddie scores 0.400 against “Aurora Hoodie” (every other name scores 0.000), below the default 0.6 threshold, so the session lowers pg_trgm.word_similarity_threshold to 0.3; the <% operator is index-backed, so on a real catalog Postgres uses products_name_trgm (on the ten-row demo table it picks a seq scan, but SET enable_seqscan = off reveals the Bitmap Index Scan on products_name_trgm). The PostgreSQL track covers trigram indexing in depth.

The trigram fallback (run only when full-text page 1 is empty)
Run these in your terminal / editor
-- lower the threshold so the index-backed <% operator keeps plausible matches
SET pg_trgm.word_similarity_threshold = 0.3;

SELECT p.id, p.name, p.unit_price, p.stock, c.slug AS category,
       (word_similarity($1, p.name) * 1000000)::bigint AS score
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE $1 <% p.name
  AND p.unit_price >= $2 AND p.unit_price <= $3
  AND (NOT $4 OR p.stock > 0)
  AND ($5 = '' OR c.slug = $5)
  AND (NOT $6 OR ((word_similarity($1, p.name) * 1000000)::bigint, p.id) < ($7, $8))
ORDER BY score DESC, p.id DESC
LIMIT $9;
What success looks like

Full-text on the misspelling returns nothing; the trigram fallback recovers the intended product (carrying its base category slug), and EXPLAIN confirms the <% operator can ride the GIN trigram index at scale. Real output from postgres:16:

-- FTS on 'hoddie' (websearch_to_tsquery) — no lexeme match
 id | name
----+------
(0 rows)

-- trigram fallback (word_similarity_threshold = 0.3) — the typo lands
 id |     name      | unit_price | stock | category | score
----+---------------+------------+-------+----------+--------
  7 | Aurora Hoodie |       5999 |    30 | apparel  | 400000

-- EXPLAIN with enable_seqscan off: the operator is index-backed
 Bitmap Heap Scan on products
   Filter: ('hoddie'::text <% name)
   ->  Bitmap Index Scan on products_name_trgm
         Index Cond: (name %> 'hoddie'::text)

Build the search screen with facets and keyset infinite scroll (Jetpack Compose)

Optional add-on Intermediate

Build a Compose search screen that shows ranked hits, renders the category facet counts as filter chips, and loads the next page by passing the server’s nextCursor back as after — keyset infinite scroll, no page numbers.

New in this step
cursor-based infinite scroll

As the LazyColumn nears its end you request the next page with the last nextCursor, appending hits — no page numbers, and stable even as the catalog changes under the shopper.

jetpack compose lazycolumn paging cursor
opaque cursor

The client never parses the cursor; it stores the server’s nextCursor and sends it back as after, so the server can change the encoding without breaking the app.

opaque pagination cursor api
FilterChip

Compose’s selectable chip; here one chip per category shows its facet count and toggles the category query parameter.

jetpack compose FilterChip track ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Consuming the cursor — why the client stays dumb about it

The screen holds the current q, the selected facets, the list of hits, and the latest nextCursor. On a query change it fetches page 1 and replaces the list; as the LazyColumn approaches its end and nextCursor is non-null, it fetches the next page with after = nextCursor and appends. The client treats the cursor as opaque — it never decodes score:id, it just echoes what the server sent — so keyset paging works identically whether the backend is Go or Spring and the server can evolve the encoding freely. The category facet chips render straight from facets.categories (name plus count); tapping one sets the category parameter and re-runs from page 1. This closes the loop on why keyset beats OFFSET for a feed: as new products are indexed while the shopper scrolls, the seek cursor still lands exactly where they were, with no skipped or repeated rows. (This screen is described for the default Compose frontend; the Flutter and SwiftUI variants consume the same JSON identically.)

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Android engineer (Jetpack Compose, Kotlin, coroutines/Flow).
Context: the backend serves GET /search?q=&minPrice=&maxPrice=&inStock=&category=&after=&limit= returning {hits:[{id,name,unitPrice,stock,category,score}], facets:{categories:[{slug,name,count}]}, nextCursor:string|null}. A hit's category and the category query param are the base category SLUG. Money is integer cents.
Task: Build a SearchScreen composable with a search field, category filter chips, a results list, and keyset infinite scroll.
Requirements:
- A ViewModel exposes UI state (query, hits, facets, nextCursor, loading, error) via StateFlow.
- On query/facet change: fetch page 1 (after omitted), replace hits, render facets as FilterChips labelled with each row's display name + count; a selected chip sets the category param to that row's SLUG.
- Infinite scroll: when the LazyColumn nears the end and nextCursor != null and not loading, fetch with after=nextCursor and append hits; stop when nextCursor is null.
- Treat the cursor as opaque (store and resend; never parse it). Format unitPrice cents as currency for display only.
- Handle the empty state (no hits) and a retryable error (show a retry affordance on 503).
Tests / acceptance:
- Scrolling past the first page appends the next page with no duplicate ids and stops when nextCursor is null.
- Selecting a category chip re-queries from page 1 with category=<slug> and the chip reflects its facet count.
Output: a unified diff of the Compose screen + ViewModel and a one-paragraph summary.
What success looks like

Searching shows ranked hits with category filter chips labelled by display name and count (for q=aurora on the seed: Accessories 3, Apparel 3, Drinkware 3, Stationery 1); scrolling to the bottom appends the next page using the server’s nextCursor with no duplicated rows, and stops cleanly when nextCursor comes back null. Tapping a chip re-runs the search from page 1 with category= set to that chip’s slug — the same JSON the Go and Spring backends both return.

Add a tiny Redis container and connect a client

Optional add-on Intermediate

Append a small redis:7-alpine service to your Compose file and point a client at it via REDIS_URL, so the module has a speed layer beside Postgres without touching the base build. Redis here is a supporting cache — not the system of record.

New in this step
in-memory cache

A fast key/value store that holds a copy of slow-to-compute reads; it sits in front of Postgres, never replaces it.

redis cache alongside database track ↗ docs ↗
disposable container (no volume)

The cache needs no disk volume — losing it only forces a cold re-read from Postgres, so it is safe to throw away and restart.

redis cache no persistence volume
REDIS_URL

One env var holding redis://host:port; unset it and the module turns off — reads go straight to Postgres and nothing is throttled.

redis connection url scheme
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why Redis is additive here, not load-bearing

This module is additive: a cache in front of GET /products and a rate-limiter in front of POST /checkout, both beside the untouched Postgres spotlight. That is the honest shape of Redis in most systems — a speed layer next to a durable store, not the source of truth (the Redis track makes this framing explicit). It contrasts with two other projects in this curriculum where Redis is the star: Ticker builds its whole change-feed and fan-out on Redis Streams, and Concord drives collaboration with Redis pub/sub. Here the guarantee stays in Postgres; Redis only makes reads fast and shields the hot path. The container is pinned (redis:7-alpine), local, and costs nothing.

docker-compose.yml — append the module-owned Redis service
Run these in your terminal / editor
services:
  # ... base "db" (postgres:16) service unchanged ...

  redis:                       # OPTIONAL MODULE: cache. Additive; $0.
    image: redis:7-alpine      # current 7.x pin; matches the Redis track
    ports:
      - "6379:6379"
    # no volume: the cache is disposable — losing it only forces a cold re-read
Bring it up and confirm it answers
Run these in your terminal / editor
docker compose up -d redis
export REDIS_URL=redis://localhost:6379
docker compose exec redis redis-cli PING       # -> PONG
What success looks like

docker compose exec redis redis-cli PING returns PONG, and your API boots whether or not REDIS_URL is set — with it unset the service runs in a no-cache mode (every read hits Postgres), proving the module is genuinely optional.

Read-through cache GET /products: miss, hit, TTL (Go)

Optional add-on Advanced

Wrap the base catalog read in a read-through cache with github.com/redis/go-redis/v9: check Redis, on a miss read Postgres and SET the serialized bytes with a TTL, and serve. A hit must return byte-identical JSON to a miss.

New in this step
read-through cache

On a request you check the cache first; a miss loads from the database and populates the cache, so the next read is fast.

read through cache pattern track ↗ docs ↗
TTL (SET ... EX)

A time-to-live tells Redis to delete the key automatically after N seconds — the dial that bounds how stale a cached read can get.

redis set ttl expire seconds docs ↗
redis.Nil

go-redis returns this sentinel error when a key is absent; it means a cache miss, not a failure — you must handle it specially.

go-redis redis.Nil sentinel miss
cache the serialized bytes

Store the exact response bytes, so a hit replays what a miss produced and the two are byte-for-byte identical (the base parity rule).

cache serialized response bytes byte identical
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why byte-parity forces you to cache bytes, not structs

The base parity invariant says a cache hit must serve exactly what the uncached endpoint serves. The reliable way is to cache the serialized bytes of the response — then a hit is a verbatim replay, no re-encoding, no chance of a different field order or escaping. Reuse the base encoder discipline (the no-HTML-escape json.Encoder with the trailing newline trimmed) when you produce the bytes on a miss, and store those same bytes. The miss sentinel redis.Nil is the one subtlety: rdb.Get returns it when the key is absent, and that is a normal miss you populate from — not an error you propagate. Version the key (catalog:products:v1) so that if the payload shape ever changes, bumping to v2 rotates the whole namespace and no stale-shape bytes survive a deploy.

internal/cache/products.go — the read-through cache (go-redis v9)
Run these in your terminal / editor
const catalogKey = "catalog:products:v1" // versioned; bump to v2 on a shape change

// getProducts checks Redis, then falls back to the base Postgres read on a miss.
// A non-redis.Nil error means Redis is down -> fail open to the DB (see the boundary step).
func (c *Cache) getProducts(ctx context.Context) (body []byte, err error) {
	b, gerr := c.rdb.Get(ctx, catalogKey).Bytes()
	switch {
	case gerr == nil:
		return b, nil // HIT: cached bytes, verbatim
	case errors.Is(gerr, redis.Nil):
		fresh := c.store.ProductsJSON(ctx)             // base read + base no-escape encoder
		_ = c.rdb.Set(ctx, catalogKey, fresh, 60*time.Second).Err()
		return fresh, nil                              // MISS: populated with a 60s TTL
	default:
		return c.store.ProductsJSON(ctx), nil          // FAIL OPEN (boundary step)
	}
}
Agent prompt — paste into an agent with repo access

Before you run it twice: on the second GET, how many times should your Postgres read function be called — and why?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: Base build complete (GET /products reads Postgres via pgx and serializes with the base no-HTML-escape json.Encoder, trailing newline trimmed). github.com/redis/go-redis/v9 is available; a *redis.Client is wired from REDIS_URL via redis.ParseURL.
Task: Put a read-through cache in front of GET /products.
Requirements:
- Cache key "catalog:products:v1"; value is the exact serialized response bytes.
- On rdb.Get(ctx,key).Bytes(): if err is nil, serve the bytes (HIT); if errors.Is(err, redis.Nil), read Postgres, serialize with the base encoder, rdb.Set(ctx,key,bytes,60*time.Second), serve (MISS).
- A HIT must be byte-identical to a MISS and to the base uncached response (Content-Type application/json, no trailing newline).
- Any OTHER Redis error must NOT fail the request: fall back to the Postgres read and return 200 (fail open).
Tests / acceptance:
- With a call-counting fake store: first GET increments the DB counter, second GET does NOT (served from Redis).
- bytes.Equal(hitBody, missBody) is true.
- go build ./... and go test ./... pass.
Output: a unified diff plus a one-paragraph note on why redis.Nil is a miss, not an error.
What success looks like

The first read is a miss that populates the key; the second is a hit that never touches Postgres (a call-counting fake store proves it — dbHits stays at 1). The hit bytes equal the miss bytes exactly. Real run against redis:7-alpine (Redis 7.4.9), go-redis v9.21.0:

first  read: source=cache-miss(populated)  dbHits=1
second read: source=cache-hit              dbHits=1
hit bytes == miss bytes: true
payload: [{"id":1,"name":"Aurora Mug","unitPrice":1499,"stock":50,"category":{"id":1,"slug":"drinkware","name":"Drinkware","imageUrl":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"},"images":[{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"}]},{"id":2,"name":"Aurora Tee","unitPrice":2999,"stock":12,"category":{"id":2,"slug":"apparel","name":"Apparel","imageUrl":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"},"images":[{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"}]},{"id":3,"name":"Aurora Sticker Pack","unitPrice":499,"stock":200,"category":{"id":3,"slug":"accessories","name":"Accessories","imageUrl":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"},"images":[{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg"}]}]

--- TTL expiry (2s demo TTL; the module uses 60s) ---
TTL right after SET: 2s
GET after expiry returns redis.Nil (a miss): true

The TTL counts down and, once elapsed, the next GET returns redis.Nil — a miss that re-reads Postgres. That is the whole staleness bound: a cached listing is at most TTL seconds old.

Read-through cache GET /products: the same contract (Spring/Kotlin)

Optional add-on Advanced

Do the same read-through cache in Spring with StringRedisTemplate (Spring Data Redis over Lettuce): opsForValue().get(key), and on a null (the miss) read Postgres, set(key, json, Duration.ofSeconds(60)), and serve — byte-identical to the Go path.

New in this step
StringRedisTemplate

Spring Data Redis’s string-keyed template (over the Lettuce client); opsForValue() gives the GET/SET operations.

spring data redis stringredistemplate docs ↗
null is the miss

opsForValue().get(key) returns null when the key is absent — Spring’s equivalent of go-redis’s redis.Nil miss sentinel.

spring redis opsforvalue get null miss
StringRedisSerializer

Stores and returns the value as-is (UTF-8), so the cached JSON comes back byte-for-byte — the Spring side of the byte-parity rule.

spring redis stringredisserializer bytes
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Reasoned, not compiled — and why it matches the Go bytes

This backend is reasoned against Spring Data Redis / Lettuce (no JVM toolchain in the authoring environment), exactly as the other optional modules reason their Spring path. The mapping is direct: rdb.Get(...).Bytes() becomes stringRedisTemplate.opsForValue().get(key), the redis.Nil miss becomes a null return, and rdb.Set(...,60*time.Second) becomes opsForValue().set(key, json, Duration.ofSeconds(60)). Byte-parity holds because the cached value is the exact String Jackson already produces for GET /products, stored and returned verbatim by a StringRedisSerializer — Jackson emits no trailing newline and does not HTML-escape, so it matches the Go no-escape encoder byte-for-byte. Configure spring.data.redis.url=${REDIS_URL} so both backends read the same env var.

ProductCacheService.kt — read-through with StringRedisTemplate (reasoned)
Run these in your terminal / editor
private const val CATALOG_KEY = "catalog:products:v1"

fun productsJson(): String {
    redis.opsForValue().get(CATALOG_KEY)?.let { return it }   // HIT
    val fresh = store.productsJson()                          // MISS: base read (JdbcTemplate) + Jackson
    return try {
        redis.opsForValue().set(CATALOG_KEY, fresh, Duration.ofSeconds(60))
        fresh
    } catch (e: RedisConnectionFailureException) {
        fresh                                                 // Redis down -> fail open, still 200
    }
}
What success looks like

Described observable: the first GET /products returns null from opsForValue().get, reads Postgres, and stores the JSON with a 60-second TTL; the second returns the stored String without a DB round-trip. redis-cli TTL catalog:products:v1 shows the countdown, and the response is byte-for-byte identical to the Go backend’s — the parity invariant holds.

Cache invalidation: bust the catalog key on a write

Optional add-on Advanced

When a write changes what the catalog shows — a price change, or a product crossing the in-stock/out-of-stock boundary — DEL the catalog key after the write commits, so the next read repopulates. This is the hard problem of caching, taught head-on: TTL-only staleness versus explicit invalidation.

New in this step
cache invalidation

Removing or refreshing a cached value when the underlying data changes — famously one of the hard problems, because a stale read is a lie.

cache invalidation hard problem track ↗
stale-read window

The span during which readers can see an out-of-date value; TTL bounds it to the TTL, an explicit bust shrinks it to the gap between commit and DEL.

cache staleness window bound
bust the key (DEL after commit)

Delete the cached key once the write has committed, so the next read misses and reloads the fresh value — never DEL before commit.

redis del invalidate cache key docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
TTL-only vs explicit invalidation — and why the answer is both

With a TTL alone, a price change is invisible to readers for up to the TTL — a bounded but real stale window (≤ 60 s here). Explicit invalidation shrinks that: on a committed catalog write that changes what the listing shows, DEL catalog:products:v1, and the next read reloads. The taught policy is both — an explicit bust for catalog-shape changes (staleness drops to milliseconds) plus the TTL as a self-healing backstop (it catches anything a bust missed, and recovers if a bust was ever dropped because Redis was briefly down). Two rules matter. First, ordering: write to Postgres, commit, then DEL — never DEL first, or a concurrent read repopulates the old value between the DEL and the commit and re-poisons the cache. Second, scope: the per-unit stock decrement on every sale does not bust the catalog. That would defeat the cache, and it is unnecessary — the listing’s stock is advisory display (the next step’s boundary), never a decision input. Only a visibility change busts: a price change, or a product crossing zero stock.

Bust after a committed catalog write (both backends)
Run these in your terminal / editor
Go:     rdb.Del(ctx, "catalog:products:v1")          // after tx.Commit
Spring: stringRedisTemplate.delete("catalog:products:v1")   // after the @Transactional write returns
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: The read-through cache from the previous step is in place (key "catalog:products:v1", 60s TTL). A catalog write path exists (e.g. an admin price update, or a product going out of stock).
Task: Add explicit cache invalidation on catalog-visibility changes.
Requirements:
- After a catalog write COMMITS, bust the key: Go rdb.Del(ctx, "catalog:products:v1"); Spring stringRedisTemplate.delete("catalog:products:v1").
- Bust ONLY on a visibility change: a price change, or a product crossing the in-stock/out-of-stock boundary. Do NOT bust on every per-unit stock decrement.
- The DEL must run AFTER commit, never before.
- A failed DEL (Redis down) must not fail the write — log it; the TTL is the backstop.
Tests / acceptance:
- After a price change + bust, the next GET /products reflects the new price immediately (a re-read, not the cached one).
- With no bust, the change appears only after the TTL elapses (demonstrates the stale window you are bounding).
Output: a unified diff plus one paragraph contrasting TTL-only staleness with explicit invalidation.
What success looks like

Before the write the key is present; after the committed price change the bust removes it, and the next read re-reads Postgres and returns the new price. Real run against Redis 7.4.9:

key present before write: true
key present after bust:   false
next read: source=cache-miss(populated)  dbHits went 1->2 (re-read), new price=1599

At the CLI the same shape holds — DEL catalog:products:v1 returns (integer) 1, then EXISTS returns (integer) 0:

> DEL catalog:products:v1      -> (integer) 1
> EXISTS catalog:products:v1   -> (integer) 0

Rate-limit POST /checkout with an atomic Redis counter

Optional add-on Advanced

Shield the hot checkout path with a per-customer fixed-window counter — INCR the key and EXPIRE it only on the first hit, in one Lua script for atomicity — and return 429 {"error":"rate_limited"} when the limit is exceeded. You throttle checkout without ever caching it.

New in this step
fixed-window rate limiter

Count requests per key in a time window and reject past a limit; a token bucket or sliding window is a refinement on the same idea.

redis fixed window rate limiting track ↗ docs ↗
INCR + EXPIRE

INCR bumps the counter atomically; EXPIRE set only on the first hit starts the window and resets it automatically.

redis incr expire rate limit docs ↗
Lua script (EVAL)

Runs the increment-and-check as one indivisible server-side step, so concurrent requests can never race between the read and the write.

redis eval lua atomic rate limiter docs ↗
429 Too Many Requests

The status a throttled request gets — a NEW code for this API (the base checkout never returns 429).

http 429 too many requests
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why one Lua script, and why 429 is new

A read-then-write limiter (GET the count, decide, SET) has a race: two requests read the same count and both proceed. Doing INCR (atomic) plus an EXPIRE guarded by n == 1 in a single Lua script collapses the whole decision into one indivisible server-side step — no window for a race, one round trip. The key is per customer (ratelimit:checkout:<customerId>), or per IP for unauthenticated traffic. The response is a code the base contract does not use: 429 with the body {"error":"rate_limited"}, byte-identical on both backends, application/json, no trailing newline. This is the crucial distinction from the cache steps: the limiter counts requests in front of the checkout transaction and never stores, copies, or caches the checkout itself — the guarded UPDATE and the idempotency key stay untouched. An over-limit request is rejected before the transaction opens, so a blocked checkout writes nothing. Go runs this with redis.NewScript(...).Run(...); Spring runs the identical Lua with a DefaultRedisScript<Long> via stringRedisTemplate.execute.

The atomic fixed-window limiter (identical Lua on both backends)
Run these in your terminal / editor
-- KEYS[1] = ratelimit:checkout:<customerId>   ARGV[1] = limit   ARGV[2] = window seconds
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if n > tonumber(ARGV[1]) then return 0 end     -- blocked
return 1                                        -- allowed
Agent prompt — paste into an agent with repo access

With a limit of 3 in the window, which request number is the first to get a 429 — and what has the counter reached by then?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: Redis is wired (previous steps). POST /checkout runs the base flow (§5.3). We want a per-customer request throttle in front of it.
Task: Add a fixed-window rate limiter to POST /checkout.
Requirements:
- Before the checkout logic, run this Lua atomically: INCR KEYS[1]; EXPIRE KEYS[1] ARGV[2] only when the result is 1; return 0 if the counter exceeds ARGV[1], else 1.
- Go: redis.NewScript(lua).Run(ctx, rdb, []string{key}, limit, windowSecs).Int(). Spring (reasoned): DefaultRedisScript<Long>(lua, Long::class.java) via stringRedisTemplate.execute(script, listOf(key), limit, windowSecs).
- key = "ratelimit:checkout:" + customerId (or ":ip:" + ip when unauthenticated).
- On a 0 result, return 429 with body {"error":"rate_limited"}, application/json, no trailing newline. Do NOT open the checkout transaction.
- Default limit 30 per 60s; make both configurable.
Tests / acceptance:
- With limit=3, requests 1-3 proceed to the base flow and request 4 returns 429 {"error":"rate_limited"}.
- The 429 body is byte-identical across backends.
- A rejected request writes nothing to Postgres (no order row).
Output: a unified diff plus a one-paragraph note on why the EXPIRE is guarded by n == 1.
What success looks like

With limit=3, the first three requests proceed and the fourth is blocked with 429 {"error":"rate_limited"}. Real run against Redis 7.4.9, go-redis v9.21.0:

request #1 -> allowed=true  200 (checkout proceeds)
request #2 -> allowed=true  200 (checkout proceeds)
request #3 -> allowed=true  200 (checkout proceeds)
request #4 -> allowed=false 429 {"error":"rate_limited"}
request #5 -> allowed=false 429 {"error":"rate_limited"}
counter=5, window TTL=1m0s

The same Lua at the CLI returns 1, 1, 1, 0 for four calls, with the counter and window visible:

> EVAL <lua> 1 ratelimit:checkout:1 3 60   -> 1, 1, 1, 0
> GET ratelimit:checkout:1                 -> "4"
> TTL ratelimit:checkout:1                 -> (integer) 59

The boundary: never cache stock or the checkout decision

Optional add-on Advanced

Draw the line the whole module rests on: cache the catalog listing (slowly-changing, safe), never the stock decision. The guarded UPDATE stays the sole, uncached source of truth, and a Redis outage fails open to Postgres — so the cache can never lie about stock in a way that matters.

New in this step
never cache the stock decision

The value checkout reads to decide a sale must be fresh and transactional; a cached stock read would reintroduce the oversell the course eliminates.

cache stock oversell danger
advisory vs authoritative

The cached listing’s stock is advisory (fine for a browse screen); the authoritative stock lives only in the guarded UPDATE, which alone decides a sale.

advisory data vs source of truth cache
fail-open vs fail-closed

On a dependency outage, fail-open keeps serving (degrade), fail-closed rejects; you pick per what the dependency guards.

fail open fail closed dependency outage
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The load-bearing rule: correctness never depends on the cache

Everything cacheable in this module is slowly-changing catalog display: the product listing, its price, an advisory stock number good enough to render a browse list. What is never cached is the stock decision and the checkout read path. The base guarded UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1 RETURNING unit_price stays the sole source of truth — uncached, inside the one transaction (spec §5.3). A cached stock number is exactly like the storefront’s advisory display price: fine to be slightly stale on a screen, never the thing that decides money. Even a wildly stale cached stock can’t cause an oversell, because the decision is never read from the cache.

That is why the failure policy is fail-open. A Redis outage on GET /products degrades to a direct Postgres read and still returns 200 — a cache outage is invisible to correctness, just slower. The rate-limiter also fails open: if Redis errors, allow the checkout, because the limiter is a shield (against abuse and cost), not a correctness mechanism — the Postgres guard still prevents oversell and the idempotency key still prevents double-orders. Losing rate-limiting for a brief blip beats rejecting real checkouts. The contrast is worth naming: fail-closed would be right for a limiter guarding a security boundary (a login throttle — if you can’t count attempts, reject), but this one guards availability, so fail-open wins. And 503 {"error":"unavailable"} (the retryable code the sibling modules define) is reserved for when the authoritative store — Postgres — is unreachable, never for Redis: a Redis outage never 503s here, precisely because both paths fail open.

What success looks like

With Redis stopped (a dead port stands in), GET /products still returns 200 by reading Postgres directly, and the rate-limiter allows the checkout — no 503, no dropped request. Real run:

read with Redis down: source=fail-open(redis-down)  dbHits 2->3, served 181 bytes (200, not 503)
rate-limiter with Redis down: allowed=true (fail-open), redis err present=true
checkout correctness is unaffected: the guarded UPDATE + idempotency key are uncached, in Postgres.

The frame the module leaves you with: make reads fast and shield the hot path, without ever letting a cache lie about stock in a way that matters. Where Redis is the star instead of a supporting cache, see the Redis track and the Ticker project.

Add the analytics views and seed a demo order history

Optional add-on Intermediate

Create three read-only analytics views over the orders you already have, then seed a small demo order history so the segmentation and trending have something to compute. There is no new datastore — the analytics are a view of the base order data, which is the whole point of this module.

New in this step
SQL VIEW

A saved query that behaves like a table but stores no data — perfect for analytics, since dropping it touches nothing in the base schema.

postgres create view track ↗ docs ↗
running total (SUM OVER)

SUM(...) OVER (ORDER BY day) accumulates revenue day by day in one pass — the cumulative curve without a self-join or a loop.

postgres window function running total sum over order by track ↗
idempotent seed via a unique key

Keying each demo order on the base orders.idempotency_key with ON CONFLICT DO NOTHING means a re-run inserts nothing — a safe, repeatable migration.

postgres on conflict do nothing idempotent seed
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Views, not tables — and a self-contained seed that can't collide

The module is additive and read-only: 0009 creates three views (sales_daily_revenue, customer_rfm, market_basket) and 0010 seeds a demo order graph. It assumes only the base build — it does not reuse the discovery module’s orders. Since the base seed makes no orders, this module seeds its own: eight extra customers (so RFM has a distribution), three extra products (so baskets have pairs), and ~19 orders dated across ~90 days (so recency and trending are observable). Orders are inserted directly, not through the checkout guard, because they are analytics data and must not decrement live stock. Because the base products.category_id is NOT NULL, the three product rows resolve their category by slug — and the one category the base seed lacks (stationery) is inserted first, the identical row discovery seeds, ON CONFLICT (slug), so whichever module runs first wins. Idempotency is layered so a re-run is a no-op and it can’t collide with discovery if both are on: order keys are seed:si:* (distinct from seed:disc:*), customers use ON CONFLICT (email), products use ON CONFLICT (name) — the module’s Aurora Hoodie / Water Bottle / Notebook rows silently defer to discovery’s same-named rows when both are on. Migrations continue after discovery’s 0007/0008 — Go 0009/0010, Spring V9/V10.

At scale, promote the basket view to a materialized view

market_basket self-joins order_items to itself to find co-purchased pairs — cheap on a demo, expensive on a real catalog. When it gets slow, promote it: CREATE MATERIALIZED VIEW market_basket_mv AS <the query>, add a unique index, and refresh on a schedule with REFRESH MATERIALIZED VIEW CONCURRENTLY market_basket_mv. The trade is freshness for speed — the matview serves instantly but is only as current as its last refresh. The taught path keeps the plain view (always fresh); the matview is the upgrade you reach for when the pair count grows.

0009_sales_insights.up.sql (Go) / V9__sales_insights.sql (Spring) — the three views
Run these in your terminal / editor
-- (1) Revenue over time: a per-day rollup with a WINDOWED running total.
CREATE OR REPLACE VIEW sales_daily_revenue AS
SELECT
  date_trunc('day', o.created_at)::date                    AS bucket_day,
  count(DISTINCT o.id)                                     AS orders,
  sum(oi.quantity)::bigint                                 AS units,
  sum(oi.quantity * oi.unit_price)::bigint                 AS revenue_cents,
  sum(sum(oi.quantity * oi.unit_price))
      OVER (ORDER BY date_trunc('day', o.created_at)::date)::bigint AS running_revenue_cents
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY date_trunc('day', o.created_at)::date;

-- (2) RFM segmentation (detailed in the RFM step): ntile(5) scores + a segment label.
-- (3) Market-basket rules (detailed in the baskets step): support / confidence / lift per product pair.
-- Both views are defined in full in the migration file; see the next two steps for their bodies.
0010_sales_insights_seed.up.sql (Go) / V10__sales_insights_seed.sql (Spring) — demo data
Run these in your terminal / editor
INSERT INTO customers (email) VALUES
  ('si1@aurora.test'),('si2@aurora.test'),('si3@aurora.test'),('si4@aurora.test'),
  ('si5@aurora.test'),('si6@aurora.test'),('si7@aurora.test'),('si8@aurora.test')
ON CONFLICT (email) DO NOTHING;

-- the one category the base seed lacks — the identical row the discovery seed inserts;
-- ON CONFLICT (slug) lets whichever module runs first win
INSERT INTO categories (name, slug, image_url) VALUES
  ('Stationery', 'stationery', 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Schenker_VIA14_Laptop_asv2021-01.jpg/500px-Schenker_VIA14_Laptop_asv2021-01.jpg')
ON CONFLICT (slug) DO NOTHING;

-- three extra products, category_id resolved BY SLUG (products.category_id is NOT NULL)
INSERT INTO products (name, unit_price, stock, category_id)
SELECT v.name, v.unit_price, v.stock, c.id
FROM (VALUES
  ('Aurora Hoodie',       5999, 30, 'apparel'),
  ('Aurora Water Bottle', 1899, 40, 'drinkware'),
  ('Aurora Notebook',      899, 60, 'stationery')
) AS v(name, unit_price, stock, slug)
JOIN categories c ON c.slug = v.slug
ON CONFLICT (name) DO NOTHING;   -- can't collide with the discovery module's same-named rows

WITH demo(tag, cust, product, qty, days_ago) AS (
  VALUES
    ('si-o1','si1@aurora.test','Aurora Mug',1,3),      ('si-o1','si1@aurora.test','Aurora Notebook',1,3),
    ('si-o2','si1@aurora.test','Aurora Mug',1,12),     ('si-o2','si1@aurora.test','Aurora Notebook',1,12), ('si-o2','si1@aurora.test','Aurora Sticker Pack',2,12),
    ('si-o3','si1@aurora.test','Aurora Hoodie',1,25),  ('si-o3','si1@aurora.test','Aurora Tee',1,25),
    ('si-o4','si1@aurora.test','Aurora Mug',1,40),     ('si-o4','si1@aurora.test','Aurora Water Bottle',1,40),
    ('si-o5','si2@aurora.test','Aurora Mug',1,5),      ('si-o5','si2@aurora.test','Aurora Notebook',1,5),
    ('si-o6','si2@aurora.test','Aurora Tee',1,20),     ('si-o6','si2@aurora.test','Aurora Sticker Pack',1,20),
    ('si-o7','si2@aurora.test','Aurora Mug',1,33),     ('si-o7','si2@aurora.test','Aurora Sticker Pack',1,33),
    ('si-o8','si3@aurora.test','Aurora Tee',1,60),     ('si-o8','si3@aurora.test','Aurora Hoodie',1,60),
    ('si-o9','si3@aurora.test','Aurora Mug',1,70),     ('si-o9','si3@aurora.test','Aurora Notebook',1,70),
    ('si-o10','si3@aurora.test','Aurora Tee',1,85),    ('si-o10','si3@aurora.test','Aurora Water Bottle',1,85),
    ('si-o11','si4@aurora.test','Aurora Sticker Pack',1,80),
    ('si-o12','si5@aurora.test','Aurora Tee',1,2),
    ('si-o13','si6@aurora.test','Aurora Mug',1,8),     ('si-o13','si6@aurora.test','Aurora Notebook',1,8),
    ('si-o14','si6@aurora.test','Aurora Tee',1,45),    ('si-o14','si6@aurora.test','Aurora Sticker Pack',1,45),
    ('si-o15','si7@aurora.test','Aurora Hoodie',1,15), ('si-o15','si7@aurora.test','Aurora Tee',1,15),
    ('si-o16','si7@aurora.test','Aurora Mug',1,50),    ('si-o16','si7@aurora.test','Aurora Water Bottle',1,50),
    ('si-o17','si8@aurora.test','Aurora Mug',1,6),     ('si-o17','si8@aurora.test','Aurora Notebook',1,6), ('si-o17','si8@aurora.test','Aurora Sticker Pack',1,6),
    ('si-o18','demo@aurora.test','Aurora Tee',1,10),   ('si-o18','demo@aurora.test','Aurora Sticker Pack',2,10),
    ('si-o19','demo@aurora.test','Aurora Mug',1,30),   ('si-o19','demo@aurora.test','Aurora Notebook',1,30)
),
ins_orders AS (
  INSERT INTO orders (customer_id, total, status, idempotency_key, created_at)
  SELECT c.id, 0, 'pending', 'seed:si:' || d.tag, now() - make_interval(days => d.days_ago)
  FROM (SELECT DISTINCT tag, cust, days_ago FROM demo) d
  JOIN customers c ON c.email = d.cust
  ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
  RETURNING id, idempotency_key
)
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT o.id, p.id, d.qty, p.unit_price
FROM demo d
JOIN products p   ON p.name = d.product
JOIN ins_orders o ON o.idempotency_key = 'seed:si:' || d.tag
ON CONFLICT (order_id, product_id) DO NOTHING;

UPDATE orders o
SET total = (SELECT COALESCE(SUM(oi.quantity * oi.unit_price),0) FROM order_items oi WHERE oi.order_id = o.id)
WHERE o.idempotency_key LIKE 'seed:si:%';
What success looks like

The views are created and the demo graph is in place — nineteen orders across nine customers and six products. Re-running the seed inserts 0 rows (idempotent), and dropping the views leaves the base schema untouched.

 orders | items | customers | products
--------+-------+-----------+----------
     19 |    38 |         9 |        6

Revenue over time: a windowed running total, AOV, and top products

Optional add-on Intermediate

Serve the $0, no-key analytics core — a daily revenue rollup with a cumulative running total, average order value, top products, low-stock, and a repeat-customer rate — all as windowed aggregations over the orders you already have.

New in this step
date_trunc

Rounds a timestamp down to a unit (date_trunc('day', created_at)), so orders collapse into one row per day (or week) for the time series.

postgres date_trunc day week track ↗ docs ↗
cumulative SUM() OVER

SUM(daily_revenue) OVER (ORDER BY day) adds up revenue through each day — the running total the dashboard plots, computed in one pass.

postgres running total window function track ↗
average order value (AOV)

Total revenue divided by order count — the headline retail metric; kept as integer cents so it never drifts.

average order value ecommerce metric
integer cents

Money is a BIGINT of cents everywhere, never a float, so sums are exact and byte-identical across backends.

store money integer cents not float
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
One view plus three cheap reads — the relational payoff, no new datastore

sales_daily_revenue (from the previous step) is the time series: it buckets orders by day and carries a running total via SUM(sum(...)) OVER (ORDER BY day) — a window over the grouped result. The endpoint filters it to the requested days window and recomputes the running total within that window so the curve starts at the window’s first day. AOV, top products, low-stock, and the repeat-customer rate are three more cheap reads over the same order data. None of this needs a warehouse or an ETL job — the orders sit in the same SQL database as the catalog, so analytics are a query away. Everything is integer cents; the one ratio (repeat rate) is scaled ×1000 to stay an exact integer on the wire.

The analytics-core reads (identical SQL on both backends)
Run these in your terminal / editor
-- Revenue over time (windowed): filter the view, running total recomputed within the window.
SELECT bucket_day, orders, units, revenue_cents,
       sum(revenue_cents) OVER (ORDER BY bucket_day) AS running_revenue_cents
FROM sales_daily_revenue
WHERE bucket_day >= current_date - $1                 -- $1 = days
ORDER BY bucket_day;

-- Average order value (integer cents), guarded against an empty window.
SELECT COALESCE((sum(oi.quantity*oi.unit_price) / NULLIF(count(DISTINCT o.id),0))::bigint, 0)
FROM orders o JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= now() - make_interval(days => $1);

-- Top products by revenue in the window.
SELECT p.id, p.name, sum(oi.quantity)::bigint AS units_sold,
       sum(oi.quantity*oi.unit_price)::bigint AS revenue_cents
FROM orders o JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id
WHERE o.created_at >= now() - make_interval(days => $1)
GROUP BY p.id, p.name ORDER BY revenue_cents DESC, p.id LIMIT $2;

-- Conversion-ish signal: repeat-customer rate, scaled x1000.
SELECT round(count(*) FILTER (WHERE n>=2)::numeric / NULLIF(count(*),0) * 1000)::bigint
FROM (SELECT customer_id, count(*) AS n FROM orders GROUP BY customer_id) t;
What success looks like

Over the seeded 90-day window: the daily rollup climbs to a running total of 73460 cents ($734.60), AOV is 3866 cents, and the repeat-customer rate is 667 (66.7% of customers ordered twice or more). Top products reveal the units-vs-revenue split — the Mug sells the most units (10) but the Tee earns the most revenue (8 units at a higher price).

-- revenue over time (tail of the running total, from sales_daily_revenue)
 bucket_day | orders | units | revenue_cents | running_revenue_cents
------------+--------+-------+---------------+-----------------------
 2026-07-08 |      1 |     3 |          2897 |                 65665
 2026-07-09 |      1 |     2 |          2398 |                 68063
 2026-07-11 |      1 |     2 |          2398 |                 70461
 2026-07-12 |      1 |     1 |          2999 |                 73460

-- top products by revenue (units != revenue)
 id |        name         | units_sold | revenue_cents
----+---------------------+------------+---------------
  2 | Aurora Tee          |          8 |         23992
  4 | Aurora Hoodie       |          3 |         17997
  1 | Aurora Mug          |         10 |         14990
  6 | Aurora Notebook     |          7 |          6293
  5 | Aurora Water Bottle |          3 |          5697

 avg_order_value_cents | repeat_rate_x1000
-----------------------+-------------------
                  3866 |               667

Segment customers with RFM and ntile(5)

Optional add-on Intermediate

Score every customer on Recency, Frequency, and Monetary value into quintiles with ntile(5), then map the scores to named segments — champions, at-risk, promising, hibernating — entirely in SQL. This is a classic, genuinely-used segmentation; the window-function and quantile mechanics are the lesson.

New in this step
RFM segmentation

A standard marketing model that ranks customers by how recently, how often, and how much they buy — the basis for “who are my champions” and “who’s slipping away”.

RFM analysis recency frequency monetary
ntile(5)

A window function that splits rows into 5 equal buckets by an ordering — turning a raw recency or spend into a comparable 1..5 score.

postgres ntile window function quantile track ↗ docs ↗
deterministic tiebreak

Adding customer_id to each ORDER BY inside ntile makes the bucket assignment reproducible, so the same data always yields the same scores (and byte-identical output across backends).

ntile deterministic order by tiebreak
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Score, then label — and why the CASE order matters

For each customer, per_customer computes recency (current_date - max(created_at)), frequency (count(*)), and monetary (sum(total)). Then ntile(5) ranks each axis into quintiles: recency is ordered descending so the most-recent buyer scores 5; frequency and monetary ascending so the busiest, highest-spending buyers score 5. Each ORDER BY breaks ties on customer_id, so the buckets are deterministic and the output is byte-stable. The CASE maps the r/f grid to a segment — and its order matters: at_risk (stale but formerly frequent) is checked before loyal, so a customer who used to buy often but has gone quiet is flagged for a win-back rather than mislabeled as loyal. Monetary (m) is reported for value-tiering and can refine the rules further.

customer_rfm — the RFM scoring view
Run these in your terminal / editor
CREATE OR REPLACE VIEW customer_rfm AS
WITH per_customer AS (
  SELECT o.customer_id,
         (current_date - max(o.created_at)::date) AS recency_days,   -- lower = better
         count(*)                                 AS frequency,
         sum(o.total)::bigint                     AS monetary_cents,
         max(o.created_at)                        AS last_order_at
  FROM orders o GROUP BY o.customer_id
),
scored AS (
  SELECT per_customer.*,
         ntile(5) OVER (ORDER BY recency_days DESC, customer_id)  AS r,  -- most recent -> 5
         ntile(5) OVER (ORDER BY frequency ASC, customer_id)      AS f,  -- most frequent -> 5
         ntile(5) OVER (ORDER BY monetary_cents ASC, customer_id) AS m   -- highest spend -> 5
  FROM per_customer
)
SELECT s.customer_id, c.email, s.recency_days, s.frequency, s.monetary_cents, s.r, s.f, s.m,
  CASE
    WHEN s.r >= 4 AND s.f >= 4 THEN 'champions'
    WHEN s.r <= 2 AND s.f >= 3 THEN 'at_risk'        -- was frequent, now stale
    WHEN s.f >= 4              THEN 'loyal'
    WHEN s.r >= 4 AND s.f <= 2 THEN 'promising'      -- recent, few orders
    WHEN s.r <= 2 AND s.f <= 2 THEN 'hibernating'
    ELSE 'needs_attention'
  END AS segment
FROM scored s JOIN customers c ON c.id = s.customer_id;
What success looks like

The nine seeded customers spread across five segments, deterministically. The two champions (recent and frequent) and the two at_risk customers stand out — and the segment rollup shows at_risk holds 28690 cents of past revenue, the actionable “win these back” number a board report would lead with.

 customer_id |      email       | recency_days | frequency | monetary_cents | r | f | m |    segment
-------------+------------------+--------------+-----------+----------------+---+---+---+-----------------
           6 | si5@aurora.test  |            2 |         1 |           2999 | 5 | 1 | 2 | promising
           2 | si1@aurora.test  |            3 |         4 |          18190 | 4 | 5 | 5 | champions
           3 | si2@aurora.test  |            5 |         3 |           7894 | 4 | 4 | 3 | champions
           7 | si6@aurora.test  |            8 |         2 |           5896 | 3 | 3 | 2 | needs_attention
           9 | si8@aurora.test  |            6 |         1 |           2897 | 3 | 2 | 1 | needs_attention
           8 | si7@aurora.test  |           15 |         2 |          12396 | 2 | 3 | 4 | at_risk
           1 | demo@aurora.test |           10 |         2 |           6395 | 2 | 2 | 3 | hibernating
           4 | si3@aurora.test  |           60 |         3 |          16294 | 1 | 4 | 4 | at_risk
           5 | si4@aurora.test  |           80 |         1 |            499 | 1 | 1 | 1 | hibernating

    segment      | customers | revenue_cents
-----------------+-----------+---------------
 at_risk         |         2 |         28690
 champions       |         2 |         26084
 needs_attention |         2 |          8793
 hibernating     |         2 |          6894
 promising       |         1 |          2999

Find real product bundles with market-basket lift

Optional add-on Advanced

Compute support, confidence, and lift for every co-purchased product pair, then rank by lift — the statistically honest version of “customers who bought X also bought Y.” A lift above 1 is a real association; a raw co-count is just popularity in disguise.

New in this step
market-basket analysis

Mining which products are bought together from order history — the engine behind “frequently bought together” done statistically, not by eyeballing counts.

market basket analysis association rules
support

How often a pair appears together across all orders (co-orders divided by total orders) — the pair’s raw prevalence.

association rule support metric
confidence

Given a shopper bought A, how often they also bought B (co-orders divided by A’s orders) — directional, unlike support.

association rule confidence metric
lift

Confidence divided by B’s overall popularity: lift above 1 means A and B are bought together more than chance predicts — a real association, not just two popular items co-occurring.

association rule lift metric
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why lift beats a raw co-count — the whole point of the metric

A raw “bought together” count rewards popularity: a best-seller co-occurs with everything simply because it is in so many orders. Lift corrects for that. For a pair (a, b), lift = P(a,b) / (P(a) * P(b)) — the observed co-occurrence over what you’d expect if the two were independent. Rearranged over counts that’s co * N / (cnt_a * cnt_b), where N is the order count. Lift above 1 means the pair is bought together more than each product’s popularity alone would predict — a genuine bundle. Lift near 1 means they co-occur only as much as chance; below 1 they are mild substitutes. So the endpoint ranks by lift, not by co-count. To keep the metrics exact and byte-identical across backends, each ratio is scaled ×1000 (independence is lift_x1000 = 1000) — a raw float on the wire is a Go/Jackson formatting hazard, so no float ever leaves SQL.

market_basket — support / confidence / lift per product pair
Run these in your terminal / editor
CREATE OR REPLACE VIEW market_basket AS
WITH order_products AS (          -- DISTINCT so a SKU bought twice in one order counts once
  SELECT DISTINCT oi.order_id, oi.product_id FROM order_items oi
),
total AS (SELECT count(DISTINCT order_id)::numeric AS n FROM order_products),
prod AS (SELECT product_id, count(*)::numeric AS cnt FROM order_products GROUP BY product_id),
pair AS (                         -- unordered pairs (a < b): each pair once
  SELECT op1.product_id AS a, op2.product_id AS b, count(*)::numeric AS co
  FROM order_products op1
  JOIN order_products op2 ON op2.order_id = op1.order_id AND op2.product_id > op1.product_id
  GROUP BY op1.product_id, op2.product_id
)
SELECT pair.a AS product_a, pa.name AS name_a, pair.b AS product_b, pb.name AS name_b,
       pair.co::bigint                                                      AS co_count,
       round(pair.co / total.n * 1000)::bigint                             AS support_x1000,
       round(pair.co / prod_a.cnt * 1000)::bigint                          AS confidence_a_to_b_x1000,
       round(pair.co / prod_b.cnt * 1000)::bigint                          AS confidence_b_to_a_x1000,
       round(pair.co * total.n / (prod_a.cnt * prod_b.cnt) * 1000)::bigint AS lift_x1000
FROM pair CROSS JOIN total
JOIN prod prod_a ON prod_a.product_id = pair.a
JOIN prod prod_b ON prod_b.product_id = pair.b
JOIN products pa ON pa.id = pair.a
JOIN products pb ON pb.id = pair.b
ORDER BY lift_x1000 DESC, co_count DESC, product_a, product_b;
Agent prompt — paste into an agent with repo access

Tee+Sticker and Tee+Hoodie both have co_count 3 — before you run it, which has the higher lift, and why?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend: Go net/http + pgx, or Spring Boot + JdbcTemplate).
Context: base build + the sales-insights views/seed (a demo order graph in orders/order_items). Money is integer cents; field names camelCase. Every endpoint is read-only.
Task: Add GET /insights/baskets?minCoCount=&limit= that reads the market_basket view and returns association rules ranked by lift.
Requirements:
- Read the market_basket view; expose per rule {productA, nameA, productB, nameB, coCount, supportX1000, confidenceX1000, liftX1000} where confidenceX1000 is the a->b direction.
- minCoCount is an integer >= 0 (default 2) filtering pairs seen together fewer times; limit is 1..100 (default 20). Bad params -> 422 {"error":"invalid_request"}.
- Order by liftX1000 desc, then coCount desc, then productA, productB (deterministic). Include totalOrders at the top level.
- Ratios are already scaled x1000 in SQL — never emit a float. Retryable DB failure -> 503 {"error":"unavailable"}; otherwise 500 {"error":"internal"}. No 400, no 404.
- Reuse the byte-parity JSON writer (no HTML escaping, no trailing newline).
Tests / acceptance:
- Tee+Hoodie ranks first (lift ~2375) above Mug+Notebook (lift ~1900) even though Mug+Notebook has the highest co_count (7).
- Tee+Sticker (co_count 3, lift ~1018) ranks well below Tee+Hoodie (co_count 3, lift ~2375), proving co-count alone is misleading.
- minCoCount=3 drops single-co-occurrence pairs; an empty result is a valid 200 {"totalOrders":N,"rules":[]}.
Output: a unified diff plus the market_basket query.
What success looks like

Ranking by lift surfaces the real bundles. Tee + Hoodie tops the list at lift 2375 (2.4×) and Mug + Notebook — the “desk bundle” — sits second at 1900 (1.9×) despite having the highest raw co-count (7). The payoff is the contrast at co-count 3: Tee + Hoodie (lift 2375) versus Tee + Sticker Pack (lift 1018, essentially independent) versus Mug + Sticker Pack (lift 814, mild substitutes) — same raw count, three very different signals. Co-count alone would have ranked them equal; lift tells the truth.

       name_a        |       name_b        | co_count | support_x1000 | conf_ab | lift_x1000
---------------------+---------------------+----------+---------------+---------+------------
 Aurora Tee          | Aurora Hoodie       |        3 |           158 |     375 |       2375
 Aurora Mug          | Aurora Notebook     |        7 |           368 |     700 |       1900
 Aurora Mug          | Aurora Water Bottle |        2 |           105 |     200 |       1267
 Aurora Tee          | Aurora Sticker Pack |        3 |           158 |     375 |       1018
 Aurora Mug          | Aurora Sticker Pack |        3 |           158 |     300 |        814
 Aurora Tee          | Aurora Water Bottle |        1 |            53 |     125 |        792
 Aurora Sticker Pack | Aurora Notebook     |        2 |           105 |     286 |        776

Serve the insights endpoints (Go)

Optional add-on Advanced

Wire the three analytics views to read-only endpoints in Go with pgx — GET /insights/sales, /insights/segments, and /insights/baskets — validating query params, degrading gracefully to empty, and reusing the byte-parity JSON writer.

New in this step
pool.Query (pgx)

The pgx call for a multi-row read; you iterate rows.Next() and rows.Scan(...) each row into a struct field.

pgx pool query rows scan
non-nil empty slice

Initialise the results as []T{}, not nil — a nil slice marshals to null, but the graceful-empty contract wants [].

go empty slice json marshals array not null
503 on connection error

A dropped/unreachable Postgres is retryable, so it maps to 503 {"error":"unavailable"} — distinct from a 500 for an unexpected bug.

service unavailable retryable database down
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Thin handlers over the views — the analytics live in SQL

The heavy lifting is in the views, so each handler is thin: parse and validate the query params (bad param → 422 {"error":"invalid_request"}), run the view read with pool.Query, scan rows into structs, and write JSON. Initialise every result slice as []T{} so an empty window serializes as [], not null (graceful empty, like Vitals /insights/weekly). A pgx connection error maps to 503 {"error":"unavailable"} (retryable) checked before the default 500; there is no 400 and no 404 (these are browse reads with no path id). Reuse the byte-parity writer the earlier modules established — json.Encoder with SetEscapeHTML(false) and the trailing newline trimmed — because product names, emails, and (next step) AI prose are free-text that Go would otherwise escape differently from Jackson.

Handler essentials (pgx + the byte-parity writer)
Run these in your terminal / editor
func writeJSON(w http.ResponseWriter, status int, v any) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)           // product names / emails / AI prose are free-text
	_ = enc.Encode(v)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write(bytes.TrimRight(buf.Bytes(), "\n"))   // Jackson emits no trailing newline
}

// GET /insights/baskets — reads the market_basket view, ranked by lift.
func (s *Store) baskets(ctx context.Context, minCo, limit int) ([]BasketRule, error) {
	const q = `SELECT product_a, name_a, product_b, name_b, co_count,
	                  support_x1000, confidence_a_to_b_x1000, lift_x1000
	           FROM market_basket WHERE co_count >= $1
	           ORDER BY lift_x1000 DESC, co_count DESC, product_a, product_b LIMIT $2`
	rows, err := s.Pool.Query(ctx, q, minCo, limit)
	if err != nil {
		return nil, err                      // handler maps a conn error -> 503, else 500
	}
	defer rows.Close()
	out := []BasketRule{}                    // non-nil: empty result serializes as []
	for rows.Next() {
		var r BasketRule
		if err := rows.Scan(&r.ProductA, &r.NameA, &r.ProductB, &r.NameB,
			&r.CoCount, &r.SupportX1000, &r.ConfidenceX1000, &r.LiftX1000); err != nil {
			return nil, err
		}
		out = append(out, r)
	}
	return out, rows.Err()
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Senior Go engineer over Postgres with pgx. The reader has no repo here — return complete code.
Context: net/http API; pgxpool as pool; the sales_daily_revenue, customer_rfm, and market_basket views exist. Money is integer cents; field names camelCase.
Task: Implement GET /insights/sales?days=&bucket=&limit=, GET /insights/segments?limit=, and GET /insights/baskets?minCoCount=&limit=, all read-only.
Requirements:
- /insights/sales: days 1..365 (default 30), bucket day|week (default day), limit 1..100 (default 10). Filter sales_daily_revenue to the window and recompute the running total within it; add avgOrderValueCents (integer cents, NULLIF-guarded), topProducts by revenue, lowStock (stock <= 40), and repeatRateX1000. Empty window -> revenueByDay:[], topProducts:[], zeros.
- /insights/segments: read customer_rfm; return a segments rollup [{segment, customers, revenueCents}] and a customers list with r/f/m and segment. Empty -> [] both.
- /insights/baskets: read market_basket; minCoCount >= 0 (default 2), limit 1..100 (default 20); rank by liftX1000 desc. Include totalOrders. Empty -> rules:[].
- Bad query param -> 422 {"error":"invalid_request"}. pgx connection error -> 503 {"error":"unavailable"} (before the default 500 {"error":"internal"}). No 400, no 404.
- Emit the bucket day as an ISO date string (YYYY-MM-DD). Never emit a float — ratios are already scaled x1000 in SQL. Use a JSON writer with SetEscapeHTML(false) and no trailing newline.
Tests / acceptance:
- /insights/sales?days=90 returns a running total ending at 73460 and avgOrderValueCents 3866; the Mug leads units, the Tee leads revenue.
- /insights/segments returns 2 champions and 2 at_risk; at_risk revenueCents is 28690.
- /insights/baskets ranks Tee+Hoodie (lift 2375) above Mug+Notebook (lift 1900); a bad days=0 -> 422.
Output: the complete handlers, no commentary.
What success looks like

GET /insights/baskets returns 200 with the lift-ranked rules (Tee + Hoodie first at liftX1000: 2375), Content-Type: application/json, and no trailing newline. GET /insights/sales?days=90 reports avgOrderValueCents: 3866 and a running total ending at 73460; GET /insights/segments reports 2 champions and 2 at-risk. A bad param (days=0, bucket=fortnight) returns 422 {"error":"invalid_request"}; an empty window returns 200 with empty arrays, never a 5xx. The module builds against pgx v5.10.0 — go build ./... passes.

Serve the same insights endpoints (Spring/Kotlin)

Optional add-on Advanced

Implement the identical three read-only endpoints in Spring Boot (Kotlin) with JdbcTemplate, reading the same views and returning the byte-identical contract — the analytics live in SQL, so only the handler glue differs from Go.

New in this step
JdbcTemplate.query + RowMapper

Runs a SQL read and maps each row to an object via a RowMapper — the JVM analogue of pgx’s rows.Scan.

spring jdbctemplate query rowmapper
positional ? placeholders

JdbcTemplate binds parameters with ?, so the view SQL’s $1/$2 become ? on this path — the query text otherwise stays identical.

jdbctemplate query parameter placeholder question mark
CannotGetJdbcConnectionException -> 503

A JDBC connection failure is retryable, so @RestControllerAdvice maps it (and DataAccessResourceFailureException) to 503 {"error":"unavailable"}.

spring CannotGetJdbcConnectionException data access resource failure
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same views, same contract — parity is in the SQL

Because the analytics live in the three views (identical SQL, kept byte-for-byte with the Go path per the in-course anti-drift rule), the Spring handlers are thin JdbcTemplate.query calls with a RowMapper per shape, swapping $1 for ?. Return empty lists (emptyList()) on no data so the JSON is [], not null. A bad query param throws and is mapped to 422 {"error":"invalid_request"} by @RestControllerAdvice; a CannotGetJdbcConnectionException / DataAccessResourceFailureException maps to 503 {"error":"unavailable"}; anything unmapped is 500 {"error":"internal"}. Jackson emits free-text (name, email, AI prose) without HTML-escaping and with no trailing newline, so its bytes match Go’s SetEscapeHTML(false) writer exactly. Emit the bucket day as a java.time.LocalDate (toString()YYYY-MM-DD), matching Go’s "2006-01-02" format; never emit a float, since every ratio is pre-scaled ×1000 in SQL.

Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Senior Spring Boot (Kotlin) engineer over Postgres with JdbcTemplate. The reader has no repo here — return complete code.
Context: spring-boot-starter-web + -jdbc; the sales_daily_revenue, customer_rfm, and market_basket views exist. Money is integer cents; field names camelCase.
Task: Implement GET /insights/sales, /insights/segments, /insights/baskets — the byte-identical contract to the Go path — read-only, using JdbcTemplate with ? placeholders and a RowMapper per shape.
Requirements:
- Same params, defaults, and ranges as Go (days 1..365/30, bucket day|week/day, limit 1..100/10; segments limit; minCoCount >= 0/2). Read the same views with the same SQL (swap $1 for ?).
- Graceful empty: emptyList() -> [] and zeros, never null and never a 5xx.
- Bad query param -> 422 {"error":"invalid_request"} via @RestControllerAdvice; CannotGetJdbcConnectionException / DataAccessResourceFailureException -> 503 {"error":"unavailable"}; unmapped -> 500 {"error":"internal"}. No 400, no 404.
- Emit the bucket day as a LocalDate (YYYY-MM-DD). Jackson defaults (no HTML escaping, no trailing newline) match the Go writer. Ratios are pre-scaled x1000 in SQL — never emit a float.
Tests / acceptance:
- Testcontainers postgres:16: /insights/sales?days=90 -> avgOrderValueCents 3866 and running total 73460; /insights/segments -> 2 champions, 2 at_risk; /insights/baskets -> Tee+Hoodie above Mug+Notebook.
- days=0 -> 422; an empty window -> 200 with empty arrays.
- The response bytes for a name containing & and < are byte-identical to the Go backend.
Output: the complete controllers, the advice, and the RowMappers — no commentary.
What success looks like

Against a Testcontainers postgres:16, the Spring endpoints return the byte-identical contract: /insights/sales?days=90 reports avgOrderValueCents: 3866 and a 73460 running total, /insights/segments reports 2 champions and 2 at-risk, and /insights/baskets ranks Tee + Hoodie above Mug + Notebook. A bad days=0 is 422 {"error":"invalid_request"}; a name containing &/< serializes byte-for-byte with the Go writer; an empty window is 200 with empty arrays. (Reasoned: no JVM toolchain in the authoring environment.)

Optional: narrate the metrics as a Gemini board report

Optional add-on Advanced

Add an optional GET /insights/report that sends the computed metrics — numbers only — to Gemini and gets back a constrained-JSON board report (a headline, a summary, and recommendations). It is a summary layer: if there is no key or the model fails, the endpoint still returns the metrics with the report null, so the SQL analytics never depend on it.

New in this step
Gemini

Google’s family of LLMs, called server-side over an HTTP API; see the Gemini track for the request shape.

gemini api overview docs ↗
constrained JSON (responseSchema)

Constrain the model to emit JSON matching a schema (responseMimeType: application/json + a responseSchema), turning the summary into typed data — the Helix judge pattern applied to a board report.

gemini structured output responseschema
aggregates only

Only the computed numbers cross the boundary to the model — never raw order rows — which keeps the payload tiny and leaks nothing customer-level.

send aggregates not raw rows llm privacy cost
graceful degradation

Any missing key, error, or timeout returns 200 with the report null and the metrics intact, so the analytics never break when the model is down.

graceful degradation fallback null
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Optional by design — the metrics are the substance, the prose is a layer

The module is fully valuable on the SQL analytics alone; this endpoint is a narration layer, not a dependency. GET /insights/report computes the sales/segments/baskets summary, sends only those aggregate numbers to Gemini with a responseSchema for {headline, summary, recommendations[]}, and returns {metrics, report}. On a missing GOOGLE_API_KEY, a model error, or a timeout it returns 200 {metrics, report: null} — the graceful degrade Vitals /insights/weekly uses, and deliberately unlike the base ai-recipe/ai-support endpoints that return 502 (there the AI is the endpoint; here the metrics stand on their own). Keep the key server-side (canonical env var GOOGLE_API_KEY); don’t hardcode a model id — read it from config, default a current gemini-2.5-flash-class model, and check the official model list. The model prose is free-text, so it flows through the same SetEscapeHTML(false) writer.

Constrained-JSON board report (google.golang.org/genai)
Run these in your terminal / editor
// report/board.go (essentials) — google.golang.org/genai v1.62.0. Numbers in, typed prose out.
client, err := genai.NewClient(ctx, &genai.ClientConfig{
	APIKey:  os.Getenv("GOOGLE_API_KEY"),          // free AI Studio key, server-side only
	Backend: genai.BackendGeminiAPI,
})
if err != nil { return nil, err }                 // caller returns {metrics, report:null}

schema := &genai.Schema{
	Type: genai.TypeObject,
	Properties: map[string]*genai.Schema{
		"headline":        {Type: genai.TypeString},
		"summary":         {Type: genai.TypeString},
		"recommendations": {Type: genai.TypeArray, Items: &genai.Schema{Type: genai.TypeString}},
	},
	Required: []string{"headline", "summary", "recommendations"},
}
cfg := &genai.GenerateContentConfig{
	ResponseMIMEType: "application/json",          // constrain to JSON matching the schema
	ResponseSchema:   schema,
}
model := cmp.Or(os.Getenv("INSIGHTS_MODEL"), "gemini-2.5-flash")   // don't hardcode; check the model list
resp, err := client.Models.GenerateContent(ctx, model, genai.Text(metricsJSON), cfg)
if err != nil { return nil, err }                 // timeout/model error -> {metrics, report:null}

var rep BoardReport
_ = json.Unmarshal([]byte(resp.Text()), &rep)     // typed, schema-constrained
Agent prompt — paste into an agent with repo access

Before you wire it, what should GET /insights/report return when GOOGLE_API_KEY is unset — a 502, or a 200 with report null?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: AI integration engineer in this repo (server calling Gemini from the selected backend: Go google.golang.org/genai, or Spring com.google.genai).
Context: the /insights/sales, /insights/segments, /insights/baskets reads exist and return integer metrics. GOOGLE_API_KEY may be unset. The feature is optional and off by default.
Task: Add GET /insights/report that computes a compact metrics summary, sends ONLY those numbers to Gemini with a constrained-JSON responseSchema {headline:string, summary:string, recommendations:[string]}, and returns {metrics, report}.
Requirements:
- Send only aggregate numbers — never raw order rows. Cap output tokens; 20s timeout.
- Use responseMimeType="application/json" + a responseSchema for {headline, summary, recommendations}.
- Graceful degrade: missing GOOGLE_API_KEY, any model error, or a timeout -> 200 {metrics, report:null} (NOT 502 — the metrics are the substance, mirroring Vitals /insights/weekly, unlike ai-recipe/ai-support which 502).
- The metrics read still maps a bad param -> 422 and Postgres-unreachable -> 503.
- Key stays server-side (GOOGLE_API_KEY); do NOT hardcode a model id — read it from config and link the official model list: https://ai.google.dev/gemini-api/docs/models . Reuse the byte-parity JSON writer (SetEscapeHTML(false), no trailing newline) so the free-text prose matches across backends.
Tests / acceptance:
- With no key, GET /insights/report returns 200 with report:null and the full metrics object present.
- A mocked Gemini failure yields report:null, not a 5xx.
- The request body to Gemini contains only aggregate fields (assert no raw order rows are serialised).
Output: a unified diff plus the responseSchema and a note on the degrade path.
What success looks like

With no GOOGLE_API_KEY in the environment, GET /insights/report returns 200 with the full metrics object and report: null — the SQL analytics are unaffected by the model being absent. The genai request/response shape compiles against google.golang.org/genai v1.62.0 (go build passes): genai.NewClient with BackendGeminiAPI, a GenerateContentConfig carrying ResponseMIMEType: "application/json" and a ResponseSchema, and client.Models.GenerateContent. With a free AI Studio key set, the same call returns a typed {headline, summary, recommendations} board report over the metrics (live model prose: needsWebCheck — no key in the authoring environment).

Where to take it next

  • Go deeper on the database that carries this whole build: the PostgreSQL track (isolation, indexes, pg_trgm, EXPLAIN).
  • Sharpen your backend: Go or Kotlin (the Spring path).
  • See why a document store scores only 2/5 here on the Compare page — the inverse of the flexible-document domains where MongoDB is the load-bearing win.