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.
// 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:
authenticate → requireActiveUser (writes) → authorize('admin') (admin) → requireSellQuota / requireBuyQuota / engagementQuotaOwnership 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 (
gstRateas 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.
