Skip to content

Design Patterns & Architectural Choices ​

Backend ​

Thin controllers, thick services ​

Controllers only validate the request (Zod), call a service, and shape the response. All business logic lives in apps/api/src/services (106 files). This keeps logic testable in isolation and out of the HTTP layer.

ts
// controller — thin
try {
  const result = await service.doThing(params)
  res.json({ success: true, data: result })
} catch (error) {
  next(error) // never swallow
}

Centralised error handling with typed errors ​

Services throw specific error classes (NotFoundError, ForbiddenError, ConflictError, ValidationError) carrying a statusCode + code. A single errorHandler middleware formats the response and reports 5xx to Sentry. Sentry's own Express error handler runs first (to capture into the trace), then forwards to the app handler.

Validation at the boundary ​

Every req.body / req.query is validated with a Zod schema in validators/. Several schemas use .strict() so unknown keys are rejected (e.g. a client sending a derived Product.name gets a 422 — the server derives it from the taxonomy leaf).

Queue-based side effects (BullMQ) ​

External calls that can fail or be slow — SMS, search indexing, scheduled sweeps — are enqueued, not called inline. Workers run in-process alongside the API but consume independently, with retries and backoff. When Redis isn't configured, queues degrade to per-process in-memory behaviour.

Middleware composition for authorization ​

Routes compose small, single-purpose middleware:

text
authenticate → requireActiveUser (writes) → authorize('admin') (admin) → requireSellQuota / requireBuyQuota / engagementQuota

Ownership is always re-checked in the service layer — never trusted from a client ID.

Idempotency & monotonic state ​

Terminal transitions are guarded and idempotent: redoing the same terminal transition is a no-op; a different transition on a terminal record is a 409. Messaging unlock is monotonic — once an offer/bid is ACCEPTED, messaging stays unlocked forever.

Data model ​

Batch is the source of truth ​

For inventory, the physical Batch (lot) owns quantity, acquired/production cost, sale price, quality metrics, GI status, and photos. Product.* equivalents (quantityKg, giStatus, qualityMetrics, photos, displayPricePerKg) are derived display snapshots recomputed from the batches. Buyer-facing reads resolve the pinned batch, not the product snapshot.

Owner-only vs public fields ​

A strict, audited split:

  • Owner-only (never on a counterparty serializer): buy cost, production cost, cost basis, blended cost, realised P&L, inventory valuation, supplier details, source-deal backlink.
  • Public (visible to any buyer): sale price, GI status/region, quality metrics, photos, and the statutory GST bracket (gstRate as a fraction, hsnCode).

Soft-delete + retention purge ​

User-facing product/batch deletion is a soft-delete (deletedAt / deletedBy) — rows, the InventoryEvent ledger, and closed-deal links are preserved for audit/tax. Every user-facing read filters deletedAt: null. Hard deletion happens only via the admin retention purge. The Product aggregate uniqueness is a partial unique index (WHERE deletedAt IS NULL) so a soft-deleted product releases its key for re-creation.

Derived, not stored (reports) ​

The Reports / P&L module derives everything from existing sources (inventory events + orders) rather than a new ledger, consistent with "acceptance IS the deal". Realised P&L reads cost basis live (so a production-cost edit re-derives historical margin) while sale price is the fixed snapshot from the event/Order.

Frontend ​

Server state vs UI state ​

All server state goes through React Query (@tanstack/react-query); local UI state uses useState/useReducer. API calls go through a single typed client — never raw fetch.

Forms are schema-first ​

Every form uses react-hook-form + zodResolver, mirrored by the server's Zod schema. Numbers map empty → undefined (not valueAsNumber), optional format fields treat blank as undefined, and errors render inline (never toast-only, never browser-native).

NativeWind-safe styling ​

The web app is styled with a constrained Tailwind subset that maps cleanly to React Native via NativeWind in Phase 2 (flex layouts, theme-token colors, rounded-*, shadow). CSS grid, hover:, backdrop-blur, transition-*, position: fixed, and arbitrary z-index are avoided.

No popups for substantive flows ​

Multi-field forms, wizards, and OTP are full pages with a <PageHeader> back-button (URL-addressable, browser-back-friendly, portable to a RN Stack screen). window.alert / confirm / prompt are banned; confirmations are inline cards or <BottomSheet>, feedback is toasts.

Accessibility as a rule ​

Native interactive elements by default; when a non-semantic element must be interactive it uses shared helpers (activateProps, handleArrowKeyNav) so Enter/Space/arrow keys and focus management work. Overlays close on Escape and manage focus.

Observability ​

@sentry/node v10 bundles OpenTelemetry and auto-instruments Express/HTTP/Prisma. Instrumentation is imported first in index.ts; errorHandler captures exceptions; authenticate sets Sentry.setUser({ id }) (DPDP-safe — id only). The /api/v1/health probe deep-checks Postgres + Redis. DSNs are configured per-environment and left empty in dev/CI so Sentry stays quiet.

Internal technical documentation — Cropto