Files
web_sport/docs/architecture.md
T

347 lines
21 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. 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.