api
Express 5 behind API Gateway. The same app runs in docker compose locally.
// CASE STUDY — STUDY PROJECT
> E-commerce API on AWS Lambda
An e-commerce API on AWS Lambda with infrastructure as code, CI/CD on GitHub Actions, full observability and Machine Learning product recommendations. The goal: every decision has a written reason, and every guarantee has a test that proves it.
// OVERVIEW
A study project built to practise how a serverless backend is really made: transactions, idempotency, async events, least-privilege IAM and a real deploy pipeline.
What the API covers
The whole stack runs locally with docker compose, and the same application runs on Lambda without changing a line.
// ARCHITECTURE
A private VPC across 2 availability zones. RDS and ElastiCache have no public access, and every connection uses TLS.
client ─HTTPS→ API Gateway ─→ λ api (Express 5)
├─→ RDS Proxy ─→ PostgreSQL
├─→ ElastiCache (Valkey)
├┄→ S3, private presigned POST
└┄→ SageMaker serverless InvokeEndpoint
EventBridge ─→ λ dispatcher ─→ SQS ─→ λ worker
└┄→ DLQ
λ ops no HTTP route: migrations and admin tasksExpress 5 behind API Gateway. The same app runs in docker compose locally.
Scheduled by EventBridge. Publishes pending outbox events to SQS.
Consumes SQS with partial batch failure, so one bad message goes to the DLQ without blocking the batch.
No HTTP route at all: migrations, training data export and admin tasks.
Three environments (dev, staging, prod), each with its own state.
Each Lambda execution environment opens its own connections and can exhaust the database in a traffic spike. The proxy keeps the pool and multiplexes them.
// PROBLEMS SOLVED
The failures a real store hits under concurrency, retries and outages, and how the API handles each one.
Stock is debited with UPDATE … WHERE stock >= quantity inside the order transaction, taking locks in a fixed order to avoid deadlocks. A database constraint is the last line of defence.
test: 4 concurrent buyers fight over 1 unit, and exactly one gets 201.
The Idempotency-Key header is mandatory. The same key with the same body returns the same order with no new stock debit. The same key with a different body returns 409.
test: 3 concurrent retries with the same key become a single order.
Transactional outbox: the event is written in the order's transaction, and a dispatcher publishes it with FOR UPDATE SKIP LOCKED, so two dispatchers never publish the same event. The worker deduplicates by event id.
test: With SQS down, the event waits in the outbox and is published when the queue comes back.
Cache-aside on Redis treats every error as a miss, with short timeouts. If Redis goes down, the catalogue keeps answering straight from the database.
Presigned POST instead of PUT, because only the POST policy can cap the file size. The server generates the object key inside the user's own prefix, so no client can write into another user's space.
RDS Proxy holds the connection pool so a burst of cold Lambda environments doesn't exhaust PostgreSQL.
// MACHINE LEARNING
GET /api/v1/recommendations suggests products from item-item collaborative filtering, served by SageMaker Serverless Inference.
PostgreSQL ─→ anonymised export ─→ S3 ─→ Python training ─→ model.tar.gz
│
λ api ─InvokeEndpoint, 800 ms→ SageMaker Serverless ←─────────────┘
└─ timeout · no history · model down ─→ best sellersItem-item collaborative filtering with cosine similarity, trained from scratch on the purchase history. Fine-tuning was ruled out on purpose: it needs a pre-trained model, a GPU and data volume a small store doesn't have.
SageMaker Serverless Inference with the official scikit-learn container. No instance running 24 hours a day.
With an 800 ms timeout, no customer history or the model down, the API answers with best sellers. The X-Recommendation-Source header says where the list came from.
The model only returns ids. Price and stock always come from the database, so an out-of-stock product is never recommended.
The training export turns the customer id into an HMAC with a fresh random salt each time. The same data produces a byte-identical artifact, and the model version is the hash of its content.
One test exports from PostgreSQL, trains, starts the inference server and checks that the API switches from “best sellers” to “model”.
// INFRASTRUCTURE AS CODE
10 modules, 3 environments, and a test suite that runs without an AWS account.
RDS generates and keeps its own password (manage_master_user_password). The JWT key is an ephemeral value written through a write-only argument, so it never appears in the Terraform state.
One role per function, each reaching only what that function uses. api can only invoke the recommendation endpoint; ops can only write under training/.
A check block refuses a production plan without Multi-AZ.
Locking happens natively in S3, with no DynamoDB table.
Validates all three environments across 8 scenarios, with no AWS account and no cost.
Each module has a README produced by terraform-docs and checked in CI.
// CI/CD
GitHub Actions authenticates with OIDC, and production needs a human to approve.
pull request ─┬─→ tests · lint · build · compose + smoke · terraform
└─→ terraform plan × 3 environments (read-only role)
push main ───→ deploy dev ─→ deploy prod (manual approval)The production role can only be assumed by a job in the prod environment, which requires manual approval.
The plan role is read-only.
Serialised per environment, followed by migrations and a smoke test.
docker compose comes up and the smoke test goes from sign-up to order, through the trained recommendation.
// OBSERVABILITY
Structured logs, end-to-end correlation, tracing and alarms on what actually breaks.
Every line carries the correlation ID and trace ID. Password, token and email are redacted automatically.
It crosses the HTTP request, the outbox, the SQS message and the worker, so one saved CloudWatch Logs Insights query shows an order's whole path.
Locally, an OpenTelemetry collector runs as a sidecar. On Lambda, where there are no side-by-side containers, the equivalent is X-Ray through a Lambda Extension.
A CloudWatch dashboard and alarms for 5xx, p95 latency, errors per function, any message in the DLQ and messages ageing in the queue.
// QUALITY
Row locks, constraints, transactions and SKIP LOCKED don't exist in a mock, so the tests use a real database and cache on purpose.
| Layer | Coverage |
|---|---|
| Backend (Jest + Supertest) | 69 tests against real PostgreSQL and Redis |
| Machine Learning (unittest) | 9 tests: training, inference contract and HTTP server |
| Infrastructure | terraform fmt, validate and test (8 scenarios) |
| Artifact | The Lambda zip is unpacked the way AWS does it and invoked with a real API Gateway event |
| Integration | Smoke test against the full Docker Compose stack |
// HONEST STATUS
The pipeline is ready. A deploy to a real AWS account has not run yet.
About US$ 0.12/hour for the dev environment. It's an estimate, not a bill. Fixed costs dominate: NAT, RDS Proxy, RDS and ElastiCache, not the Lambdas. That's why the project runs 100% locally and goes up to AWS only to test, torn down the same day.