4.10.4 Extension Design Patterns
Module extensions are most useful when they turn messy setup logic into a small, stable MODULE.bazel API. The design work is not just "move the old WORKSPACE macro into an extension." A good extension separates graph-wide configuration from repository creation, keeps generated repositories in the right scope, and gives users a predictable path from tags to use_repo() and register_toolchains().1
One extension cannot create a repo and then load from it.
The dependency stage can use data from the earlier repo.
Split only for genuine stages.
The extension chooses repos; Bazel still resolves toolchains.
register_toolchains()
The module imports and registers the public toolchain repo.
Keep proven setup logic, but not WORKSPACE ordering.
WORKSPACE and Bzlmod share logic without ordering tricks.
The root module needs the extension's public repo names.
Bazel can warn and repair the root module's imports.
Pattern 1: Split configuration from dependency creation
The first pressure point appears when old setup code creates a repository and later loads a .bzl file from that repository. In WORKSPACE, that often worked because statements ran in order. In Bzlmod, a module extension can only load() from repositories that are already visible to the extension's defining module. It cannot create a repository and then load() from it inside the same extension. This is a circular repository definition problem: one extension creates the config repo, a second extension loads code that depends on that repo.2
That split gives the user a two-stage API:
scala_config = use_extension("@rules_scala//scala/extensions:config.bzl", "scala_config")
scala_config.settings(scala_version = "2.13.16")
use_repo(scala_config, "rules_scala_config")
scala_deps = use_extension("@rules_scala//scala/extensions:deps.bzl", "scala_deps")
scala_deps.scala()
scala_deps.scalatest()
The shape matters more than the Scala example. The config extension parses tags, applies defaults, and generates config repositories. The deps extension creates repositories that depend on that config. This keeps each stage loadable and gives maintainers a clearer boundary for testing and evolution.2 The same idea generalizes to legacy macros that computed repository names: when Label() accessors break under Bzlmod scope rules, simpler fixes come first, but separate extensions are a valid option when the setup really has distinct repository-instantiation stages.3
Use this split when the dependency stage genuinely needs data produced by an earlier stage. Do not split extensions merely for aesthetic layering. Each use_extension() path is part of the extension identity, so moving or re-exporting extensions later can create a new identity and cause tags to be evaluated separately.1
Pattern 2: Put toolchain declarations in a generated toolchain repository
Toolchain setup is a common reason old WORKSPACE APIs were painful. At the extension-design level, the important move is to generate a small repository of toolchain() targets while keeping implementation repositories inside the extension's namespace. The extension turns graph-wide version intent into repositories. It does not replace Bazel's platform-aware toolchain resolution.4,5
That is enough to recognize the pattern while designing an extension. Continue with 4.11.2 Toolchainization for the full public ruleset contract: hub and artifact repository shape, use_repo() and registration, lazy heavy downloads, root overrides, development-only setup, and shared WORKSPACE/Bzlmod implementations.
Pattern 3: Wrap old WORKSPACE setup without preserving old WORKSPACE semantics
Many rulesets already have mature *_deps(), *_setup(), or *_toolchains() macros. A module extension can call those macros internally, which is often the fastest migration path and keeps Bzlmod and legacy WORKSPACE users on shared implementation code.2,4,6 The thin-wrapper version looks like this:
def _deps_impl(module_ctx):
settings = _collect_settings(module_ctx.modules)
scala_toolchains(**settings)
scala_deps = module_extension(
implementation = _deps_impl,
tag_classes = {"settings": _settings},
)
The wrapper should translate, not blindly expose. MODULE.bazel has different semantics from WORKSPACE: tags are gathered across the module graph, extension repositories live in an extension namespace, use_repo() controls which generated repos enter a module's scope, and native.register_toolchains() is not called from the extension implementation.2 A Bzlmod API should make those semantics explicit instead of leaking old ordering assumptions into new tags.
Keep the files split by audience while you do this. Extension and WORKSPACE setup files need repository rules, tag classes, and module-extension objects. BUILD-facing rule and aspect files should not need that setup surface.6 This makes it easier to keep the wrapper thin without turning every .bzl file into a bootstrapping grab bag.
When wrapping legacy macros, review every place the macro computes repository names or constructs labels. Legacy macros that pass computed names through Label() can fail when called from a module extension unless the needed repo is in scope. Better designs pass repo names as dependency attributes, emit config for later evaluation inside a generated repository, chain extensions only when necessary, or move target generation into a BUILD-file macro.3 This is the same boundary discipline introduced in 4.9 Repository Rules: repository rules and setup macros are powerful because they run before the normal build graph, so the extension API must model their inputs deliberately.
Pattern 4: Make the generated repository set tidyable
An extension's generated repositories are part of its public API. If a user's module should import some of them with use_repo(), return module_ctx.extension_metadata() with root_module_direct_deps and root_module_direct_dev_deps so Bazel can warn and bazel mod tidy can repair the root module's imports.7 Use "all" only when every generated repository should be imported. Otherwise return the explicit names that form the public surface.
This is also where a design pattern becomes a maintenance contract. Put each public extension in a stable .bzl file, document the tag classes and generated repo names, and test with bazel mod deps when developing because normal extension evaluation is lazy.1 The user-facing docs should talk in apparent repository names. 4.10.5 Repo Name Handling explains canonical generated names and the deeper label-mapping details.
Design a module extension around stages, not around the old macro file you happened to inherit. If setup needs generated configuration, split config and deps. If setup installs tools, produce a small toolchain-registration repository and leave the complete consumer-facing setup contract to 4.11.2 Toolchainization. If setup wraps WORKSPACE macros, translate them into graph-wide tags and explicit use_repo() contracts instead of preserving ordering tricks.
The best extension API is small in MODULE.bazel, but precise underneath: stable extension identity, deliberate generated repo names, tidy metadata, and repository rules that remain reusable outside the extension.
Check your understanding · 4 questions
1.When should a ruleset split module-extension setup into separate config and deps extensions?
Select one answer
2.Which statements describe healthy module-extension design patterns?
Select all that apply
3.An extension generates rules_x_toolchains, rules_x_docs, and private per-platform SDK repos. Only the first two should be imported by root modules. What should its tidy metadata do?
Select one answer
4.What is the module-extension design boundary for generated toolchains?
Select one answer
Footnotes
-
Module extensions — extension usage, lazy evaluation, identity, generated repository visibility, and best practices for extension files. ↩1 ↩2 ↩3
-
Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensions — WORKSPACE vs MODULE.bazel differences, circular definition constraint, config/deps split, and wrapping setup macros. ↩1 ↩2 ↩3 ↩4
-
Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 2 — computed repository name and
Label()pitfalls, dependency-attribute fixes, generated config, chained extensions, and macro extraction. ↩1 ↩2 -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization — toolchainization pattern, generated toolchain repositories, dependency encapsulation, and WORKSPACE/Bzlmod shared implementation. ↩1 ↩2
-
Writing Bazel rules: module extensions — Go toolchainization example with tag aggregation, highest-version selection, per-platform repos, and
go_toolchains. ↩ -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1 — similar WORKSPACE/Bzlmod APIs,
scala_toolchains, non-module dependency wrappers, and separating extension-facing.bzlfiles from rule/aspect files. ↩1 ↩2 -
module_ctx —
module_ctx.modules,extension_metadata(),root_module_direct_deps,root_module_direct_dev_deps, and shared repository-rule-like APIs. ↩