3.1.11 MODULE.bazel Management at Scale

extra

Bzlmod from 3.1.1 Bzlmod (MODULE.bazel) makes dependency declaration much cleaner than legacy WORKSPACE, but at enterprise scale the hard part shifts from syntax to governance. Because overrides only take effect in the root module, every emergency pin, patch, registry redirect, or local checkout still converges in one dependency-control plane. In the largest repos, the problem is not that MODULE.bazel is hard to read. It is that one file becomes the review and ownership choke point for the whole dependency graph.1,2,3

Central root file versus segmented root module
include() splits the document into files. Bzlmod still treats those files as one root module with one effective override policy.
ONE ROOT FILE
Every dependency area edits the same MODULE.bazel
One diff surface for every team
CODEOWNERS and merges all touch one file
MODULE.bazel root
GO DEPS
bazel_dep(name = "go_rules...", ...)
bazel_dep(name = "gazelle", ...)
JS DEPS
bazel_dep(name = "aspect_rules_js", ...)
OVERRIDES / PINS
single_version_override(...)

Unrelated edits collide in review. Language silos also stop mapping cleanly to directory ownership.

ROOT + include()
Semantic slices reduce merge noise in the root module
Same integration boundary, clearer ownership
Each segment is a *.MODULE.bazel in this repo
MODULE.bazel root
module(name = "monorepo", ...)
 
include("//:go.MODULE.bazel")
include("//:js.MODULE.bazel")
include("//:security.MODULE.bazel")
go.MODULE.bazel slice
bazel_dep(name = "rules_go", ...)
js.MODULE.bazel slice
bazel_dep(name = "aspect_rules_js", ...)
security.MODULE.bazel slice
single_version_override(...)

Included text behaves as if it were inlined at each include() call. Variable bindings stay file-local. This is not a separate published module and not a substitute for load().

include() is structure, not federation — fewer merge collisions on ordinary bazel_dep() edits, while overrides still express one root module’s policy for the whole graph.

The root module becomes the integration boundary

Root-only overrides are a useful safety property. A dependency cannot quietly smuggle its own override policy into your build, and the integrating repository stays in charge of the final graph.1 The operational consequence is that the integrating repository also owns the pain when dependency policy grows large.

That pain is not hypothetical. A single Bzlmod migration can carry more than 1,000 direct Go dependencies, a MODULE.bazel already around 2,000 lines, and the prospect of 100 to 200 overrides that would be awkward to maintain one by one.3 The organizational shape is the same from another angle: under a single-version policy, the root monorepo governance group or root CODEOWNERS end up responsible for making the shared dependency closure work for everyone.2

Once a repository reaches that point, "add one more bazel_dep" stops being a purely technical edit. It becomes a change to a shared policy surface. That is why teams that used to keep repository rules in team-owned directories often feel a regression when moving to one central MODULE.bazel: the Bazel model got cleaner, but the ownership model got more centralized.3

include() is the scaling valve Bazel gives you

Bazel does not let MODULE.bazel use load(). The supported answer for monorepos with huge root module files is include(), while programmable logic belongs in module extensions instead.4 include() behaves as though another MODULE.bazel-like file were placed at that location, but variable bindings stay local to the file where they were created, rather than leaking across included files.5

Since Bazel 7.2, that gives maintainers a real way to split a large root module into semantic slices instead of treating it as one giant document.6 The common pattern is language- or domain-oriented segmentation such as:

include("//:go.MODULE.bazel")
include("//:js.MODULE.bazel")

That exact style is a better fit for large repos with code-ownership boundaries, and the feature exists to improve structure and reduce merge conflicts in large MODULE.bazel files.7,8

Two details matter in practice:

  • Included files must live in the current repository, be referenced with a //... label, and end with .MODULE.bazel.5
  • include() is available only to the root module and modules under a non-registry override, so it is mainly a tool for the integrating repository, not a general abstraction published modules can rely on.5,4

That means include() is not a substitute for load(), and it is not a way to build a reusable library of dependency snippets in some other repository. It is a structuring tool for one repo's root dependency policy.5,4

What segmentation fixes, and what it does not

Segmentation fixes the mechanics of editing. A repo can separate Go dependencies from JavaScript dependencies, or keep a dedicated file for security-sensitive overrides, so unrelated changes collide less often in code review and merges.5,7,8 It also makes the file easier to browse: a team can learn "our changes usually live in go.MODULE.bazel" instead of scanning a 2,000-line root file.3,7

What segmentation does not fix is the policy model behind that file.

The remaining gaps are worth naming directly: there is still no grouping construct that applies one override directive to a set of dependencies, the root file still remains the place where organizationally important dependency edits land, and directory-based reviewer ownership no longer maps naturally onto the Bzlmod control plane.3 A common workaround is custom tooling that inspects MODULE.bazel diffs and assigns reviewers dynamically.3

That is the key mental model for this chapter: include() reduces merge-conflict pressure, but it does not decentralize dependency authority. You still have one root module, one effective override policy, and one final place where cross-repo dependency decisions are integrated.1,3,5

Treat scale as an ownership problem, not a syntax problem

The practical playbook is therefore half Bazel, half organization:

  • Use include() to split the root module by semantic area or by the team boundary that most often changes those dependencies.5,7
  • Keep the central surface intentionally boring: ordinary bazel_dep() declarations where possible, overrides only where necessary, and explicit review around files that contain policy exceptions.1,3
  • Move ownership and reviewer routing outside the Bazel graph itself. The broader patterns for that live in H.4.2 Stewardship and H.4.4 Review Authority.
  • Once dependency churn is continuous, pair that ownership model with automation rather than expecting humans to hand-curate every edit. That is where 6.7.3 Automating Bazel Dependency Updates become the next layer.
key takeaway

include() is structure, not federation. It helps you turn one huge MODULE.bazel into readable semantic segments, and it reduces merge conflicts. But Bzlmod still keeps dependency authority at the root module on purpose. At scale, the winning pattern is a small, segmented control plane plus explicit ownership and automation around it, not a return to scattered, independently managed dependency declarations.

Check your understanding · 3 questions

1.A monorepo's MODULE.bazel has grown to 2,000 lines with 100+ overrides. The team uses include() to split it into go.MODULE.bazel and js.MODULE.bazel. What does this achieve, and what does it NOT achieve?

Select one answer

2.True or false about MODULE.bazel include():

Choose True or False for each sentence

Included files can live in any external repository and be referenced by their repository label.
Variable bindings defined in an included file are local to that file and do not leak into the including file.
include() is available to all modules, including published library modules in BCR.

3.Match each scale challenge to the correct response strategy:

Drag each answer onto the matching prompt, or click an answer and then click a prompt

Answers
2,000-line MODULE.bazel causing review churn
100+ overrides owned by different teams
No reviewer routing tied to Bzlmod file sections
Continuous dependency version updates
0 of 3 answered

Footnotes

  1. Bazel modules — only the root module's overrides take effect, including overrides for transitive dependencies 1 2 3 4

  2. Multiple external dependency closures in Bazel — single-version policy as a governance model owned by a root monorepo group or CODEOWNERS 1 2

  3. How Uber Manages Go Dependencies with Bzlmod — 1,000+ direct deps, 2,000-line MODULE.bazel, 100-200 overrides, ownership mismatch, and dynamic reviewer routing 1 2 3 4 5 6 7 8

  4. Frequently asked questions — why load() is forbidden in MODULE.bazel and why root-module include() is the supported monorepo-scale alternative 1 2 3

  5. MODULE.bazel filesinclude() semantics, file-local bindings, root/non-registry restriction, and *.MODULE.bazel file requirements 1 2 3 4 5 6 7

  6. Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensionsinclude available as of Bazel 7.2.0 in the WORKSPACE vs MODULE.bazel comparison

  7. Bzlmod and Bazel 8 — language-specific include() pattern (go.MODULE.bazel, js.MODULE.bazel) and code-ownership motivation 1 2 3 4

  8. State of the Union - Tobias Werth & John Field, Googleinclude support as a way to improve structure and reduce merge conflicts 1 2