Files
vmall/docs/backend-guidelines.md
T

3.2 KiB

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/<ctx>/: handlers.rs (Axum extractors only) → service.rs (ApiResult<Dto>, 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/<ctx>.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)

-- 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

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.