24 KiB
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/.
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.
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
@/componentsor@/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.
| 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.
// 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.
| 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
- Locale in, resolved out. The API takes
?locale=(falling back toAccept-Language, thenvi) and returns already-resolved strings. The frontend never sees a translation table and never writes fallback logic. - 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.
- 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. - Every cache key is namespaced by locale. Forgetting this serves the first visitor's
language to everyone — the classic i18n caching bug.
CACHE_KEYSbuilds 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 feedshreflangalternates 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:
- 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.
process.envis read in exactly one place per app. Everything else injects a typed config object.NEXT_PUBLIC_*is public. It is inlined into the client bundle. No secret ever carries that prefix.NEXT_PUBLIC_*is baked at build time, not container start — which is why the Dockerfiles take them as build args.- Secrets are never committed.
pnpm setupgenerates 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) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units (ADR-0011) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard (ADR-0007) |
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin (ADR-0004) |
| 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) |
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage (ADR-0009) |
| 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_sellingandrelevancesorts 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.