408 lines
24 KiB
Markdown
408 lines
24 KiB
Markdown
# 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. Internationalisation
|
||
|
||
Two languages, **two mechanisms**, split by who owns the string. Conflating them is the usual
|
||
way an i18n project ends up half-finished. See
|
||
[ADR-0013](./adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md).
|
||
|
||
| String | Owner | Lives in | Changes via |
|
||
| -------------------------------------- | ----------------- | ----------------------------------- | ---------------- |
|
||
| "Add to bag", "Filters" | Designers/devs | `src/messages/{vi,en}.json` | Deploy |
|
||
| Sport names, sort labels, availability | Devs (code enums) | Message catalogs | Deploy |
|
||
| Product name, description, spec rows | Merchandisers | `*_translations` tables | Admin, no deploy |
|
||
| Category / collection / brand names | Merchandisers | `*_translations` tables | Admin, no deploy |
|
||
| Colour labels ("Đen" / "Black") | Merchandisers | `product_option_value_translations` | Admin, no deploy |
|
||
|
||
### Resolution rules
|
||
|
||
1. **Locale in, resolved out.** The API takes `?locale=` (falling back to `Accept-Language`,
|
||
then `vi`) and returns already-resolved strings. The frontend never sees a translation table
|
||
and never writes fallback logic.
|
||
2. **Field-level fallback.** A translation row wins per column; any column that is null or blank
|
||
falls back to the base row. A product with a translated name but no translated description
|
||
renders the translated name and the original description — never a blank.
|
||
3. **Missing translations are legitimate.** Sizes (`S`, `M`, `L`) carry no translation rows at
|
||
all, because they are identical in both languages. The fallback covers it.
|
||
4. **Every cache key is namespaced by locale.** Forgetting this serves the first visitor's
|
||
language to everyone — the classic i18n caching bug. `CACHE_KEYS` builds every key in one file
|
||
precisely so this cannot be forgotten locally.
|
||
|
||
### URLs
|
||
|
||
Storefront uses `localePrefix: 'as-needed'`: Vietnamese from clean paths, English under `/en`.
|
||
Product slugs are themselves translated, so `/products/ao-chay-bo-aero` and
|
||
`/en/products/aero-run-tee` are the same product. Consequently:
|
||
|
||
- Every product carries `alternateSlugs`, which feeds `hreflang` alternates and the language
|
||
switcher.
|
||
- Product lookup matches a slug in _any_ locale, then redirects to the canonical URL for the
|
||
requested locale. One redirect keeps every cross-locale link alive without duplicate content.
|
||
|
||
The admin resolves locale from a cookie with no URL segment — it is `noindex` everywhere, so
|
||
locale-in-path would add a proxy hop and double the route tree for nothing.
|
||
|
||
---
|
||
|
||
## 10. 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.
|
||
|
||
---
|
||
|
||
## 11. 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.
|
||
|
||
---
|
||
|
||
## 12. 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.
|
||
|
||
---
|
||
|
||
## 13. 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 |
|
||
|
||
---
|
||
|
||
## 14. Deliberate limitations
|
||
|
||
Stated plainly so they are choices rather than oversights.
|
||
|
||
**Shipped (M0–M1)**
|
||
|
||
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
|
||
localised, cached, filtered and faceted.
|
||
- RBAC enforcement, response envelope, structured logging, media pipeline.
|
||
- Storefront browsing and PDP in Vietnamese and English.
|
||
|
||
**Not built yet, and why**
|
||
|
||
- **No token issuance.** Guards verify and enforce; login, refresh rotation and registration are
|
||
M2. Every endpoint written from here is protected by default, before a credential exists.
|
||
- **No cart, order or payment tables.** They arrive in M5, so the migration history stays
|
||
reviewable and the variant model gets proven against real reads first.
|
||
- **`best_selling` and `relevance` sorts fall back to newest.** There is no order data (M5) and
|
||
no ranking (M6). Falling back is honest; a fake ranking would not be.
|
||
- **No sport/gender facet counts.** Those dimensions are navigated by route, not refined within a
|
||
page, so a count would render nowhere.
|
||
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it stops
|
||
being fine is a search index (ADR-0012), not a bigger query.
|
||
- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same
|
||
database transaction as its cause.
|
||
- **No observability beyond logs.** 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.
|