15 KiB
MercadoDeVida backend — modular monolith skeleton
TypeScript + Fastify modular monolith. Simple code, clear modules, small changes, no magic.
Requirements
- Node.js >= 22
- npm
Commands
npm install # install dependencies
npm run build # compile to dist/
npm start # run compiled server (PORT, HOST env vars)
npm test # vitest unit tests (no database needed)
npm run typecheck # tsc --noEmit
npm run lint # eslint + prettier check
npm run lint:boundaries # module boundary check
Frontend shell commands live in storefront/:
cd storefront
npm install
npm run dev # Next.js development server
npm run lint # storefront formatting check
npm run typecheck # frontend TypeScript check
npm run build # production Next.js build
Configuration
Startup is fail-fast: src/infrastructure/config parses env once and refuses to boot
on missing/invalid required vars. DATABASE_URL is required; PORT, HOST,
LOG_LEVEL, NODE_ENV, REDIS_URL are optional with defaults. COOKIE_SECURE
defaults to true (set false only for local http dev). Errors name variable
NAMES only, never values.
Feature flags: FLAG_<NAME>=true|false env vars seed the flag store at boot. Unknown
flags default to OFF (fail-safe). Flags flip at runtime through the store — activation
is separate from deployment (no redeploy). Copy .env.example to .env to start.
HTTP contract
- Every response carries an
x-request-idheader (propagated from a safe incomingx-request-id, or a fresh UUID). Every JSON log line for a request carries the same id. - Errors always use one envelope:
{ "error": { "statusCode", "code", "message", "details?" }, "requestId" }Codes:NOT_FOUND,VALIDATION_ERROR,BAD_REQUEST/Fastify 4xx codes,INTERNAL_ERROR. Category-specific codes:CATEGORY_SLUG_EXISTS(409),CATEGORY_PARENT_NOT_FOUND(422),CATEGORY_TREE_CYCLE(422),CATEGORY_HAS_CHILDREN(409). Product-specific codes:PRODUCT_SLUG_EXISTS(409),PRODUCT_CATEGORY_NOT_FOUND(422),PRODUCT_BRAND_NOT_FOUND(422),PRODUCT_VARIANT_CODE_EXISTS(409). Brand-specific codes:BRAND_SLUG_EXISTS(409). 5xx messages are always generic; stack traces stay in server logs only. - Input validation is explicit per route:
parseJson(schema, body)(zod) in the handler. - Auth codes:
UNAUTHORIZED(401, missing/invalid/revoked session),FORBIDDEN(403, role or ownership check failed),INVALID_CREDENTIALS(401),EMAIL_ALREADY_REGISTERED(409),TOO_MANY_ATTEMPTS(429, withRetry-Afterheader). - Log level via
LOG_LEVELenv var (defaultinfo); logs are JSON only.
Authentication (identity module)
The server is the only authority for identity; the frontend is never trusted with session or credential state.
| Route | Result |
|---|---|
| POST /auth/register | 201 + { id, email, role, createdAt } |
| POST /auth/login | 200 + { id, email, role } + Set-Cookie: mdv_session |
| POST /auth/logout | 204, cookie cleared, session revoked (idempotent) |
- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never plaintext or anything reversible.
- Sessions: opaque 512-bit token in the cookie; the DB stores only its SHA-256
hash (
identity_sessions.token_hash). TTL 7 days; logout revokes server-side. - Cookie:
HttpOnly,Secure(COOKIE_SECURE, default true),SameSite=Lax,Path=/,Max-Age=604800. - Login failures: identical generic 401 for unknown email and wrong password (no
enumeration; timing equalized via dummy hash). After 10 consecutive failures per
email, further attempts get
429withRetry-Afterfor 15 minutes. The limiter is in-memory per instance behind aLoginRateLimiterinterface (Redis-backed swap later without touching use cases). - Identity routes are wired only when the app is built with a DB pool.
Users and RBAC (users module)
Every route resolves the session cookie against the DB first (expired/revoked
sessions and missing cookies get 401 UNAUTHORIZED). Roles are customer
(default) and admin; the role is read from identity_users on every request,
so promotions/demotions apply immediately. Role changes are an out-of-band DB
operation in this slice (no admin API yet).
| Route | Access | Result |
|---|---|---|
| GET /users | admin only | 200 + { items: [profile] } |
| GET /users/:id | owner or admin | 200 profile, 404 if none yet |
| PATCH /users/:id | owner or admin | 200 upserted profile |
| GET /users/:id/addresses | owner or admin | 200 + { items: [address] } |
| POST /users/:id/addresses | owner or admin | 201 address |
| PATCH /users/:id/addresses/:addressId | owner or admin | 200 address, 404 if not theirs |
| DELETE /users/:id/addresses/:addressId | owner or admin | 204, 404 if not theirs |
- Authorization is checked before existence: a non-owner gets
403 FORBIDDENregardless of whether the target resource exists (no enumeration). - Address queries are scoped by
user_idin SQL, so a valid foreign address id is unreachable. GET /userslists users that have a profile row (users who have patched their profile at least once).- The users module never imports identity: session resolution arrives as an
injected
Authenticatefunction from the composition root.
Categories (categories module)
Categories provide the public taxonomy for SEO-friendly catalog URLs. The module
owns categories_categories; products are not assigned to categories until the
catalog slice.
| Route | Access | Result |
|---|---|---|
| GET /categories/tree | public | 200 + { items: [categoryTreeNode] } |
| GET /categoria/:slug | public | 200 category by slug, 404 if missing |
| POST /categories | admin only | 201 category, 409 on duplicate slug |
| PATCH /categories/:id | admin only | 200 category, 422 on invalid tree move |
| DELETE /categories/:id | admin only | 204, 409 when the category has children |
- Public URLs are slug-based:
/categoria/<slug>, never internal ids. - Slugs are globally unique for this slice.
- Category hierarchy uses
parentId; self-parenting and descendant-as-parent moves are rejected withCATEGORY_TREE_CYCLE. - Category rows include
seoTitleandseoDescription.
Catalog core (catalog module)
Catalog owns product identity, variants, rich product data, product images and public product discovery. Stock, prices and external sync jobs are intentionally outside this slice.
| Route | Access | Result |
|---|---|---|
| GET /productos/:slug | public | 200 active product by slug, 404 otherwise |
| GET /products/search | public | 200 + { items: [activeProduct] } via PostgreSQL FTS |
| POST /products | admin only | 201 product, 409 on duplicate slug |
| PATCH /products/:id | admin only | 200 product, 422 on unknown category |
| GET /products/:id/variants | public | 200 + { items: [variant] } |
| POST /products/:id/variants | admin only | 201 variant, 409 on duplicate SKU/EAN |
| PATCH /products/:id/variants/:variantId | admin only | 200 variant, 404 if missing |
| PATCH /products/:id/rich-data | admin only | 200 rich data with nutrition provenance |
| GET /products/:id/images | public | 200 + { items: [image] } |
| POST /products/:id/images | admin only | 201 image, 404 on missing product |
| DELETE /products/:id/images/:imageId | admin only | 204, 404 if missing |
| PATCH /products/:id/images/reorder | admin only | 200 + reordered { items: [image] } |
- Public URLs are slug-based:
/productos/<slug>, never internal ids. - Product states are
draft,activeandarchived; public reads/search return onlyactiveproducts. - Product rows include
seoTitleandseoDescription. - Category assignment is stored in
catalog_product_categoriesand validates category ids againstcategories_categorieswithout importing categories internals. - Product search is behind a
ProductSearchRepositoryport so future engines can replace PostgreSQL without changing the HTTP API.GET /products/search?q=<term>uses PostgreSQL full-text search over product, brand and category text with stable pagination/relevance ordering. - Product reads can be filtered by brand with
GET /products/search?brandSlug=<slug>and by category withGET /products/search?categorySlug=<slug>. - Product brand assignment is stored as
catalog_products.brand_idand validates brand ids againstbrands_brandswithout importing brands internals. - Variants live in
catalog_product_variants; non-null SKU and optional EAN are globally unique. - Images live in
catalog_product_images; product pages expose ordered image metadata withurl,altText,positionandrole(mainorgallery). Images can be product-level or variant-level. Storage is behind a catalog infrastructure adapter; this slice stores URLs only, with no binary upload, CDN or processing pipeline. - Rich data lives in
catalog_product_rich_data; nutrition payloads requirenutritionSource(manual,manufacturer,openfoodfacts). - Manual nutrition is trusted: external-source updates cannot overwrite existing manual nutrition.
- Search logs structured
catalog_searchtelemetry with duration, result count and sanitized bounded query metadata for later popular-search/cache work.
Brands (brands module)
Brands provide public SEO-friendly brand identity and product filtering support.
The module owns brands_brands; product assignment remains catalog-owned.
| Route | Access | Result |
|---|---|---|
| GET /brands | public | 200 brand list for sitemap/catalog |
| GET /marca/:slug | public | 200 brand by slug, 404 if missing |
| POST /brands | admin only | 201 brand, 409 on duplicate slug |
| PATCH /brands/:id | admin only | 200 brand, 404 if missing |
- Public URLs are slug-based:
/marca/<slug>, never internal ids. - Slugs are globally unique for this slice.
- Brand rows include
seoTitleandseoDescription.
Storefront shell (Next.js)
The customer-facing shell lives in storefront/ as a separate frontend package. It uses
Next.js App Router, React, TypeScript and Tailwind CSS.
- Home renders as a server component with global layout, navigation and footer.
- Product detail pages live at
/productos/<slug>; category pages at/categoria/<slug>; brand pages at/marca/<slug>; search results at/products/search. - Product, category and brand pages use ISR (
revalidate = 300) and generate metadata with canonical URLs and OpenGraph fields. Search results userevalidate = 120. - Product pages embed Product JSON-LD; product/category/brand pages embed BreadcrumbList JSON-LD; the root layout embeds Organization JSON-LD.
sitemap.xmlandrobots.txtare generated by Next.js metadata routes. Sitemap includes static public URLs, active products, categories and brands, and degrades to static URLs if the API is unavailable.- Permanent SEO redirects are configured with
REDIRECTS_JSON, an array of local same-origin{ "from": "/old", "to": "/new" }path entries handled bysrc/proxy.tswith HTTP 301. - On-demand catalog revalidation is available at
POST /api/revalidatewithREVALIDATE_SECRETandx-revalidate-secret; accepted paths are catalog public paths only. - The frontend consumes backend public endpoints only through
src/lib/api.tstyped DTOs. src/lib/api.tsimportsserver-only, so API calls are not accidentally bundled into client components.- Backend internals under
src/are never imported by the storefront. - Configure API origin with
API_BASE_URLfor server-side rendering orNEXT_PUBLIC_API_BASE_URLwhen needed; the local default ishttp://localhost:3000. - Cart and checkout UI are intentionally outside this slice.
Database (local dev)
cp .env.example .env # once
npm run docker:up # start PostgreSQL 16 + Redis 7
npm run db:up # apply migrations
npm run db:status # list applied migrations
npm run db:down # revert last migration
npm run test:integration # integration tests (need TEST_DATABASE_URL from .env)
npm run docker:down # stop services (add -v to wipe volumes)
Table naming convention
<module>_<table> e.g. catalog_products, inventory_stock, orders_orders
- Every table is prefixed with its owning module.
- A module never queries tables without its own prefix; data flows through module interfaces.
- Migrations are immutable once merged: fixes ship as new migrations.
- No schema change without migration.
Layout
src/
├── app/ # composition root (only place that wires modules)
├── infrastructure/ # http server, db pool, config, logging
├── modules/ # business modules, one folder each
│ ├── health/ # exemplar module: public API only via index.ts
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
│ ├── identity/ # register/login/logout, argon2, sessions, rate limit
│ ├── users/ # profile + address CRUD, owner-or-admin RBAC
│ ├── categories/ # category tree, slugs, SEO metadata
│ ├── catalog/ # products, states, slugs, category/brand assignment
│ └── brands/ # brands, slugs, SEO metadata
└── shared/ # cross-cutting helpers (error envelope, input parsing)
Module rules
- A module exposes its public API only through its
index.ts. - Files inside a module may import: own subtree,
src/shared, Node builtins, npm packages. - Code outside modules (app/infrastructure) may import a module only via its
index.ts. npm run lint:boundariesenforces these rules.