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

TermConcrete question it answers
CohesionDo the things inside one candidate group actually work with the same data?
CouplingHow 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

def calculate_order_total(order): return sum(order['items']) def apply_order_discount(order, discount_pct): total = calculate_order_total(order) return total * (1 - discount_pct) def validate_order_items(order): return len(order['items']) > 0 def get_user_profile(user_id): return {'id': user_id, 'name': 'Alice'} def update_user_address(user, address): user['address'] = address; return user def validate_user_email(user): return '@' in user.get('email', '') # two functions that reference names from BOTH candidate groups def send_order_confirmation_email(order, user): total = calculate_order_total(order) profile = get_user_profile(user['id']) return f"Emailing {profile['name']}: your order total is {total}" def update_user_loyalty_points(user, order): total = calculate_order_total(order) user['points'] = user.get('points', 0) + int(total // 10) return user

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.

Verified directly — the call graph correctly separates 6 of 8 functions, and flags one clean boundary
Scanning all 8 functions' source for calls to one another produced a real graph: 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.

Verified directly — the call-graph-only method misclassifies this function
Classifying strictly by function calls, 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?

Verified directly — combining both metrics finds a second genuine boundary function
Direct-data scanning confirms 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.
The actual lesson here
Coupling analysis based only on "who calls whom" is a real, useful start — it correctly handled 6 of 8 functions with zero ambiguity — but it can miss coupling that happens through shared, directly-mutated data rather than through an explicit call. A genuine bounded-context exercise needs to check both: what does this function call, and what data does it touch, directly or indirectly.

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 findingWhat it connects to
2 verified boundary functions, needing logic from both domainsChapter 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 spotChapter 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 pictureChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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 solution
Exercise 3

Explain, 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 solution

Chapter 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