Lesson 0020 · System Designer · Module 2
Shared State and State Ownership
Lesson 0019 removed the globals. This one handles the harder case: state that must be shared, but must have exactly one owner who is responsible for it.
Mission tie-in: "manage shared state and understand state ownership" — the rule that keeps concurrency bugs from becoming your daily life.
Knowledge: one owner per state
Every piece of state has exactly one owner: the object (or module, or process) responsible for its correctness — for its invariants (lesson 0011), its lifecycle, and its writes. Everyone else is a reader or a requester.
The failure is state with no owner — shared and edited by whoever happens to hold it:
# two modules both "own" the user's profile
class ProfileService:
def update_email(self, user: UserProfile, new_email: str) -> None:
user.email = new_email # writes directly
class SyncService:
def sync(self, user: UserProfile) -> None:
if user.email.endswith("legacy.com"):
user.email = user.email.replace("@legacy.com", "@new.com") # also writes
# whichever runs last wins; nobody validated the other's write
Two writers, no coordination: the profile's invariants (unique email, verified flag) can be broken by either path, and tests pass or fail depending on call order — the same disease as lesson 0019, now in objects.
Ownership made explicit:
class UserProfile:
def __init__(self, email: str, verified: bool) -> None:
self._email = email
self._verified = verified
def change_email(self, new_email: str) -> None: # the owner's door
if self._verified:
raise ProfileLockedError()
self._email = new_email
class ProfileService:
def update_email(self, profile: UserProfile, new_email: str) -> None:
profile.change_email(new_email) # requests; doesn't grab
class SyncService:
def sync(self, profile: UserProfile) -> None:
if profile.email.endswith("legacy.com"):
profile.change_email(profile.email.replace("@legacy.com", "@new.com"))
Both services still change the email — but only through the owner's door, which enforces the invariant and owns the write. That's the definition of ownership: the object guards its own state; outsiders request.
Ownership is the mechanism that makes invariants composable. With one writer, the states a piece of data can reach are the states that writer's methods can produce — a finite list you can read and test. With n writers, you must reason about every interleaving and every partial update, and each writer only guarantees the rules it happens to know: a rule enforced by two writers out of three is enforced by none. Funnelling writes through one owner collapses that reasoning surface back to a method list. It is the same counting argument as lesson 0011, extended from one object's rules to a system's.
The boundary condition is that the owner must coincide with the consistency boundary. When state outlives a process — a database row, a cache entry, a message on a queue — your in-memory object is not the owner; it is holding a copy. Ownership then becomes a systems question: one authoritative source per fact, and everybody else holds a replica that has an age and no right to enforce rules on it. And there is a real class of systems where multi-writer is the requirement, not the defect: collaborative editors, offline-first clients, anything replicated across regions. There, ownership moves from "who may write" to "how conflicts resolve deterministically", which is a harder design you should choose on purpose rather than arrive at by accident.
Ownership also leaks through references, which is where careful-looking code goes wrong. A private attribute handed out by a property is not private:
class Return:
def __init__(self) -> None:
self._lines: list[ReturnLine] = []
@property
def lines(self) -> tuple[ReturnLine, ...]:
return tuple(self._lines) # a copy — callers cannot append behind your back
def add_line(self, line: ReturnLine) -> None:
if self._state is not ReturnState.REQUESTED:
raise ReturnLockedError()
self._lines.append(line)
How a senior designer works the case
A customer platform. The profile service owns customer records; the CRM has an inline edit field for the email address; a nightly job syncs the two. The symptoms arrive separately: tens of thousands of rows updated every night, a handful of customers whose address flips back and forth over days, and refund notifications failing for addresses that were verified and then quietly changed.
What they look at first. Not the sync job. The fact: which system is authoritative for a customer's email, and what is everyone else holding? Nobody can answer, which is the finding. The sync job was written on the assumption that both sides hold truth and disagreement is a merge problem.
The question they ask. "Which invariant is supposed to hold here, and can the system that enforces it see every write?" The rule is "a verified address cannot change without re-verification". The profile service enforces it. The CRM does not know it exists. So the rule is not enforced at all — it is enforced on one of two paths, which is the same as none.
The alternative they reject. Last-write-wins on timestamps, the standard proposal. It resolves the flapping and fixes nothing that matters: clock skew between two systems makes "last" unknowable at the granularity of a single second, and, more fundamentally, LWW has no opinion about verification. It converts a visible disagreement into a silent one, which is the direction you never want to move.
What they choose, and what it costs. One owner: the profile service. The CRM keeps a replica stamped with a sync time, and its edit control becomes a request — an event the owner may apply, reject, or apply after re-verification. The sync becomes one-directional. The cost is named out loud rather than hidden: the CRM team loses instant inline editing and has to show a pending state, which is a product decision made in favour of an invariant over a UI convenience, and it needs a new state to test.
The consequence over time. The nightly diff falls to near zero, so the job's row count becomes a usable alarm instead of noise. The flapping stops. And when the verification rule changes next year — it will — exactly one codebase changes, and no one has to find out which other system also had an opinion.
† Handoff over sharing: when you can, hand immutable snapshots instead of shared mutable objects — "value objects as messages" (lesson 0012). Sharing is for state that must be live; snapshots are for everything else.| You see this | What it costs | The move |
|---|---|---|
| obj.field = value from outside the class | Every writer must remember the rules; one of them won’t. | A method on the owner: obj.change_x(). |
| a dict mutated by three functions | No owner, no invariants, order-dependent results. | Return new values, or give the dict an owning class. |
| copy.deepcopy sprinkled defensively | A symptom of shared mutable state, not a cure for it. | Hand over a frozen value object instead. |
| two classes with a sync() fixing each other | Ownership is contested; each undoes the other’s work. | Name the single owner; the other becomes a reader. |
- "A lock gives me ownership."
- A lock serialises access; it does not decide who is allowed to change what. Two writers with a shared lock still break the same invariant — politely, one at a time. Locks solve interleaving; ownership solves authority.
- "If the value is immutable, nobody needs to own it."
- Someone still owns which version is current. Immutability makes handoff safe and makes stale copies harmless to hold; it says nothing about whose update wins.
- "Private attributes mean the object owns its state."
- Not if a property hands back the mutable object itself. A returned list, dict or nested entity is a door into your state, and callers will use it — usually with a comment saying it was easier this way.
- "One owner means one thread, so it's a bottleneck."
- The owner is a logical authority, not necessarily a single execution point. Sharding by key gives you one owner per customer, per order, per tenant — thousands of owners, still exactly one per fact.
When this is the wrong move
- Deliberate replicas and caches. They exist to be stale. Give them an age and no authority, and never let a rule be enforced against a copy.
- Collaborative and offline-first products. Multi-writer is the feature. Then design the merge semantics explicitly — CRDTs, operational transforms, or an application-level conflict rule — rather than pretending one writer exists.
- Read models and projections. Written by a projector, owned by nobody in the domain sense, and correct only as a derivative of the events that produced them.
- Facts that genuinely have two owners. Sometimes the answer is that the fact is two facts: the profile owns identity, the CRM owns marketing consent. Split the data, not the write path — a jointly owned field is an unresolved argument stored in a column.
Skill: who's the owner?
Two services write a user's email directly. The defect is:
The fix is to route writes through:
When sharing isn't required, prefer:
Practice on your own code
Pick a shared mutable object in your codebase (a profile, a session, a document). List its writers. If there is more than one, route them through the object's own methods — then ask whether an immutable handoff would serve better.
In the lab: the phase-2 checkpoint has two services that both assign to a return's status, plus an aggregate that hands its internal line list out through a property. You name the owner, convert the second writer into a requester, and close the reference leak.
Reveal: an ownership audit
A Cart object is mutated by CartView (adds items) and PriceService (rewrites prices). Audit: the view owns line composition; pricing owns prices. Split: Cart.add_line() guarded by the cart; pricing returns a new priced cart snapshot instead of editing in place. Two owners, zero overlap.
- In two sentences, explain to a junior why a rule enforced by two of three writers is enforced by none.
- Find one piece of state in your systems this week with more than one writer. Name the invariant nobody is checking on the second path.
- Assume both writers stay for a year while the data volume triples. Describe the reconciliation job someone will be asked to write, and what it will silently overwrite.
Your win
You can run an ownership audit on any shared state, route writes through the owner's door, and choose immutable handoffs where sharing isn't needed.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 9 "Better Together or Better Apart": deciding what should share what.
- Designing Data-Intensive Applications, Kleppmann — ch. 7 "Transactions": ownership and coordination when the state outlives a process.
- Robust Python, Viafore — the chapters on state management in Python systems.
- Designing Data-Intensive Applications, Kleppmann — ch. 5 "Replication": single-leader replication is this lesson's rule at system scale, and the multi-leader section is the honest account of what you take on when you allow two writers.
- Watch: ArjanCodes YouTube — search "shared state" or "state management".
- Next: lesson 0021 — error handling strategies.
- Reference: Design Principles — tell, don't ask; glossary — shared state.
Bring a two-writer object to your agent-teacher and design the ownership split together.