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.