Stage M1
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user