08 / WORK / CASE STUDY
Multi-tenant Retail POS System
Checkout, shift cash reconciliation, B2B customers and e-invoicing for chain stores on one multi-tenant platform — one physical database per tenant. In production.
- TYPE
- FULL STACK
- SCOPE
- Internal · in production · full stack
- STACK
- DJANGO · DRF · MYSQL · REDIS · REACT · TYPESCRIPT
- LINKS
- Internal project (private)
01 / PROBLEM
A familiar scene in beauty-industry chain retail: stores and registers run independently, so prices, stock and member data drift apart; shift handovers reconcile cash on paper; corporate monthly billing tangles with consumer orders; e-invoicing without system support is one more manual chore. This platform folds checkout, shift cash reconciliation, corporate customers and e-invoice issuance into one multi-tenant system — headquarters manages every store from one console, and stores share one consistent interface.
02 / CONSTRAINTS
- Company-internal; the codebase is private. The frontend is deployed and in production.
- One physical MySQL database per tenant: middleware must resolve and bind the tenant at the start of every request — any miss is a leak or data corruption.
- The checkout hot path must survive flaky networks: offline sales persist locally and retry with an idempotency key on reconnect — never charging twice.
- Redis and MySQL are shared with ~30 other platform services: Celery tasks must ride a service-specific queue or other services’ workers will steal them.
03 / ARCHITECTURE
A Django REST Framework service — one member of a larger multi-service platform, deliberately not subdivided further: checkout, cart, shifts, orders and refunds are tightly coupled flows sharing one database transaction boundary, far simpler than distributed transactions. Scale: 8 apps, 24 models, 288 API routes (measured via Django’s URL resolver, framework routes excluded), ~27k lines of Python; the frontend is a standalone React 19 + TypeScript SPA — 24 pages, 49 components, 94 API call sites, ~45k lines.
Multi-tenancy is thread-local state plus a custom DB router — one database per tenant, bound as each request enters. Redis caching applies only to read-only reference prices (120s TTL); cart totals are always recomputed live to protect transactional consistency. Celery rides a dedicated queue; ECPay handles e-invoice issuance and printing; the service calls the platform’s auth, membership, inventory and B2B services; CI runs three GitHub Actions workflows, and the backend deploys on gunicorn (gthread).
04 / RESPONSIBILITIES
Independent full-stack development (2026/04–07; all 93 frontend and 20 backend commits are mine — the surrounding monorepo has other contributors, but no one else touched the two POS paths): data models and multi-tenancy, APIs, checkout and shift flows, the React frontend, performance work and deployment config.
05 / CHALLENGES → SOLUTIONS
CHALLENGE
Rapid-fire taps on the cart made the register throw network errors: at 20 concurrent requests, p95 hit 427ms. Four causes stacked — one request per tap, a full cart re-query per response, a synchronous external pricing call with a 3-second timeout in the path, and only 6 concurrent slots on the backend.
SOLUTION
Four matching fixes: the backend stopped re-querying the whole cart and gained a batch endpoint (one transaction, one recalculation per batch); read-only reference prices got Redis caching while totals stayed uncached; the frontend debounced taps — the first 180ms window was shorter than real tap intervals (200–400ms) and merged nothing, so it became “idle 500ms or max 2000ms”; concurrency went 6 → 24 slots. Result: p95 at 20 concurrent fell 427ms → 206ms with zero failures, and ten items submit as one ~74ms batch.
CHALLENGE
After a successful login, nearly every API returned 500. The tenant’s Snowflake ID exceeded JavaScript’s safe-integer range: JSON.parse rounded it to a float64 approximation, off by 4 — every request carried the wrong tenant ID, and queries landed in a default database with no business tables.
SOLUTION
The backend now serializes the field as a string and the frontend type changed to match — with a standing rule: any integer identifier that reaches the frontend and could exceed 2^53 travels as a string, without waiting for it to break.
CHALLENGE
The frontend originally shipped as one bundle: 666KB (gzipped) of JavaScript before first paint — a long boot on store-grade networks.
SOLUTION
Moved to route-level lazy loading with only login, terms and the register screen eager; the scanner component left the checkout hot path; manual chunking pins only the stable vendors (React, MUI, React Query, Router) — hand-chunking the chart and scanner libraries had backfired, cross-references dragging them back into the entry, so they now follow their lazy boundaries. First-load JavaScript fell 666KB → 310KB (−53%).
06 / SYSTEM FACTS
288
backend API routes
~45k
lines of TypeScript
606
test cases (BE + FE)
113
commits, single author
07 / LESSONS
Concurrency problems are usually stacked, not singular: debounce alone leaves the 3-second synchronous call to sink you; more worker slots alone leave the database drowning in tiny transactions. Request merging, cache boundaries and resource quotas have to move together — a single-point fix only feels like a fix.
Cross-language numeric precision is a system boundary that is easy to forget: the Snowflake ID was flawless in the database and in Python, and broke only on entry into JavaScript — a failure no backend test can catch. Identifiers that might exceed 2^53 should travel as strings from day one.
NEXT
Multi-tenant B2B Wholesale Commerce Platform