AWS Builder Center
Building an E-Commerce Platform on AWS: A Production-Grade Microservices Blueprint

Building an E-Commerce Platform on AWS: A Production-Grade Microservices Blueprint

You can find a hundred diagrams online showing boxes labeled "API Gateway," "Auth Service," "Order Service," with arrows pointing at a database icon. Almost none of them tell you what happens when the payment gateway times out three seconds after you've already deducted stock. That gap — between the tidy diagram and the messy reality of distributed systems — is where most e-commerce platforms actually fail in production.

Building an E-Commerce Platform on AWS: A Production-Grade Microservices Blueprint

You can find a hundred diagrams online showing boxes labeled "API Gateway," "Auth Service," "Order Service," with arrows pointing at a database icon. Almost none of them tell you what happens when the payment gateway times out three seconds after you've already deducted stock. That gap — between the tidy diagram and the messy reality of distributed systems — is where most e-commerce platforms actually fail in production.
This article walks through a microservices architecture for e-commerce, mapped end-to-end onto AWS, with the parts that usually get glossed over: how services stay consistent without two-phase commit, how events survive a broker restart, how a checkout that fails halfway through gets cleaned up automatically, and what it costs to run all of this at a startup's scale versus an enterprise's.



1. Why microservices, and why it's harder than it looks

The pitch for microservices in e-commerce is straightforward: your catalog team shouldn't have to wait for the payments team's deploy window, a spike in checkout traffic shouldn't take down product search, and you want to scale inventory writes independently from notification sends. Domain-Driven Design gives each service a bounded context — Auth owns identity, Order owns the purchase lifecycle, Payment owns money movement, Inventory owns stock counts — and each one gets its own database, so no service can reach into another's tables and create a hidden coupling that breaks everything six months later.
The part that's harder than it looks is everything that used to be "free" with a monolith and a single database transaction. When one  INSERT  statement created the order and decremented stock atomically, you never had to think about what happens if the second half fails. Once those two things live in different services with different databases, you've taken on distributed-transaction problems on purpose, in exchange for independent scaling and deployment. The rest of this article is mostly about paying that cost responsibly.



2. The architecture at a glance

Here's the full system, redrawn as an AWS deployment. It keeps the same domain boundaries as the reference diagram — client layer, edge, gateway, service mesh, dedicated data stores, event backbone, and external integrations — but every managed component now maps to a specific AWS service rather than a generic icon.

A few deliberate choices baked into this diagram, that are worth calling out before we go service by service:
  • Aurora PostgreSQL for anything transactional (Auth, Users, Orders, Payments) because these need strong consistency and relational integrity — you don't want an order referencing a user that doesn't exist.
  • DynamoDB for catalog and inventory, because product reads are extremely high-volume, mostly key-based, and benefit from single-digit-millisecond latency and near-infinite horizontal scale far more than they benefit from joins.
  • EventBridge + SQS instead of raw Kafka, because for most e-commerce workloads you don't need Kafka's throughput or replay semantics — you need reliable, ordered-enough, at-least-once delivery with minimal operational overhead. (I'll flag below where MSK/Kafka does start to make sense.)



3. Edge and API Gateway layer

Route 53 handles DNS and can run health checks against the ALB for automated failover to a secondary region if you're running active-passive DR. CloudFront sits in front of everything, caching static assets and product images at edge locations, which matters more than people expect for a storefront — a product listing page with 40 images loads noticeably faster once those are edge-cached rather than round-tripping to  us-east-1  on every request.
AWS WAF attaches to CloudFront or the ALB and handles the boring-but-critical stuff: rate-based rules to stop credential-stuffing against the login endpoint, managed rule groups for SQL injection and known bad IPs, and a bot-control rule group, which matters a lot for e-commerce specifically because scalpers and inventory-scraping bots are a real operational problem during flash sales.
For the gateway itself, you have two honest options, and the choice depends on team size more than technical merit:
Amazon API Gateway if you want less to operate — it does routing, throttling, and Cognito-based JWT authorization out of the box, and you pay per request instead of running idle compute. It gets awkward once you need custom middleware logic (response transformation, complex correlation-ID propagation, SSE for live order status) that doesn't fit its request/response model cleanly.
A self-hosted gateway (Express, or a lightweight framework in Go) running on ECS Fargate behind the ALB, if you need that custom behavior — this is what the reference architecture uses for its SSE hub that pushes live order status updates to the storefront. You lose some of API Gateway's built-in throttling niceties but gain full control over routing, and you can centralize JWT verification, rate limiting (via a Redis-backed token bucket), and request validation in one place before anything touches a downstream service.
Either way, the gateway is the one place that should own cross-cutting concerns — correlation IDs, auth verification, and centralized logging — so that no individual service has to reimplement them.



4. The services

Here's the domain breakdown, with the storage choice and AWS compute target for each:
Service
Responsibility
Datastore
Compute
Auth
Login, JWT issue/refresh, OAuth2/SSO, password mgmt
Aurora PostgreSQL
ECS Fargate
User/Profile
Registration, profile, address, KYC docs
Aurora PostgreSQL + S3 (docs)
ECS Fargate
Catalog & Inventory
Product catalog, stock counts, pricing, categories
DynamoDB (+ ElastiCache read-through)
ECS Fargate
Order
Order creation, lifecycle, history, status
Aurora PostgreSQL
ECS Fargate
Payment
Charge processing, refunds, transaction ledger
Aurora PostgreSQL
ECS Fargate
Shipping
Rate calc, carrier integration, tracking
DynamoDB
ECS Fargate
Notification
Email/SMS/push dispatch, templates
DynamoDB
ECS Fargate / Lambda
Review
Ratings, moderation
DynamoDB
ECS Fargate / Lambda
Reporting & Analytics
Sales/inventory reports, business metrics
Redshift or Athena over S3
Batch (Fargate) / Glue
Two things about this table that aren't arbitrary. First, Notification and Review are good Lambda candidates — they're event-driven, bursty, and stateless, so paying for idle ECS tasks between traffic spikes is wasted money. Order and Payment stay on ECS Fargate because they hold long-lived connections (that SSE stream to the storefront) and benefit from predictable warm-start latency, which matters at checkout in a way it doesn't for sending a confirmation email a few hundred milliseconds late.
Second, Reporting is deliberately not querying the live transactional databases. It reads from a data lake built by streaming domain events into S3 (via Kinesis Firehose subscribed to EventBridge) and querying with Athena, or loading into Redshift for heavier BI workloads. This keeps analytics queries from ever competing with production traffic for database connections — a mistake that's taken down more than one Black Friday.



5. The part that actually matters: staying consistent without a shared database

This is the crux of the whole system, so it's worth spending real time here instead of hand-waving "services talk to a message broker."

The problem

A checkout touches at least three services: Order (create the order), Catalog (reserve stock), and Payment (charge the card). With one database, this is one transaction. With three services and three databases, you need a way to make sure that either all three succeed, or the ones that did succeed get cleanly undone — without locking anything for the two-phase-commit protocol's duration, which doesn't scale and doesn't play well with different database engines.

The pattern: Saga via choreography, stock-first

Instead of a central orchestrator telling each service what to do, each service reacts to events published by the others. The checkout flow reserves stock before charging the card — this ordering choice matters, because it means a declined card never has to trigger a refund, only a stock release, which is the cheaper and safer failure mode.

And the failure branch — a declined card triggers three independent compensations, running in parallel, each owned by the service that made the original change:

No central coordinator, no distributed lock. Each service only knows about events, not about other services' internals. The trade-off is that debugging requires tracing a chain of events across services rather than reading one call stack — which is exactly what the correlation ID and distributed tracing setup in Section 8 is for.

The transactional outbox pattern

There's a subtle failure mode hiding in "save order to the database, then publish an event": what happens if the database write succeeds but the process crashes before the publish call goes out? You get an order that exists but that nothing downstream ever hears about. This is the dual-write problem, and it's the single most common source of "phantom" bugs in event-driven systems.
The fix is to write the event into the same database transaction as the business data, into an  outbox_events  table, so the write is atomic — either both happen or neither does:

On AWS, that "outbox worker" is usually implemented one of two ways: a small polling Lambda on a schedule, or — cleaner if you're on Aurora — DynamoDB Streams / Aurora's native CDC via Debezium on MSK Connect, which tails the transaction log and publishes automatically without your application code needing a polling loop at all. The Debezium route is more infrastructure to run, but it removes polling latency and an entire class of "the worker died" failure modes, so it's worth it once you're past the earliest stage of the product.

Idempotent consumers

At-least-once delivery means every consumer will eventually see a duplicate event — a network retry, a visibility timeout expiring before a slow consumer finishes, a Lambda cold-starting into a re-delivery. Every event carries a UUID  eventId , and every consumer checks a  processed_events  table for that ID before doing anything:

This one table is what makes "at-least-once delivery" behave like "exactly-once processing" from the application's point of view, without needing a message broker that actually guarantees exactly-once semantics (which, in practice, none of them cleanly do end-to-end).

The standard event envelope

Every event, from every service, has the same shape, which keeps every consumer's parsing logic identical:
interface EventEnvelope<T = unknown> {
eventId: string; // UUID v4, used for the idempotency check above
eventType: string; // "order.created", "payment.success", etc.
version: number; // schema version, for safe evolution
occurredAt: string; // ISO 8601 timestamp
correlationId: string; // propagated from the originating HTTP request
source: string; // "order-service"
data: T;
}

The  version  field matters more than it looks like it should — it's what lets you change a payload shape without a synchronized, flag-day deploy across nine services. Old consumers can keep reading  v1  events while you roll out  v2  producers gradually.



6. EventBridge, SQS, and when to actually reach for Kafka

EventBridge is the event bus: services publish domain events to it, and rules route matching events to the right destinations. Each consumer gets its own SQS queue subscribed via an EventBridge rule — this is the fan-out pattern, and it's the reason a slow Notification service can't back up the Order service's processing. Each queue also gets a dead-letter queue, so an event that fails processing repeatedly (a bug, a malformed payload, a downstream outage) lands somewhere visible instead of being silently retried forever or silently dropped.
This combination handles the vast majority of e-commerce event volume comfortably and with very little to operate — no brokers to patch, no partition rebalancing to think about. Where it starts to strain: if you need guaranteed ordering across event types for a single entity at high volume, or you need consumers to replay months of history for a new use case, or your event volume is genuinely in the tens of thousands of messages per second sustained. At that point, Amazon MSK (managed Kafka) earns its operational cost. Most e-commerce platforms don't hit that point until they're doing meaningful revenue — starting with EventBridge/SQS and migrating specific high-volume topics to MSK later is a perfectly reasonable path, and a much cheaper one early on.



7. Caching, sessions, and the parts nobody diagrams

ElastiCache for Redis does double duty: session storage for the gateway's JWT/rate-limiting logic, and a read-through cache in front of the catalog service for product detail pages, which are read orders of magnitude more often than they're written. A product page hit should almost never touch DynamoDB directly in steady state — cache it with a short TTL and invalidate on price/stock updates via the same event bus everything else uses.
S3 holds anything that's a blob rather than a row: product images (served through CloudFront), KYC documents, generated invoices and receipts. Lifecycle policies move older receipts to Glacier automatically, since nobody's fetching a two-year-old invoice at low latency.



8. Observability: you cannot debug a saga by staring at logs

Distributed tracing isn't optional once you have nine services participating in one checkout flow. AWS X-Ray (or OpenTelemetry exported to X-Ray/Grafana Tempo, if you want vendor portability) stitches a single trace across the HTTP request into the gateway, the async hops through EventBridge and SQS, and every service's processing — as long as the correlation ID from Section 3 is propagated consistently into every event envelope and every log line.
Practically, the stack looks like:
  • CloudWatch Logs for structured JSON logs from every service, with the correlation ID as an indexed field so you can pull every log line for one checkout across nine services in one query.
  • CloudWatch Metrics + Alarms for the basics — p99 latency per service, 5xx rate, queue depth, DLQ message count (a non-zero DLQ count should page someone).
  • X-Ray for the trace itself, so when an order gets stuck in  STOCK_RESERVED  for ten minutes, you can see exactly which consumer never processed the event, rather than guessing.
  • Managed Grafana + Managed Prometheus, if you want dashboards that aren't locked into CloudWatch's UI, which is genuinely worse for building the kind of dense operational dashboard a NOC actually wants.
The DLQ alarm deserves a callout on its own: a message sitting in a dead-letter queue represents a customer whose order, refund, or notification is silently stuck. Treat DLQ depth as a first-class SLO metric, not an afterthought.



9. Security, concretely

  • Identity: Amazon Cognito for customer-facing auth (or the self-hosted Auth service issuing JWTs, if you need custom claims Cognito doesn't support cleanly), with short-lived access tokens and rotating refresh tokens.
  • Secrets: AWS Secrets Manager for DB credentials and third-party API keys, with automatic rotation for RDS credentials — no secrets in environment variables or task definitions in plaintext.
  • Encryption: KMS-managed keys for RDS/Aurora, DynamoDB, and S3 encryption at rest; TLS everywhere in transit, including service-to-service traffic if you're on EKS with a service mesh (App Mesh or Istio) enforcing mTLS.
  • Network isolation: services in private subnets, only the ALB in the public subnet, security groups scoped tightly per service (Payment's security group should not accept traffic from anything except the gateway and its own queue consumers).
  • IAM: per-service task roles with least-privilege policies — the Notification service's task role has no business having  dynamodb:*  on the Orders table, and a well-scoped policy is what turns "a bug in one service" into a contained incident instead of a platform-wide one.
  • PCI scope reduction: never let card data touch anything other than the Payment service and the payment gateway's hosted fields/tokenization — this is what keeps your PCI DSS compliance scope small instead of encompassing your entire platform.
  • GuardDuty + Security Hub for continuous threat detection and a consolidated compliance posture view across accounts, if you're running multi-account (which you should be, by the time this is a real business).



10. CI/CD and infrastructure as code

Each service gets its own pipeline — this is where the "independently deployable" promise of microservices actually gets tested. A typical pipeline: GitHub Actions (or CodePipeline) runs tests and builds a container image on every merge to main, pushes to ECR, and deploys via ECS with a rolling or blue/green strategy through CodeDeploy. Blue/green matters more for Order and Payment than for, say, Reporting — you want the ability to shift 10% of traffic to a new Payment service version, watch error rates for five minutes, and auto-rollback on a spike, rather than a hard cutover.
Infrastructure itself lives in Terraform or AWS CDK, with one module per service (VPC, ECS service, task definition, security group, IAM role, database) and shared modules for the VPC, EventBridge bus, and observability stack. Environments (dev/staging/prod) are separate AWS accounts under AWS Organizations, not separate namespaces in one account — this is the difference between "a staging bug can't accidentally touch production data" being structurally true versus being a hope.



11. Resilience: designing for things that will go wrong

A few patterns that pull real weight in production:
  • Circuit breakers on every outbound call to another service or third party (payment gateway, shipping carrier) — if Payment's downstream card processor starts timing out, the circuit opens and fails fast instead of piling up threads waiting on a dead dependency.
  • Retries with exponential backoff and jitter for transient failures, capped, with a DLQ as the final resting place for anything that keeps failing.
  • Multi-AZ everything — Aurora with a standby replica in a second AZ, ECS tasks spread across at least two AZs, ElastiCache with automatic failover enabled. This is the baseline, not the advanced tier.
  • Health checks that mean something — an ECS health check that just returns 200 from an empty route tells you the process is alive, not that it can reach its database. A real readiness check verifies the DB connection and, ideally, that the outbox worker is running.
  • Graceful degradation — if the Review service is down, product pages should still render without reviews, not 500. If Recommendations is down, show the catalog without personalization. Nothing peripheral should be able to take checkout down.



12. What this costs, roughly

For a platform doing moderate traffic (say, low tens of thousands of orders a month), a reasonable starting footprint: ECS Fargate services sized conservatively (2 tasks minimum per service for HA) land somewhere in the low thousands of dollars a month combined; Aurora with a Multi-AZ standby for the transactional databases is another meaningful chunk; DynamoDB on-demand pricing for catalog keeps costs proportional to actual traffic rather than provisioned capacity you're not using; EventBridge and SQS are cheap at this volume, usually under a hundred dollars a month combined; CloudFront and S3 scale with traffic and storage and are rarely the bottleneck line item.
The honest advice: start smaller than this architecture on day one. Not every service needs its own Fargate cluster from the first customer — several of the lower-traffic services (Review, Reporting, Notification) can share a cluster, or run as Lambda, and you split them out into dedicated infrastructure as their individual load actually justifies it. The database-per-service boundary is worth keeping from day one, even if two of those databases start out on the same underlying Aurora cluster in different schemas. Splitting a shared schema later is a Tuesday afternoon. Untangling a shared database that's grown implicit cross-service foreign keys over eighteen months is a quarter of someone's life.



13. Closing thought

The diagram is the easy 20%. The real architecture lives in the outbox table, the idempotency check, the DLQ alarm that pages someone at 2am, and the discipline to let a service fail without taking eight others down with it. If you're building this for real, get the saga and the outbox pattern right before you worry about which container orchestrator to use — that choice is reversible; a checkout flow that silently double-charges customers under load is the kind of bug that ends up in a postmortem with your name on it.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article