Basic Architecture of Sport Web
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user