Case study
Fake E-commerce — Redis cart, Postgres orders
A production-minded Next.js storefront where I started with Redis JSON blobs for order speed, then migrated to Postgres + Prisma on Neon for relational integrity—without changing the public API.
← Back to portfolio



Home
1. Problem
v1 stored orders as Redis JSON blobs keyed by Clerk userId (orders:user:{userId}). That was fast to ship and fine for a demo checkout—but it broke down when I needed relational integrity, per-line-item queries, and an admin view over all orders.
Cart data stayed the right fit for Redis: session-scoped, high-churn key/value with no joins. Orders needed rows, foreign keys, and migrations. The migration had to swap the persistence adapter behind order-store.ts while keeping REST and GraphQL contracts identical.
2. Architecture
Two stores on purpose—each matches its access pattern:
- Cart — /api/cart/[sessionId] on Redis (in-memory fallback without REDIS_URL).
- Orders — /api/orders and GraphQL createOrder via Prisma; scoped by Clerk userId.
- Deploy — Neon pooled DATABASE_URL; prisma migrate deploy runs before next build on Vercel.
3. The migration
Four commits, same day—schema first, adapter swap, production bug fix, then docs:
- — Bootstrap Prisma 6 with Order + OrderItem schema, Docker Compose Postgres for local dev, initial migration.
- — Replace Redis order-store with Prisma adapter; map models to existing Order type; Redis stays for cart only. 45/45 tests green.
- — Fix duplicate order creation on /checkout/success (race condition deep-dive below).
- — README documents Redis cart vs Postgres orders and the migration narrative.
Before the adapter swap I locked Redis behavior with commit 9668a49 so the test suite would catch regressions during the Prisma cutover.
4. Race condition deep-dive
After Postgres went live, checkout success sometimes created two orders for one cart. The bug lived in a useEffect on /checkout/success—not in Prisma. That’s the kind of issue that separates “it works on my machine” from production-aware React.
- The effect depended on [addOrder, clearCart, total, cart, setOrderPaid, sendEvent]—unstable references on every render.
- hasSubmitted.current = true ran in .finally() after the async addOrder call—too late if the effect re-fired first.
- total was derived from async fetchProducts(), so dependency churn retriggered the effect while the first submission was still in flight.
- Set hasSubmitted.current = true synchronously before addOrder.
- Snapshot cart items at submit time (cart.map(...)) to avoid stale closures.
- Replace async product fetch with computeOrderTotal() from static catalog data.
- Depend only on [cart.length]—fire once when the cart hydrates; reset guard on API failure.
5. Outcomes
- 45/45 tests passing after the Prisma adapter swap (Vitest unit + integration).
- Live smoke test — sign in, add to cart, complete checkout; order appears in /my-purchases backed by Neon rows.
- Production URL with Clerk auth, SSG/ISR catalog, and Playwright checkout E2E in CI:
- Accessibility — Lighthouse 100 on home, products, PDP, and cart after landmark and labeling fixes.