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