The problem
A stationery retailer was managing orders over WhatsApp and inventory in spreadsheets. Every order took 15+ minutes to process, stock was perpetually wrong, and GST invoicing was manual.
Requirements
- Product catalog with variants (size, color, page count)
- Real-time inventory with reserved-stock tracking during checkout
- GST-compliant invoicing with HSN codes
- Order lifecycle: placed → confirmed → shipped → delivered
- Admin dashboard for inventory, orders, and analytics
- Customer accounts with order history
Architecture
- Next.js 14 (App Router) frontend with server components for SEO-critical pages
- Express + TypeScript REST API with modular feature architecture
- MongoDB with compound indexes on { category, brand, price } and text index on name/description
- Redis for session storage and cart persistence
- Cloudinary for product image storage and CDN delivery
- Razorpay + PhonePe for payments with webhook verification
What I built
- Product catalog with hierarchical categories and dynamic specification filters
- Variant-based inventory (each variant tracked independently)
- Reserved-stock system that locks inventory during checkout for 10 minutes
- GST calculator that splits base price, CGST, and SGST per item
- Order state machine with audit log of every transition
- Admin panel with bulk product upload (CSV), inventory alerts, and revenue analytics
Technical decisions
Reserved stock vs. checkout locks
Instead of pessimistic locks (which would block concurrent buyers), we track a 'reservedStock' counter per variant. When a checkout starts, it increments reservedStock. If the payment succeeds, it converts reservation to a sale. If it times out, the reservation is released by a cron job. This avoids deadlocks and lets multiple users check out simultaneously without race conditions.
Denormalized product ratings
Rather than aggregating reviews on every product list query, we maintain avgRating and totalReviews fields on the product document. These update whenever a review is created, edited, or deleted. One write cost, zero read cost — a worthwhile trade for a read-heavy catalog.
Modular monolith over microservices
The system has clear module boundaries (products, orders, payments, users) but runs as one process. For a business with ~50 orders/day, the operational overhead of microservices would be pure cost with no benefit. If scale demands it later, the module boundaries make extraction trivial.
Deployment
- Docker Compose for local dev — API, MongoDB, Redis, and frontend in one command
- GitHub Actions CI: type check, lint, test, build Docker image, push to registry
- Deployed to a DigitalOcean VPS with Nginx reverse proxy and Certbot HTTPS
- Cloudflare for DNS, DDoS protection, and edge caching
- MongoDB Atlas M10 with automated daily backups and point-in-time recovery
Challenges
Payment webhook idempotency
Payment gateways retry webhooks, sometimes multiple times for the same event. We handle this by storing a unique payment event ID and using a MongoDB unique index to reject duplicates. If a webhook arrives twice, the second write fails silently and returns 200 to stop retries.
Inventory race conditions
Two users adding the same last-in-stock item to cart simultaneously caused overselling. Fixed by moving inventory decrements to atomic MongoDB operations with conditional filters: 'decrement stock only if stock >= quantity'. If the update fails, we show 'sold out' instantly instead of accepting an order that can't be fulfilled.
Result
Order processing dropped from 15 minutes to under 30 seconds per order. Manual inventory reconciliation was eliminated. The platform now handles 500+ orders/month with zero overselling incidents in 12 months of operation.