Finding Service Boundaries
Software Architecture Fundamentals
Chapter 5 · Finding Service Boundaries
Chapter 4 asked whether a system should split at all. This chapter asks the harder, more useful question: where, specifically? The standard answer is "high cohesion within a boundary, low coupling across it" — but that's a definition, not a method. This chapter builds an actual, runnable technique for finding real boundaries in real code, and is honest about where that technique can fail.
Coupling and Cohesion, Defined Concretely
| Term | Concrete question it answers |
|---|---|
| Cohesion | Do the things inside one candidate group actually work with the same data? |
| Coupling | How many calls or direct data touches cross from one candidate group into another? |
High cohesion, low coupling means: group things that share data together, and minimize how often one group has to reach into another.
An 8-Function Domain, Two Candidate Groups
Two candidate groups: {calculate_order_total, apply_order_discount, validate_order_items} ("Order") and {get_user_profile, update_user_address, validate_user_email} ("User").
Method 1: A Real Call Graph, via Static Source Inspection
Rather than eyeballing it, build an actual dependency graph: scan each function's own source (via Python's inspect.getsource()) for calls to any other function in the domain.
apply_order_discount calls calculate_order_total (within Order); send_order_confirmation_email calls both calculate_order_total (Order) and get_user_profile (User). Classifying each function by which group(s) its own calls touch put all 3 pure-Order and all 3 pure-User functions correctly in their own groups, and flagged send_order_confirmation_email as a genuine boundary function — the only one, according to this method.
The Honest Gap: What the Call Graph Alone Misses
update_user_loyalty_points calls calculate_order_total (an Order function) — but it also does user['points'] = user.get('points', 0) + ..., directly mutating User data, without ever calling a User-group function to do it.
update_user_loyalty_points only calls into the Order group — so the calls-only method labels it "pure Order". But it directly reads and writes user[...], genuinely touching User data. The calls-only method's own coupling metric is blind to this, because it only tracks function calls, not direct data access — a real, verified limitation, not a hypothetical one.
Method 2: Adding Direct Data Access to the Analysis
A second, complementary scan: does a function's own source directly reference order[...]/order.get(...) or user[...]/user.get(...), regardless of what it calls?
update_user_loyalty_points touches user[...] directly. Combined with its own call into the Order group (calculate_order_total), the corrected classification is BOUNDARY, not "pure Order." The combined method correctly finds 2 boundary functions total — send_order_confirmation_email and update_user_loyalty_points — where the calls-only method found only 1, silently missing the second one.
What to Do With a Genuine Boundary Function
Both send_order_confirmation_email and update_user_loyalty_points exist specifically because "an order was placed" needs to trigger something in the User domain. Forcing either function to live entirely inside one service means that service has to directly reach into the other's data — exactly Chapter 4's shared-database anti-pattern, verified breaking an "independent" service. Chapter 6 covers the standard fix: instead of Order code directly touching User data, OrderService publishes an event ("an order was placed"), and UserService — which actually owns the loyalty-points and email logic — reacts to it independently.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 2 verified boundary functions, needing logic from both domains | Chapter 6's Event-Driven Architecture — the standard way to let two domains react to each other without directly touching each other's data |
| The call-graph-only method's honest, verified blind spot | Chapter 4's shared-database anti-pattern — the same kind of hidden coupling this chapter's own combined method was built specifically to catch |
| Two independent, complementary measurement methods, combined for a more complete picture | Chapter 9's ADRs — a real service-boundary decision should record which method(s) were used, since (as verified here) a single metric alone can miss real coupling |
Hands-On Exercises
Add a ninth function, get_order_history_for_user(user, orders), that loops over orders calling validate_order_items on each, and also reads user['id'] directly. Run this chapter's own combined (calls + data) classification method on it and verify it's correctly flagged as a boundary function.
This chapter's own call-graph-only method has a known blind spot for direct data access. Construct a second, different example function that the call-graph-only method would also misclassify, and verify your example reproduces the same kind of gap.
📄 View solutionExplain, using this chapter's own two verified boundary functions, why neither one is a sign that the Order/User split is a bad idea — and what it WOULD mean if half of this domain's 8 functions had come back classified as boundary functions instead of just 2.
📄 View solutionChapter 5 Quick Reference
- Cohesion: do the things in one candidate group share the same data? Coupling: how many calls/data touches cross group lines?
- Verified — call graph alone: correctly grouped 6 of 8 functions and found 1 genuine boundary function (
send_order_confirmation_email) - Verified — the honest gap: the calls-only method missed a second real boundary function (
update_user_loyalty_points) because it mutated another domain's data directly, without a function call to catch it - Verified — combined method: correctly found both boundary functions once direct data access was measured alongside calls
- Next chapter: Event-Driven Architecture — the standard fix for what a genuine boundary function should actually become