0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Designing a Production-Grade E-Commerce Web Application: Caching, Inventory Consistency, Idempotency, and Failure Recovery

0
Posted at

Designing a Production-Grade E-Commerce Web Application: Caching, Inventory Consistency, Idempotency, and Failure Recovery

An e-commerce website can look deceptively simple from the outside.

Show a product. Search for it. Add it to a cart. Pay. View the order.

The engineering problem changes once the application is exposed to real traffic.

Traffic can spike during a campaign. Two customers can attempt to purchase the last available item at almost exactly the same time. A payment provider can time out after accepting a request. A mobile client can retry a request after reconnecting. A cache can fail while the database is still healthy.

At that point, making the happy path work is not enough.

The real design question is:

How do we keep an e-commerce system predictable when parts of the system fail independently?

This article focuses on the engineering decisions behind that problem: domain boundaries, caching, search, idempotency, inventory consistency, payment state, partial failure, observability, and failure-oriented testing.

The examples are framework-agnostic so the ideas can be applied to Java, Node.js, .NET, Go, Python, PHP, or other backend stacks. When the same commerce backend also serves a mobile client, retry, offline, session, and API-state decisions become part of the overall system design; these concerns are closely related to mobile app design.

1. Define Responsibilities Before Splitting Services

A common mistake in a growing commerce platform is to turn every domain object into a microservice immediately.

Product service. Cart service. Order service. Payment service. Notification service.

That can create a system with many network calls while business logic and database dependencies remain tightly coupled.

A better starting point is to define ownership first.

For example:

Catalog
├── Products
├── Categories
├── Attributes
└── Pricing

Customer
├── Accounts
├── Addresses
└── Sessions

Commerce
├── Cart
├── Orders
├── Payments
├── Discounts
└── Inventory

Platform
├── Search
├── Notifications
└── Analytics

These boundaries do not have to become separate deployments.

A modular monolith can keep the same logical ownership model while running as one application. This often gives a team simpler deployment, easier local development, and straightforward transactions without giving up architectural boundaries.

A useful rule is:

Separate responsibilities before you separate processes.

For example, the checkout module should not reach directly into the private implementation of pricing just because both modules use the same ORM.

Instead, expose a contract:

interface PricingService {
    PriceQuote quote(
        ProductId productId,
        CustomerId customerId,
        CartId cartId
    );
}

The caller depends on a capability, not on the internal implementation.

That boundary also gives you a cleaner path if pricing later needs to become an independent service.

2. Treat Product Reads and Checkout Writes Differently

A product listing and a checkout request may both arrive over HTTP, but their engineering requirements are very different.

Product browsing is usually read-heavy:

Browser
  |
  v
CDN / Edge
  |
  v
Application
  |
  +----> Cache
  |
  +----> Search Index
  |
  +----> Catalog DB

Checkout is correctness-heavy:

Client
  |
  v
Checkout API
  |
  +----> Validate Cart
  |
  +----> Reserve Inventory
  |
  +----> Create Order
  |
  +----> Authorize Payment
  |
  +----> Confirm

A design optimized for the first path should not automatically be applied to the second.

Product descriptions and category navigation may tolerate a small amount of staleness.

Order totals, payment state, and inventory ownership require a different consistency model.

Once that distinction is explicit, decisions about caching, transactions, and retries become easier to justify.

3. Design Cache Policy Around Business Semantics

The cache-aside pattern is easy:

value = cache.get(key)

if value exists:
    return value

value = database.load(key)

cache.set(key, value, ttl)

return value

The difficult part is deciding what the cache is allowed to return.

A TTL should come from business semantics, not from a generic performance rule.

For example:

Product description       -> minutes
Category navigation       -> minutes
Promotional price         -> short TTL
Inventory availability    -> very short or authoritative read
Payment state             -> do not treat cache as source of truth
Order status              -> controlled freshness

Ask:

How stale can this value be before the business behavior becomes incorrect?

That question is more useful than asking only how many seconds a value should stay in Redis.

Protect the database from cache stampedes

Suppose a popular product expires from cache while thousands of requests arrive at the same time.

Without protection:

10,000 requests
       |
       v
   cache miss
       |
       v
10,000 DB reads

Single-flight, request coalescing, or stale-while-revalidate techniques can reduce the number of identical refresh operations.

Caching is therefore not only a latency optimization. Done correctly, it is also a way to protect the database and preserve availability under load.

4. Treat Search as a Derived Read Model

As the catalog grows, it is usually a mistake to force the transactional database to handle every free-text search pattern.

A common approach is to treat search as a projection:

Catalog DB
      |
      | product.created
      | product.updated
      v
Message Queue
      |
      v
Search Indexer
      |
      v
Search Engine

The database remains the source of truth.

The search index becomes derived data.

That introduces an intentional consistency window:

Database commit
      |
      v
Event publication
      |
      v
Index update

A newly created product may therefore exist in the catalog before it appears in search.

That is not necessarily a bug.

It becomes a bug when the product requirements assume strong consistency but the implementation actually provides eventual consistency.

Naming the consistency model explicitly is far more useful than assuming that every part of the platform updates instantly.

5. Make Checkout Idempotent

Retries are normal in distributed systems.

A browser can resend a request. A mobile client can retry after a connection drops. An API gateway can retry an upstream request. A payment provider can deliver the same webhook more than once.

For operations with side effects, a retry without idempotency can turn one logical action into two business actions.

A common pattern is:

POST /checkout
Idempotency-Key: 7b5bb2d1-...

Store enough information to recognize the operation:

idempotency_key
request_hash
status
response
created_at

Then:

existing = idempotencyStore.find(key)

if existing exists:
    if existing.request_hash != current_hash:
        return 409 Conflict

    return existing.response

result = performCheckout()

idempotencyStore.save(
    key,
    current_hash,
    result
)

return result

The request hash matters.

If the same idempotency key is reused with different input, returning the old response blindly can hide a client-side bug. Rejecting the mismatch makes the problem visible.

Idempotency does not mean the network becomes exactly-once.

The practical goal is:

Allow at-least-once delivery while keeping the business effect idempotent.

That is a much more useful reliability target.

6. Treat Inventory as a Concurrency Problem

Inventory bugs often survive ordinary testing because the application looks correct when only one request is running.

Consider the last unit of a product.

Two requests arrive at the same time.

A naive flow can do:

SELECT stock

followed by:

UPDATE inventory
SET stock = stock - 1

Without a concurrency strategy, both requests can believe the unit is available.

Optimistic locking

One approach is to store a version:

product_id | stock | version
-----------+-------+--------
42         | 1     | 17

Then update conditionally:

UPDATE inventory
SET stock = stock - 1,
    version = version + 1
WHERE product_id = 42
  AND stock > 0
  AND version = 17;

If the affected row count is zero, another transaction won the race.

Inventory reservation

For a multi-step checkout, reserving stock can be easier to reason about:

available
   |
   v
reserved
   |
   +----> payment success -> committed
   |
   +----> payment failure -> released
   |
   +----> timeout         -> released

Reservations need expiration and cleanup. Otherwise abandoned carts can hold stock indefinitely.

The important part is not which strategy you choose. It is making the concurrency rule explicit.

7. Model Payment as a State Machine

A single boolean such as:

paid = true

usually becomes insufficient once an external payment provider is involved.

A more explicit state model might be:

CREATED
   |
   v
PAYMENT_PENDING
   |
   +----> PAYMENT_AUTHORIZED
   |             |
   |             v
   |          CONFIRMED
   |
   +----> PAYMENT_FAILED

The difficult case is a timeout.

Suppose the application sends an authorization request and the provider does not respond within the timeout.

It is tempting to mark the payment as failed.

That is not always safe.

The provider may have accepted the request and the response may simply have been lost.

So the real state can become:

payment timeout
      |
      v
unknown state

A reconciliation process can then use a provider status endpoint or verified webhook to determine the final state.

Webhooks need idempotent processing too. Store the provider event ID and make duplicate delivery harmless.

8. Define Partial-Failure Behavior in Advance

Production failures are often partial.

The database may work while the cache is unavailable.

Search may fail while category browsing still works.

Recommendations may be down while checkout should continue.

Notifications may be delayed without invalidating a successful order.

Define the fallback strategy for each dependency:

Dependency Failure behavior
Cache Fall back to source of truth
Search Degrade to browsing or simpler queries
Recommendations Hide or skip the module
Notifications Queue and retry asynchronously
Payment Do not guess the result
Analytics Buffer or drop non-critical events

The important question is not only “What happens if Redis goes down?”

It is:

Which user-visible capabilities must remain safe when Redis goes down?

That turns graceful degradation into an engineering specification.

9. Retry Only Errors That Are Actually Retryable

A policy such as “retry everything three times” is dangerous.

Retrying a GET may be harmless.

Retrying order creation without idempotency can create duplicates.

Retrying a payment operation without understanding provider semantics can create uncertainty about the final state.

For transient errors, exponential backoff with jitter is a common pattern:

delay = min(cap, base * 2^attempt) + random_jitter

But the exact numbers should come from observed behavior.

A validation error is not transient.

An authorization error is not transient.

A business-rule violation is not transient.

Retries are a reliability mechanism, not a substitute for understanding failure classes.

10. Instrument the Business Transaction, Not Just the Server

CPU and memory metrics can tell you that infrastructure is busy.

They do not necessarily tell you that checkout is failing.

A checkout trace might look like:

checkout.request
  |
  +--> cart.validate
  |
  +--> inventory.reserve
  |
  +--> order.create
  |
  +--> payment.authorize
  |
  +--> order.confirm
  |
  +--> notification.enqueue

Useful correlation data can include:

trace_id
order_id
payment_provider
inventory_reservation_id
latency_ms
result

Do not put passwords, access tokens, card data, or other secrets into logs.

Metrics should cover both infrastructure and business behavior:

HTTP p95 / p99 latency
checkout failure rate
payment timeout rate
inventory conflict count
cache hit ratio
queue lag
search latency
order confirmation latency

A platform can have healthy CPU utilization while the payment timeout rate is climbing.

Business metrics make that kind of failure visible.

11. Test Failure Paths Deliberately

Happy-path integration tests are necessary, but they are not enough.

A resilient commerce application should also test:

Duplicate checkout

Request A -> success
Request A retry -> same business result

Concurrent inventory update

Buyer A -> reserve last unit
Buyer B -> conflict

Cache outage

Cache unavailable
      |
      v
Catalog -> database fallback

Payment timeout

Provider timeout
      |
      v
Do not mark payment as successful automatically

Duplicate webhook

Event 123 -> processed
Event 123 -> ignored

These tests verify the actual reliability contract of the system.

12. Evolve From a Modular Monolith When Necessary

Microservices are not automatically better.

A modular monolith can offer:

+ Simpler deployment
+ Easier local development
+ Lower operational overhead
+ Straightforward transactions
+ Easier debugging

Microservices add trade-offs:

- Network failures
- Distributed tracing
- API versioning
- Eventual consistency
- Deployment coordination
- More operational work

A practical evolution path is:

Modular Monolith
      |
      v
Clear module contracts
      |
      v
Identify a real scaling or ownership boundary
      |
      v
Extract one service

That is usually easier to reason about than starting with many services and trying to discover the boundaries afterward.

13. Production Checklist

Before launching a serious e-commerce application, verify:

Architecture
[ ] Domain ownership is explicit
[ ] Module dependencies are controlled
[ ] Database access is intentional

Performance
[ ] Read-heavy paths can be cached
[ ] Cache stampedes are controlled
[ ] Search has an appropriate read model

Correctness
[ ] Checkout is idempotent
[ ] Inventory updates are concurrency-safe
[ ] Payment states are explicit
[ ] Webhooks are deduplicated

Reliability
[ ] Dependency timeouts are defined
[ ] Retry rules are operation-specific
[ ] Partial-failure behavior is documented
[ ] Failure paths are tested

Security
[ ] Authorization is enforced server-side
[ ] Secrets are externalized
[ ] Webhooks are verified
[ ] Sensitive data is excluded from logs

Observability
[ ] Business transactions have trace correlation
[ ] p95/p99 latency is measured
[ ] Queue lag is visible
[ ] Payment and checkout failures generate alerts

Conclusion

The difficult part of e-commerce engineering is not rendering a product card.

It is deciding what should happen when two customers compete for the same inventory, when a payment request times out after the provider may have accepted it, when a cache expires under heavy traffic, or when the same checkout request is sent twice.

Good architecture makes those situations explicit.

Define responsibilities before splitting services.

Decide which data can be stale before adding cache rules.

Design idempotency before adding retries to side-effecting operations.

Treat inventory as a concurrency problem.

Model external payment state explicitly.

Design graceful degradation before the first outage.

Then test those decisions under failure.

That is what turns a web application that works in a demo into an application that engineers can still reason about in production.

References

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?