# 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.** `` 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` 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 `` 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.