Full-Stack Engineering12 min read

Good Bowls: Building a Production-Grade E-Commerce Platform

Engineering a scalable food ordering system with three-tier architecture, secure payment processing, and modern state management.

ReactReduxNode.jsMongoDBStripeJWT

The Problem Space

Building an e-commerce platform for a salad bowl restaurant requires solving several interconnected challenges: real-time cart management, secure payment processing, dynamic pricing logic for custom orders, and user authentication, all while maintaining separation of concerns.

The core question: how do you architect a system where the frontend handles optimistic UI updates, the backend enforces pricing integrity, and the database maintains order history accuracy?

Three-Tier Architecture

Presentation Layer

React + Redux for UI rendering and client-side state with localStorage persistence

Business Logic Layer

Express + Node.js with stateless JWT auth for horizontal scaling

Data Layer

MongoDB Atlas with document-oriented storage for natural order modeling

Why Not a Monolith?

Separating client and server wasn't just about following best practices. It solved real deployment constraints. Vercel offers instant CDN-backed static hosting for the React build, while Render provides containerized Node.js hosting.

Benefits

  • • Specialized platform advantages
  • • Independent scaling per tier
  • • Optimal caching strategies

Tradeoffs

  • • Network latency between tiers
  • • CORS configuration complexity
  • • Deployment orchestration

In development, I proxy API requests from port 3000 to 8080 via the client's package.json. In production, explicit CORS headers whitelist the Vercel origin.

State Management Strategy

The cart presented an interesting state management problem: it needs to be reactive (updates reflect immediately), persistent (survives refreshes), and eventually consistent with the backend.

1

Redux as Single Source of Truth

All cart operations dispatch actions that update a normalized state tree. Components subscribe to derived selectors.

2

LocalStorage as Persistence

A Redux middleware watches for mutations and syncs to localStorage. On app init, we hydrate from storage.

3

Backend as Authority on Checkout

The frontend sends the cart to the backend, which validates prices against the database before creating the order.

The Custom Bowl Builder Challenge

Custom bowls introduced complexity: users select a base, multiple toppings, proteins, cheeses, and dressings, each with individual prices. The builder needs to show a running total as users add/remove ingredients.

I solved this with a component-local state machine that tracks selections, then calculates price on-the-fly. When the user clicks "Add to Cart," it dispatches to Redux with the complete specification.

Why not Redux? The builder is ephemeral. Selections only matter while the modal is open. Lifting this to Redux would pollute the global store with transient data.

Authentication & Authorization

The authentication flow uses JWTs with a design choice: the payload includes both a user ID and an admin role flag. This eliminates database lookups on every request just to check permissions.

// Token Generation
const payload = { _id: user._id, isAdmin: user.isAdmin };
const token = jwt.sign(payload, process.env.JWTPRIVATEKEY, { expiresIn: "7d" });

Verification Middleware

Extracts token from x-auth-token header, verifies signature, attaches decoded payload to req.user

Password Security

Passwords are hashed with bcrypt using async operations (`await bcrypt.hash()`) to avoid blocking Node.js's event loop. Salt rounds are configurable via environment variables.

Tradeoff: JWTs can't be revoked before expiry. For a restaurant app, this is acceptable. The blast radius is limited to order history visibility.

Payment Integration

Payment processing follows Stripe's recommended pattern: the client collects card details using Stripe Elements (PCI-compliant inputs), the backend creates a PaymentIntent, and the client confirms payment.

// Payment Flow
1.Clientsends cart items to /api/payment/create-payment-intent
2.Backendcalculates total from database prices
3.Backendcreates PaymentIntent with Stripe API
4.Clientreceives clientSecret, confirms payment

Key Insight: Never send the dollar amount from the client. The backend always calculates totals from database prices to prevent client-side manipulation.

Database Design Patterns

Price as Array

Bowl prices stored as [Small, Medium, Large] arrays. Keeps size pricing denormalized for fast reads with no joins on menu fetch.

Order Snapshots

Orders embed full details rather than referencing bowls by ID. This creates a snapshot at order time. If menu prices change later, historical orders remain accurate.

Repository Pattern

Database access abstracted behind repositories. Controllers call createOrder(data) rather than invoking Mongoose directly. This keeps business logic testable.

Deployment & The Cold Start Problem

Free-tier Render hosting spins down the container after 15 minutes of inactivity. The first request after downtime takes up to 50 seconds while the container restarts.

Cron Ping

Keep service warm with scheduled pings every 10 minutes

Paid Tier

Upgrade to persistent containers without cold starts

Serverless

Move to functions with better cold start characteristics

For a portfolio project, the cold start is an acceptable UX tradeoff. The frontend loads instantly from Vercel's CDN, and a loading indicator manages user expectations.

Technical Depth: The Interesting Bits

Race Conditions in Cart Updates

When multiple tabs are open, localStorage writes from one tab don't automatically sync to Redux in others. I handle this with a storage event listener that watches for external changes and dispatches sync actions.

Dynamic Pricing Validation

Custom bowl price = base + toppings + proteins + cheeses + dressing + sauce. The backend recomputes and validates by fetching each ingredient from the database.

If the client-reported total differs by more than $0.01, the request is rejected. This protects against code manipulation, mid-cart price changes, and floating-point rounding errors.

Middleware Composition

router.post('/admin/bowls', auth, isAdmin, validateBowl, createBowl);

Each middleware is focused and reusable: auth verifies JWT, isAdmin checks role, validateBowl runs Joi schema validation.

Lessons Learned

What Worked Well

  • Redux Toolkit eliminated boilerplate
  • DevTools invaluable for debugging
  • Chakra + Ant Design mix worked
  • Layered architecture simplified swaps

What I'd Change

  • Add email verification with Gmail SMTP
  • Validate cart on every page load
  • Add order status tracking state machine
  • Implement webhook for payment confirmation
"Every architectural decision is a tradeoff."

Building this system taught me that good architecture isn't about perfection. It's about making intentional choices you can defend.

Try Good Bowls

Experience the live demo. Allow ~50s for initial backend wake-up.