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.
|
||||
|
||||
Reference in New Issue
Block a user