This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
@@ -0,0 +1,90 @@
# ADR-0013: Content translations in typed tables, UI strings in message catalogs
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
The store must serve Vietnamese and English. "Add a language" sounds like one
decision but is actually two, and conflating them is the usual mistake:
1. **UI strings** — "Add to bag", "Filters", "Only a few left". These live in
code, change when designers change them, and are the same for every product.
2. **Content** — product names, descriptions, category names, colour labels,
spec rows. These live in the database, are authored by merchandisers, and
change without a deploy.
A third question rides along: product URLs. `/products/aero-run-tee` is the
English SEO surface; `/products/ao-chay-bo-aero` is the Vietnamese one. They
must both exist and must be linked to each other.
## Decision
**Two mechanisms, split by who owns the string.**
UI strings live in `src/messages/{vi,en}.json` and are resolved by `next-intl`.
Enums that exist in code — sport types, sort options, availability states — are
UI strings too, because their values are a deploy-time concern.
Content is translated in the database via six typed translation tables
(`product_translations`, `category_translations`, `collection_translations`,
`brand_translations`, `product_option_translations`,
`product_option_value_translations`, plus `product_attribute_translations`).
Each has a composite `(entityId, locale)` primary key and, where the entity has
a URL, a `@@unique([locale, slug])`.
The base row keeps canonical values. Resolution is **field-level**: a translation
row wins per column, and any column that is null or blank falls back to the
base. A half-translated product renders a translated name and the original
description — never a blank.
Locale reaches the API as an explicit `?locale=` query parameter, with
`Accept-Language` as a fallback. Every Redis cache key is namespaced by locale.
For URLs: the storefront uses `localePrefix: 'as-needed'`, so Vietnamese is
served from clean paths and English is prefixed with `/en`. Product lookup
matches a slug in **any** locale and then redirects to the canonical URL for the
requested locale, and every product carries `alternateSlugs` so `hreflang` links
and the language switcher both work.
The admin resolves its locale from a cookie instead, with no URL segment.
## Consequences
Merchandisers translate content without a deploy; designers change copy without
a migration. The two never block each other.
Per-locale slugs give each language a real, indexable URL space with correct
`hreflang` and canonical tags — the single largest SEO win available here.
The cost is real and worth stating plainly:
- Seven translation tables and a mapper that resolves them. Every new
translatable entity is a table, not a column.
- Every catalog query joins translations, and every cache key carries a locale,
which halves the effective hit rate per language.
- Adding a third locale means backfilling content, not just adding a JSON file.
That is a feature: a locale with no content should not be launchable.
- Forgetting `locale` in a cache key serves one visitor's language to everyone.
This is why `CACHE_KEYS` builds every key in one file.
## Alternatives considered
**A JSONB `translations` column per table.** Fewer tables, but you cannot put a
unique index on "slug within a locale", filtering and sorting degrade to JSON
operators, and the shape is unenforced — a typo in a locale key fails silently.
Rejected: slugs are the reason this exists, and JSONB cannot constrain them.
**One generic `translations(entityType, entityId, field, locale, value)` table.**
Superficially DRY. It cannot express a per-locale unique slug, returns
`string | undefined` for everything, needs a join per field, and makes referential
integrity impossible. Rejected — this is the "clever" option that costs more
every month it survives.
**Translate content in the frontend / machine translation at render.** Wrong
layer, unreviewable output, and no way for a merchandiser to correct a product
name. Rejected.
**Locale in a header only, no query parameter.** Makes a URL non-self-describing:
the same link renders differently for different people, and every CDN entry needs
`Vary: Accept-Language`, which fragments the cache badly. Rejected.
@@ -0,0 +1,59 @@
# ADR-0014: A denormalised price/stock projection on Product
- **Status:** Accepted
- **Date:** 2026-08-11
## Context
Price lives on `ProductVariant` (ADR-0003), because a size M and a size L can
genuinely cost different amounts. A listing page, however, needs to:
- sort by price ("low to high") across products,
- filter by a price band,
- render a price-range facet,
- filter to "in stock only".
Every one of those needs `MIN`/`MAX` over a product's variants inside `WHERE`
and `ORDER BY`. Prisma cannot express "order by the minimum price of a related
collection" — no ORM comfortably can — and the same is true of "has at least one
variant with available stock".
## Decision
Four derived columns on `products`: `min_price_amount`, `max_price_amount`,
`is_on_sale`, `in_stock`.
They are used **only** in `WHERE` and `ORDER BY`. Everything a page _displays_ is
computed from the variant rows already loaded, so a stale projection can shift
result ordering but can never show a wrong price to a customer. That asymmetry is
the whole reason this is acceptable.
`ProductsRepository.recomputePricing()` is the single writer. Every variant,
price or stock mutation must call it; a direct `UPDATE` of these columns is a bug.
## Consequences
Listing queries stay ordinary Prisma queries — no raw SQL in the hottest path in
the catalog, and the query builder keeps its type safety.
The cost is a classic denormalisation risk: a write path that forgets to
recompute leaves a product sorted or filtered wrongly. Mitigations are the ones
that actually work — a single writer, an explicit contract in the schema comment,
and the display/ordering split above so the blast radius is a mis-sort rather
than a mis-priced order.
## Alternatives considered
**Raw SQL for the listing query.** Correct and fast, but the listing query is the
one that grows the most filters over time; hand-maintaining a dynamic SQL builder
with a dozen optional predicates is where injection bugs and subtle
`AND`/`OR` precedence errors come from. Rejected for now — if the query grows
past what Prisma can express cleanly, this becomes the fallback.
**A materialised view.** PostgreSQL materialised views cannot be incrementally
refreshed, so every variant edit would trigger a full rebuild. Rejected.
**Push it into the search engine.** This is genuinely the right long-term answer
and is what ADR-0012 anticipates. Rejected for now because introducing
OpenSearch to sort a few hundred products by price is exactly the premature
infrastructure that ADR-0012 exists to avoid.
+20 -14
View File
@@ -7,20 +7,22 @@ written down along with what was rejected and why.
An ADR is immutable once accepted. If a decision changes, add a new ADR that supersedes it.
| ADR | Decision | Status |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------- |
| [0001](./0001-monorepo-with-pnpm-workspaces-and-turborepo.md) | Monorepo with pnpm workspaces and Turborepo | Accepted |
| [0002](./0002-modular-monolith-not-microservices.md) | Modular monolith, not microservices | Accepted |
| [0003](./0003-product-and-productvariant-as-separate-entities.md) | Product and ProductVariant as separate entities | Accepted |
| [0004](./0004-the-admin-dashboard-has-no-database-access.md) | The admin dashboard has no database access | Accepted |
| [0005](./0005-uri-based-api-versioning.md) | URI-based API versioning | Accepted |
| [0006](./0006-zod-schemas-shared-between-api-and-frontends.md) | Zod schemas shared between API and frontends | Accepted |
| [0007](./0007-rbac-permissions-instead-of-role-checks.md) | RBAC permissions instead of role checks | Accepted |
| [0008](./0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) | Short access tokens, rotating refresh tokens, separate audiences | Accepted |
| [0009](./0009-media-in-s3-compatible-storage-metadata-in-postgresql.md) | Media in S3-compatible storage, metadata in PostgreSQL | Accepted |
| [0010](./0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md) | Redis is a cache and an ephemeral store, never a system of record | Accepted |
| [0011](./0011-money-as-integer-minor-units.md) | Money as integer minor units | Accepted |
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
| ADR | Decision | Status |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- |
| [0001](./0001-monorepo-with-pnpm-workspaces-and-turborepo.md) | Monorepo with pnpm workspaces and Turborepo | Accepted |
| [0002](./0002-modular-monolith-not-microservices.md) | Modular monolith, not microservices | Accepted |
| [0003](./0003-product-and-productvariant-as-separate-entities.md) | Product and ProductVariant as separate entities | Accepted |
| [0004](./0004-the-admin-dashboard-has-no-database-access.md) | The admin dashboard has no database access | Accepted |
| [0005](./0005-uri-based-api-versioning.md) | URI-based API versioning | Accepted |
| [0006](./0006-zod-schemas-shared-between-api-and-frontends.md) | Zod schemas shared between API and frontends | Accepted |
| [0007](./0007-rbac-permissions-instead-of-role-checks.md) | RBAC permissions instead of role checks | Accepted |
| [0008](./0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) | Short access tokens, rotating refresh tokens, separate audiences | Accepted |
| [0009](./0009-media-in-s3-compatible-storage-metadata-in-postgresql.md) | Media in S3-compatible storage, metadata in PostgreSQL | Accepted |
| [0010](./0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md) | Redis is a cache and an ephemeral store, never a system of record | Accepted |
| [0011](./0011-money-as-integer-minor-units.md) | Money as integer minor units | Accepted |
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
## Decisions deliberately NOT recorded yet
@@ -28,6 +30,10 @@ These are open and should become ADRs when the need is real, not before:
- Payment provider abstraction shape (VNPay / MoMo / ZaloPay / COD) — write it when the second
provider is integrated, not the first. One provider does not reveal the right abstraction.
- Whether a third locale ever ships, and whether localised _pathnames_
(`/vi/san-pham/...`) are worth the routing complexity on top of localised slugs.
- Facet counts for sport and gender, which need `unnest()` over the array columns —
worth doing when a UI displays them.
- Shipping-rate provider integration.
- Whether guest carts ever get promoted to PostgreSQL before sign-in.
- Multi-warehouse allocation strategy. The schema supports it; the policy does not exist yet.
+73 -12
View File
@@ -240,7 +240,51 @@ consumer transient too, which for `PrismaService` would mean a second connection
---
## 9. Naming conventions
## 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 |
| --------------------- | ------------------------- | ------------------------------ |
@@ -265,7 +309,7 @@ Booleans read as assertions: `isActive`, `hasVariants`, `canRefund`. Money field
---
## 10. Configuration and environment
## 11. Configuration and environment
Four `.env` files, each with a committed `.env.example`:
@@ -292,7 +336,7 @@ Rules:
---
## 11. Where premature abstraction must be avoided
## 12. Where premature abstraction must be avoided
Places where the instinct to generalise should be resisted until a second real case appears:
@@ -314,7 +358,7 @@ Places where the instinct to generalise should be resisted until a second real c
---
## 12. Architectural risks to prevent from day one
## 13. Architectural risks to prevent from day one
| Risk | Why it is fatal later | Prevention in place |
| ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
@@ -333,14 +377,31 @@ Places where the instinct to generalise should be resisted until a second real c
---
## 13. Deliberate limitations of milestone 0
## 14. Deliberate limitations
Stated plainly so they are choices rather than oversights:
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.
**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. 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.
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.