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

One Component at a Time

How to adopt any of this without a rewrite weekend, and how to leave at any point without having lost anything.

bazel, adoption, migration, build-systems, learn

By the end you will know how to run a new build beside an existing one in the same directory, how to hand a single artifact across the boundary, and which direction that dependency has to point if you want to be able to walk away.

Nothing has to go first

The instinct is to start at the bottom — convert the base library, then everything above it. It is the wrong order, and it is wrong for a reason worth naming: the base library is the thing most likely to break everyone, and the thing whose conversion you understand least, because you are converting it before you have learned anything.

Start instead with something small enough that being wrong is cheap. A code generator. A documentation build. One test suite. Something with few consumers and an output you can look at.

Two builds, one directory

They do not conflict. The existing build stays exactly as it is:

all: build/app.txt

build/app.txt: legacy/main.txt
	mkdir -p build && cp legacy/main.txt build/app.txt

The build you already have, untouched.

And the new one arrives beside it, describing one component:

# MODULE.bazel
module(name = "adopt")

# tool/BUILD.bazel
genrule(
    name = "greeting",
    outs = ["greeting.txt"],
    cmd = "echo 'the new artifact' > $@",
)

A module file and one target. This is the whole footprint of the first step.

Both work, independently, in the same checkout:

$ bazel build //tool:greeting && cat bazel-bin/tool/greeting.txt
the new artifact

$ make && cat build/app.txt
the existing artifact

Neither build knows about the other yet, and neither is disturbed by it.

That is already worth having. You have one component with declared inputs, a graph you can query, and a cache — and you have risked nothing, because the thing that ships is still produced the old way.

Handing an artifact across

The next step is the one that makes it real: the existing build consuming the new build's output.

build/greeting.txt:
	bazel build //tool:greeting
	mkdir -p build && cp bazel-bin/tool/greeting.txt $@

build/app.txt: legacy/main.txt build/greeting.txt
	mkdir -p build && cat legacy/main.txt build/greeting.txt > build/app.txt

One recipe that shells out to the new build and copies the result. Everything else in the Makefile is unchanged.

$ make
$ cat build/app.txt
the existing artifact
the new artifact

The combined artifact. One half came from each build.

Now the new build is genuinely load-bearing — its output is in the artifact you ship — and the old build is still in charge of shipping it.

The direction is the whole design

Notice which way that dependency points. The old build calls the new one. Never the reverse.

That asymmetry is what makes the migration abandonable. If the experiment fails, you delete two files and put the original recipe back; the old build never learned anything about the new one beyond a shell command and a path. Nothing else in the repository acquired a dependency on a decision you are still evaluating.

Point it the other way — teach the new build to invoke the old one — and you have the opposite property. Now the new system's correctness depends on the old system's behavior, including all the parts of it you were trying to get away from, and you cannot remove either without the other.

A migration is abandonable when the new system is a leaf: it depends on nothing you have, and things you have depend on it. Every step in that shape is independently reversible, which means you can stop after any of them and still be better off than when you started.

Renovating a house you are living in. You do one room, and you keep using the kitchen. What you do not do is take the roof off in October because the plan says roof comes first.

What the boundary costs

Being honest about the seam, because it is not free.

The handoff is not hermetic. That cp is outside both builds' knowledge. The old build does not know when the new one's output changed, so it will either rebuild too often or not often enough — the exact problem chapter one opened on, now living in one recipe instead of throughout the project.

You are running two caches. Both are warm, neither knows about the other, and a clean of one does nothing to the other. That is confusing on the day somebody debugs a stale artifact.

The seam is where the bugs live. Not inside either build — at the boundary, where two sets of assumptions meet and neither owns the result.

All three are real, and all three are temporary. They are the cost of being able to stop, and they shrink as the boundary moves outward.

Which suggests the exit criterion: the migration is finished not when everything is converted, but when the seam has moved to a place where nothing crosses it that anybody cares about.

When to stop

You may not need to convert everything, and it is worth deciding that deliberately rather than discovering it after the fact.

A component earns conversion when it has real dependencies you want tracked, when its build is slow enough that caching matters, or when it is shared by two things that keep disagreeing about it. A component that is fast, standalone, and rarely touched earns nothing — converting it is work you do for symmetry, which is not a reason.

So a partly-migrated repository is a legitimate end state, not a failure to finish. The question is never "is it all converted"; it is "is every remaining boundary somewhere sensible."

Start with something small enough that being wrong is cheap. Two builds coexist in one directory without conflict. Hand artifacts across the seam in one direction only — old calls new — so the new system stays a leaf and every step stays reversible. The seam costs hermeticity, a second cache, and a place for bugs to live, and those costs shrink as it moves outward. A partly-migrated repository is a legitimate place to stop.

Try this in your own repository

Pick the component you would convert first. Not the most important one — the one where being wrong is cheapest. Write down what it takes as input and what it produces, and notice how long that takes you. If it is hard, that is the answer to whether your build currently knows what it is doing.

Work out what the seam would be. Which artifact would cross, in which direction, and what would have to change in your existing build to consume it. If the answer is "the new build would have to call the old one," pick a different component — that shape is the one you cannot walk away from.

What you can now do

Adopt any of this incrementally: run a new build beside an existing one, hand a single artifact across the boundary, and keep the whole thing reversible by making sure the dependency only ever points one way.