Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide. Co-authored-by: Cursor <cursoragent@cursor.com>
6.0 KiB
Rust API tech spec
Companion to ADR 0001 and ADR 0002. This document describes the current vmall-api layout and the rules new code must follow. HTTP behavior for each domain remains in openspec/specs/ (auth, catalog, cart, order, …).
Context and motivation
Handlers previously mixed Axum extracts, business rules, and sqlx. The crate is now a modular monolith so checkout, fulfillment, and identity can be shared across the three role surfaces without changing REST URLs or JSON.
Goals:
- One crate, one process, Postgres + Redis.
- Handler → service → repository (or store) per bounded context.
- Stable REST contract for
@vmall/sharedand HTTP integration tests.
Non-goals:
- GraphQL as the primary API (ADR 0002).
- Hexagonal ports/adapters, a generic Repository trait, or a second web framework.
- Changing error envelope, money representation, or RBAC roles.
Implementation considerations
- Crate:
apps/api, packagevmall-api, Axum 0.8, sqlx 0.8, Redis connection manager. - Migrations:
apps/api/migrations/, append-only; run on boot against a singlePgPoolshared with the server (seemain.rs+state::assemble). - Money:
i64minor units + ISO code;money::convert_minoris a pure function, not a repository. - SQL:
sqlx::query*/query_aswith explicit binds. Prefer column lists overSELECT *onusers(useUSER_COLUMNS+Userrow vsUserPublicJSON). - State machines:
UPDATE … WHERE status = …; zero rows →ApiError::Conflict, never a silent no-op success. - Concurrent counters: any column that concurrent requests decrement (today:
skus.stock) follows the same “predicate or it did not happen” rule as status.SELECT … FOR UPDATEof multiple rows MUSTORDER BYthe primary key so lock order is global. Decrement MUST beUPDATE … SET col = col - $qty WHERE id = $1 AND col >= $qty;rows_affected = 0→Conflict(do not rely onCHECK (col >= 0)as the only signal). Restore withcol = col + n, neverSET col = $absolutefrom a stale read. Merchant overwrite (SET stock = $non SKU upsert) is assignment, not decrement. Remaining shipment qty is not a stored counter:createserializes onorders … FOR UPDATEand checks remainder in that transaction. - RBAC:
AuthUser::require,require_customer,require_admin,require_shop/own_shop. Cross-shop resource access is 404, not 403.
High-level request flow
Nuxt + @vmall/shared → Handler (Axum)
→ Service (use case)
→ Repo / cart store
→ Postgres | Redis
- Handler extracts
State<AppState>,AuthUser, path/query/JSON. - Handler maps HTTP-only concerns (
StatusCode::CREATED) after the service returnsApiResult<Dto>. - Service opens transactions when more than one write must commit together (checkout, default address, shipment create).
- Repository functions take
&PgPool,&mut PgConnection, or&mut Transactionso a service can compose them.
Module map
| Module | Bounded context | Typical routes |
|---|---|---|
identity |
Auth, users, role assignment | /auth/*, /admin/users |
catalog |
Products, SKUs, categories, brands | /products, /shop/products, /brands |
cart |
Redis cart + purchasable snapshots | /cart |
order |
Checkout, pay, cancel, lists by scope | /orders, /shop/orders, /admin/orders |
fulfillment |
Shipments, delivery confirmation | /shipments, /shop/shipments |
billing |
Invoice request / issue | /invoices, /shop/invoices |
address |
Customer address book | /addresses |
shop |
Shop CRUD, profiles, /shop/profile |
/shops, /admin/shops |
content |
Home banners / promos / links | /content/home, /admin/content |
currency |
Rates and convert | /currencies |
health |
Liveness / readiness | /health, /ready |
Each module typically contains mod.rs, handlers.rs, service.rs, and optionally repo.rs / dto.rs / store.rs. Merge routers in modules::api_router().
Shared types live in models.rs (sqlx FromRow + enums). API-facing user JSON is UserPublic (no password_hash).
Error handling
ApiError serializes as {"error":{"code","message"}}:
| Variant | HTTP | code |
|---|---|---|
NotFound |
404 | NOT_FOUND |
BadRequest |
400 | BAD_REQUEST |
Unauthorized |
401 | UNAUTHORIZED |
Forbidden |
403 | FORBIDDEN |
Conflict |
409 | CONFLICT |
Internal |
500 | INTERNAL (generic message; details in logs) |
From<sqlx::Error>: RowNotFound → 404; unique / check violations → 409. Domain-specific unique messages use unique_conflict(err, "email already registered").
Future-proofing
- New capabilities get a new or existing
modules/<ctx>plus OpenSpec; they do not add SQL to handlers. - A later read-only GraphQL surface would sit beside REST and call the same services.
- Compile-time
query_as!may replace string SQL incrementally; it is not required for new queries.
Testing approach
- HTTP contract:
apps/api/tests/*.rsviatests/common/mod.rs(spawn_app, unique slugs). These tests are the REST regression gate. - Service:
tests/order_service.rs(and similar) call services withspawn_state()— empty cart, stock 409, split-by-shop, illegal status transitions. - Pure functions:
money,http::paginationunit tests in-module.
Acceptance criteria
- New write use cases live in a service; handlers do not embed sqlx except trivial reads if a service would be empty ceremony.
cargo test -p vmall-apistays green and repeatable againstvmall_test+ Redis.- REST paths and JSON shapes used by
@vmall/shareddo not change without an OpenSpec change.