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
BeginnerStart 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.
DATABASE_URL An environment variable holding the connection string; reading it from the env means the same build runs locally and in the cloud.
postgres:// connection string (DSN) The single-line address of a database: user:password@host:port/dbname plus options.
sslmode=disable Turns off TLS for the local container (fine for localhost; never for a real server).
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 pluginThe 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 variantdocker-compose.yml
# 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
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
BeginnerCreate 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.
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.
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.
BIGINT GENERATED ALWAYS AS IDENTITY The modern auto-incrementing 64-bit primary key (the successor to serial).
TIMESTAMPTZ A timestamp that stores the instant in UTC, so created_at is unambiguous across time zones.
money as BIGINT cents Integers can’t drift the way floats do; store 1499, not 14.99.
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.
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.
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
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
# 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.sqlWhat 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
BeginnerInsert 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.
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.
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
-- 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
# 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.sqlWhat 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 | 1Scaffold the Go API: composed main + GET /products
Go BeginnerCreate 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.
connection pool A fixed set of live DB connections handed out and returned per query; the pool, not your code, manages reuse and limits.
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.
http.ServeMux Go’s standard request router; on Go 1.22+ it matches method + path patterns like GET /products.
context (ctx) A value threaded through every call that carries cancellation and deadlines, so a dropped request can abort its DB query.
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).
PORT env var The port to listen on, injected by the host (Cloud Run sets it); default to 8080 locally.
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
go mod init github.com/you/aurora-api
go get github.com/jackc/pgx/v5Go 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)
// 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
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) BeginnerGenerate 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.
@SpringBootApplication The annotation on the main class that turns on auto-configuration and component scanning; it composes the server, controllers, and filters for you.
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.
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.
@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.
application.properties Spring’s config file; ${ENV_VAR:default} reads the datasource from the environment with a local fallback.
server.shutdown=graceful Drains in-flight requests on stop (on by default in Boot 3).
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)
export JDBC_DATABASE_URL="jdbc:postgresql://localhost:5432/aurora"
export DB_USER=postgres
export DB_PASSWORD=devbuild.gradle.kts (key deps)
// 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
# 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=gracefulGenerate 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.zipThe 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)
// 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
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 IntermediateBuild 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.
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.
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.
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”.
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
// 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)
// 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?
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) IntermediateBuild 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.
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.
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.
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.
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
CartNotFoundException → 404 {"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 DataIntegrityViolationException → 404 not_found advice mapping the
checkout step already establishes.
CartService + CartController (essentials; JdbcTemplate, reasoned)
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?
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
IntermediateBefore 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.
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.
Open two psql sessions
# 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
UPDATE products SET stock = 1 WHERE id = 1;Session A (run first)
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 unblocksSession B (run after A's UPDATE, before A commits)
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 onceWhat 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 IntermediateWrap 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.
ACID The four guarantees a transaction gives: Atomicity, Consistency, Isolation, Durability — the reason the database, not your code, keeps money and stock correct.
atomicity The all-or-nothing part of ACID: there is no state where stock dropped but the order wasn’t created.
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.
conditional UPDATE … WHERE stock >= qty The oversell guard: it decrements only if enough stock remains; otherwise it touches zero rows.
pgx.ErrNoRows pgx’s signal that a query returned no row; here it means the guard matched nothing, i.e. out of stock.
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.
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.
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 cart — UPDATE 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
// 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?
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) IntermediatePut 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.
ACID The four transaction guarantees (Atomicity, Consistency, Isolation, Durability) — why the database, not your code, keeps money and stock correct.
atomicity The all-or-nothing part: there is no state where stock dropped but the order wasn’t created.
@Transactional Spring’s declarative transaction boundary: opens a tx when the method is entered, commits on normal return, rolls back when it throws.
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.
conditional UPDATE … WHERE stock >= qty The oversell guard: it decrements only if enough stock remains; otherwise it touches zero rows.
queryForObject JdbcTemplate’s call for a query expected to return exactly one value/row.
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.
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
// 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?
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 IntermediateAdd 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-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.
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.
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.
pgconn.PgError pgx’s typed error that exposes the SQLSTATE Code, so you can branch on 23505 specifically.
http.MaxBytesReader Caps the request body size so a giant or malformed payload can’t exhaust memory.
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.
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
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?
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) IntermediateAdd 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-Key header A client-generated UUID sent with the request; the server uses it to recognise a retry and return the original result.
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.
@RequestBody Binds the JSON request body to a Kotlin data class.
@RequestHeader Reads a header (here X-Cart-Token and Idempotency-Key, both required = false so the controller decides what absence means).
ResponseEntity Lets you set the status code and body explicitly (e.g. 422 with an error map).
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.
@RestControllerAdvice / @ExceptionHandler One place that maps thrown exceptions to HTTP responses (e.g. OutOfStockException to 409), keeping controllers clean.
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 OutOfStockException → 409, CartNotFoundException → 404 {"error":"cart_not_found"},
and EmptyCartException → 422. 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
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?
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 lookup — 200, 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 customerId → 404 {"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}
IntermediateExpose 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.
r.PathValue(id) (Go) Go 1.22+ reads a {id} pattern segment as a string; convert it with strconv.Atoi before querying.
@PathVariable (Spring) Binds the {id} segment to a method parameter, already typed as Long.
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)
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_atinto atime.Time, then emit it as a string field viat.UTC().Format(time.RFC3339). Plain.UTC()+ defaulttime.Timemarshal 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 theZdesignator. 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.
- Go —
r.PathValue("id")is a string; on astrconv.Atoierror return404(don’t 500). - Spring —
@PathVariable id: LongraisesMethodArgumentTypeMismatchException→400by default. Either bind it asStringandtoLongOrNull() ?: return 404, or add@ExceptionHandler(org.springframework.web.method.annotation.MethodArgumentTypeMismatchException::class)returning404 {"error":"not_found"}.
Agent prompt — paste into an agent with repo access
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
AdvancedThe 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.
transaction isolation level How much one in-flight transaction can see of another’s uncommitted work; Postgres defaults to READ COMMITTED.
SERIALIZABLE The strictest level: transactions behave as if run one-at-a-time, catching anomalies a per-row guard can’t.
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).
SQLSTATE 40001 (serialization_failure) What SERIALIZABLE raises to abort one conflicting transaction; the app retries the whole transaction (nothing committed, so it’s safe).
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.
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)
# 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: abortAgent 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?
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=1The 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
IntermediateTrace 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)
-- 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
IntermediateWire 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.
up vs down up applies a change (create the tables); down reverses it (drop them) so you can roll back.
golang-migrate The common Go migration runner; applies db/migrations/000N_*.up.sql in order against DATABASE_URL.
Flyway The JVM equivalent; Spring Boot auto-applies db/migration/V*.sql on startup.
never edit an applied migration Once a file has run somewhere, add a new migration instead; editing it desyncs environments.
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)
# 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" upKeep 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
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 driftIntegration-test the money path (Go)
Go IntermediateRun 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.
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.
t.Skip Go’s way to skip a test at runtime (here, when DATABASE_URL is unset) instead of failing it.
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
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.42sIntegration-test the money path (Spring/Kotlin)
Spring Boot (Kotlin) IntermediateUse @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.
@SpringBootTest Boots the full Spring application context for a test, so controllers, services, and the datasource are all real.
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.
@DynamicPropertySource Feeds the container’s generated JDBC URL into spring.datasource.* at runtime, so the app connects to the test container.
Agent prompt — paste into an agent with repo access
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 24sShow the catalog (Jetpack Compose)
Jetpack Compose BeginnerBuild 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.
@Serializable (kotlinx.serialization) Marks a data class so the JSON (unitPrice) maps straight onto its fields.
@Composable A function that describes a piece of UI; Compose redraws it when its inputs change.
LazyColumn A scrolling list that only composes the rows on screen, efficient for a catalog.
10.0.2.2 The special address the Android emulator uses to reach the host machine’s localhost (where your API runs).
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)
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)
// 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 BeginnerFetch 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.
jsonDecode + a fromJson factory Parse the response string into a map, then build a typed Product from it.
FutureBuilder A widget that rebuilds when an async call (the fetch) completes, so you can show a spinner then the list.
ListView Flutter’s scrolling list of widgets for the catalog rows.
10.0.2.2 The address the Android emulator uses to reach the host machine’s localhost (where your API runs).
Fetch + list (Flutter)
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
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 BeginnerFetch 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.
Codable The protocol that auto-decodes matching JSON keys (unitPrice) into a Swift struct.
Identifiable Lets List track each row by its id.
List SwiftUI’s scrolling list view for the catalog rows.
.task Runs an async job (the fetch) when the view appears.
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).
Fetch + list (SwiftUI)
// 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
<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 IntermediateAdd 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.
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()).
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.
UI state machine Model the screen as one-of {idle, loading, success(orderId), outOfStock} so a 409 can’t be mistaken for success.
Agent prompt — paste into an agent with repo access
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 IntermediateAdd 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.
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.
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.
Agent prompt — paste into an agent with repo access
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 IntermediateAdd 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.
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.
@Observable The Swift macro that makes a model class publish changes to SwiftUI, so the view updates when the cart or checkout result lands.
@MainActor Guarantees UI state updates run on the main thread, avoiding races when the async POST returns.
Agent prompt — paste into an agent with repo access
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
AdvancedPush 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.
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 SQL Managed Postgres with patching, backups, and TLS handled for you.
gcloud run deploy --source . (Cloud Buildpacks) Builds the container image from your source automatically, so no Dockerfile is required.
Cloud SQL connector / SocketFactory Connects over an authenticated socket instead of a raw password URL; the Spring path adds the postgres-socket-factory dependency.
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.
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
# 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-pgParse a recipe into ingredients with Gemini
Optional add-on IntermediateSend 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.
generateContent The core Gemini call: send a prompt, get a response.
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.
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.
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
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
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 IntermediateResolve 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.
pg_trgm extension Adds trigram matching to Postgres; enable it once with CREATE EXTENSION IF NOT EXISTS pg_trgm.
trigram similarity Compares two strings by their 3-character chunks to score fuzzy matches (handles typos and plurals); similarity(name, $1) returns that score.
% operator pg_trgm’s is-similar-to test, used in the WHERE to keep only plausible matches.
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).
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)
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- required: without it, similarity()/% raise "function similarity(text, unknown) does not exist"Fuzzy match in SQL
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 IntermediateExpose 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.
unmatched[] Ingredients with no confident product match are returned in their own list, not silently dropped, so the shopper sees what was skipped.
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.
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
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 IntermediateGive 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.
FunctionDeclaration How you describe a tool to the model: a name, a description the model reads to decide, and typed parameters.
JSON-Schema parameters The typed shape of a tool’s inputs (e.g. order_id: integer, required), so the model returns well-formed arguments.
read-only tools Every tool here only SELECTs; the worst case is a wrong read, never a wrong write — writes are gated separately later.
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)
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 IntermediateImplement 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.
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.
FunctionCall / FunctionResponse The model returns a FunctionCall (tool + args); you reply with a FunctionResponse (the query result) so it can answer with real data.
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.
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)
// 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 answerChat prompt — paste into a chat to get the code
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 IntermediateImplement 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.
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.
manual loop Read response.functionCalls(), run the query yourself, send a FunctionResponse, then call generateContent again for the grounded answer.
FunctionCall / FunctionResponse The model returns the chosen tool + args; you reply with the query result so it answers with real data.
don't hardcode the model id Ids change; read it from config and check the official model list before deploying.
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)
// 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 answerChat prompt — paste into a chat to get the code
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 IntermediateWhen 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.
proposal with requiresConfirmation: true Instead of acting, the agent returns { action, orderId, summary, requiresConfirmation: true } and the UI shows a confirm button.
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.
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.
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)
# 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
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 IntermediateDecide 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).
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.
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.
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.
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)
{"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 IntermediateConfigure 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.
slog.NewJSONHandler A handler that writes one JSON object per log line to a writer (here os.Stdout).
HTTP middleware A wrapper around your handler that runs on every request (here, to generate and attach the trace id) before passing control on.
unexported context key Store the trace id on r.Context() under a private key type so other packages can’t collide with it.
InfoContext / ErrorContext slog calls that take the context, so a custom handler can pull the trace id from it automatically on every line.
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)
go get github.com/google/uuid # for uuid.NewString(); skip if you use the crypto/rand variant belowslog JSON + trace-id middleware (essentials)
// 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_hitAgent prompt — paste into an agent with repo access
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 IntermediateTurn 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.
SLF4J The logging facade you code against (LoggerFactory.getLogger(...)); Spring Boot wires the JSON output behind it.
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.
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.
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)
// 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_hitAgent prompt — paste into an agent with repo access
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 IntermediateEvery 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 AdvancedFor 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.
span One timed, named operation (e.g. checkout) that can nest child spans, so a request becomes a timeline you can read.
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).
Tempo Grafana’s trace backend; it receives the OTLP spans.
Grafana The UI where you explore the traces (the Tempo datasource), on :3000, all local Docker, free.
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)
# 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
# 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
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 IntermediateAdd 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.
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.
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.
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.
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.
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
-- 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
-- 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
# 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.sqlWhat 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 IntermediateExpose 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.
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.
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.
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
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 JacksonThe /storefront reads (SQL, shared by both backends)
-- 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
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 AdvancedIn 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.
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.
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.
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.
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
// 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?
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 AdvancedAdd 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.
EmptyResultDataAccessException What queryForMap/queryForObject throws when the guarded statement matches zero rows — here it means the cap blocked the reservation or the upsert.
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.
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
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_priceAgent 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?
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 AdvancedFire 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?
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=2Build the flash-sale home screen (Jetpack Compose)
Optional add-on IntermediateRender 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.
parse endsAt (RFC3339 instant) Instant.parse(endsAt) reads the Z-suffixed wire time; diff it against Clock.System.now() for the seconds remaining.
struck-through price (textDecoration) Text(..., textDecoration = TextDecoration.LineThrough) shows the original unitPrice crossed out beside the salePrice, the familiar sale affordance.
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.
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)
@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
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 IntermediateAdd 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.
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.
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.
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.
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
-- 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
-- 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
# 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.sqlWhat 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 IntermediateAdd 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.
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.
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.
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)
-- 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?
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_transitionReturn expired holds to stock with SELECT … FOR UPDATE SKIP LOCKED
Optional add-on AdvancedAdd 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.
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.
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.
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
// 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?
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 AdvancedExtend 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.
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).
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.
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
// 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?
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 AdvancedAdd 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.
?::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.
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
// 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?
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 IntermediateAdd 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.
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.
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.
capture (mock provider) capture charges the funds authorize reserved; here capture is the authorized → paid hop, recorded as a payments('capture') row.
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)
-- 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_foundAgent 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?
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_foundRefund as a compensating transaction
Optional add-on AdvancedAdd 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.
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).
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.
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)
-- 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?
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 AdvancedAdd 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.
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.
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.
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
-- 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?
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: 0Read inventory from the ledger: restock and low-stock
Optional add-on IntermediateUse 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.
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.
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.
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)
-- 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?
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=trueIndex the catalog for search: a generated tsvector, GIN, and trigram
Optional add-on IntermediateAdd 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.
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.
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.
GIN index The inverted index that makes tsvector @@ tsquery fast — it maps each lexeme to the rows that contain it.
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).
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
-- 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)
docker compose exec -T db psql -U postgres -d aurora < db/migrations/0007_discovery.up.sqlWhat 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 IntermediateBackfill 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”.
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.
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
-- 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 | 21Serve GET /search: full-text, facets, and keyset pagination (Go)
Optional add-on AdvancedBuild 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 @@.
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.
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.
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.
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.
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)
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?
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 | 60793Serve GET /search: the same contract (Spring/Kotlin)
Optional add-on AdvancedImplement 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.
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.
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
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 AdvancedWhen 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.
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”.
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.
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.
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)
-- 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)Recommend from real orders: also-bought, bought-together, trending
Optional add-on IntermediateServe recommendations straight from the order data with three read-only queries — GET /products/{id}/related (customers also bought), GET /cart/related (frequently bought together), and GET /trending (a windowed sales count). This is the relational payoff: no new datastore, because the orders already live beside the catalog.
New in this step
co-purchase self-join Joining order_items to itself on order_id pairs every product in an order with the others, so grouping by the partner product counts how often two products were bought together.
COUNT(DISTINCT order_id) Counts the distinct orders a product co-occurred in (not raw line rows), so a product bought twice in one order still counts once toward “bought together”.
ANY and ALL array operators product_id = ANY($1) matches any id in the cart array and product_id <> ALL($1) excludes the cart’s own items — one bind carries the whole cart.
RANK() window function RANK() OVER (ORDER BY SUM(quantity) DESC) numbers rows by sales within the window, sharing a rank on ties — the “trending” ordering, computed in one pass.
Three queries over order_items — and why this is the module's payoff
“Customers also bought” is a self-join of order_items on order_id: pair the anchor product with every other product in its orders and rank by co-occurrence count. “Frequently bought together” generalises it to a cart — product_id = ANY(cart) finds the orders, <> ALL(cart) excludes the cart’s own items, and COUNT(DISTINCT order_id) ranks the partners. “Trending” is a windowed count: filter to created_at >= now() - make_interval(days => $1) and sum quantities, with RANK() OVER (ORDER BY SUM(quantity) DESC) attaching a tie-aware rank. The load-bearing idea is that none of this needs a new datastore — the orders already sit in the same SQL database as the catalog, so a self-join is the entire recommendation engine. These queries are strictly read-only and never touch the checkout transaction; the transactional core stays exactly as it was. The resource path GET /products/{id}/related returns 404 for an unknown id (a cheap SELECT EXISTS tells “no such product” from “a real product with no co-purchases yet”), while the browse-style GET /cart/related and GET /trending never 404 — an empty result is a valid 200, exactly like the base GET /storefront.
The three recommendation reads (identical SQL on both backends)
-- (1) GET /products/{id}/related — customers also bought (co-purchase self-join)
SELECT oi2.product_id, p.name, p.unit_price, p.stock, COUNT(*) AS weight
FROM order_items oi1
JOIN order_items oi2 ON oi2.order_id = oi1.order_id AND oi2.product_id <> oi1.product_id
JOIN products p ON p.id = oi2.product_id
WHERE oi1.product_id = $1
GROUP BY oi2.product_id, p.name, p.unit_price, p.stock
ORDER BY weight DESC, oi2.product_id
LIMIT $2;
-- (2) GET /cart/related?ids=1,2 — frequently bought together (cart array)
SELECT oi2.product_id, p.name, p.unit_price, p.stock, COUNT(DISTINCT oi1.order_id) AS weight
FROM order_items oi1
JOIN order_items oi2 ON oi2.order_id = oi1.order_id
JOIN products p ON p.id = oi2.product_id
WHERE oi1.product_id = ANY($1) AND oi2.product_id <> ALL($1)
GROUP BY oi2.product_id, p.name, p.unit_price, p.stock
ORDER BY weight DESC, oi2.product_id
LIMIT $2;
-- (3) GET /trending?days=7 — windowed sales count (RANK() window fn shown for teaching)
SELECT RANK() OVER (ORDER BY SUM(oi.quantity) DESC) AS rank,
oi.product_id, p.name, SUM(oi.quantity) AS weight
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 oi.product_id, p.name
ORDER BY weight DESC, oi.product_id
LIMIT $2;Agent prompt — paste into an agent with repo access
Two of the seeded orders are 30+ days old (Mug+Tee, and Hoodie+Water Bottle). Before you run it, which endpoint still counts them — /products/{id}/related or /trending — and why?
Role: Senior backend engineer in this repo (use the selected backend: Go net/http + pgx, or Spring Boot + JdbcTemplate).
Context: base build + the discovery schema/seed (a demo order graph in orders/order_items). Money is integer cents; field names camelCase. All three endpoints are read-only.
Task: Add GET /products/{id}/related, GET /cart/related?ids=CSV, and GET /trending?days=&limit=.
Requirements:
- /products/{id}/related: co-purchase self-join over order_items ranked by count; weight = co-purchase count. Confirm the product exists (SELECT EXISTS) so an unknown or non-numeric {id} is 404 {"error":"not_found"}; a real product with no co-purchases is a 200 with related: []. Response {productId, related:[{id,name,unitPrice,stock,weight}]}.
- /cart/related: parse ids as a comma-separated integer list; product_id = ANY(ids) AND <> ALL(ids); weight = COUNT(DISTINCT order_id). Missing/empty/non-integer ids -> 422. Unknown ids contribute nothing (never 404). Response {ids, related:[...]}.
- /trending: days 1..365 (default 7), limit 1..100 (default 20); window on created_at >= now() - days; weight = SUM(quantity). Bad days/limit -> 422. Response {trending:[{id,name,unitPrice,stock,weight}]}.
- Retryable DB failure -> 503 {"error":"unavailable"}; otherwise 500 {"error":"internal"}. No 400.
- Reuse the base byte-parity JSON writer (no HTML escaping, no trailing newline).
Tests / acceptance (assert by NAME — module product ids depend on the seed's insert order):
- /products/1/related (Mug) ranks Aurora Tee first with weight 4 (the count INCLUDES the 30-day-old order), then Sticker Pack and Enamel Pin (1 each).
- /cart/related?ids=1,2 excludes Mug and Tee and returns Sticker Pack and Enamel Pin (weight 1 each).
- /trending?days=7 has Mug (4) on top, then Tee and Sticker Pack (3 each); the 30+-day orders are excluded, so Hoodie shows weight 1 and the Hoodie+Water Bottle co-purchase appears ONLY in /related, never in the 7-day trending.
- /products/999/related and /products/abc/related both return 404.
Output: a unified diff plus the three SQL queries.What success looks like
All three read straight from the seeded order graph. “Also bought” for the Mug ranks the Tee first — weight 4, which includes the 30-day-old order; “bought together” for a {Mug, Tee} cart excludes both and returns the partners; 7-day trending puts the Mug on top at 4 — the same window filter that keeps the 32-day-old Hoodie+Water Bottle order out of trending while it still counts in /related. Real output from postgres:16:
-- /products/1/related (Aurora Mug) — customers also bought (all-time)
product_id | name | weight
------------+---------------------+--------
2 | Aurora Tee | 4
3 | Aurora Sticker Pack | 1
8 | Aurora Enamel Pin | 1
-- /cart/related?ids=1,2 ({Mug, Tee}) — frequently bought together
product_id | name | weight
------------+---------------------+--------
3 | Aurora Sticker Pack | 1
8 | Aurora Enamel Pin | 1
-- /trending?days=7 — windowed sales (RANK ties; the 30+-day orders are outside the window)
rank | product_id | name | weight
------+------------+---------------------+--------
1 | 1 | Aurora Mug | 4
2 | 2 | Aurora Tee | 3
2 | 3 | Aurora Sticker Pack | 3
4 | 8 | Aurora Enamel Pin | 2
5 | 4 | Aurora Coaster Set | 1
5 | 5 | Aurora Water Bottle | 1
5 | 6 | Aurora Cap | 1
5 | 7 | Aurora Hoodie | 1
5 | 9 | Aurora Tote Bag | 1
5 | 10 | Aurora Notebook | 1Build the search screen with facets and keyset infinite scroll (Jetpack Compose)
Optional add-on IntermediateBuild 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.
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.
FilterChip Compose’s selectable chip; here one chip per category shows its facet count and toggles the category query parameter.
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
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 IntermediateAppend 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.
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_URL One env var holding redis://host:port; unset it and the module turns off — reads go straight to Postgres and nothing is throttled.
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
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-readBring it up and confirm it answers
docker compose up -d redis
export REDIS_URL=redis://localhost:6379
docker compose exec redis redis-cli PING # -> PONGWhat 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 AdvancedWrap 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.
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.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.
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).
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)
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?
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): trueThe 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 AdvancedDo 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.
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.
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.
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)
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 AdvancedWhen 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.
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.
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.
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)
Go: rdb.Del(ctx, "catalog:products:v1") // after tx.Commit
Spring: stringRedisTemplate.delete("catalog:products:v1") // after the @Transactional write returnsAgent prompt — paste into an agent with repo access
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=1599At 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) 0Rate-limit POST /checkout with an atomic Redis counter
Optional add-on AdvancedShield 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.
INCR + EXPIRE INCR bumps the counter atomically; EXPIRE set only on the first hit starts the window and resets it automatically.
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.
429 Too Many Requests The status a throttled request gets — a NEW code for this API (the base checkout never returns 429).
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)
-- 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 -- allowedAgent 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?
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=1m0sThe 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) 59The boundary: never cache stock or the checkout decision
Optional add-on AdvancedDraw 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.
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.
fail-open vs fail-closed On a dependency outage, fail-open keeps serving (degrade), fail-closed rejects; you pick per what the dependency guards.
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 IntermediateCreate 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.
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.
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.
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
-- (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
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 | 6Revenue over time: a windowed running total, AOV, and top products
Optional add-on IntermediateServe 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.
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.
average order value (AOV) Total revenue divided by order count — the headline retail metric; kept as integer cents so it never drifts.
integer cents Money is a BIGINT of cents everywhere, never a float, so sums are exact and byte-identical across backends.
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)
-- 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 | 667Segment customers with RFM and ntile(5)
Optional add-on IntermediateScore 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”.
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.
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).
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
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 | 2999Find real product bundles with market-basket lift
Optional add-on AdvancedCompute 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.
support How often a pair appears together across all orders (co-orders divided by total orders) — the pair’s raw prevalence.
confidence Given a shopper bought A, how often they also bought B (co-orders divided by A’s orders) — directional, unlike support.
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.
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
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?
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 | 776Serve the insights endpoints (Go)
Optional add-on AdvancedWire 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.
non-nil empty slice Initialise the results as []T{}, not nil — a nil slice marshals to null, but the graceful-empty contract wants [].
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.
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)
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
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 AdvancedImplement 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.
positional ? placeholders JdbcTemplate binds parameters with ?, so the view SQL’s $1/$2 become ? on this path — the query text otherwise stays identical.
CannotGetJdbcConnectionException -> 503 A JDBC connection failure is retryable, so @RestControllerAdvice maps it (and DataAccessResourceFailureException) to 503 {"error":"unavailable"}.
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
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 AdvancedAdd 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.
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.
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.
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.
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)
// 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-constrainedAgent 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?
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.