Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
@@ -0,0 +1,36 @@
# ADR-0001: Monorepo with pnpm workspaces and Turborepo
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Three deployable applications (storefront, admin, API) share domain types, validation
rules and an HTTP client. Split across three repositories, every contract change becomes a
version bump, a publish and three coordinated pull requests — and in practice the types drift
because nobody wants to pay that cost for a one-field change.
## Decision
A single repository with pnpm workspaces for dependency linking and Turborepo for task
orchestration and caching. Shared code lives in `packages/*`; deployables live in `apps/*`.
`@sport/types`, `@sport/validation` and `@sport/api-client` compile to CommonJS + `.d.ts`
because NestJS consumes them at runtime. `@sport/ui` ships raw TypeScript and is compiled by
each Next.js app via `transpilePackages` — no build step, no watcher, faster HMR.
Versions that must stay identical across the workspace (TypeScript, React, Next, Zod, ESLint)
are pinned once in the `catalog:` block of `pnpm-workspace.yaml`.
## Consequences
A backend field rename surfaces as a frontend type error in the same commit. One
lockfile, one CI pipeline, one lint configuration. The cost is a heavier initial install and
the need for discipline about dependency direction (ADR-0004), which the ESLint boundary rules
enforce mechanically.
## Alternatives considered
Polyrepo with a private npm registry — rejected: the publish/consume loop is
slower than the entire feature it serves. Nx — comparable, but Turborepo's smaller surface fits
a team that wants a build cache, not a build framework.
@@ -0,0 +1,38 @@
# ADR-0002: Modular monolith, not microservices
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
The system will eventually need order processing, inventory, payments, search and
notifications. That list reads like a microservice diagram, and the temptation is to start
there. But on day one there is no traffic, no team boundary and no independent scaling
requirement — only the cost of distributed transactions, network failure modes and per-service
CI.
## Decision
One NestJS process, internally partitioned into modules that own their tables
exclusively. Cross-module access happens two ways only: a synchronous call to the other
module's `public/` service when an answer is needed now, or a domain event when something
merely needs to react.
Four modules — `inventory`, `orders`, `payments`, `search` — are marked EXTRACTION CANDIDATE
and additionally forbidden from sharing transactions with the rest of the monolith.
## Consequences
A single deploy, a single database, real foreign keys and real transactions —
which is exactly what an order/inventory/payment flow wants. Extraction stays possible because
the boundaries are enforced now, while they are cheap to enforce.
The risk is boundary erosion: one "quick" cross-module join and the seam is gone. This is why
the rule is an ESLint error rather than a paragraph in a wiki.
## Alternatives considered
Microservices from day one — rejected as premature: it buys independent scaling
nobody needs and pays in distributed-transaction complexity that a checkout flow can least
afford. A single unstructured application — rejected: retrofitting boundaries after the fact
is the expensive path.
@@ -0,0 +1,39 @@
# ADR-0003: Product and ProductVariant as separate entities
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
A "Running Shirt" in black, size M is a different physical good from the same shirt in
white, size L: different barcode, different stock, potentially different price. Modelling
`sizes: string[]` and `colors: string[]` on a product makes every one of those facts
unrepresentable.
## Decision
`Product` is the marketing entity — it has a name, a slug and a page, and deliberately
has no SKU, no price and no stock. It owns an ordered list of `ProductOption`s (Colour, Size),
each owning ordered `ProductOptionValue`s. Every purchasable combination is a
`ProductVariant` with its own SKU, price, sale price, barcode, weight and stock.
`ProductVariantOptionValue` resolves a variant to exactly one value per option, with
`@@id([variantId, optionId])` enforcing at the database level that a variant cannot have two
colours. Stock lives in `StockLevel` keyed by (variant, location), never on the variant row.
## Consequences
Cart lines, order lines, stock movements and marketplace listings all reference a
variant id — the same granularity Shopee, Lazada, TikTok Shop, ERP and POS systems use, so
integrations map 1:1 instead of needing a translation layer. Adding a third option (width, fit)
is data, not a migration.
The cost is real: the PDP must resolve option selections to a variant, and the admin needs a
variant-matrix editor rather than two text inputs. That cost is paid once and is the reason the
model survives contact with a warehouse.
## Alternatives considered
Size/colour as columns on Product — rejected: cannot express per-combination stock
or price, which is the entire job. A single flat SKU table with no product grouping — rejected:
there would be nothing to hang a product page, gallery or description on.
@@ -0,0 +1,32 @@
# ADR-0004: The admin dashboard has no database access
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
The admin is a Next.js application and could trivially import Prisma and query
PostgreSQL from a Server Action. It would be faster to write. It would also create a second
write path in which RBAC, validation and audit logging are re-implemented — or forgotten.
## Decision
The admin has no database driver, no Prisma client, no Redis client and no storage
credentials. Every read and write goes through the REST API via `@sport/api-client`. The rule
is enforced by ESLint (`no-restricted-imports` on `@prisma/client` and `ioredis` in both
frontends) and by the absence of `DATABASE_URL` from the admin's environment.
## Consequences
Authorization is checked in exactly one place. The audit log cannot be bypassed.
The API surface stays honest, because the admin is its most demanding consumer — and a future
mobile app or partner integration inherits a proven API rather than a thin one.
The cost is an extra network hop for back-office screens, which is irrelevant at back-office
traffic levels.
## Alternatives considered
Direct database access from Server Actions — rejected for the reasons above.
A separate "admin API" service — rejected: two APIs over one database is the same problem with
more deployment.
+31
View File
@@ -0,0 +1,31 @@
# ADR-0005: URI-based API versioning
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
The API will outlive its first client. A mobile app, marketplace connectors and partner
integrations will pin to whatever exists when they are written, and some of them will never be
updated.
## Decision
`/api/v1/...`, via NestJS `VersioningType.URI` with `defaultVersion: '1'`. A version
is introduced only for a genuinely breaking change; additive fields ship inside the current
version. Old versions get a documented sunset date, not silent removal.
## Consequences
The version is visible in logs, in Nginx access logs, in CDN cache keys and in a
curl command. Two versions can run side by side in one process, sharing services and differing
only in controllers and mappers.
URLs are slightly longer, and the version is technically part of the resource identity, which
purists dislike. In exchange, nobody ever debugs a version mismatch caused by a missing header.
## Alternatives considered
Header-based (`Accept-Version`) — rejected: invisible in logs, easy to omit, and
awkward for CDN caching. No versioning — rejected: it works right up until the first
integration nobody can update.
@@ -0,0 +1,35 @@
# ADR-0006: Zod schemas shared between API and frontends
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Validation rules exist twice by default: once in the API and once in the form. They
drift, and the drift shows up as a form that accepts input the server rejects.
## Decision
One Zod schema per input shape, defined in `@sport/validation` and imported by both
sides. The API applies it through `ZodValidationPipe`; the frontends apply the same object to
their forms. Zod was chosen over class-validator specifically because a class with decorators
cannot cross into a React form, whereas a schema object can.
Scope is deliberately limited to shape and format rules. Anything requiring database state —
"is this coupon still valid", "is this variant in stock" — is a business rule and lives in the
backend service layer.
## Consequences
A rule change happens once. Field-level errors come back keyed by dotted path
(`items.0.quantity`), which forms consume directly. The parsed output carries coercions and
defaults, so controllers receive clean typed data.
The discipline required is keeping business rules out of the schemas; a validation package that
starts querying is a validation package that can no longer be shared.
## Alternatives considered
class-validator + class-transformer, the NestJS default — rejected: not shareable
with the frontends. Duplicating rules with a test to keep them in sync — rejected: the test
tells you about drift after it has already shipped.
@@ -0,0 +1,35 @@
# ADR-0007: RBAC permissions instead of role checks
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
`if (user.role === 'ADMIN')` spreads. Six months later authorization logic is scattered
across dozens of files, no one can answer "who can refund an order?" without grepping, and
adding a "Warehouse Supervisor" role means editing and redeploying application code.
## Decision
Authorization is expressed only as permissions (`product.update`, `order.refund`),
declared on routes with `@RequirePermissions(...)` and evaluated by a global `PermissionsGuard`.
Permissions are code: the catalog in `@sport/types` is the source of truth, and the seed
reconciles the database against it. Roles are data: rows in `roles`/`role_permissions` that a
SUPER_ADMIN edits at runtime with no deploy.
The admin sidebar is built from the same catalog, so a user never sees a link to a screen they
cannot use — presentation only; the API re-checks every request.
## Consequences
Every authorization rule is one greppable decorator. New roles need no code. The
permission set travels inside the access token, so guards do no database work on the hot path —
which is precisely why access tokens are short-lived (ADR-0008): a revoked permission takes at
most one token lifetime to take effect.
## Alternatives considered
Role checks in code — rejected above. Full ABAC/policy engine — rejected as
premature: nothing yet needs "can edit orders from their own store only". The permission model
can grow into that if a real requirement appears.
@@ -0,0 +1,39 @@
# ADR-0008: Short access tokens, rotating refresh tokens, separate audiences
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Storefront customers and back-office staff authenticate against the same API. A single
token type shared between them means an XSS on the storefront is a path into the admin.
## Decision
A short-lived (15 min) stateless JWT access token carrying the permission set, plus a
long-lived refresh token that is opaque to the client, delivered as an httpOnly SameSite cookie,
stored server-side only as a SHA-256 hash, and rotated on every use.
Rotation is tracked with `Session.replacedById`. Presenting an already-rotated refresh token
means the token leaked, so the entire token family is revoked — theft detection, not just theft
mitigation.
Every token carries an audience (`storefront` or `admin`). Admin controllers declare
`@RequireAudience('admin')`, and the check runs before any permission logic.
## Consequences
A stolen access token expires in minutes. A stolen refresh token is detectable and
self-revoking. A stolen storefront token is rejected by admin endpoints on audience alone,
before permissions are consulted.
The trade-off is that permission changes are not instantaneous — bounded by the access token
lifetime. For an immediate kill switch, `CACHE_KEYS.revokedSession` exists as a Redis
denylist checked per request; it is deliberately not enabled by default because it reintroduces
a hot-path lookup.
## Alternatives considered
Server-side sessions — simpler to revoke, but adds a datastore read to every
request and complicates a future mobile client. Long-lived access tokens — rejected: the blast
radius of a leak is unacceptable for a system holding payment and address data.
@@ -0,0 +1,37 @@
# ADR-0009: Media in S3-compatible storage, metadata in PostgreSQL
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Product photography is the bulk of an apparel store's bytes. Storing binaries in
PostgreSQL bloats backups, makes replication slow, and forces image delivery through the
application tier.
## Decision
Bytes live in Cloudflare R2 (MinIO locally). PostgreSQL stores only metadata: object
key, MIME type, dimensions, size, alt text and a small base64 blur placeholder.
Browsers upload directly to the bucket using a short-lived presigned URL, so files never stream
through the API. Public URLs are composed at read time from `STORAGE_PUBLIC_URL + storageKey`,
never stored — so changing bucket, CDN domain or provider is a config change, not a data
migration.
Uploads are restricted by a MIME allow-list, and generated keys are date-partitioned UUIDs that
never echo the user's filename.
## Consequences
Database backups stay small and fast. Images are served by a CDN at the edge. The
API scales on CPU, not bandwidth.
The cost is eventual-consistency between the two stores: a failed upload can leave an orphaned
row, and a deleted row can leave an orphaned object. A periodic reconciliation job is the
accepted mitigation; two-phase commit across a database and object storage is not worth it.
## Alternatives considered
`bytea` columns — rejected for the reasons above. Serving uploads through the API
— rejected: it makes the API a bandwidth bottleneck and a timeout risk on large files.
@@ -0,0 +1,37 @@
# ADR-0010: Redis is a cache and an ephemeral store, never a system of record
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Redis is fast and tempting. Once a cart or an order lives only in Redis, an eviction or a
restart becomes lost revenue.
## Decision
Everything in Redis must be either reconstructible from PostgreSQL or genuinely
disposable. Current uses: catalog read caching, guest carts, OTPs, password-reset tokens, rate
limit counters, checkout stock reservations and idempotency keys.
The local container runs `--maxmemory-policy allkeys-lru` with persistence off — an explicit
statement that eviction is always preferable to refusing writes. `RedisService.getOrSet`
swallows cache read and write failures and falls through to the source, so a Redis outage
degrades latency rather than availability.
Every key is built in `cache-keys.ts`; no ad-hoc key strings anywhere.
## Consequences
Redis can be flushed at any moment and the store keeps working. Guest carts are the
one place where loss is user-visible, which is why they are promoted to PostgreSQL at sign-in
and carry a 30-day TTL.
Stock reservations need care: they are held in Redis with a TTL, but the authoritative
`reserved` count is a PostgreSQL column, so an eviction cannot silently oversell.
## Alternatives considered
Redis as primary store for carts — rejected: the failure mode is losing a customer's
basket. In-memory caching in the Node process — rejected: it does not survive a restart and
cannot be shared across instances.
@@ -0,0 +1,29 @@
# ADR-0011: Money as integer minor units
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
`0.1 + 0.2 !== 0.3`. Floating-point money produces discrepancies that are invisible in
testing and unfixable once they are in an order history.
## Decision
All monetary values are integers in the currency's minor unit, in the database
(`priceAmount Int`), across the API (`{ amount, currency }`), and in TypeScript (`Money`).
VND has a minor-unit scale of 0, so `250000` means ₫250.000. Conversion to a display string
happens in exactly one function, `formatMoney`, using `Intl.NumberFormat`.
## Consequences
Arithmetic is exact. No conversion happens between layers because every layer holds
the same integer. Multi-currency is already representable without a schema change.
Developers must remember that `price.amount` is not a display value; the single formatter and
the absence of any other division by 100 are what keep that from going wrong.
## Alternatives considered
`Decimal`/`numeric` columns — correct in the database but arrive in JavaScript as
strings or a Decimal object that must be handled at every boundary. Floats — never.
@@ -0,0 +1,34 @@
# ADR-0012: PostgreSQL full-text search before a dedicated search engine
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Search is a headline feature of a storefront, and reaching for Elasticsearch or
OpenSearch is the reflex. It is also a second datastore to run, secure, back up and keep in
sync — for a catalog that starts at a few hundred products.
## Decision
Start with PostgreSQL full-text search plus `pg_trgm` for fuzzy matching and typo
tolerance, behind a `SearchProvider` interface owned by `SearchModule`. The module is marked
EXTRACTION CANDIDATE and reads the catalog only through public services, so it holds no
privileged coupling.
## Consequences
One datastore, no sync pipeline, no index drift, and search results that are
transactionally consistent with the catalog. This is genuinely adequate below roughly 50k
products with straightforward faceting.
The limits are known and will eventually bind: no relevance tuning to speak of, no
learning-to-rank, weak multilingual analysis for Vietnamese. When they do, the provider
interface is the seam — swapping in OpenSearch changes one implementation, not every listing
page.
## Alternatives considered
Elasticsearch/OpenSearch from day one — rejected as premature infrastructure.
A hosted service (Algolia, Typesense Cloud) — a reasonable future option; deferred because it
adds per-record cost and a sync pipeline before there is a search-quality problem to solve.
+36
View File
@@ -0,0 +1,36 @@
# Architecture Decision Records
Each file records one decision that was expensive to make and would be expensive to reverse.
The purpose is not documentation for its own sake — it is so that in a year, when someone asks
"why is money an integer?" or "why doesn't the admin just query the database?", the answer is
written down along with what was rejected and why.
An ADR is immutable once accepted. If a decision changes, add a new ADR that supersedes it.
| ADR | Decision | Status |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------- |
| [0001](./0001-monorepo-with-pnpm-workspaces-and-turborepo.md) | Monorepo with pnpm workspaces and Turborepo | Accepted |
| [0002](./0002-modular-monolith-not-microservices.md) | Modular monolith, not microservices | Accepted |
| [0003](./0003-product-and-productvariant-as-separate-entities.md) | Product and ProductVariant as separate entities | Accepted |
| [0004](./0004-the-admin-dashboard-has-no-database-access.md) | The admin dashboard has no database access | Accepted |
| [0005](./0005-uri-based-api-versioning.md) | URI-based API versioning | Accepted |
| [0006](./0006-zod-schemas-shared-between-api-and-frontends.md) | Zod schemas shared between API and frontends | Accepted |
| [0007](./0007-rbac-permissions-instead-of-role-checks.md) | RBAC permissions instead of role checks | Accepted |
| [0008](./0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) | Short access tokens, rotating refresh tokens, separate audiences | Accepted |
| [0009](./0009-media-in-s3-compatible-storage-metadata-in-postgresql.md) | Media in S3-compatible storage, metadata in PostgreSQL | Accepted |
| [0010](./0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md) | Redis is a cache and an ephemeral store, never a system of record | Accepted |
| [0011](./0011-money-as-integer-minor-units.md) | Money as integer minor units | Accepted |
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
## Decisions deliberately NOT recorded yet
These are open and should become ADRs when the need is real, not before:
- Payment provider abstraction shape (VNPay / MoMo / ZaloPay / COD) — write it when the second
provider is integrated, not the first. One provider does not reveal the right abstraction.
- Shipping-rate provider integration.
- Whether guest carts ever get promoted to PostgreSQL before sign-in.
- Multi-warehouse allocation strategy. The schema supports it; the policy does not exist yet.
- i18n / multi-currency rollout.
- Read replicas and connection pooling (PgBouncer) — a scaling decision that needs real traffic
numbers to make well.
+346
View File
@@ -0,0 +1,346 @@
# Architecture
The reference document for how this system is put together and, more importantly, which rules
must not be broken. Decisions and their trade-offs live in [`docs/adr/`](./adr/README.md).
---
## 1. System topology
```
┌─────────────┐
Customer ───────────────▶│ │
│ Cloudflare │ TLS, WAF, CDN, DDoS
Admin ──────────────────▶│ │
└──────┬──────┘
│
┌──────▼──────┐
│ Nginx │ routing, gzip, rate ceiling,
└──┬───┬───┬──┘ immutable asset caching
┌────────────────┘ │ └────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Storefront │ │ Admin │ │ API │
│ Next.js │ │ Next.js │ │ NestJS │
│ :3000 │ │ :3001 │ │ :4000 │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└────── REST ────────┴──── REST ──────────┤
│
┌───────────────┬───────────────┼───────────────┐
│ │ │ │
┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐
│ PostgreSQL │ │ Redis │ │ R2 / S3 │ │ Providers │
│ (record) │ │ (cache) │ │ (media) │ │ (future) │
└────────────┘ └────────────┘ └────────────┘ └────────────┘
```
**The load-bearing rule:** only the API touches PostgreSQL, Redis or object storage. Both
frontends reach data exclusively through the REST API. See [ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md).
Server-side rendering in both Next apps calls the API over the internal Docker network
(`API_INTERNAL_URL`), skipping the public hostname and TLS entirely.
---
## 2. Responsibilities
| Component | Owns | Explicitly does not |
| ------------------ | ---------------------------------------------------------------- | --------------------------------------------- |
| `apps/storefront` | Customer UX, SEO, rendering strategy, client cart state | Business rules, pricing maths, DB access |
| `apps/admin` | Back-office UX, bulk editing, operational views | Business rules, DB access, its own auth model |
| `apps/api` | **All** business logic, persistence, authorization, integrations | Rendering, presentation concerns |
| `packages/*` | Contracts and reusable primitives | Anything app-specific or stateful |
| `infrastructure/*` | Runtime topology, container builds, local dev | Application behaviour |
Pricing is the clarifying example. The storefront may _format_ `{ amount: 250000, currency: 'VND' }`
as `₫250.000`. It may never _compute_ a discount, a subtotal or a shipping cost. If a number
appears on a receipt, the API produced it.
---
## 3. Dependency rules
```
apps/storefront ──┐
├──▶ @sport/api-client ──▶ @sport/types
apps/admin ───────┤ ▲
├──▶ @sport/ui ────────────────┤
└──▶ @sport/validation ────────┤
│
apps/api ────────────▶ @sport/validation ────────┤
└───────────▶ @sport/types ─────────────┘
```
Allowed:
- Any app → any package.
- `@sport/validation`, `@sport/api-client` → `@sport/types`.
- `@sport/ui` → nothing but React and styling utilities.
Forbidden, and enforced rather than merely documented:
| Rule | Enforced by |
| ---------------------------------------- | ------------------------------------------------- |
| App → app | No workspace dependency exists |
| Package → app | No workspace dependency exists |
| Frontend → `@prisma/client` or `ioredis` | ESLint `no-restricted-imports` |
| `@sport/types` → any framework | Zero dependencies in its `package.json` |
| Cross-module deep imports in the API | ESLint `no-restricted-imports` on `@/modules/*/*` |
| Circular imports | ESLint `import-x/no-cycle` |
`@sport/types` has no dependencies at all, and that is a deliberate constraint: it is imported
by a NestJS server, two React apps and eventually a React Native app. The moment it depends on
a framework, one of those consumers breaks.
---
## 4. What may and may not be shared
**Share** — things that are true everywhere:
- Domain types and API contracts (`@sport/types`)
- Input shape/format rules (`@sport/validation`)
- API access (`@sport/api-client`)
- Design-system primitives: Button, Input, Badge, Skeleton (`@sport/ui`)
- Build configuration (`@sport/config`, `@sport/eslint-config`)
**Do not share** — things that only look shareable:
- **Domain components.** `<ProductCard>` knows about sale badges, price ranges and colour
swatches. It belongs to the storefront. The admin's product row needs status, stock and
margin. Merging them produces a component with fourteen props and two consumers who both
fight it.
- **Business logic.** It lives in the API. A discount calculated in a shared package is a
discount that can disagree with the invoice.
- **App state.** Cart, auth session and filter state are app-specific. Shared stores create
invisible coupling between two applications that must be free to diverge.
- **Feature-to-feature imports.** If two storefront features need the same thing, it moves up
to `@/components` or `@/lib` — it does not get imported sideways.
The test for `@sport/ui`: _would this component be meaningful in the admin dashboard?_ If not,
it is not a design-system primitive.
---
## 5. Backend module boundaries
Twenty modules, each owning its tables exclusively. Full anatomy in
[`apps/api/src/modules/README.md`](../apps/api/src/modules/README.md).
| Group | Modules |
| ------------------- | ---------------------------------------------------------------------------------------- |
| Cross-cutting | `auth`, `health` |
| Identity | `users`, `customers` |
| Catalog | `products`, `product-variants`, `categories`, `collections`, `brands`, `media`, `search` |
| Commerce | `inventory`, `carts`, `checkout`, `orders`, `payments` |
| Marketing & content | `promotions`, `coupons`, `wishlist`, `reviews`, `cms` |
Communication:
| Need | Mechanism |
| --------------------------------------- | ------------------------------------------------ |
| An answer now, to continue this request | Call the other module's `public/` service |
| To react to something that happened | Subscribe to its domain event |
| To change another module's data | Call its public service — never write its tables |
`inventory`, `orders`, `payments` and `search` carry an additional constraint: no shared
transactions with the rest of the monolith, so they remain extractable.
---
## 6. Request lifecycle
```
Request
│
├─▶ RequestIdMiddleware assign/propagate x-request-id
├─▶ ThrottlerGuard per-IP rate limit
├─▶ AccessTokenGuard verify JWT, check audience, attach actor ← opt-out via @Public()
├─▶ PermissionsGuard evaluate @RequirePermissions
├─▶ ZodValidationPipe parse + coerce body/query
│
├─▶ Controller ─▶ Service ─▶ Repository ─▶ Prisma
│
├─▶ ResponseEnvelopeInterceptor wrap in { success: true, data, meta }
└─▶ AllExceptionsFilter any throw → { success: false, error, meta }
```
Authentication is **on by default**. `AccessTokenGuard` is registered globally and a route
becomes public only by explicitly declaring `@Public()`. Forgetting a decorator therefore fails
closed — a new endpoint is never accidentally exposed.
---
## 7. API conventions
### Response envelope
Every response uses one of exactly two shapes, including 500s. Clients branch on `success`,
never on the status code.
```jsonc
// success
{ "success": true, "data": { }, "meta": { "requestId": "019f…", "timestamp": "2026-08-11T…" } }
// failure
{
"success": false,
"error": { "code": "INSUFFICIENT_STOCK", "message": "Only 2 left in size M.",
"fields": { "items.0.quantity": ["Only 2 available"] } },
"meta": { "requestId": "019f…", "timestamp": "2026-08-11T…" }
}
```
`error.code` is a stable machine identifier from `API_ERROR_CODES`. **A code is never renamed or
repurposed once shipped** — mobile apps and partner integrations branch on those strings. Adding
a code is always safe; changing one is a breaking API change.
`error.message` is safe to display to an end user. Internal detail (SQL, Prisma metadata, stack
traces) never reaches the client in production; it goes to the log, correlated by `requestId`.
### Status codes
| Code | Meaning |
| --------------- | -------------------------------------------------- |
| 200 / 201 / 204 | Success |
| 400 | Malformed request |
| 401 | Missing, invalid or expired credentials |
| 403 | Authenticated but not permitted |
| 404 | Not found, or hidden from this actor |
| 409 | Conflict (duplicate, invalid state transition) |
| 422 | Validation failed — carries `error.fields` |
| 429 | Rate limited |
| 500 | Unexpected — always generic message, always logged |
### Pagination
Offset (`?page=&perPage=`) for admin tables, where "page 7 of 42" is a real requirement. Cursor
(`?cursor=&limit=`) for storefront listings, where correctness under concurrent writes matters
more than random access. Chosen per endpoint, never mixed.
---
## 8. Logging
Structured JSON via pino. One line per request: method, path, status, duration, `requestId`,
`actorId`. Domain logs carry the same `requestId`, so a full trace — Cloudflare → Nginx → API —
is one grep.
Levels: `error` for 5xx and unexpected failures; `warn` for 4xx and degraded dependencies;
`info` for lifecycle and significant domain events; `debug` for development only.
Redaction happens **at the logger**, not at each call site: `authorization`, `cookie`,
`set-cookie`, and password/token/card fields are censored centrally. Relying on developers to
remember is how credentials end up in log storage.
Application code uses Nest's standard `Logger`, which `main.ts` routes into pino. Nothing
injects `PinoLogger` directly — it is transient-scoped, and injecting it would silently make the
consumer transient too, which for `PrismaService` would mean a second connection pool.
---
## 9. Naming conventions
| Thing | Convention | Example |
| --------------------- | ------------------------- | ------------------------------ |
| Files | kebab-case | `product-variant.service.ts` |
| React components | PascalCase file + export | `ProductCard.tsx` |
| Classes | PascalCase | `ProductVariantService` |
| Variables / functions | camelCase | `calculateSubtotal` |
| Constants | SCREAMING_SNAKE | `PAGINATION_DEFAULTS` |
| Types / interfaces | PascalCase, no `I` prefix | `ProductVariant` |
| Database tables | snake_case plural | `product_variants` |
| Database columns | snake_case | `sale_price_amount` |
| Prisma models | PascalCase singular | `ProductVariant` |
| API routes | kebab-case plural | `/api/v1/product-variants` |
| Permissions | `resource.action` | `product.update` |
| Domain events | `resource.past_tense` | `order.placed` |
| Redis keys | `domain:entity:id` | `catalog:product:slug:air-tee` |
| Env vars | SCREAMING_SNAKE | `JWT_ACCESS_SECRET` |
| Branches | `type/short-description` | `feat/variant-matrix-editor` |
Booleans read as assertions: `isActive`, `hasVariants`, `canRefund`. Money fields end in
`Amount` and are always integers.
---
## 10. Configuration and environment
Four `.env` files, each with a committed `.env.example`:
| File | Consumed by | Contains |
| ---------------------- | ------------------- | ----------------------------------------------------------- |
| `/.env` | docker-compose only | Ports, container credentials |
| `apps/api/.env` | API | `DATABASE_URL`, JWT secrets, storage credentials |
| `apps/storefront/.env` | Storefront | `NEXT_PUBLIC_*`, `API_INTERNAL_URL` |
| `apps/admin/.env` | Admin | `NEXT_PUBLIC_*`, `API_INTERNAL_URL` — **no `DATABASE_URL`** |
Rules:
1. **The API validates its entire environment at boot** with Zod and refuses to start on any
problem, listing all of them at once. A missing JWT secret is discovered at deploy time, not
at 2am by a customer.
2. **`process.env` is read in exactly one place per app.** Everything else injects a typed
config object.
3. **`NEXT_PUBLIC_*` is public.** It is inlined into the client bundle. No secret ever carries
that prefix.
4. **`NEXT_PUBLIC_*` is baked at build time**, not container start — which is why the
Dockerfiles take them as build args.
5. Secrets are never committed. `pnpm setup` generates real JWT secrets locally so that not even
a laptop runs on a value present in the repository.
---
## 11. Where premature abstraction must be avoided
Places where the instinct to generalise should be resisted until a second real case appears:
- **Payment providers.** Build VNPay concretely first. One implementation does not reveal the
right interface; two do. Guessing produces an abstraction shaped like VNPay with a misleading
name.
- **A generic repository layer.** `BaseRepository<T>` with generic CRUD sounds appealing and
ends as a layer that obstructs every non-trivial query. Prisma is already the abstraction.
- **CQRS / event sourcing.** The inventory ledger is append-only because inventory genuinely
needs an audit trail — that is not a mandate to apply the pattern everywhere.
- **A shared `<DataTable>` before three tables exist.** Two tables with different needs produce
a component with thirty props.
- **A plugin architecture for the CMS.** Build the homepage blocks that are needed. A page
builder is a product, not a feature.
- **Micro-optimising the cache.** Add caching when a query is measurably slow, keyed and
invalidated deliberately. Cache invalidation bugs are worse than slow pages.
- **Extracting a service.** The boundaries exist so extraction _stays possible_, not so it
happens. Extract when there is a real scaling or team-boundary problem.
---
## 12. Architectural risks to prevent from day one
| Risk | Why it is fatal later | Prevention in place |
| ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
| No request correlation | Production incidents become guesswork | `x-request-id` end to end |
---
## 13. Deliberate limitations of milestone 0
Stated plainly so they are choices rather than oversights:
- **No token issuance.** Guards verify and enforce; login, refresh and registration are M2.
- **No cart/order/payment tables.** The first migration stays reviewable; they arrive in M5.
- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same
database transaction as its cause. A durable outbox comes when a use case demands it.
- **No observability beyond logs.** OpenTelemetry traces and metrics are worth adding once there
is production traffic to explain.
- **No CDN, TLS or WAF config.** That belongs to the deployment repository, not this one.