Overview
Nine decisions that shaped the platform. Each has context, choice, why, and trade-off — grounded in the repository's own decision records, docs/adr/*, and the task ledger. The portfolio overview keeps the narrative high-level; the full record lives here.
ADR-001: Client-Side Signed Uploads via ImageKit
Status: Accepted (repository ADR-0001)
Context: Product and review images are large; proxying them through the API wastes server resources.
Decision: The server issues short-lived signed auth parameters (token, signature, expire); browsers upload directly to ImageKit. Persisted URLs are re-validated for host, folder allowlist, and file extension.
Why: The API never touches image bytes, yet stays the security boundary — folder allowlists and URL provenance checks mean clients can't plant arbitrary URLs in the database.
Trade-off: Two-step flow (sign, then upload) and a validation layer that must be maintained — but no bandwidth cost on the API.
ADR-002: Opaque Server-Side Sessions, Not JWTs
Status: Accepted
Context: Commerce needs instant revocation: password reset must kill every session everywhere.
Decision: Random 32-byte tokens, stored as peppered SHA-256(token + SESSION_SECRET) hashes in a sessions table, delivered as httpOnly SameSite=Lax cookies (pepper rotation kills all sessions by design), validated on every request with 14-day idle timeout, last_activity_at touch, and device metadata (device_name, ip_address, user_agent, country/city).
Why: Revocation is a row update, not a token blacklist. No refresh-token complexity, no client-side storage of credentials.
Alternatives: JWT access/refresh pairs (revocation requires extra infrastructure; XSS exposure via readable tokens).
Trade-off: A database lookup per request — acceptable, and it enables per-device session management UI.
ADR-003: Concurrency by Locks and Guarded Writes
Status: Accepted
Context: Two customers buying the last shirt simultaneously is the canonical commerce bug.
Decision: Checkout serializes per user via pg_advisory_xact_lock inside a single Prisma transaction; stock reservation uses conditional UPDATE … WHERE available >= qty (and coupon usage_count uses guarded increments); order transitions lock rows with SELECT … FOR UPDATE and assert affected-row counts. All four cart mutations (add/update/remove/clear) reuse the same withUserCartLock so cart-vs-checkout races serialize too.
Why: Correctness is enforced by the database, not by application hope — and each race has a named regression test.
Alternatives: Optimistic version columns (more code paths), application-level mutexes (don't survive multiple instances).
Trade-off: Per-user serialization limits checkout throughput per account — irrelevant at real-world click rates.
ADR-004: Public IDs Hide Internal Ones
Status: Accepted
Context: Sequential integer IDs leak business volume ("we have 40,000 orders") and enable enumeration.
Decision: Every entity gets a prefixed nanoid public ID (usr_, prd_, var_, ord_, pimg_, vimg_, cat_, adr_, ses_) used in all URLs and responses; internal autoincrement integers never leave the database. public_id patterns are validated by zod on every route.
Trade-off: Slightly larger indexes and an extra lookup dimension — cheap privacy.
ADR-005: Orders Snapshot Their World
Status: Accepted
Context: Prices change; invoices can't.
Decision: order_items copies unit price, discount, names, SKUs, and full variant attributes (including dimensions/weight) at purchase time; shipments copies the selected user_addresses row. Both are immutable — catalog/address evolution cannot rewrite them.
Why: Order history, returns, and accounting read data as it was, independent of catalog evolution.
Alternative rejected: Live joins to current product data (history rewrites itself on every price edit).
ADR-006: The Storefront Proxies the API at the Edge
Status: Accepted (client-side decision)
Context: Browser → separate API origin means CORS configuration and third-party cookie risks.
Decision: Next.js rewrites /api/v1/* (next.config.ts, API_ORIGIN env) to the backend origin (Render/Neon), so the browser talks to one domain; session cookies stay first-party (SameSite=Lax, TRUST_PROXY=1 on the API).
Why: CORS disappears entirely, cookies work identically locally and in production, and the backend can move hosts without client changes.
Trade-off: Requests hop through the frontend host — negligible latency, one more moving part in deployment.
ADR-007: Layered Module-Per-Feature Structure
Status: Accepted implementation
Context: Catalog, account, cart, inventory, order, and review rules need to evolve without turning route handlers into a single application-wide dependency cluster.
Decision: Organize each domain into routes → controller → service → repository → Prisma where needed. Dependency direction is strictly Router → Controller → Service → Repository → Database across 15 domains (auth, users, addresses, products, categories, cart, orders, reviews, inventory, coupons, uploads, admins, analytics, audit, stats). Plain function composition — no classes, no DI framework. Repositories accept either the normal Prisma client or the transaction client so services can reuse the same queries inside prisma.$transaction.
Why: HTTP concerns remain thin, business rules are testable in isolation, database queries have a clear ownership boundary, and transactions live in the service layer where advisory locks and FSM side effects are coordinated.
Alternatives: A flat route-and-query structure (quick to start, hard to maintain); a microservice split per domain (independent deployability at the cost of distributed transactions).
Trade-off: More files and some repeated module scaffolding — acceptable because the monolith keeps cross-table transactions and pg_advisory_xact_lock coordination simple without distributed-system overhead.
ADR-008: Payments Behind a Gateway Interface
Status: Accepted implementation
Context: Checkout needs to record payment results now, while a real payment provider (Paymob) is outside the current scope.
Decision: Define a PaymentGateway interface and ship MockPaymentGateway (synchronous, returns a mock transaction_reference) in v1. orders service depends on the interface, not on any SDK.
Why: Order orchestration — stock reservation, coupon redemption, snapshots, cart clear — can be exercised and tested with a deterministic payment step, and a future provider swap does not require rewriting checkout or the FSM.
Alternatives: Calling a provider SDK directly from the order service (coupling); postponing payment modeling entirely (checkout would lack its real shape).
Trade-off: Current payment results are not real-world settlement, but replacing the adapter is a boundary change, not a rewrite of order and inventory logic. Real integration is tracked (tasks T-081–T-087).
ADR-009: Financial Reports as Pure pdfkit Vector PDFs
Status: Accepted (reports epic T-088–T-099)
Context: Administrators need downloadable P&L, Expenses and Revenue reports by month|quarter|year|custom with configurable currency and charts — without pulling in a headless browser or a native canvas build that breaks Windows CI.
Decision: Stream application/pdf directly from pdfkit with vector tables (auto-paging, Cache-Control: no-store) + line/bar/pie charts drawn with PDFDocument paths and Intl.NumberFormat currency formatting. Use the same P&L aggregates as analytics (SUM(subtotal−discount) product revenue, SUM(quantity×cost_price) COGS via current cost_price, operating_expenses filtered by spent_at) with date_trunc('day'|'month') series and zero-filled buckets. Validate with a superRefine period schema (month needs year+month, quarter needs year+quarter, year needs year, custom needs date_from<date_to ≤366d) and require SUPER_ADMIN + validate(); add ?format=json preview for debugging and Content-Disposition: attachment|inline; filename="pnl-*.pdf" + X-Report-Currency.
Why: No Chromium (unlike puppeteer) and no native canvas (unlike chartjs-node-canvas) — keeps the build ~590kb via esbuild --packages=external, works on every CI image, and still provides tables + charts via vector drawing. Reuses the analytics repository so P&L numbers have a single source of truth.
Alternatives: puppeteer + HTML → PDF (heaviest, best fidelity); chartjs-node-canvas + pdfkit PNG embed (needs native canvas prebuilds); pdf-lib (weaker table/Chart ergonomics).
Trade-off: Charts are vector approximations (not chart.js pixel-perfect) and large windows (>20 buckets) omit the detail table (see ?format=json) — acceptable for admin reports.
Common thread
Correctness is pushed into the database, security into the boundary, and history into snapshots — so the application layer stays boring on purpose.