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

When One File Gets Too Long

Forty lines, two languages interleaved, and the first reorganization this book asks you to make — proved to change nothing at all.

bazel, organization, monorepo, maintenance, learn

By the end you will have split one manifest into three files, hoisted versions into named constants, and confirmed with two commands that the build is byte-for-byte the build you had before.

Read what you have

Ten chapters of incremental additions have produced this:

MODULE.bazel

module(name = "greet")

# Go.
bazel_dep(name = "rules_go", version = "0.61.1")

go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.26.5")

# TypeScript. rules_ts needs a compiler version, which is the chapter's
# point in miniature: the compiler is an input too.
bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "aspect_rules_ts", version = "3.8.11")
bazel_dep(name = "rules_nodejs", version = "6.7.4")

node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = "22.22.2")

rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext")
rules_ts_ext.deps(ts_version = "5.9.3")
use_repo(rules_ts_ext, "npm_typescript")

# Build-file generation for Go.
bazel_dep(name = "gazelle", version = "0.51.3")

# Third-party Go modules come from go.mod. The extension reads it and
# creates one repository per module; use_repo names the ones the build
# actually depends on.
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps, "org_golang_x_text")

# npm packages come from the pnpm lockfile, which stays the source of
# truth. The extension turns it into one repository per package.
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
npm.npm_translate_lock(
    name = "npm",
    pnpm_lock = "//:pnpm-lock.yaml",
    verify_node_modules_ignored = "//:.bazelignore",
)
use_repo(npm, "npm")

Forty lines. Go and TypeScript concerns alternate; versions are scattered through as string literals.

Every line is justified. That is what makes this the interesting case — there is no mistake to point at, just accumulation, which is how the long files in your own repository got long.

The specific problems are worth naming, because they are the ones that cost you later:

Versions are buried. Four version strings sit at four different depths. Answering "what Node do we build with?" means reading the file rather than glancing at it.

Two subjects, one file. Someone bumping the Go SDK has to scroll past npm configuration to find it, and a change to either lands in a diff that touches the same file — so two unrelated pieces of work collide for no reason.

The split

One file per language stack, and a root file that says almost nothing:

MODULE.bazel

module(name = "greet")

bazel_dep(name = "gazelle", version = "0.51.3")

# One file per language stack. Each is a complete, readable account of
# what that language needs; this file stays a table of contents.
include("//:go.MODULE.bazel")
include("//:typescript.MODULE.bazel")

The root becomes a table of contents. include() pulls in each stack's file.

go.MODULE.bazel

"""Go: SDK, rules, and third-party modules."""

# Versions as named constants, so a bump is one obvious edit.
GO_VERSION = "1.26.5"

bazel_dep(name = "rules_go", version = "0.61.1")

go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = GO_VERSION)

# Third-party modules come from go.mod. The extension reads it and
# creates one repository per module; use_repo names the ones the build
# actually depends on.
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps, "org_golang_x_text")

Everything Go, in one place, with its version at the top as a named constant.

typescript.MODULE.bazel

"""TypeScript: Node, the compiler, and npm packages."""

NODE_VERSION = "22.22.2"

TS_VERSION = "5.9.3"

bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "aspect_rules_ts", version = "3.8.11")
bazel_dep(name = "rules_nodejs", version = "6.7.4")

node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = NODE_VERSION)

# The compiler is an input like any other, so it carries a version.
rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext")
rules_ts_ext.deps(ts_version = TS_VERSION)
use_repo(rules_ts_ext, "npm_typescript")

# npm packages come from the pnpm lockfile, which stays the source of
# truth. The extension turns it into one repository per package.
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
npm.npm_translate_lock(
    name = "npm",
    pnpm_lock = "//:pnpm-lock.yaml",
    verify_node_modules_ignored = "//:.bazelignore",
)
use_repo(npm, "npm")

And everything TypeScript. A version bump is now one obvious edit at the top of the file that owns it.

Three things changed and each has a reason.

Named constants at the top. GO_VERSION = "1.26.4" used once is not about avoiding repetition — it is about putting the answer where someone looking for it will find it in two seconds.

One file per stack. The unit of change is usually a language. Making that the unit of the file means most edits touch one file, and two people bumping different toolchains do not collide.

A root that is a table of contents. Someone new can read eight lines and know what this repository builds.

Prove it changed nothing

Here is the part that makes this engineering rather than preference.

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

The complete target list, before and after the split. Identical.

$ bazel aquery '//server' | grep Mnemonic | sort > after-actions.txt
$ diff before-actions.txt after-actions.txt
$ echo $?
0

And the actions each target will run. Also identical — the reorganization touched no behavior whatsoever.

A reorganization you cannot prove is behavior-preserving is a reorganization somebody will be afraid to review. Two commands turn "trust me, this is tidier" into a fact — and they are the same two commands that proved the macro added nothing in chapter seven.

The failure the split introduces

Splitting one file into three creates a problem the single file could not have. Two stacks can now reach for the same dependency without either author seeing the other.

It happens the ordinary way: someone adds a utility library to the Go file, and weeks later someone else needs the same library for the TypeScript side and adds it where they are working.

# go.MODULE.bazel
bazel_dep(name = "bazel_skylib", version = "1.7.1")

# typescript.MODULE.bazel
bazel_dep(name = "bazel_skylib", version = "1.9.0")

Two stack files, each declaring the same dependency at a version its author chose.

Neither edit is unreasonable and neither author did anything wrong. Build it:

$ bazel build //server
Error in bazel_dep: The repo name 'bazel_skylib' cannot be defined by a
bazel_dep at typescript.MODULE.bazel:30:10 as it is already defined by
a bazel_dep at go.MODULE.bazel:19:10

The error names both declarations, with files and line numbers.

This is the reorganization's hazard and its safeguard arriving together. Splitting the file made the collision possible; the build refusing duplicate declarations makes it loud, at the first build, naming both sites.

The fix is not to pick a winner in one of the two files. A dependency that more than one stack needs is not a Go concern or a TypeScript concern — it belongs where shared things belong:

# MODULE.bazel
bazel_dep(name = "gazelle", version = "0.51.3")

# Shared by more than one stack, so it lives here rather than in either.
bazel_dep(name = "bazel_skylib", version = "1.9.0")

Hoisted to the root file, which is where the table of contents already lives.

The rule that falls out: a file per stack holds what only that stack needs, and the root holds what more than one does. The build tells you when you have got it wrong, so this is a boundary you can discover rather than one you have to design correctly in advance.

When to do this, and when not to

The threshold is not a line count. It is the moment you notice yourself scrolling past one language's configuration to reach another's, or hunting for a version you know is in there somewhere.

Doing it earlier is worse, not better. A three-line module file split across three files is harder to read than a three-line module file, and you will have paid the indirection cost for nothing. The reason this chapter sits at eleven rather than two is that the mess had to be real before the fix could be judged.

The same reasoning applies to the build configuration file. Chapter fifteen splits that one too, into shared defaults and a named continuous-integration config — again, only once the difference between a developer machine and a build machine has actually shown up.

A drawer where everything lives is fine until you own enough things. Then you get one drawer per kind — not because drawers are virtuous, but because you were losing time to the one drawer.

Split the module manifest one file per language stack once two subjects are genuinely interleaved, hoist versions to named constants at the top of the file that owns them, and leave the root as a table of contents. Prove it with a target diff and an action diff — an organization change that cannot be shown to preserve behavior is one nobody will trust.

Try this in your own repository

Find the file everybody scrolls past. The one where people search for their section rather than reading. Work out what its natural split would be — usually the axis its changes actually arrive on, which is often not the axis it is currently organized by.

Then leave it alone until it hurts. Note what would have to be true for the split to be worth doing, write that down, and revisit in a month. Reorganizations done early are indistinguishable from reorganizations done for their own sake.

What you can now do

Reorganize a growing configuration file along the axis its changes actually arrive on, and prove the reorganization is safe. Part three is finished: your build reaches outside for both languages and the manifest is still readable. Part four is where all of it starts paying you back.