Software Design Reference System Architect · Understand the Problem and the Domain

Lesson 0053 · System Architect · Module 1

Cut Boundaries by Responsibility and Change

Before modules, databases, or services exist, the system has boundaries to find: who owns each decision, what changes together, and what must stay hidden. Responsibility answers it, not naming conventions.

Reading ≈ 10 minPractice ≈ 25 min

Mission tie-in: every later boundary decision — modules, data ownership, deployables — inherits this map. Finding boundaries from responsibility and change keeps them stable when the technology moves.

Knowledge: responsibility first, structure second

Lesson 0052 produced the concepts and vocabulary. Now assign each concept a responsibility: the knowledge it holds and the decisions it owns. Responsibility-driven design asks three questions of every candidate component:

For the returns system, one defensible first assignment is:

Returns system · responsibilities before boxes
ResponsibilityOwnerWhy there
Decide eligibility, with reasonsEligibilityIt holds the policy knowledge and the decision rights.
Show the customer progressReturn journeyIt knows the customer-visible steps and wording.
Establish settled moneyPayments (external)Financial truth belongs to the money system, not to us.
Produce a shipping labelCarrier (external)A solved problem we consume, not own.

Responsibilities cluster into boundaries along two tests, both about change:

The principle behind the principle

A boundary is a bet about which changes arrive together. Its value is the change it localizes; its price is the coordination it adds to every change that has to cross it. So the boundary you want is the one that minimizes the number of future changes touching both sides — which makes this an empirical question, not a taxonomic one. Nouns are the wrong input because nouns are the stable part of a domain; decisions are what change, and that is why responsibility (who knows, who does, who decides) predicts the seam better than any diagram of entities.

The boundary conditions follow directly. On a greenfield system you have no change history, so you are substituting the experts' estimate of cadence for evidence — expect to be wrong about at least one seam and pick the cheapest kind of boundary to move (a module, not a service). In a system small enough that nearly every change touches everything, boundaries cost more than they save; one cohesive module is the honest answer. And Conway's law outranks your table: if the organization will hand you four teams whatever you draw, a boundary map that ignores those four teams will be eroded by the people who need to ship without asking each other.

The cohesion side comes from lesson 0001: what belongs together is what changes together and is used together. The coupling side sets the rule: dependencies point from the variable world toward the stable one — the policy engine depends on nothing; the carrier adapter depends on the engine's vocabulary, never the reverse.

Two warnings keep this honest. First, a shared database does not establish a boundary. Multiple components may share a store while one authoritative writer owns each fact, but unrestricted writes erase that ownership. Name the writer of every table. Second, don't cut boundaries you don't need yet: a six-person team does not need eight deployables or elaborate contracts. Preserve meaningful model seams, then add structural cost only where change or ownership demands it.

# A boundary as data: the responsibility, its owner, and what changes it.
# This is the spec you will enforce in module organization (lesson 0022).
from dataclasses import dataclass


@dataclass(frozen=True)
class Responsibility:
    what: str
    owner: str
    changes_with: str


BOUNDARIES = [
    Responsibility("Eligibility decisions with reasons",
                   "eligibility", "policy rules"),
    Responsibility("Customer-visible progress",
                   "return_journey", "communication wording"),
    Responsibility("Settled money truth",
                   "payments_external", "payment rail"),
    Responsibility("Shipping labels",
                   "carrier_external", "carrier API"),
]

Worked: letting the change log draw the line

The returns work is not greenfield — returns are handled today inside the order platform's support tooling. So the first thing the architect opened was not a whiteboard but git log, restricted to the returns-handling code, for the previous twelve months: 63 commits. The question was not “what are the entities?” but “what changed, and what changed with it?”

Twelve months of returns changes · 63 commits
AreaCommitsAlso touched policy rulesReading
Policy rules38—The fast-moving part; weekly cadence.
Carrier integration112Vendor cadence; almost never co-changes.
Customer notifications93Wording changes on its own beat.
Return journey / status1717Never changes without the policy changing.
Payment call50Owned elsewhere; we only call it.
# Co-change, computed rather than argued about.
COMMITS = [                       # one entry per commit: the areas it touched
    {"policy", "journey"}, {"policy"}, {"carrier"}, {"policy", "journey"},
    {"notifications"}, {"policy", "carrier"}, {"payments"}, {"policy"},
    # ... 55 more, extracted from twelve months of history
]


def co_change(commits: list[set[str]], a: str, b: str) -> int:
    """How often a change to a forced a change to b in the same commit."""
    return sum(1 for touched in commits if a in touched and b in touched)


def seam_is_cheap(commits: list[set[str]], a: str, b: str,
                  tolerance: float = 0.1) -> bool:
    """A seam is cheap when few changes have to cross it."""
    involving_a = sum(1 for touched in commits if a in touched)
    return co_change(commits, a, b) <= tolerance * involving_a

Two of the rows decide the design. Carrier integration co-changed with policy twice across 38 policy changes and 11 carrier changes — under 5%. That seam is nearly free: put a contract between them and almost nothing has to cross it. The return journey co-changed with policy 17 times out of 17 — it never moved on its own. Splitting those two would have meant that every one of the 38 policy changes now needs a coordinated change on both sides of a boundary, which is the exact cost a boundary is supposed to remove.

The alternative the architect rejected was the one the team wanted: extract eligibility as its own service now, “so it is clean from day one”. The change log says the seam between eligibility and the journey is the least justified one in the system, and lesson 0059 prices what a process boundary costs. The rejection was recorded with its reversal condition: if a second team takes ownership of policy, or if the journey stops co-changing with it for two quarters, the seam has become real and the extraction becomes a three-week job instead of a rewrite — precisely because the module boundary was kept enforced in the meantime.

The consequence over time is the part worth internalizing. The seams you place well are invisible: nobody notices the coordination that did not happen. The seams you place badly announce themselves as a standing tax — every release note that says “deploy A before B”, every pull request that spans two repositories for a one-line rule change.

Field notes · boundary smells
You seeWhat it hidesThe move
One giant "everything" moduleOwnership is unassigned.Ask who knows, who does, who decides.
Two things change on different beatsA candidate seam.Test whether separation reduces change cost.
A shared table with no writerOwnership is fictional.Name the writer or split it.
A seam nobody needsFashion over evidence.Merge; cut it when change demands.

Teams that adopt bounded context vocabulary will recognize this as boundary-finding too — they call the seams contexts and label relationships between them on a context map. That vocabulary is a useful refinement when two teams must coordinate; it is not required to find good boundaries. The responsibility questions above work with or without it.

What people get wrong after reading this
“Boundaries follow the entities.”
Entities are the stable nouns; boundaries are about the volatile decisions. Two components can both read Return and still belong apart, because one decides eligibility weekly and the other formats a carrier payload on the vendor's schedule.
“A boundary is only real if it is a separate service or repository.”
A boundary is real when crossing it is checked. An enforced import rule in one codebase is a stronger boundary than two services that share a database, and it costs nothing at runtime. Choose the weakest enforcement that actually fails a build.
“Sharing a database means sharing a boundary.”
Ownership is about the writer, not the connection string. A shared store can preserve boundaries when each table has one authoritative writer — but only for as long as that rule is enforced by something other than good intentions, because the first cross-module UPDATE under deadline pressure is silent.
“Get the boundaries right up front.”
You will get one or two wrong. The useful goal is not correctness but cheap correction: keep the mistakes inside a codebase where moving a seam is a refactor, not a data migration and a contract negotiation.

When this is the wrong move. Do not cut boundaries into a system whose change history says everything moves together — that is a cohesive core, and splitting it converts every change into a coordination problem. Do not cut them in a codebase small enough to hold in your head, or in one owned by a single person for the foreseeable future; the ceremony has no beneficiary. And do not cut a boundary you cannot enforce: an unenforced seam decays into a naming convention within two quarters, and then the map lies, which is worse than having no map. Merge instead, and wait for change to give you evidence.

Skill: find the seam from the change

A boundary becomes operationally credible when…

Two responsibilities belong together when…

A shared database between two parts means…

Practice: assign responsibility before structure

Take the model from lesson 0052, using either your domain or the supplied returns case. For every concept, answer who knows, who does, and who decides. Cluster the answers by the change and ownership tests, then record candidate boundaries as a table — name, responsibilities, decision rights, authoritative facts, and reasons to separate. Identify one candidate you would keep together for now and state what future evidence would justify splitting it.

In the lab and the capstone: the companion lab's phase-5 checkpoint starts from a returns-resolution service with no enforced seams at all. Run the same analysis there — read what the code's own history and its callers say changes together, then propose boundaries and back each one with a rule something can check. The worked capstone shows where this reasoning ends up in its workflow and drivers artifacts.

Reveal: the two-writer test

Pick any fact both of your parts could plausibly write — a status, a count, a decision. If both can write it, you have not assigned responsibility. Name one writer, make the other a consumer of a contract, and the boundary appears by itself.

Teach it back
  1. Explain to a senior engineer, in two sentences, why co-change evidence beats a diagram of entities when placing a boundary.
  2. Take one system you own this week: name the two parts that most often appear in the same pull request, and say whether your current boundary agrees with that fact.
  3. Predict the damage: if a team splits two responsibilities that always change together, name what shows up in their release process a year later.

Your win

You can find system boundaries from responsibility and change — who knows, who does, who decides — and catch unowned state before it becomes a shared table or a service with no owner.

Read and watch deeper

Give your agent-teacher your responsibility table and ask it to argue that two rows belong to the same component — then rebut with the change and ownership evidence.