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.
docker-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"
psql "$DATABASE_URL" -c "select version();"What success looks like
docker compose ps shows the db service running (healthy), and the psql query connects and prints a PostgreSQL 16.x … banner — proof the container is up and DATABASE_URL reaches it.
Design the schema around invariants
BeginnerCreate products, customers, orders, and order_items with foreign keys and the constraints that encode your business rules — so impossible data (negative stock, an orphan order) 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.
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.
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.
schema.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
unit_price BIGINT NOT NULL CHECK (unit_price >= 0), -- cents
stock INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT UNIQUE NOT NULL
);
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',
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)
);What success looks like
\dt lists four tables: products, customers, orders, and order_items. \d orders shows the partial unique index orders_idem_key on idempotency_key WHERE idempotency_key IS NOT NULL. Inserting a row with stock = -1 is rejected: ERROR: new row for relation "products" violates check constraint "products_stock_check".
Seed the catalog and a customer
BeginnerInsert a few products to list and buy — and one customer, because every order references one and the first checkout fails without it.
New in this step
INSERT … ON CONFLICT … DO NOTHING Postgres’s upsert: if the row already exists (here, a duplicate email), skip it instead of erroring, so re-running the seed is safe.
Why a customer row has to exist first
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 a demo customer now; on a fresh database
its id is 1, and that’s the customerId the cart and checkout will use. Production would create or look
up the customer; for this build the seeded demo customer is the canonical id. See the foreign-key treatment
in the PostgreSQL track.
Seed data
-- one customer first: orders.customer_id NOT NULL REFERENCES customers(id)
INSERT INTO customers (email) VALUES ('demo@aurora.test')
ON CONFLICT (email) DO NOTHING;
INSERT INTO products (name, unit_price, stock) VALUES
('Aurora Mug', 1499, 50),
('Aurora Tee', 2999, 12),
('Aurora Sticker Pack', 499, 200);What success looks like
SELECT count(*) FROM products; returns 3 and SELECT count(*) FROM customers; returns 1. On a fresh DB the demo customer is id = 1 — the canonical customerId the checkout uses.
count
-------
3Scaffold 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 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/v5Composed entrypoint + GET /products (essentials)
// cmd/api/main.go
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
UnitPrice int64 `json:"unitPrice"` // cents; camelCase on the wire
Stock int `json:"stock"`
}
// Store is the contract the handlers consume; checkout/order 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 id, name, unit_price, stock FROM products ORDER BY id`)
if err != nil { return nil, err }
defer rows.Close()
out := []Product{}
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.UnitPrice, &p.Stock); err != nil { return nil, err }
out = append(out, p)
}
return out, rows.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 { http.Error(w, err.Error(), 500); return }
writeJSON(w, 200, ps)
})
return mux // checkout + orders routes register here in later steps
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
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()
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)
}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; 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; /healthz returns 200 "ok".
Tests / acceptance:
- `go build ./...` and `go vet ./...` pass.
- After seeding, `curl -s localhost:8080/products | jq length` returns 3.
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). curl -s localhost:8080/products | jq length prints 3.
[ { "id": 1, "name": "Aurora Mug", "unitPrice": 1499, "stock": 50 } ]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 — 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=devDependencies + datasource from env
// 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")
}
// 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=gracefulList products
// web/ProductController.kt
@RestController
class ProductController(private val jdbc: JdbcTemplate) {
data class Product(val id: Long, val name: String, val unitPrice: Long, val stock: Int)
@GetMapping("/products")
fun list(): List<Product> = jdbc.query(
"SELECT id, name, unit_price, stock FROM products ORDER BY id",
) { rs, _ -> Product(rs.getLong("id"), rs.getString("name"), rs.getLong("unit_price"), rs.getInt("stock")) }
}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.
Task: Create the @SpringBootApplication entrypoint and a ProductController exposing GET /products via JdbcTemplate.
Requirements:
- Kotlin data class Product with unitPrice: Long (cents); no JPA, use JdbcTemplate.
- Parameterised queries only; configure the datasource from the environment; set server.shutdown=graceful.
- Money is Long cents; field names camelCase on the wire (unitPrice).
Tests / acceptance:
- `./gradlew bootRun` starts; `curl -s localhost:8080/products | jq length` returns 3 after seeding.
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 and unitPrice cents (the parity invariant). … | jq length prints 3.
[ { "id": 1, "name": "Aurora Mug", "unitPrice": 1499, "stock": 50 } ]Model the cart: a preview-total endpoint
IntermediateAdd a read-only POST /cart/preview that takes {lines:[{productId,quantity}]} and returns the current per-line and grand total — no stock mutation.
Why the cart is cheap and the checkout is sacred
A cart is a guess — prices and stock can change before purchase, so a preview total is allowed to be
slightly stale. The checkout is where truth is fixed. Keeping the cart read-only and side-effect-free
makes the eventual transaction the only place that mutates stock — in any backend. This step gives the
frontend cart screen something real to call: it sums unit_price * quantity from the current catalog and
returns the total in cents, so the UI can show a running total before the shopper commits.
The preview contract (shared by both backends)
POST /cart/preview
Request: { "lines": [ { "productId": 1, "quantity": 2 } ] }
Response: { "total": 2998, "lines": [ { "productId": 1, "quantity": 2, "unitPrice": 1499, "lineTotal": 2998 } ] }Agent prompt — paste into an agent with repo access
Role: Senior backend engineer in this repo (use the selected backend).
Context: GET /products and the Store/JdbcTemplate access already exist; money is integer cents.
Task: Add a read-only POST /cart/preview that returns per-line and grand totals from CURRENT prices.
Requirements:
- Request {lines:[{productId,quantity}]}; response {total, lines:[{productId,quantity,unitPrice,lineTotal}]}, all integer cents.
- Read unit_price with a parameterised query; sum lineTotal = unitPrice*quantity; total = sum(lineTotal).
- Mutate NOTHING (no stock change); an unknown productId returns 404 {"error":"not_found"}.
Tests / acceptance:
- Two lines of the seeded products return the exact integer total and unchanged stock.
- A nonexistent productId yields 404, not a 500.
Output: a unified diff plus a one-line note on why the preview may be stale but the checkout cannot.What success looks like
POST /cart/preview with 2 Aurora Mugs returns 200 with lineTotal = unitPrice * quantity and a grand total, all integer cents — and SELECT stock FROM products WHERE id = 1; is unchanged (the preview mutates nothing). An unknown productId returns 404 {"error":"not_found"}.
{ "total": 2998, "lines": [ { "productId": 1, "quantity": 2, "unitPrice": 1499, "lineTotal": 2998 } ] }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).
Two psql sessions, one last unit
-- setup (either session):
UPDATE products SET stock = 1 WHERE id = 1;
-- SESSION A: -- SESSION B (run after A's UPDATE, before A commits):
BEGIN; BEGIN;
UPDATE products UPDATE products
SET stock = stock - 1 SET stock = stock - 1
WHERE id = 1 AND stock >= 1; WHERE id = 1 AND stock >= 1; -- BLOCKS here, waiting on A
-- A sees: UPDATE 1
COMMIT; -- B unblocks → 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 — guard-and-decrement stock, 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 conditional UPDATE … WHERE stock >= quantity 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). If the order insert fails after the decrement, Rollback
restores stock. 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) — price captured once via RETURNING
// idemKey may be ""; if set, a replay returns the original order (handled in POST /checkout).
func (s *Store) Checkout(ctx context.Context, customerID int64, lines []Line, 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
var total int64
prices := make([]int64, len(lines))
for i, l := range lines {
// guard + decrement + capture price in ONE statement — no second read, no drift
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 (or missing product)
return 0, fmt.Errorf("product %d: %w", l.ProductID, ErrOutOfStock)
}
if err != nil { return 0, err }
total += prices[i] * int64(l.Qty)
}
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)
}Agent prompt — paste into an agent with repo access
Before you run this: if a 3-line cart has enough stock for lines 1 and 2 but the 3rd line exceeds stock, how many product rows stay decremented after Checkout returns — and what error comes back?
Role: Senior Go + Postgres engineer in this repo.
Context: pgxpool is s.pool; schema + idempotency column applied; ErrOutOfStock defined; type Line{ProductID int64; Qty int}.
Task: Implement Store.Checkout(ctx, customerID, lines, idemKey) as ONE transaction.
Requirements:
- Begin a tx; defer Rollback; Commit only at the end.
- 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.
- 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: ordering more than stock returns ErrOutOfStock and leaves stock unchanged.
- A success reduces stock by exactly the quantity and total equals sum(captured price*qty).
Output: a unified diff plus a short proof there is no oversell window and no double price read.What success looks like
On a satisfiable cart, Checkout returns a new orderID and the DB shows: each line’s products.stock dropped by exactly its qty, one orders row whose total equals sum(captured unit_price * qty), and one order_items row per line carrying the captured price. On any line that exceeds stock it returns ErrOutOfStock and the deferred Rollback leaves every products.stock unchanged — partial decrements never persist.
★ Checkout as a single transaction (Spring/Kotlin)
Spring Boot (Kotlin) IntermediatePut the whole checkout in one @Transactional method, using the same guard-and-capture UPDATE … RETURNING and persisting 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. Throwing on a zero-row update both signals out-of-stock and triggers the rollback. Use
UPDATE … WHERE stock >= ? RETURNING unit_price so price is read once in the same statement you
decrement against — no second SELECT, no money drift if a price changes concurrently. queryForObject
throws EmptyResultDataAccessException when the guard matches 0 rows; treat that as out-of-stock. 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) — RETURNING captures price once
class OutOfStockException(productId: Long) : RuntimeException("product $productId out of stock")
@Service
class CheckoutService(private val jdbc: JdbcTemplate) {
// idemKey may be null; a replay returns the original order (handled in the controller).
@Transactional
fun checkout(customerId: Long, lines: List<Line>, idemKey: String?): Long {
var total = 0L
val prices = LongArray(lines.size)
for ((i, l) in lines.withIndex()) {
// 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
}
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
}
}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 decrements already applied to earlier lines in this call?
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3 (Kotlin), JdbcTemplate, Postgres; idempotency column applied. Line is data class Line(productId: Long, qty: Int).
Task: Implement CheckoutService.checkout(customerId, lines, idemKey) as one @Transactional method.
Requirements:
- Guard+decrement+capture price in ONE statement: `UPDATE products SET stock=stock-? WHERE id=? AND stock>=? RETURNING unit_price`; EmptyResultDataAccessException -> OutOfStockException (triggers rollback). Do NOT read unit_price a second time.
- 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.
- Map OutOfStockException to HTTP 409 {"error":"out_of_stock"} in a @RestControllerAdvice.
Tests / acceptance:
- @SpringBootTest + Testcontainers: over-quantity throws and leaves stock unchanged; success decrements exactly and total == sum(captured price*qty).
Output: a unified diff plus the @RestControllerAdvice mapping.What success looks like
Identical to the Go path: a satisfiable cart returns a new orderId, each products.stock drops by exactly its qty, and the orders.total equals sum(captured unit_price * qty). When the guarded UPDATE matches 0 rows the method throws OutOfStockException; @Transactional rolls back the whole call, so any earlier line’s decrement is undone — products.stock is unchanged everywhere.
Build POST /checkout (Go)
Go IntermediateAdd the HTTP endpoint — decode the cart, read the Idempotency-Key, call Store.Checkout, and map the result to 200 {orderId} / 409 {error} / 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.
SQLSTATE 23505 (unique_violation) Postgres’s error code when a row breaks a unique index; here, a second insert with the same idempotency key — caught to return the original 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 / unknown id — 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. Decode
{customerId, lines:[{productId, quantity}]}, validate it (empty lines or quantity <= 0 → 422), read
the optional Idempotency-Key header, and call Checkout. Map ErrOutOfStock to
409 {"error":"out_of_stock"}. For idempotency, catch a duplicate-key violation (Postgres SQLSTATE 23505 on
orders_idem_key): the order already exists for this key, so look it up and return the original
orderId with 200 — a network retry can’t create a second order. Register the route on the same
newRouter from the scaffold. This is the exact §5 contract the frontend’s checkout button calls.
POST /checkout handler (pgx)
import "github.com/jackc/pgx/v5/pgconn"
type checkoutReq struct {
CustomerID int64 `json:"customerId"`
Lines []Line `json:"lines"`
}
// 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 {
writeJSON(w, 422, map[string]string{"error": "invalid_request"}); return
}
if req.CustomerID == 0 || len(req.Lines) == 0 {
writeJSON(w, 422, map[string]string{"error": "invalid_request"}); return
}
for _, l := range req.Lines {
if l.Qty <= 0 { writeJSON(w, 422, map[string]string{"error": "invalid_request"}); return }
}
idem := r.Header.Get("Idempotency-Key")
orderID, err := s.Checkout(r.Context(), req.CustomerID, req.Lines, 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"})
default:
var pgErr *pgconn.PgError
if idem != "" && errors.As(err, &pgErr) && pgErr.Code == "23505" { // duplicate idempotency key
if id, e := s.orderIDByKey(r.Context(), idem); e == nil { // return the ORIGINAL order
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 2nd POST /checkout with the SAME Idempotency-Key — what status and orderId does it return, and how many times does stock decrement across the two requests?
Role: Senior Go engineer in this repo.
Context: Store.Checkout(ctx, customerID, lines, idemKey) exists; ErrOutOfStock defined; newRouter(*Store); github.com/jackc/pgx/v5. Contract: POST /checkout {customerId, lines:[{productId,quantity}]} + optional Idempotency-Key -> 200 {orderId} | 409 {error:"out_of_stock"} | 422 {error:"invalid_request"} | 404 {error:"not_found"}.
Task: Register POST /checkout on newRouter and add a Store.orderIDByKey(ctx, key) lookup.
Requirements:
- Decode with a 1MB MaxBytesReader; empty lines / quantity<=0 / missing customerId -> 422.
- Read the Idempotency-Key header; pass it to Checkout.
- Map ErrOutOfStock -> 409 {"error":"out_of_stock"}.
- On a pgconn.PgError SQLSTATE 23505 (duplicate idempotency key) and a non-empty key, SELECT the existing order and return its id with 200 (the ORIGINAL order). An unknown customer/product FK violation -> 404 {"error":"not_found"}.
Tests / acceptance:
- POST with empty lines returns 422; over-stock returns 409 and leaves stock unchanged.
- POSTing twice with the SAME Idempotency-Key returns the SAME orderId and creates exactly one order.
Output: a unified diff plus where the duplicate-key path returns the original order.What success looks like
A valid POST /checkout returns 200 {"orderId": N}. Re-sending it with the same Idempotency-Key returns the identical orderId with 200 and writes nothing new — the duplicate insert hits orders_idem_key (SQLSTATE 23505) and the handler SELECTs the original. Over-stock returns 409 {"error":"out_of_stock"}; empty lines or quantity <= 0 returns 422 {"error":"invalid_request"}.
# both calls share -H "Idempotency-Key: 11111111-1111-1111-1111-111111111111"
# 1st -> {"orderId":4821} 2nd -> {"orderId":4821} (one order; stock decremented once)Build POST /checkout (Spring/Kotlin)
Spring Boot (Kotlin) IntermediateAdd the controller — bind the body, read the Idempotency-Key header, 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.
@RequestBody Binds the JSON request body to a Kotlin data class.
@RequestHeader Reads a header (here Idempotency-Key, required = false so it’s optional).
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 the idempotency key already produced an order, so return the original.
@RestControllerAdvice / @ExceptionHandler One place that maps thrown exceptions to HTTP responses (e.g. OutOfStockException to 409), keeping controllers clean.
Same contract, Spring wiring
The @RestController binds {customerId, lines} and the Idempotency-Key header, validates (empty lines /
quantity <= 0 → 422), and calls checkout(...). OutOfStockException maps to
409 {"error":"out_of_stock"} via the @RestControllerAdvice. For idempotency, catch Spring’s
DuplicateKeyException (it wraps the Postgres 23505 on orders_idem_key): the key already produced an
order, so SELECT it and return the original orderId with 200. This is the same §5 contract the Go
path serves, so one mobile client works against either backend.
CheckoutController + advice (essentials)
data class CheckoutReq(val customerId: Long, val lines: List<Line>)
@RestController
class CheckoutController(private val svc: CheckoutService, private val jdbc: JdbcTemplate) {
@PostMapping("/checkout")
fun checkout(
@RequestBody req: CheckoutReq,
@RequestHeader(value = "Idempotency-Key", required = false) idemKey: String?,
): ResponseEntity<Map<String, Any>> {
if (req.customerId == 0L || req.lines.isEmpty() || req.lines.any { it.qty <= 0 })
return ResponseEntity.unprocessableEntity().body(mapOf("error" to "invalid_request"))
return try {
ResponseEntity.ok(mapOf("orderId" to svc.checkout(req.customerId, req.lines, idemKey)))
} catch (e: DuplicateKeyException) { // duplicate Idempotency-Key -> return the original order
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(EmptyResultDataAccessException::class)
fun notFound(e: EmptyResultDataAccessException) =
ResponseEntity.status(404).body(mapOf("error" to "not_found"))
}Agent prompt — paste into an agent with repo access
Before you run this: which Spring exception does the duplicate Idempotency-Key raise, and what does the controller return for that 2nd request — a new order, an error, or the original orderId?
Role: Senior Kotlin/Spring engineer in this repo.
Context: CheckoutService.checkout(customerId, lines, idemKey) + OutOfStockException exist; JdbcTemplate over Postgres with orders_idem_key. Contract: POST /checkout {customerId, lines:[{productId,quantity}]} + optional Idempotency-Key -> 200 {orderId} | 409 {error:"out_of_stock"} | 422 {error:"invalid_request"} | 404 {error:"not_found"}.
Task: Add a CheckoutController and a @RestControllerAdvice mapping the errors.
Requirements:
- Bind the body and the Idempotency-Key header (required=false); empty lines / qty<=0 / missing customerId -> 422 {"error":"invalid_request"}.
- OutOfStockException -> 409 {"error":"out_of_stock"} via the advice.
- Catch DuplicateKeyException (duplicate idempotency key): SELECT the existing order and return its id with 200 (the ORIGINAL order).
Tests / acceptance:
- POST with empty lines returns 422; over-stock returns 409 and leaves stock unchanged.
- POSTing twice with the SAME Idempotency-Key returns the SAME orderId and creates exactly one order.
Output: a unified diff plus the advice class.What success looks like
Byte-for-byte the same as the Go endpoint: 200 {"orderId": N} on success; a replay with the same Idempotency-Key catches DuplicateKeyException, SELECTs the original, and returns the same orderId with 200 (one order, stock decremented once); over-stock → 409 {"error":"out_of_stock"}; bad body → 422 {"error":"invalid_request"}. 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, "createdAt":"...",
"items":[ { "productId":2, "quantity":2, "unitPrice":2999 } ] }
404: { "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.
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, createdAt, items:[{productId, quantity, unitPrice}]} (camelCase, integer cents).
- A missing id returns 404 {"error":"not_found"} (Go: map pgx.ErrNoRows; Spring: the advice handles EmptyResultDataAccessException).
- Parameterised query only; path id parsed as an integer (Go: `r.PathValue("id")` returns a string — convert with `strconv.Atoi` before use; Spring: `@PathVariable` already binds to `Long`).
Tests / acceptance:
- After a checkout, GET /orders/{returnedId} returns the order with its exact items and total.
- GET /orders/999999 returns 404.
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, items[] carrying the price captured at purchase. A missing id returns 404 {"error":"not_found"}.
{ "id": 4821, "customerId": 1, "total": 5998, "createdAt": "…",
"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, using only the single conditional UPDATE (no SERIALIZABLE). How many succeed, how many get out-of-stock, and what is the final stock?
Role: Senior backend engineer in this repo (use the selected backend).
Context: Checkout exists. 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 checkouts of quantity 1.
- Assert: exactly 1 success, 19 out-of-stock, final stock = 0 (the single-statement guard, no SERIALIZABLE needed).
- 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 checkouts of quantity 1, the test asserts exactly 1 success + 19 out-of-stock, and SELECT stock FROM products WHERE id = 1; ends at 0 — the single conditional UPDATE never oversells, with no SERIALIZABLE needed. The budget scenario, by contrast, write-skews under READ COMMITTED and is caught only by SERIALIZABLE (one tx aborts with 40001, then retries).
Make retries safe with idempotency keys
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: the Idempotency-Key header reaches Checkout, which inserts it into
orders.idempotency_key inside the same transaction as the order — so a crash can never leave a key
without its order. The partial unique index orders_idem_key (from the schema step) makes a second insert
with the same key fail with SQLSTATE 23505; the POST /checkout handler catches that, SELECTs the
existing order, and returns the original orderId with 200. The uniqueness guarantee comes from the
database, so it holds no matter which backend writes it. Two rules make this airtight: (1) write the key in
the same tx as the order; (2) 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 -> Checkout inserts key in-tx -> duplicate hits 23505 -> handler returns the original order.
SELECT id, total, created_at FROM orders WHERE idempotency_key = $1; -- the duplicate-path lookupWhat success looks like
After a successful checkout, SELECT count(*) FROM orders WHERE idempotency_key = $1; is 1. Re-POSTing the same cart with that key adds no row — the duplicate INSERT raises 23505, the lookup returns the original, and the response repeats the same orderId. The count stays 1 no matter how many times the client retries.
Version the schema with migrations
IntermediateReplace hand-run SQL with ordered migration files applied by a tool (e.g. golang-migrate, or 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. Fold the schema you wrote by hand into the first migration 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 # tables + V1__init.sql # tables + orders_idem_key
0001_init.down.sql # idem index V2__seed.sql # 1 customer + 3 products
0002_seed.up.sql # customer + # (Flyway runs these automatically on boot)
0002_seed.down.sql # products
# apply: migrate -path db/migrations -database "$DATABASE_URL" upIntegration-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.
Agent prompt — paste into an agent with repo access
Role: Senior Go engineer in this repo.
Context: Checkout + schema exist; Postgres via DATABASE_URL.
Task: Add integration tests for the checkout money + stock path.
Requirements:
- Seed known products; multi-line checkout; assert total == sum(unit_price*qty) exactly.
- Assert stock decremented by exact quantities; an over-quantity line rolls ALL changes back.
- 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 multi-line checkout asserts total == sum(unit_price*qty) to the cent and stock down by exact quantities, while an over-quantity line rolls all changes back. 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: CheckoutService exists; Testcontainers + JUnit 5 available.
Task: Add a @SpringBootTest integration test backed by a Postgres Testcontainer.
Requirements:
- Spin up postgres:16 via @Container; apply schema/migrations on start.
- Seed products; assert total == sum(unitPrice*qty); assert exact stock decrements.
- An over-quantity checkout throws OutOfStockException and leaves stock unchanged (rolled back).
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 and asserts total == sum(unitPrice*qty) and exact stock decrements, while an over-quantity checkout throws OutOfStockException and leaves stock unchanged (rolled back) — 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 localized currency string for display only. Keep
every layer honest about the canonical integer. 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
@Serializable
data class Product(val id: Long, val name: String, val unitPrice: Long, val stock: Int)
// 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.
fun formatCents(cents: Long): String = "$%,.2f".format(cents / 100.0)Catalog screen (Compose)
@Composable
fun CatalogScreen(baseUrl: String) {
var products by remember { mutableStateOf<List<Product>>(emptyList()) }
LaunchedEffect(Unit) { products = fetchProducts(baseUrl) }
LazyColumn { items(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(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"
}
}What success looks like
The 3 seeded products render as rows showing name and formatted price (e.g. “$14.99”); “Sold out” appears in the error color for zero-stock items; a failed or empty fetch shows the error state instead of a blank list.
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)
// From the Android emulator, "http://10.0.2.2:8080" reaches the host's localhost.
const baseUrl = 'http://10.0.2.2:8080';
class Product {
final int id;
final String name;
final int unitPrice; // integer cents
final int stock;
const Product({required this.id, required this.name, required this.unitPrice, required this.stock});
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);
}
// 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'));
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.hasData) return const CircularProgressIndicator();
return ListView(children: [
for (final p in snap.data!)
ListTile(
title: Text(p.name),
subtitle: Text(p.stock > 0 ? 'In stock' : 'Sold out'),
trailing: Text(formatCents(p.unitPrice)),
),
]);
},
);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 Product: Codable, Identifiable {
let id: Int; let name: String; let unitPrice: Int; let stock: Int
}
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] = []
var body: some View {
List(products) { p in
HStack {
VStack(alignment: .leading) { Text(p.name); Text(p.stock > 0 ? "In stock" : "Sold out").foregroundStyle(.secondary) }
Spacer(); Text(formatCents(p.unitPrice))
}
}
.task { products = (try? await fetchProducts()) ?? [] }
}
}Wire the checkout button (Jetpack Compose)
Jetpack Compose IntermediatePOST the cart to /checkout with an Idempotency-Key, showing success or the out-of-stock error — so the shopper can actually buy, and a retry is safe.
New in this step
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 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: POST /checkout takes {customerId, lines:[{productId,quantity}]} + Idempotency-Key; returns 200 {orderId} or 409 {error:"out_of_stock"}.
Task: Add a checkout action to the cart screen.
Requirements:
- One UUID Idempotency-Key per attempt, reused across retries.
- Loading state; on success render the returned orderId in a success UI; on 409 show "out of stock" and refresh that item.
Tests / acceptance:
- ViewModel unit test: 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
Tapping Checkout posts the cart and navigates to a confirmation screen showing the orderId; a 409 response shows the out-of-stock message in place (refreshing that item’s stock) and does not navigate — re-sending the same request with the same Idempotency-Key returns the original order, not a duplicate.
Wire the checkout button (Flutter)
Flutter IntermediatePOST the cart to /checkout with an Idempotency-Key header and handle the 200/409 outcomes — so the shopper can actually buy, and a retry is safe.
New in this step
UUID for the Idempotency-Key Generate one random id per checkout attempt and reuse it on retry (e.g. the uuid package), so a resend returns the original order, not a duplicate.
state notifier (Riverpod / Bloc) A class that holds the 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: POST /checkout takes {customerId, lines:[{productId,quantity}]} + Idempotency-Key; returns 200 {orderId} or 409 {error}.
Task: Add a checkout action with proper state handling (e.g. a Riverpod/Bloc notifier).
Requirements:
- One UUID Idempotency-Key per attempt, reused on retry.
- Loading/success/error states; on 409 show an out-of-stock message and refresh the item.
Tests / acceptance:
- A unit test on the notifier: a 409 response yields an OutOfStock state and no success state transition.
Output: a unified diff plus the state model.Wire the checkout button (SwiftUI)
SwiftUI IntermediatePOST the cart with URLSession + 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
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 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: POST /checkout takes {customerId, lines:[{productId,quantity}]} + Idempotency-Key; returns 200 {orderId} or 409 {error}.
Task: Add an async checkout to an @Observable CartModel.
Requirements:
- One UUID Idempotency-Key per attempt, reused on retry; @MainActor state updates.
- Loading/success/error; on 409 set an outOfStock flag and refresh the item; on 200 expose orderId.
Tests / acceptance:
- A unit test: 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 a parked demo service costs nothing.
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 Cloud SQL tier; the only part of this course that can incur cost.
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 shown below, 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).
Deploy
gcloud sql instances create aurora-pg --database-version=POSTGRES_16 --tier=db-f1-micro --region=us-central1
gcloud run deploy aurora-api \
--source . \
--add-cloudsql-instances PROJECT:us-central1:aurora-pg \
--set-env-vars DATABASE_URL="postgres://YOUR_DB_USER:YOUR_DB_PASSWORD@/aurora?host=/cloudsql/PROJECT:us-central1:aurora-pg" \
--region us-central1 --allow-unauthenticated
# Spring path instead: drop the libpq DATABASE_URL and pass the JDBC SocketFactory env, e.g.
# --set-env-vars JDBC_DATABASE_URL="jdbc:postgresql:///aurora?cloudSqlInstance=PROJECT:us-central1:aurora-pg&socketFactory=com.google.cloud.sql.postgres.SocketFactory",DB_USER=...,DB_PASSWORD=...Parse 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.
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.
confidence score The similarity value returned to the client so the UI can ask the shopper to confirm a fuzzy hit instead of silently adding the wrong product.
Keep matching in Postgres, where the catalog lives
A simple, honest first pass is ILIKE/full-text search against products.name; you can upgrade to trigram
similarity (pg_trgm) or embeddings later. Return the top match per ingredient plus a confidence so the UI
can let the shopper confirm fuzzy hits — never silently add the wrong product. The SQL below uses Postgres
positional placeholders ($1); on the Spring path, JdbcTemplate binds with ? instead, so swap the
placeholder when you wire it.
Fuzzy match in SQL
-- enable once: CREATE EXTENSION IF NOT EXISTS pg_trgm;
SELECT id, name, similarity(name, $1) AS score
FROM products
WHERE name % $1 -- trigram "is similar"
ORDER BY score 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 similarity score to count as a match; below it, the ingredient goes to unmatched instead of being guessed.
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, confidence}], unmatched: [string] }.
Requirements:
- Call parseRecipe, then match each ingredient via the trigram query (top 1, with score as confidence).
- Ingredients with no match (score below a threshold) go in `unmatched`, NOT silently dropped.
- Do NOT mutate the cart server-side; return a PROPOSAL the client confirms (respects "no auto-actions").
Tests / acceptance:
- A recipe with a known product (e.g. "Aurora Mug") returns it with confidence > threshold.
- A nonsense ingredient appears in `unmatched`.
Output: a unified diff plus the matching threshold and why.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, placed_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"] } }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)
_ = part // append the model's call turn + this function response to contents, then call again
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 {answer:string}.
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, execute it, send a FunctionResponse back, and call GenerateContent again for the grounded answer.
- NEVER declare or execute a write (cancel/refund) here — reads only.
- 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.
- No code path mutates the database.
Output: the complete handler, no commentary.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/
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()
resp = client.models.generateContent(model, contentsWith(fr), cfg) // append fr, ask again
}
// 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 {answer:string}.
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, send a FunctionResponse, call generateContent again for the grounded answer.
- NEVER declare or execute a write (cancel/refund) here — reads only.
- 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/
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.
- No code path mutates the database.
Output: the complete service + controller, no commentary.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.
reuse the Idempotency-Key The cancel endpoint is idempotent like checkout, so a double-tapped confirm returns the same result instead of cancelling twice.
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, authenticated, idempotent endpoint 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. Reuse the idempotency key from the checkout work so a double-tap can’t double-refund.
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, idempotent like checkout:
POST /orders/{id}/cancel Header: Idempotency-Key: <uuid>
Body: { "action": "cancel", "orderId": 4821, "summary": "..." } # the confirmed proposal
200: { "orderId": 4821, "status": "cancelled" } # replay returns the same result
404: { "error": "not_found" }
409: { "error": "not_cancellable" } # e.g. already shippedAgent prompt — paste into an agent with repo access
Role: Senior backend engineer in this repo (use the selected backend).
Context: The /support function-calling endpoint exists with read-only tools. Checkout already supports Idempotency-Key.
Task: Add the write-confirmation boundary so cancel/refund are proposed, never auto-executed.
Requirements:
- Do NOT add cancel/refund as callable Gemini tools. If the shopper asks to cancel/refund, return {action, orderId, summary, requiresConfirmation: true} — no DB change.
- Implement the real mutation as a SEPARATE endpoint (e.g. POST /orders/{id}/cancel) that requires the confirmed proposal and an Idempotency-Key, and is the ONLY place the write happens.
- 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 and performs NO mutation (order + stock unchanged).
- The cancel endpoint mutates only when called with the confirmation, and is idempotent under retry.
Output: a unified diff plus where the read-vs-write boundary is enforced.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. A small custom
slog.Handler can pull the id straight from the context so you never thread it by hand. Costs nothing —
stdout only. Guide: https://go.dev/blog/slog
slog JSON + trace-id middleware (essentials)
// logging setup
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
// trace-id middleware
type ctxKey struct{}
func withTrace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxKey{}, uuid.NewString())
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// inside Checkout, log each phase with the request's trace id:
tid, _ := ctx.Value(ctxKey{}).(string)
log := slog.With("trace_id", tid)
log.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() under an unexported key type; handlers and log lines read it.
- 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 trace_id.
- 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.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
@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.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.