All projects

E-Commerce Backend API

A store backend where two buyers cannot take the last unit and a payment is never settled twice.

  • Node.js
  • Express 5
  • PostgreSQL
  • Prisma
  • MongoDB
  • Mongoose
  • Redis
  • Razorpay
  • Vitest
  • Supertest

Architecture

An Express 5 API backed by PostgreSQL through Prisma, MongoDB through Mongoose, and Redis. The product catalog uses an EAV model for variants and Atlas Search for product search. List endpoints support cursor and offset pagination.

Checkout, in order.

Key decisions

1. Redis lock plus an atomic guarded stock update

Decision. Checkout takes a Redis distributed lock, then decrements stock with a single MongoDB update that only matches when stock >= quantity. If a later item fails, earlier decrements are rolled back.

Why. The guarded update makes overselling impossible at the database level, even if the lock fails or expires. The lock keeps concurrent checkouts from colliding and rolling each other back.

Tradeoff. One extra Redis round trip on every checkout, a lock expiry to tune, and rollback code that has to be correct.

2. Single-use refresh tokens, stored hashed, with an access-token blacklist

Decision. Each refresh token works once and is rotated on use. Only its SHA-256 hash is stored. Revoked access tokens are blacklisted in Redis.

Why. A stolen database does not give working tokens, a replayed refresh token is detectable, and logout takes effect before the access token expires.

Tradeoff. Authenticated requests depend on a Redis lookup, so the API is no longer purely stateless.

3. Webhooks verified on the raw body, deduplicated, settled in a transaction

Decision. The Razorpay webhook route reads the raw request body, verifies the HMAC-SHA256 signature, records the event ID in Redis to drop repeats, and applies settlement inside a transaction.

Why. The signature only matches the exact bytes Razorpay sent, Razorpay retries deliveries, and a half-applied settlement is worse than none.

Tradeoff. The webhook route needs its own body parsing ahead of the JSON parser, and deduplication depends on Redis.

What broke and how I fixed it

I found these by reading my own code as if I were reviewing someone else's, after the tests were already passing.

BugImpact
Duplicate PENDING ordersThe same checkout could create more than one pending order
Non-atomic order cancelCancelling could restore stock twice
Webhook secret defaulted to emptyWith the variable unset, signature checks ran against an empty secret
Non-atomic payment.failed pathA failure part way through left the order and payment out of step
Blocking redis.keys callKEYS scans the whole keyspace and blocks Redis while it runs

Measured results

  • 103 integration tests with Vitest and Supertest.
  • Caching benchmark, a negative result. With Redis on Upstash and MongoDB on Atlas, both remote, responses took about 57 ms with the cache and about 57 ms without it. Both paths are bound by network round trips, so replacing one remote call with another gains nothing. I expect caching to pay off with Redis co-located with the API, or with heavier queries.

Known limitations and next steps

  • The cache currently adds complexity without a measured benefit in this deployment.
  • Next: rerun the benchmark with co-located Redis.