You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

Learn · Learning Bazel

seedling

The Shape of a Repository

Where build logic lives, how configuration layers, and what holds together at the scale where nobody knows all of it.

bazel, monorepo, architecture, maintenance, learn

By the end you will have moved build logic out of the code tree, layered your build configuration into shared defaults and a named continuous-integration config, and confirmed the reorganization changed nothing. You will also have an honest account of what adopting this costs.

Build logic does not live beside the code

The macro from chapter seven landed in a directory called tools, beside the code it stamps out. At this size that is fine. At ten times this size it is the beginning of a problem, because build logic scattered through the source tree is build logic nobody can inventory.

Move it somewhere a stranger would look:

bazel/BUILD.bazel

# Build logic lives here, not beside the code it builds. At this size
# the directory holds one macro; at ten times this size it holds custom
# rules, shared settings, and third-party patches — and a newcomer still
# knows where to look.

A directory for build logic. Here it holds one macro; at scale it holds custom rules, shared settings, and third-party patches.

The call sites change by one word — the label they load from. Nothing else moves.

The convention is worth more than the specific name. What matters is that there is one answer to "where does build logic live," and that somebody arriving next year can find it without asking.

Configuration layers

The cache chapter added a build configuration file with two lines in it. Real repositories need more than one audience for that file:

.bazelrc

# Defaults everyone gets. Kept short on purpose: a flag here is a flag
# nobody will remember is set.
build --disk_cache=~/.cache/learning-bazel

# Continuous integration. Named configs keep the difference between a
# developer machine and a build machine explicit rather than tribal.
#
# The lockfile is an error rather than an update: a dependency bump must
# arrive as a committed change, never as a silent re-resolution on a
# build machine.
build:ci --lockfile_mode=error
build:ci --announce_rc
test:ci --test_output=errors

# Local overrides that should never be committed.
try-import %workspace%/user.bazelrc

Shared defaults, a named config for build machines, and an escape hatch for personal settings.

Three ideas in twenty lines.

Shared defaults stay short. Every flag here is a flag nobody will remember is set. A long default section is how a build acquires behavior that nobody can explain and nobody dares remove.

Named configs make context explicit. --config=ci collects everything that should be true on a build machine and nowhere else. The difference between a developer's machine and a build machine stops being tribal knowledge and becomes a section you can read.

The lockfile mode is the one to notice. On a build machine, lockfile drift is an error rather than an update. A dependency bump must arrive as a committed change somebody reviewed, never as a silent re-resolution during a build. Run it once and it either passes — which proves your lockfile is genuinely current — or it fails and tells you what nobody committed.

Personal settings are imported, not committed. The try-import line lets each person keep local overrides in a file that does not exist for anyone else.

What the strict config actually catches

--lockfile_mode=error is one line in a config block, which makes it easy to skim past. Here is the failure it exists for.

Bump a dependency and do not commit the updated lockfile — a hurried change, or a rebase that kept one side:

$ bazel build //server
INFO: Build completed successfully, 19 total actions

Locally, with the ordinary defaults. It builds.

Nothing complains. The build resolves the new version, updates the lockfile in place, and carries on — which is the right behavior on a machine where you are actively changing dependencies.

Now the same tree on a build machine:

$ bazel build --config=ci //server
INFO: Found applicable config definition build:ci: --lockfile_mode=error
ERROR: Missing checksum for registry file
  https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel
  not permitted with --lockfile_mode=error.
  Please run `bazel mod deps --lockfile_mode=update` to update your lockfile.

The named config, and the same commit stops.

This is the shape of every "works on my machine" report, caught at the one moment it is cheap to fix. The dependency change was real, the lockfile update was simply never committed, and the difference between the two machines is one flag rather than one person's luck.

The value of a named config is not the flags in it. It is that the difference between a developer machine and a build machine stops being tribal knowledge and becomes a block somebody can read — and disagree with in review.

Note what the error does: it names the module, the version, and the command that fixes it. That is the standard worth holding your own build configuration to. A gate that fails without saying what to do teaches people to route around it.

Prove it, one more time

Same discipline as chapters seven and eleven:

$ bazel query '//...' | sort > after.txt
$ diff before.txt after.txt
$ echo $?
0

The complete target list, before and after moving build logic and layering the configuration. Identical.

Three reorganizations in this book, three times the same proof. That is deliberate. Organization advice is the easiest thing in engineering to assert and the hardest to justify, and an audience that maintains large repositories has heard enough of it asserted.

If you cannot show that a reorganization preserved behavior, you are asking people to accept your taste as engineering. Two commands is a low price for the difference.

What this costs

An honest closing requires saying what gets worse, because things do.

Everything is more explicit, including the tedious parts. Adding a dependency means editing a declaration, not just an import. The generator removes most of that for Go and none of it for the parts a generator cannot infer.

The error messages are sometimes bad. You have seen two in this book: a package manager key whose newer spelling fails with a message about a list having no items method, and a compiler configuration that could not reach a sibling directory. Both took longer to diagnose than they should have.

The first week is slow. Everything you knew how to do quickly, you now do carefully. That cost is real, it is front-loaded, and it is the main reason adoptions stall.

Not every project should adopt this. A single-language service with one deployable and a two-minute build has little to gain. The value grows with the number of languages, the number of people, and the length of the build — and below some threshold the cost is simply larger than the benefit. Saying so is not a hedge; recommending a tool for projects it does not help is how tools get resented.

The honest adoption path is incremental. Keep your existing build working, bring one component across, and let the graph grow. A rewrite weekend is how you get a half-migrated repository with two build systems and nobody willing to own either.

The edge of this map

Some things this book deliberately did not cover, and where they live:

Writing your own rules. Every rule here came from a library. Writing one is ordinary programming, and it is the subject of the next book — along with aspects and persistent workers.

Running the cache and the execution across a fleet. Chapter twelve's store on your disk becomes a shared service, and then a pool of machines executing actions. The concepts are the same; the operations are a book of their own.

Architecture at scale. Repository topology, one-version policies, and how a build stays comprehensible past a certain size — the fourth book.

None of those are prerequisites for what you have. You can run this setup indefinitely without any of them.

A workshop where the tools live on one wall, the settings are written where everyone can read them, and the note about which settings only apply to the delivery van is on the van's page rather than in someone's head.

What you built

Fifteen chapters ago there was a Makefile that reported success while handing you a stale file. What you have now is a build that knows exactly what every step read and wrote — and everything that followed came from taking that one sentence seriously.

The sandbox is that sentence enforced. The cache is that sentence cashed in. The generated schema is that sentence extended across a language boundary neither compiler can see over. The visibility lists, the pinned toolchains, the lockfiles, the explain output — all of it is the same idea, applied at a different place.

Build logic belongs in one findable directory rather than scattered beside the code. Build configuration layers into short shared defaults plus named configs, with lockfile drift an error on build machines and personal settings imported rather than committed. Prove reorganizations preserve behavior. Be honest about the costs: more explicitness, some poor error messages, a slow first week, and a real threshold below which this is not worth adopting.

Try this in your own repository

Time a newcomer. Ask somebody who has never touched your build configuration to make a small change — bump a dependency, add a flag for CI. Watch without helping. Every place they stall is a place your repository's shape is costing time it does not need to.

Find the difference between your machine and your build machine. Write down every flag, variable, and installed tool that differs. If that list is not somewhere a person can read it, it is tribal knowledge, and this chapter is about turning it into a file.

Decide honestly whether to adopt any of this. For a single-language service with a two-minute build, the answer may be no. The costs section above is the argument against; make it seriously before making the argument for.

What you can now do

Set up a repository other people can maintain, decide whether this is worth adopting for a given project, and answer the question the book opened with — what did this step actually read? — for every step in your build.