# Backend Guidelines (vmall-api) Normative layering lives in `docs/tech-specs/rust-api.md` and `openspec/specs/api-architecture/spec.md`. This file is the practical playbook with patterns proven by the existing modules. ## Layering `src/modules//`: `handlers.rs` (Axum extractors only) → `service.rs` (`ApiResult`, no `Json`/`StatusCode`) → optional `repo.rs` (sqlx in `&mut PgConnection` / `&mut Transaction`). Simple CRUD may call repo from handlers. No generic Repository trait. New domain checklist: migration → `models.rs` rows/enums → module (`mod.rs`/`service.rs`/`handlers.rs`) → register in `modules/mod.rs` → `apps/api/tests/.rs` → shared contract → frontends. ## Errors `ApiError` envelope `{"error":{"code","message"}}`. `NotFound` 404, `BadRequest` 400, `Forbidden` 403, `Conflict` 409. Use `unique_conflict(err, msg)` to map unique violations to 409. ## Concurrency and state machines (do not improvise) ```sql -- status transition: condition on the expected prior state UPDATE aftersales SET status = $1, updated_at = now() WHERE id = $2 AND status = ANY($3) -- 0 rows → ApiError::Conflict -- guarded decrement (stock): never SET col = col - n unconditionally UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2 -- one-time flag (reply, reopen): guard on the empty state UPDATE product_reviews SET reply = $2 WHERE id = $1 AND reply IS NULL -- one-time review out of a pending state (wallet withdrawals, payouts) UPDATE wallet_withdrawals SET status = $2, reviewed_by = $3, reviewed_at = now() WHERE id = $1 AND status = 'pending' -- 0 rows → ApiError::Conflict ``` Idempotent generation guarded by a unique index (settlement statements): insert with `ON CONFLICT () DO NOTHING RETURNING id`; a `None` result means a repeat or a racing request won, so re-read the existing row and return it unchanged instead of recomputing. "At most one live row per owner" (merchant applications: one `pending`/`approved` per user) is a **partial unique index** — `UNIQUE (user_id) WHERE status IN (...)` — checked first in the service for a friendly 409 and relied on as the concurrency backstop via `unique_conflict`. Transactional provisioning (approval creating a shop + owner account) reads the row `FOR UPDATE`, provisions, then runs the guarded status flip last so any failure rolls the whole thing back. Event side effects (membership growth, system messages) run **inside the transition's transaction** and are made idempotent by a partial unique index plus `ON CONFLICT DO NOTHING`, so a retried handler cannot double-write: ```sql -- one message per customer, kind, and reference INSERT INTO messages (...) VALUES (...) ON CONFLICT (user_id, kind, reference_type, reference_id) WHERE reference_id IS NOT NULL DO NOTHING ``` A per-customer ledger total (growth) serializes on `SELECT id FROM users WHERE id = $1 FOR UPDATE`, then reads `COALESCE(SUM(delta),0)::bigint` and appends the entry carrying the new running total. Multi-row `FOR UPDATE` must `ORDER BY` primary key. Ledger writes are append-only: change a balance only via `account::service::credit/debit` inside the caller's transaction, never by writing an absolute balance. A credit in a currency the customer never held: create the zero-balance row first (`account::service::ensure_monetary_account`). ## Money and i18n `i64` minor units, `BIGINT` in SQL, `number` in TS. Currency conversion only via `money::convert_minor(amount, from, to)`. User-facing text columns are JSONB `{en, zh}`; validate with the `bilingual()` pattern (both locales non-empty) or `some_locale()` (at least one — chat/message-style content). ## Watch out: Postgres type traps - `SUM(bigint)` returns `NUMERIC` — always `COALESCE(SUM(x), 0)::bigint` before decoding into `i64`. (Regression test: `tests/aftersales.rs` and `tests/freight.rs` cover this.) - `INSERT` column count must equal value count; when a table grows, update **every** column-list constant (`ORDER_COLS`, `ORDER_ITEM_COLS`, …) and both INSERT lists. sqlx decodes at runtime, not compile time — `cargo check` green does not prove queries. ## Testing `apps/api/tests/` with `tests/common/mod.rs` fixtures; the shared test DB is never truncated, so fixtures use unique slugs/emails and tests assert only on ids they created. `spawn_state()` initializes tracing (`RUST_LOG` works). Run `cargo test -p vmall-api` twice before archiving a change; list/discovery endpoints especially. Mind pipefail: `cargo test | grep` hides failures. ## Adding a column to an existing table 1. Migration: `ALTER TABLE … ADD COLUMN` (append-only file, never edit old ones once applied anywhere). 2. Add the field to the model struct in `models.rs`. 3. Add the column to **every** SELECT/RETURNING list for that table. 4. Extend INSERT binds if writable. 5. Mirror in `packages/shared/src/types.ts`. Nuance: a column that only one module writes and reads may stay out of the shared `models.rs` row when adding the field would force every existing `SELECT`/`RETURNING` list for that table to change. Example: `orders.completed_at` (settlement period attribution) is written by the order completion transition and read by `modules/settlement/repo.rs`; `models::Order` does not carry it. Also remember `updated_at` is not a completion timestamp — refunds bump it — so period attribution needs a dedicated column.