Project: Decompose a Monolith Into Two Real Services¶
What was run¶
The monolith.py shop app from Lesson 1 was split into two real, independently
running services — customers_service.py (port 8031) and orders_service.py (port 8032) — with the former
in-process dict lookup replaced by a real httpx call between them. Every number and error below is real, observed
output from these two running processes.
The split, and what it actually cost¶
# orders_service.py — the join that was one line in monolith.py is now a network call
@app.get("/orders/{oid}")
def get_order(oid: int):
order = orders[oid]
r = httpx.get(f"{CUSTOMERS_URL}/customers/{order['customer_id']}", timeout=2.0)
customer = r.json()
return {**order, "customer_name": customer["name"], "customer_email": customer["email"]}
curl -s -X POST localhost:8031/customers -H 'Content-Type: application/json' \
-d '{"name":"Grace Hopper","email":"grace@example.com"}'
# {"id":1,"name":"Grace Hopper","email":"grace@example.com"}
curl -s -X POST localhost:8032/orders -H 'Content-Type: application/json' \
-d '{"customer_id":1,"item":"compiler manual","quantity":1}'
# {"id":1,"customer_id":1,"item":"compiler manual","quantity":1}
curl -s localhost:8032/orders/1 -w "\ntime:%{time_total}\n"
# {"id":1,"customer_id":1,"item":"compiler manual","quantity":1,"customer_name":"Grace Hopper","customer_email":"grace@example.com"}
# time:0.011994
The monolith's equivalent call in Lesson 1 measured time_total: 0.000911. This split version measured 0.011994 —
roughly 13x slower for the exact same logical operation, entirely because a dict lookup became an HTTP round
trip. That cost was predicted in Lesson 1's discussion; here it's measured directly, on the same two pieces of
business logic, before and after the split.
Two new failure modes that didn't exist in the monolith¶
curl -s -w "\nHTTP:%{http_code}\n" -X POST localhost:8032/orders \
-H 'Content-Type: application/json' -d '{"customer_id":999,"item":"ghost item","quantity":1}'
# {"detail":"unknown customer_id"}
# HTTP:400
This one is a correct, intentional validation — Orders Service explicitly checks with Customers Service before creating an order, something the monolith got for free from a single shared dict.
pkill -f "uvicorn customers_service:app"
curl -s -w "\nHTTP:%{http_code}\n" localhost:8032/orders/1
# {"detail":"order found, but customers service is unreachable to enrich it"}
# HTTP:503
curl -s -w "\nHTTP:%{http_code}\n" -X POST localhost:8032/orders \
-H 'Content-Type: application/json' -d '{"customer_id":1,"item":"another item","quantity":1}'
# {"detail":"customers service unreachable"}
# HTTP:503
Both of these failure modes are genuinely new. In the monolith, customers could never be "unreachable" relative to
orders — they were the same process. Now, Orders Service's ability to create or even just read an order is
coupled to Customers Service's availability, exactly as Lesson 1 warned in the
abstract. Notice also that the two 503s have different, honest messages — get_order says the order was found
but couldn't be enriched, while create_order never got far enough to create anything. That distinction matters: a
client seeing the first error knows the order exists; a client seeing the second knows it doesn't.
How It Actually Works¶
This project makes concrete every tradeoff discussed across Lessons 1–9: the bounded-context split ( Customer vs
Order, Lesson 2) was sized correctly enough that each service has a clear, independent reason to exist (Lesson 3);
the communication between them is synchronous REST (Lesson 4, Lesson 5) because create_order genuinely cannot
proceed without knowing the customer exists; and the new 503 failure paths are exactly the kind of partial failure
that a production system would want wrapped in a circuit breaker (Level 3) rather than left as a raw, immediate
error on every single request while Customers Service is down.
What this project does not yet have, and what the next two levels build: no saga or compensation logic if a
multi-step operation partially fails (Level 2), no resilience pattern softening the all-or-nothing 503 into a
degraded-but-functional response (Level 3), and no story for how Orders Service finds Customers Service other than a
hardcoded URL (solved for a single hardcoded case already, but see Lesson 9 for why that
doesn't scale).
Common mistakes¶
- Treating "the split works" (requests succeed) as the finish line without also measuring the real cost (the 13x latency increase shown above) — a split that's never actually necessary for this system's real scale or team structure just paid that cost for nothing.
- Writing the cross-service validation (
unknown customer_id) but forgetting the cross-service unavailability case (customers service unreachable) — bothhttpx.ConnectErrorand a404response are real thingsorders_service.pyhad to handle thatmonolith.pynever needed to think about. - Giving the two new
503error paths the same generic message, which would recreate the exact gateway mistake from Lesson 7 — this project's two distinct, honest error messages are intentional.
Exercise¶
Add a third endpoint to orders_service.py, GET /orders/{oid}/summary, that returns just {"item": ..., "quantity": ...}
with no call to Customers Service at all. Confirm with curl -w "time:%{time_total}\n" that this endpoint's
latency is close to the original monolith's ~1ms, not the ~12ms of the enriched /orders/{oid} endpoint — proving
that the 13x cost measured above is specifically the price of that one join, not an inherent property of Orders
Service as a whole.