A table that was correct and useless
We have twenty-three products in one repository. At some point we needed a cross-product table — the handful of facts that describe every product regardless of what else it does: its directory, its slug, its bundle identifier.
There was an obvious way to build it. Each product already declares all of this, including everything it changes as it ships. So load the declarations and project the few fields you need:
IDENTITIES = [
{field: getattr(decl, field) for field in ("dir", "slug", "bundle_id")}
for decl in ALL_PRODUCT_DECLARATIONS
]
It is correct. It is DRY in the way people mean when they say DRY. And it was quietly costing us the property that matters most in a build system: blast radius.
The reason is a single fact about how the build graph works. Bazel invalidates a .bzl file as a unit. It does not invalidate per symbol. Loading one field out of a file still means loading the file, and the file's contents are now part of your dependency.
Which means:
A table derived by loading all twenty-three product declarations puts every product in every other product's blast radius — even for a consumer that reads exactly one field from it.
The obvious next move is to make the projection narrower: export a dedicated symbol that contains only the three fields, and have the table load that. It does not help, and understanding why is the part worth keeping:
Exporting a narrower symbol does not help. Only a narrower load closure does.
The narrow symbol still lives in the file that loads everything. As long as the derivation reaches those declarations, the invalidation does too. The unit of invalidation is the file, so the only thing you can shrink is the set of files.
What it actually cost
This is not a theoretical concern we protected against on principle. It showed up as wall-clock time on real hardware.
Editing one product's kernel dependency invalidated every sibling's UI test bundles. That is twenty-five suites across the fleet of physical mac minis, all of them re-running because the fleet ran the invalidation. The expensive part was not the compilation. It was that the thing being invalidated was a test that boots a simulator and drives a real user interface.
So the failure mode was not "the build got slower." It was "an unrelated change to a terminal app's dependency now costs you the entire UI test suite of twenty-two other apps." Which is a property of the graph, visible to everyone, and — this is the part that should worry you — completely silent. Nothing errors. The suite is not flaky. It just takes forty minutes longer than it should on the days someone touches the wrong product.
The interesting thing about this cost is that it is invisible until you look for it. Nobody notices a test suite that is slower than it should be. Everybody notices a test suite that fails.
Split by rate of change, not by convenience
The fix is to ask a different question than "how do I avoid duplicating these fields?" The question that works is which facts actually change, and how often.
Permanent identity — the directory a product lives in, the slug it publishes under, the bundle identifier it ships with — goes in a file that declares it literally and loads nothing at all. Those three fields do not change while a product is in the market; changing one is an event, not a revision.
Everything a product revises while it ships — its kernel dependencies, its permissions, its analytics configuration, its release cadence, its versions — stays in that product's own declaration, and is read from there.
The consequence is that invalidation becomes correct by construction rather than by care:
- Changing a shared scheme — a bundle identifier convention, a slug format — correctly invalidates every product, because every product genuinely depends on it.
- Changing anything else — one product's permissions — invalidates exactly one product.
That is the outcome you wanted from the derived table, and it comes from the split rather than from the derivation. The narrow table is not a narrower version of the wide one; it holds a different set of facts, chosen so that the set's invalidation behaviour is what you actually want.
There is a cost, and it is worth stating: the same fact now lives in two places, and two places can disagree. That is a real hazard, and pretending otherwise is how these designs rot.
So make the disagreement loud, and local
The duplicated fields are exactly the permanent ones, which means drift is detectable at build time — if you check. Each product's own package asserts that its declaration still agrees with its registry entry.
The placement is the part that matters. The assertion runs inside the product that drifted, so a mismatch fails that product's build rather than failing somewhere unrelated. That is the difference between a divergence you discover immediately and one you discover when a bundle identifier is wrong in an App Store submission.
The rule is that changing a scheme costs two edits — the registry and the product — and correctly invalidates every sibling. Changing anything else costs one and invalidates only its owner. Both halves of that sentence are enforced by the same mechanism, and the second half is the one that makes the first affordable.
The transferable part
The general shape is worth more than the specific fix, because it applies anywhere a build graph, a dependency graph, or a cache has per-node granularity finer than per-file:
Before you derive a shared table from per-item declarations, find out what your tool invalidates. If it invalidates the file and not the symbol, the derivation costs every item its independence.
This is the same failure as a header file that every translation unit includes, or a barrel module that every file imports to reach one constant. The abstraction is free and the invalidation is total, and the two are never both visible at the point where you write the line.
The diagnostic is unglamorous and reliable: ask what the tool recomputes when one byte of a file you depend on changes. If the answer is "the file," then your dependency is the file, and no amount of narrow exported symbols changes that. Write the data down twice, in a file that depends on nothing, and add the assertion that catches the copy drifting.
That is more duplication than the elegant version, and it is the reason it works: the duplicated lines are the ones that never change, so they are the ones that never cost you a rebuild.
Takeaways
Find out what your tool invalidates before you design around it. Per-file versus per-symbol granularity is the difference between a shared table that costs one product its independence and one that does not. Our abstraction was correct in every way except the one that mattered, which is the standard way abstractions are wrong.
Then split shared data by rate of change, not by convenience. Permanent facts belong in a dependency-free declaration; everything a thing revises while it is in the market belongs with it. This is what makes invalidation correct without care — a scheme change should invalidate everyone, and nothing else should.
Accept the duplication and make drift cheap. Two copies of a permanent fact is a better trade than one derived copy that puts twenty-three products in one blast radius. The duplication is only acceptable because those facts never change, so the copy never costs a rebuild.
And put the consistency check where the mistake is. A check that runs in the product that drifted turns a divergence into a load-time failure there. A check that runs in some central place turns it into a failure nobody can attribute — and a check that does not exist turns it into a bundle identifier that is wrong in production.