Python Advance 📂 Important concepts · 6 of 6 45 min read

Python Microservices Tutorial — FastAPI + RabbitMQ + Docker Project

Learn to design and build a real Python microservices project from scratch. We start with an architecture diagram of an e-commerce system, then implement four independent services (User, Product, Order, API Gateway) using FastAPI, RabbitMQ for async messaging, and Docker Compose to orchestrate them — with practical code, health checks, and golden rules for production.

Section 01

The Story That Explains Microservices

The Restaurant vs The Food Court
Picture one giant restaurant where the same chef takes your order, cooks the pizza, bakes the dessert, brews coffee, handles payments and cleans the table. If the oven breaks — everything stops. Adding a biryani menu means training this one chef all over again, testing every dish, and shutting the kitchen for a week.

Now picture a food court: pizza stall, biryani stall, coffee counter, dessert kiosk. Each has its own chef, its own equipment, its own supplier. If the coffee machine breaks, biryani still sells. Want to add sushi? Open a new stall — the pizza guy doesn't even notice.

That's exactly the difference between a monolith and a microservices architecture.

A microservice is a small, independently deployable service that owns one business capability — users, products, payments — and talks to other services through well-defined APIs or messages. Each service has its own database, its own codebase, its own deploy cycle. Together they form a system that scales, fails, and evolves in pieces.

🌟
The Core Insight

Microservices are not about size — they're about independence. A service is "micro" when a small team can build it, ship it, and replace it without asking permission from any other team. Everything else — Docker, Kubernetes, message queues — is just plumbing to support that independence.


Section 02

Monolith vs Microservices — Side by Side

🔧 Monolith
TraitReality
CodebaseOne huge repo
DatabaseOne shared DB
DeployAll-or-nothing
ScalingScale the whole app
FailureOne bug = full outage
TeamEveryone in one repo
🏗️ Microservices
TraitReality
CodebaseOne repo per service
DatabaseOne DB per service
DeployShip one at a time
ScalingScale hot service only
FailureIsolated blast radius
TeamOne team per service
⚠️
Microservices Are Not Free

You trade one hard problem (a giant codebase) for a different one: network calls, distributed data, and eventual consistency. If your team is under five people and traffic is modest, a well-structured monolith almost always wins. Reach for microservices when you have independent teams and independent scaling needs — not because it's trendy.


Section 03

The Project — E-commerce Backend

We'll build a small but realistic e-commerce backend called ShopKart. It has four independent Python services that together let a customer register, browse products, and place an order. Each service is a real, runnable FastAPI app with its own SQLite database and its own Dockerfile.

👤
User Service
port 8001 · users.db
Owns registration, login, JWT token issuance and user profile. Nothing else in the system knows how a user is stored — only how to ask this service.
📦
Product Service
port 8002 · products.db
Product catalog, stock levels, price. Read-heavy — this is the one we'd scale horizontally first when Black Friday traffic hits.
🛒
Order Service
port 8003 · orders.db
Creates orders, calls Product Service to check stock, then publishes an order.created event on RabbitMQ so downstream workers (email, invoicing) can react asynchronously.
🔑
API Gateway
port 8000 · single entry point
The only door to the outside world. Validates JWTs, routes requests to the right internal service, hides the internal topology from clients.
📧
RabbitMQ Broker
port 5672 · messaging
The nervous system for async events. Services publish and consume without knowing about each other — the queue does the introductions.
📡
Notification Worker
background consumer
Not a web server — a background process that listens for order.created events and fires off (mock) emails. A pattern you'll use everywhere.

Section 04

The Architecture Diagram

Before writing a single line of code we draw the picture. This diagram is the contract — every arrow is a network call, every box is a deployable unit.

🛠️ ShopKart — Service Topology
                         📱  Client (Web / Mobile)
                                  ↓   HTTPS
                    ┌───────────────────────────────────────┐
                    │         🔑  API GATEWAY  (:8000)              │
                    │   JWT auth • routing • rate-limit • CORS      │
                    └─────┌─────────────┌─────────────┌─────────┘
                           │             │             │
                     HTTP  │       HTTP   │      HTTP   │
                           ↓             ↓             ↓
                ┌───────────┐   ┌───────────┐   ┌───────────┐
                │   USER   │   │ PRODUCT  │   │  ORDER   │
                │  :8001   │   │  :8002   │   │  :8003   │
                │ users.db │   │ prods.db │   │ ords.db  │
                └───────────┘   └───────────┘   └─────┌─────┘
                                       ↑              │
                                       └── check stock ─┘
                                                      │
                                          publish     ↓
                                    ┌───────────────────────────┐
                                    │   📧 RabbitMQ Broker   │
                                    │ queue: order.created  │
                                    └───────────┌───────────────┘
                                                │  consume
                                                ↓
                                      ┌────────────────────┐
                                      │  📡 Notification    │
                                      │     Worker             │
                                      └────────────────────┘

Solid arrows = synchronous HTTP. Dashed path = asynchronous message via RabbitMQ.

💡
Two Kinds of Communication

Notice we use HTTP when the caller must know the answer right now (Order needs to know if the product is in stock before confirming). We use messaging when the caller just needs to say "this happened, whoever cares can react" (an order was created — send an email, update analytics, generate an invoice). Mixing the two well is the real skill of microservice design.


Section 05

Project Structure

📁 Directory Layout
Root
shopkart/ — one Git repo, one docker-compose.yml
Service
Each service is a self-contained folder with its own main.py, requirements.txt and Dockerfile
DB
Each service owns its own SQLite file — no shared schema, ever
Shared
A tiny common/ module for JSON schemas — but no shared business logic
# shopkart/
shopkart/
├── docker-compose.yml
├── gateway/
│   ├── main.py
│   ├── requirements.txt
│   └── Dockerfile
├── user_service/
│   ├── main.py
│   ├── models.py
│   ├── requirements.txt
│   └── Dockerfile
├── product_service/
│   ├── main.py
│   ├── models.py
│   ├── requirements.txt
│   └── Dockerfile
├── order_service/
│   ├── main.py
│   ├── models.py
│   ├── broker.py
│   ├── requirements.txt
│   └── Dockerfile
└── notification_worker/
    ├── worker.py
    ├── requirements.txt
    └── Dockerfile

Section 06

Service 1 — User Service (FastAPI + JWT)

The User Service is our simplest — it registers users, verifies passwords and issues JWTs. Any other service that wants to know "is this token valid?" calls us.

user_service/models.py

from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
 
engine = create_engine("sqlite:///users.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False)
Base = declarative_base()
 
class User(Base):
    __tablename__ = "users"
    id            = Column(Integer, primary_key=True)
    email         = Column(String, unique=True, index=True, nullable=False)
    hashed_pw     = Column(String, nullable=False)
    full_name     = Column(String)
 
Base.metadata.create_all(engine)

user_service/main.py

from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, EmailStr
from passlib.hash import bcrypt
from jose import jwt
from datetime import datetime, timedelta
from models import User, SessionLocal
 
SECRET  = "change-me-in-prod"
ALGO    = "HS256"
app     = FastAPI(title="User Service")
 
class RegisterIn(BaseModel):
    email: EmailStr
    password: str
    full_name: str
 
class LoginIn(BaseModel):
    email: EmailStr
    password: str
 
def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()
 
# ------------------------------------------------------- register
@app.post("/register")
def register(payload: RegisterIn, db=Depends(get_db)):
    if db.query(User).filter_by(email=payload.email).first():
        raise HTTPException(400, "Email already registered")
    u = User(
        email=payload.email,
        hashed_pw=bcrypt.hash(payload.password),
        full_name=payload.full_name,
    )
    db.add(u); db.commit(); db.refresh(u)
    return {"id": u.id, "email": u.email}
 
# ------------------------------------------------------- login
@app.post("/login")
def login(payload: LoginIn, db=Depends(get_db)):
    u = db.query(User).filter_by(email=payload.email).first()
    if not u or not bcrypt.verify(payload.password, u.hashed_pw):
        raise HTTPException(401, "Invalid credentials")
    token = jwt.encode(
        {"sub": u.id, "email": u.email,
         "exp": datetime.utcnow() + timedelta(hours=2)},
        SECRET, algorithm=ALGO)
    return {"access_token": token, "token_type": "bearer"}
 
# ------------------------------------------------------- verify (for gateway)
@app.get("/verify/{token}")
def verify(token: str):
    try:
        data = jwt.decode(token, SECRET, algorithms=[ALGO])
        return {"valid": True, "user_id": data["sub"]}
    except Exception:
        raise HTTPException(401, "Invalid or expired token")
 
@app.get("/health")
def health(): return {"status": "ok", "service": "user"}
OUTPUT — POST /register
{ "id": 1, "email": "aditi@example.com" } OUTPUT — POST /login { "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "bearer" }

Section 07

Service 2 — Product Service

The Product Service is stateless-ish: it holds the catalog, exposes read endpoints, and has one authoritative reserve endpoint that the Order Service calls before confirming a sale. Notice how it never talks to the users database directly.

# product_service/main.py
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy import Column, Integer, String, Float, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
 
engine = create_engine("sqlite:///products.db", connect_args={"check_same_thread": False})
Session = sessionmaker(bind=engine)
Base = declarative_base()
 
class Product(Base):
    __tablename__ = "products"
    id    = Column(Integer, primary_key=True)
    name  = Column(String, nullable=False)
    price = Column(Float,  nullable=False)
    stock = Column(Integer, default=0)
 
Base.metadata.create_all(engine)
app = FastAPI(title="Product Service")
 
class ReserveIn(BaseModel):
    product_id: int
    quantity:   int
 
def db():
    s = Session()
    try: yield s
    finally: s.close()
 
@app.get("/products")
def list_products(s=Depends(db)):
    return [{"id": p.id, "name": p.name,
             "price": p.price, "stock": p.stock}
            for p in s.query(Product).all()]
 
@app.get("/products/{pid}")
def detail(pid: int, s=Depends(db)):
    p = s.get(Product, pid)
    if not p: raise HTTPException(404, "Product not found")
    return {"id": p.id, "name": p.name,
            "price": p.price, "stock": p.stock}
 
# ------------------------- called BY the Order Service -----------
@app.post("/reserve")
def reserve(payload: ReserveIn, s=Depends(db)):
    p = s.get(Product, payload.product_id)
    if not p:
        raise HTTPException(404, "Product not found")
    if p.stock < payload.quantity:
        raise HTTPException(409, "Insufficient stock")
    p.stock -= payload.quantity
    s.commit()
    return {"reserved": payload.quantity,
            "remaining_stock": p.stock,
            "unit_price": p.price}
 
@app.get("/health")
def health(): return {"status": "ok", "service": "product"}
🔒
Database Per Service — Non-Negotiable

The moment two services share a database, you have a distributed monolith — the worst of both worlds. Product owns products.db. User owns users.db. If Order needs a product's price, it asks Product; it never runs its own SELECT across the wire.


Section 08

Service 3 — Order Service (HTTP + RabbitMQ)

Order is where the two communication styles meet. It synchronously calls Product to reserve stock, then asynchronously publishes an event so downstream workers can react without slowing down the customer.

order_service/broker.py — reusable RabbitMQ publisher

import json, os, pika
 
RABBIT_URL = os.getenv("RABBIT_URL", "amqp://guest:guest@rabbitmq:5672/")
 
def publish(queue: str, message: dict):
    """Fire-and-forget publish. Creates queue if missing."""
    conn = pika.BlockingConnection(pika.URLParameters(RABBIT_URL))
    ch   = conn.channel()
    ch.queue_declare(queue=queue, durable=True)
    ch.basic_publish(
        exchange="",
        routing_key=queue,
        body=json.dumps(message).encode(),
        properties=pika.BasicProperties(delivery_mode=2)  # persistent
    )
    conn.close()

order_service/main.py

from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
import httpx, os
from sqlalchemy import Column, Integer, Float, String, DateTime, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
from broker import publish
 
PRODUCT_URL = os.getenv("PRODUCT_URL", "http://product:8002")
 
engine  = create_engine("sqlite:///orders.db", connect_args={"check_same_thread": False})
Session = sessionmaker(bind=engine)
Base    = declarative_base()
 
class Order(Base):
    __tablename__ = "orders"
    id         = Column(Integer, primary_key=True)
    user_id    = Column(Integer, nullable=False)
    product_id = Column(Integer, nullable=False)
    quantity   = Column(Integer, nullable=False)
    total      = Column(Float,   nullable=False)
    status     = Column(String,  default="CREATED")
    created_at = Column(DateTime, default=datetime.utcnow)
 
Base.metadata.create_all(engine)
app = FastAPI(title="Order Service")
 
class OrderIn(BaseModel):
    user_id:    int
    product_id: int
    quantity:   int
 
def db():
    s = Session()
    try: yield s
    finally: s.close()
 
@app.post("/orders")
def create_order(payload: OrderIn, s=Depends(db)):
    # 1) SYNCHRONOUS call to Product Service
    try:
        r = httpx.post(f"{PRODUCT_URL}/reserve",
                      json={"product_id": payload.product_id,
                            "quantity":   payload.quantity},
                      timeout=3.0)
        r.raise_for_status()
    except httpx.HTTPStatusError as e:
        raise HTTPException(e.response.status_code, e.response.json()["detail"])
    except httpx.RequestError:
        raise HTTPException(503, "Product service unreachable")
 
    unit_price = r.json()["unit_price"]
 
    # 2) Persist the order in OUR OWN database
    order = Order(user_id=payload.user_id,
                  product_id=payload.product_id,
                  quantity=payload.quantity,
                  total=unit_price * payload.quantity)
    s.add(order); s.commit(); s.refresh(order)
 
    # 3) ASYNC event -> whoever cares (email, invoicing, analytics)
    publish("order.created", {
        "order_id":   order.id,
        "user_id":    order.user_id,
        "product_id": order.product_id,
        "quantity":   order.quantity,
        "total":      order.total,
    })
 
    return {"order_id": order.id, "total": order.total,
            "status": order.status}
 
@app.get("/orders/{oid}")
def get_order(oid: int, s=Depends(db)):
    o = s.get(Order, oid)
    if not o: raise HTTPException(404, "Order not found")
    return {"id": o.id, "user_id": o.user_id,
            "total": o.total, "status": o.status}
 
@app.get("/health")
def health(): return {"status": "ok", "service": "order"}
OUTPUT — POST /orders
{ "order_id": 42, "total": 2599.00, "status": "CREATED" } // Meanwhile RabbitMQ delivers to the notification worker: { "order_id": 42, "user_id": 1, "product_id": 7, "quantity": 1, "total": 2599.00 }

Section 09

Service 4 — API Gateway

Clients should never call our internal services directly. The gateway is a thin reverse proxy that validates the JWT once, then forwards. Everything sensitive (which port Product runs on, whether we use Redis) stays hidden behind it.

# gateway/main.py
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx, os
 
app = FastAPI(title="API Gateway")
 
ROUTES = {
    "user":    os.getenv("USER_URL",    "http://user:8001"),
    "product": os.getenv("PRODUCT_URL", "http://product:8002"),
    "order":   os.getenv("ORDER_URL",   "http://order:8003"),
}
 
PUBLIC = {"/user/register", "/user/login", "/health"}
 
async def verify_token(token: str) -> int:
    async with httpx.AsyncClient(timeout=2.0) as c:
        r = await c.get(f"{ROUTES['user']}/verify/{token}")
    if r.status_code != 200:
        raise HTTPException(401, "Invalid token")
    return r.json()["user_id"]
 
@app.api_route("/{service}/{path:path}",
                methods=["GET","POST","PUT","DELETE"])
async def proxy(service: str, path: str, request: Request):
    if service not in ROUTES:
        raise HTTPException(404, "Unknown service")
 
    full_path = f"/{service}/{path}"
    if full_path not in PUBLIC:
        auth = request.headers.get("authorization", "")
        if not auth.startswith("Bearer "):
            raise HTTPException(401, "Missing bearer token")
        await verify_token(auth.split()[1])
 
    target = f"{ROUTES[service]}/{path}"
    body   = await request.body()
 
    async with httpx.AsyncClient(timeout=5.0) as c:
        r = await c.request(request.method, target,
                            content=body, params=request.query_params)
    return JSONResponse(status_code=r.status_code, content=r.json())
 
@app.get("/health")
def health(): return {"status": "ok", "service": "gateway"}
🔑
Why Route Through a Gateway

One place to enforce auth, rate limits, request logging, CORS, and API versioning. Move a service to a new port, split it in two, rewrite it in Go — clients never know. That's the single biggest operational win of the microservices style.


Section 10

Notification Worker — the Async Consumer

Not every service is a web server. The notification worker has no HTTP port — it just listens to RabbitMQ and reacts. This decouples "an order happened" from "who cares that an order happened".

# notification_worker/worker.py
import json, os, time, pika
 
RABBIT_URL = os.getenv("RABBIT_URL", "amqp://guest:guest@rabbitmq:5672/")
 
def handle_order(ch, method, _props, body):
    event = json.loads(body)
    print(f"[EMAIL] Order #{event['order_id']} confirmed "
          f"for user {event['user_id']} - total ${event['total']}")
    # TODO: send real email here
    ch.basic_ack(delivery_tag=method.delivery_tag)
 
def main():
    while True:
        try:
            conn = pika.BlockingConnection(pika.URLParameters(RABBIT_URL))
            ch   = conn.channel()
            ch.queue_declare(queue="order.created", durable=True)
            ch.basic_qos(prefetch_count=1)
            ch.basic_consume(queue="order.created",
                              on_message_callback=handle_order)
            print("[worker] waiting for messages...")
            ch.start_consuming()
        except pika.exceptions.AMQPConnectionError:
            print("[worker] rabbit down, retry in 3s")
            time.sleep(3)
 
if __name__ == "__main__":
    main()
Add Another Consumer, Zero Code Changes Elsewhere

Tomorrow you want to also write orders to an analytics warehouse? Spin up a new worker that consumes the same queue (or a new exchange binding). The Order Service doesn't change — it doesn't even know new consumers exist. This is the payoff of event-driven design.


Section 11

Dockerfile Template & docker-compose.yml

Every service uses the exact same slim Dockerfile — only the port and the entry file differ. This uniformity is the point.

Dockerfile — reused across services

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

docker-compose.yml — one command to run everything

version: "3.9"
 
services:
 
  rabbitmq:
    image: rabbitmq:3-management
    ports: ["5672:5672", "15672:15672"]
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 10s
 
  user:
    build: ./user_service
    command: uvicorn main:app --host 0.0.0.0 --port 8001
    ports: ["8001:8001"]
 
  product:
    build: ./product_service
    command: uvicorn main:app --host 0.0.0.0 --port 8002
    ports: ["8002:8002"]
 
  order:
    build: ./order_service
    command: uvicorn main:app --host 0.0.0.0 --port 8003
    environment:
      PRODUCT_URL: http://product:8002
      RABBIT_URL:  amqp://guest:guest@rabbitmq:5672/
    ports: ["8003:8003"]
    depends_on: [product, rabbitmq]
 
  gateway:
    build: ./gateway
    command: uvicorn main:app --host 0.0.0.0 --port 8000
    environment:
      USER_URL:    http://user:8001
      PRODUCT_URL: http://product:8002
      ORDER_URL:   http://order:8003
    ports: ["8000:8000"]
    depends_on: [user, product, order]
 
  notifier:
    build: ./notification_worker
    command: python worker.py
    environment:
      RABBIT_URL: amqp://guest:guest@rabbitmq:5672/
    depends_on: [rabbitmq]
# Bring the whole stack up
$ docker compose up --build
 
# In another terminal — smoke test end to end
$ curl -X POST http://localhost:8000/user/register \
    -H "Content-Type: application/json" \
    -d '{"email":"a@b.com","password":"secret","full_name":"Aditi"}'
 
$ TOKEN=$(curl -s -X POST http://localhost:8000/user/login \
    -H "Content-Type: application/json" \
    -d '{"email":"a@b.com","password":"secret"}' | jq -r .access_token)
 
$ curl -X POST http://localhost:8000/order/orders \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"user_id":1,"product_id":1,"quantity":2}'

Section 12

Request Flow — What Happens When a User Places an Order

01
Client → Gateway
POST /order/orders with a JWT in the Authorization header. Gateway is the only URL the client knows.
02
Gateway → User Service (verify)
Gateway asks User Service to validate the JWT. On failure it returns 401 immediately — the request never reaches Order.
03
Gateway → Order Service
Request is proxied to http://order:8003/orders. Order Service takes over.
04
Order → Product (reserve)
Synchronous HTTP. If stock is insufficient Order returns 409 to the client — nothing is persisted, no event is fired.
05
Order writes to its own DB
Only after stock is confirmed does an Order row appear in orders.db. Total is computed from Product's authoritative price.
06
Order publishes order.created
A durable message hits RabbitMQ. Order Service responds 200 to the client immediately after publishing.
07
Notification worker consumes
Milliseconds later the worker picks up the event and (in a real system) fires off a confirmation email. The customer already has their response.

Section 13

Sync vs Async — When to Use Which

SituationStyleWhy
Need the answer to reply to the userHTTP (sync)Order needs price + stock before committing
Downstream can lag by secondsRabbitMQ (async)Emails, invoices, analytics — customer doesn't wait
Fan-out to many consumersRabbitMQ (async)One event, N reactions. Add/remove consumers freely
Strong consistency requiredHTTP + retriesMoney movement — better to fail loudly than eventually
Long-running workRabbitMQ (async)PDF generation, image resize — offload from web workers
Broadcast a state changeRabbitMQ (fan-out)"user.email.changed" — every cache invalidates itself

Section 14

Health Checks, Resilience & Failure Handling

👨‍⚕️
Health Endpoints
/health · every service
A trivial 200 endpoint Docker & Kubernetes hit to decide if the container is alive. Distinguish liveness (am I running?) from readiness (can I take traffic?) in serious systems.
Timeouts
httpx timeout=3.0
Every inter-service HTTP call sets an explicit timeout. Never let a slow downstream hang your service forever — cascading timeouts are the #1 cause of outages.
🔄
Retries + Backoff
tenacity library
Transient network hiccups deserve a retry with exponential backoff. Business errors (409 Insufficient Stock) do not — retrying just wastes cycles.
🛑
Circuit Breaker
pybreaker
If Product Service fails 20 times in a row, stop calling it for 30 seconds. Give it room to recover instead of pounding it with retries.
📋
Structured Logging
correlation IDs
Attach a request_id at the gateway and pass it in headers to every downstream. Suddenly grepping one log file becomes tracing across five services.
📊
Metrics + Tracing
Prometheus · OpenTelemetry
You cannot debug what you cannot measure. Export request latency and error rate per service, and set up distributed traces so slow calls surface visually.
# Order service with retries + timeout
from tenacity import retry, stop_after_attempt, wait_exponential
import httpx
 
@retry(stop=stop_after_attempt(3),
       wait=wait_exponential(multiplier=0.3, min=0.3, max=2))
def reserve_stock(product_id: int, qty: int) -> dict:
    r = httpx.post(f"{PRODUCT_URL}/reserve",
                  json={"product_id": product_id, "quantity": qty},
                  timeout=3.0)
    if r.status_code >= 500:
        r.raise_for_status()  # let tenacity retry only on 5xx
    return r.json()

Section 15

Testing Microservices

Each service is a standalone FastAPI app — you can and should unit-test it in isolation using TestClient, mocking any external calls.

# tests/test_order.py
from fastapi.testclient import TestClient
from unittest.mock import patch
from order_service.main import app
 
client = TestClient(app)
 
def test_order_created_when_stock_available():
    fake_response = MagicMock(status_code=200)
    fake_response.json.return_value = {"unit_price": 1000,
                                      "remaining_stock": 10,
                                      "reserved": 1}
    with patch("order_service.main.httpx.post",
               return_value=fake_response) as mock_post, \
         patch("order_service.main.publish") as mock_pub:
        r = client.post("/orders",
                        json={"user_id":1,"product_id":1,"quantity":1})
 
    assert r.status_code == 200
    assert r.json()["total"] == 1000
    mock_post.assert_called_once()
    mock_pub.assert_called_once()      # event was published
 
def test_order_fails_on_insufficient_stock():
    fake_response = MagicMock(status_code=409)
    fake_response.json.return_value = {"detail": "Insufficient stock"}
    fake_response.raise_for_status.side_effect = httpx.HTTPStatusError(
        "409", request=None, response=fake_response)
 
    with patch("order_service.main.httpx.post", return_value=fake_response):
        r = client.post("/orders",
                        json={"user_id":1,"product_id":1,"quantity":999})
    assert r.status_code == 409
🧪
The Testing Pyramid for Microservices

Lots of unit tests per service (fast, mock the network). A handful of contract tests (Order asserts the shape of Product's response — caught in CI without spinning up Product). A few end-to-end tests running against docker-compose — enough to catch wiring bugs, not enough to slow your builds.


Section 16

When to Use Microservices — and When to Run Away

Multiple Independent Teams
Ten engineers stepping on each other in one repo? Split by bounded context. Each team owns a service end-to-end.
team autonomy
Wildly Different Scale Profiles
Product catalog serves millions of reads; checkout writes hundreds per second. Scale each independently.
independent scaling
Polyglot Needs
ML service best in Python, high-throughput ledger in Go, analytics in Rust. Language per service is fine here.
right tool per job
Small Team, Simple Domain
Three engineers, one product? A modular monolith deploys faster, debugs easier and doesn't need Kubernetes.
start with a monolith
No CI/CD Discipline
Ten services × zero automation = ten times the manual pain. Build the deploy pipeline before you split.
automation is the price of entry
Transactional Consistency Needed Everywhere
A hard ACID transaction across five databases is a nightmare. Keep tightly-coupled data in one service.
honor bounded contexts

Section 17

Golden Rules

🏆 Python Microservices — Non-Negotiable Rules
1
One database per service. The moment two services share tables you have a distributed monolith — every schema change becomes a cross-team migration and every performance problem is everyone's problem.
2
Never call another service's DB directly. Always go through its API. That's what lets the other team refactor their storage without breaking you.
3
Set explicit timeouts on every inter-service call. Default httpx timeouts are effectively infinite. Cascade failures start when one slow service holds the caller's threads hostage.
4
Prefer async messaging for anything that isn't user-blocking. Sync HTTP couples availability — if Product is down, Order is down. A queue lets consumers catch up when they come back.
5
All state changes flow through a single door per service. Never let two services write to the same table. Ever. Not even in a hurry.
6
Everything is a Docker image. If it doesn't run identically on your laptop and in CI and in prod, you'll spend Fridays debugging that. Compose today, Kubernetes tomorrow — the images don't change.
7
Emit events, don't command downstreams. Say "order.created", not "send an email for order 42". The event is a fact; who reacts is their problem — and new reactors can be added without touching the publisher.
8
Start with a modular monolith. Extract a service only when a real pain point demands it — not because a blog post said microservices are cool. The best microservices are carved out of a monolith that outgrew itself, never designed on a whiteboard on day one.
You have completed Important concepts. View all sections →