Microservices Architecture: From Monolith to Production
Build a real food delivery platform — piece by piece — by decomposing a monolith into independent services, wiring them together with HTTP and RabbitMQ, containerizing everything with Docker, and hardening it with production patterns. Every lesson produces working code. By the end, you'll have a portfolio project and the vocabulary to ace FAANG system design interviews.
Build it yourself, get guided when you are stuck, and leave with proof you can actually show.
What you learn by building this
- Decompose a monolithic app into independently deployable services with clearly defined boundaries
- Implement synchronous HTTP communication between services and handle failures with circuit breakers
- Design event-driven systems using RabbitMQ — publish events and build multiple async consumers
- Apply the database-per-service pattern with separate PostgreSQL instances
- Containerize a multi-service platform using Docker and Docker Compose
- Add production-grade observability: health checks, graceful shutdown, structured logging, and distributed tracing via correlation IDs
- Articulate microservices trade-offs fluently in a FAANG system design interview
Challenge
Think first, then write
Before We Build Anything
You're going to build a food delivery app called OrderEats.
But before a single line of code — answer this:
A company runs their entire app as one Node.js server: user accounts, restaurant menus, orders, payments, notifications. All in one process, one database. It works fine at 100 users/day.
At 1 million users/day, the payments team needs to deploy a hotfix every 2 hours. Each deploy takes down the entire app for 30 seconds.
What's the real problem here, and how would you fix it?
Don't look anything up. Write your answer in your own words — even if it's rough. You'll compare it to reality in a moment.
The Monolith: Not Evil, Just Limited
Here's what you were describing: a monolith — one deployable unit that does everything.
That's actually fine early on. One codebase, one deploy, one database. Simple to reason about. Your current full-stack projects? Monoliths. Nothing wrong with that at small scale.
The problem hits when:
- Any deploy = full downtime. One team's hotfix takes down everyone.
- One slow feature = whole app slows down. The PDF export job crushes your API response times.
- You can't scale parts independently. Orders spike on Friday night. You can't scale just the order handler — you have to clone the whole app.
- Teams step on each other. 10 engineers, one repo, constant merge conflicts.
Microservices split the app into independently deployable services. Each service owns one domain, one database, one deployment pipeline. Payments goes down? Orders keep working.
But here's the thing FAANG interviewers know and junior devs don't: microservices introduce complexity. Network calls fail. Distributed state is hard. You trade one set of problems for another.
We're going to feel both — starting by building the monolith first.
Tasks
Build the OrderEats Monolith
You're building a food delivery backend. Right now: one process, everything together. We'll rip it apart later — but you need to feel the monolith first.
Step 1: Project Setup
In your terminal:
mkdir ordereats && cd ordereats
npm init -y
npm install express pg
npm install -D typescript ts-node-dev @types/express @types/node @types/pg
npx tsc --init
mkdir ordereats && cd ordereats
npm init -y
npm install express pg
npm install -D typescript ts-node-dev @types/express @types/node @types/pg
npx tsc --init
Now create this folder structure:
ordereats/
src/
routes/
users.ts
restaurants.ts
orders.ts
db.ts
index.ts
tsconfig.json
package.json
ordereats/
src/
routes/
users.ts
restaurants.ts
orders.ts
db.ts
index.ts
tsconfig.json
package.json
Update your tsconfig.json — find "outDir" and set it to "./dist", and set "rootDir" to "./src". Also make sure "strict": true is uncommented.
In package.json, add this to "scripts":
"dev": "ts-node-dev --respawn src/index.ts"
"dev": "ts-node-dev --respawn src/index.ts"
Step 2: The Database Layer
In src/db.ts, create a PostgreSQL connection pool. You'll need Pool from the pg package. Export two things:
- A
queryfunction that takes SQL text and optional params - An
initDBfunction that creates three tables:users,restaurants, andorders
Here's the schema to implement:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS restaurants (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
cuisine VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
restaurant_id INTEGER REFERENCES restaurants(id),
items JSONB NOT NULL,
total_price DECIMAL(10,2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS restaurants (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
cuisine VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
restaurant_id INTEGER REFERENCES restaurants(id),
items JSONB NOT NULL,
total_price DECIMAL(10,2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
Your db.ts should use DATABASE_URL from process.env for the connection string.
If stuck:
new Pool({ connectionString: process.env.DATABASE_URL })— then export aquerywrapper and yourinitDBasync function that runs all threeCREATE TABLEstatements.
Step 3: The Routes
You're going to write three route files. Each is a standard Express Router. Here's what each needs:
src/routes/users.ts
POST /users— create a user (name, email required)GET /users/:id— get a user by ID
src/routes/restaurants.ts
POST /restaurants— create a restaurant (name, cuisine)GET /restaurants— list all restaurants
src/routes/orders.ts
POST /orders— create an order (userId, restaurantId, items, totalPrice)GET /orders/:id— get an order by ID
Each route should return 400 for missing required fields, 404 when a resource isn't found, and 500 on DB errors.
If stuck on POST /orders: The
itemsfield is JSONB — pass it asJSON.stringify(items)in your SQL params.
Step 4: Wire It Together
In src/index.ts:
- Create an Express app
- Add
express.json()middleware - Add a
GET /healthroute that returns{ status: 'ok' } - Mount your three routers:
/users→ users router/restaurants→ restaurants router/orders→ orders router
- Call
initDB()before starting the server - Listen on
process.env.PORT || 3000
Step 5: Run It
You'll need PostgreSQL running. Start it however you normally do, then:
DATABASE_URL=postgres://postgres:secret@localhost:5432/ordereats_db npm run dev
DATABASE_URL=postgres://postgres:secret@localhost:5432/ordereats_db npm run dev
(Create the ordereats_db database first if it doesn't exist: createdb ordereats_db)
Checkpoint: You should see:
DB initialized
Server running on port 3000
DB initialized
Server running on port 3000
Test it:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Hieu", "email": "hieu@test.com"}'
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Hieu", "email": "hieu@test.com"}'
You should get back a user object with an id. ✓
Now create a restaurant:
curl -X POST http://localhost:3000/restaurants \
-H "Content-Type: application/json" \
-d '{"name": "Pho Saigon", "cuisine": "Vietnamese"}'
curl -X POST http://localhost:3000/restaurants \
-H "Content-Type: application/json" \
-d '{"name": "Pho Saigon", "cuisine": "Vietnamese"}'
And place an order:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"userId": 1, "restaurantId": 1, "items": [{"name": "Pho", "qty": 2}], "totalPrice": 25.00}'
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"userId": 1, "restaurantId": 1, "items": [{"name": "Pho", "qty": 2}], "totalPrice": 25.00}'
All three should return proper JSON with IDs. If one fails, read the error — it'll tell you exactly what's missing.
You now have a working monolith. All three domains — users, restaurants, orders — live in one process, share one database, and deploy together.
Next lesson: we figure out exactly where this breaks.
How this build unfolds
Breaking the Monolith
Service Communication & Resilience
Event-Driven Architecture with RabbitMQ
Docker, Observability & Production Readiness
Learn by building your own version.
Remix this public project to open the workspace, follow the guided build, and let the AI mentor teach you through the work instead of doing it for you.