Ahmed Abdelaziz

Custom Ecommerce Backend API for a Clothing BrandDesign Docs

Architecture Decisions

Nine key choices — sessions, uploads, concurrency, IDs, snapshots, edge proxy, module layering, payment gateway and PDF reports.

Last updated September 2026

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.