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)returnsNUMERIC— alwaysCOALESCE(SUM(x), 0)::bigintbefore decoding intoi64. (Regression test:tests/aftersales.rsandtests/freight.rscover this.)INSERTcolumn 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 checkgreen 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
- Migration:
ALTER TABLE … ADD COLUMN(append-only file, never edit old ones once applied anywhere). - Add the field to the model struct in
models.rs. - Add the column to every SELECT/RETURNING list for that table.
- Extend INSERT binds if writable.
- Mirror in
packages/shared/src/types.ts.