III · Many Machines · Chapter 14

Whose Data Is It?

Ran out: the schema's ownersStaleness

Chapter 13 split Courtyard into services so that teams could work independently, but what was split was the code. The data mostly stayed where it was, in one big database, because moving it was hard and everyone already knew the tables. So the accounts service and the notifications service both read and write the users table. Accounts owns it, in the sense that everyone would say so if asked, but the notifications team found it easier to read the table directly than to call accounts, and along the way they added a last_notified column of their own. It works, and it goes on working until the accounts team runs a migration.

The accounts team, doing their own work on their own schedule, which was the whole point of Chapter 13, decide to rename a column. As part of a general tidying, display_name becomes full_name. They update their service, test it, and deploy it, and their service works perfectly.

The notifications service breaks. It was reading display_name straight from the table, the column is gone, and now every notification fails. The notifications team is paged at two in the morning for a change they did not make, were not told about, and cannot find in their own code, because the cause is in the accounts team’s repository, which they do not watch.

Nothing in Chapter 13’s split prevented this, and the reason is that the two services were independent in code and coupled through data. They shared a table, and a shared table is a shared secret about structure. It is Part I Chapter 19’s message shape, except that neither team agreed to a shape or knew they were party to one. The accounts team thought they owned that column. The notifications team depended on it. Nobody wrote the dependency down, because reading a table directly does not announce itself. What you have is the distributed monolith that Chapter 13 warned about, arriving through the database instead of through the code, a pair of services that cannot in fact deploy independently, because one team’s schema change is another team’s outage. Nobody can say who owns the table, and the schema’s owners are what has run out.

loading…

The users table, read and written by two services. The accounts team renames a column and their service stays green. The notifications service, which read that column directly, goes red. No code was shared; the coupling was invisible, and it ran through the data.

The rule that fixes this is simple to state and hard to live by. Every piece of data has exactly one owning service, and no one else touches its storage directly. Everyone else goes through the owner’s interface.

The users table belongs to the accounts service, and only accounts reads or writes it. When notifications needs a display name, it calls accounts, with getUser(id), the network call from Chapter 13 and all of Part I’s bill along with it. It does not read the table. It cannot, because it no longer has access. Now the accounts team can rename display_name to full_name whenever they like, because the column is behind their interface. They change the column and keep the interface returning displayName, and notifications never knows or cares. The interface is the contract, and the storage is private.

This is data ownership, and it is the other half of Chapter 13, the half that makes the independence real. Splitting the code without splitting the ownership of the data gives you services that are still coupled where it matters most. The discipline is that a service’s database is its own, invisible to everyone else, and the only way to its data is through its published interface. Amazon’s early mandate was precisely this. No team may access another team’s data store directly, and all access goes through service interfaces. It is what turned their teams into independent units in fact and not only on the organisation chart.

Ownership makes independence real, and it also takes something valuable away. You should feel the loss clearly, because it is the reason people cheat and share tables in the first place. On one database, showing each user’s name next to their count of unread notifications was one query with a join, users joined to notifications, one line, one trip, and the database did the work. Joins are the thing relational databases are best at.

With ownership, users belongs to accounts and notifications belongs to the notifications service, and they live in two separate databases. You cannot join across them, because there is no single database that holds both tables. So the query becomes three steps. You call the notifications service for the counts, you call the accounts service for the names, and you merge the two results in your own code. A one-line join is now three network calls, each carrying Chapter 13’s bill, and a manual merge. It is slower, any of the calls can fail, and there is no transaction across them.

loading…

One database, and a join across three tables is one query and one trip. Three services, and it is three calls to three owners followed by a merge in application code, slower, prone to failure, and outside any transaction. The same result at two costs.

This is the real tax of ownership, and it is why Chapter 13 asked you to draw service boundaries carefully. Things that are frequently joined should, where possible, be owned together, so that the join stays inside one service. When they cannot be, because two pieces of data that are constantly combined belong to two different teams, you are back to Chapter 11’s denormalisation. The notifications service keeps its own copy of the display name, received from accounts and cached, so that it can show the name without a call, and it accepts that the copy goes stale when accounts changes it. The join problem and the staleness problem are the same problem seen from two sides, and ownership forces you to choose which one to pay.

The getUser interface that accounts publishes is a message shape, precisely the thing Part I Chapter 19 was about, and now it has many consumers: notifications, feed, chat, search, and every other service that needs a user’s name. There are dozens of them, owned by different teams, deployed on different schedules, none of which upgrade at the same instant.

So Chapter 19’s rules are not good practice here. They are survival. Accounts can add a field to what getUser returns, because old consumers ignore it, which is Chapter 19’s first rule. Accounts can never remove a field, because some consumer somewhere depends on it and that team is on holiday. Accounts can never rename a field or change its meaning, for the same reason. The interface can only grow, compatibly, forever, because the consumers are other teams and you cannot upgrade them all at once. You cannot upgrade them at all. They upgrade themselves, when they feel like it. Chapter 19 said that a message shape is an agreement with versions and that both sides never upgrade together. At organisational scale, both sides means dozens of teams, and the agreement is the only thing holding the company’s software together.

When a breaking change cannot be avoided, you take Chapter 19’s expensive path. You publish a second version of the interface, getUserV2, run both in parallel, get every consuming team to migrate to the new one on their own schedule, and retire the old one only once you have confirmed, through the observability of Chapter 18, that nobody calls it any more. That migration can take months, because it moves at the speed of the slowest team. This is the daily reality of a large service architecture: interfaces that only grow, versioned migrations that take quarters, and a deep reluctance ever to break a contract, because breaking one breaks teams you have never met.

Ownership removes the silent break and adds the costs you have just seen. Queries across owners are slow, prone to failure, and outside any transaction. Denormalised copies go stale, which is the antagonist again, as the price of avoiding cross-service joins. And the evolution of an interface is now a cross-team, multi-quarter coordination problem in which a single removed field can break a service two teams away. The coupling did not disappear. It moved from the invisible and dangerous kind, shared tables, to the visible and manageable kind, published contracts. That is a strict improvement, but manageable still means a standing tax on every change.

That is the fourteenth revision of Courtyard. The schema’s owners ran out, and you gave each datum one owning service, reached only through a versioned interface. Cross-owner joins became multi-call merges or stale denormalised copies, and changes to an interface are now compatibility-constrained migrations paced by the slowest consuming team. Each service, meanwhile, has been left to mind its own front door.

Coming up · III·15 — The Front Door Does More Than Route

Six services now each implement their own login checks, their own rate limits, and their own logging, and three of them do it wrong. There is a layer every request already passes through, and it could do all of this once.

→