Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1¶
As we're well aware by now, Bzlmod is the new hotness, and WORKSPACE is old and
busted and going away in Bazel 9. However, Bazel 8 still supports
WORKSPACE
, and thus legacy WORKSPACE
usage won't completely disappear for
some years yet. Despite the Bazel community's efforts to help facilitate Bzlmod
migrations (including this blog series), some projects may remain unable to
migrate sooner than later.
What's more, publishing your repository for use by other projects raises the
challenge of supporting a range of older and newer versions of its dependencies.
Your repository should work with the newest versions of Bazel, rules_java
,
protobuf
, and other dependencies in order to stay current. However, not every
project that could benefit from the latest version of your repository will want
to upgrade these other dependencies right away.
This post describes how to design a repository to remain compatible with both
WORKSPACE
and Bzlmod, and with newer and older dependency versions. Just as
importantly, in the next two posts, we'll discuss testing approaches to help
ensure that this remains the case.
This article is part of the series "Migrating to Bazel Modules (a.k.a. Bzlmod)":
- Migrating to Bazel Modules (a.k.a. Bzlmod) - The Easy Parts
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names and Runfiles
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names and rules_pkg
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names, Macros, and Variables
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensions
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Fixing and Patching Breakages
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names, Again…
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1
Prerequisites¶
As always, please acquaint yourself with the following concepts pertaining to external repositories if you've yet to do so:
Library vs. root modules¶
The advice in this post applies primarily to "library" modules that are dependencies of other modules—especially open source Bazel Central Registry modules.
"Root" modules that are never dependencies of other modules need not maintain
compatibility with WORKSPACE
or older dependency versions. For these modules,
using Bzlmod exclusively and up to date dependency versions is generally the
best approach.
Note that we're overloading the term "root" in the Bazel context. When developing a "library" module, it will be the root module of its own Bazel build.
Compatibility with WORKSPACE and older dependencies eases Bzlmod adoption¶
Maintaining compatibility with WORKSPACE
and with a broad range of dependency
versions (including Bazel versions) helps establish a workable path towards
Bzlmod adoption. It enables projects to adopt newer, Bzlmod compatible versions
of your repository without forcing disruptive changes—like unwanted
dependency updates or an immediate Bzlmod migration. When they're eventually
ready to migrate to Bzlmod, or to update other dependencies, your project
will have already made it possible.
Explicit version ranges are especially helpful to WORKSPACE users.
Maintaining and documenting support for an explicit range of dependency
versions can prove especially helpful to WORKSPACE
users. Whereas Bzlmod
can automatically detect and clearly report version incompatibilities,
WORKSPACE
conflicts are generally more opaque. Identifying which
dependency versions are compatible with your repository may provide insight
into otherwise difficult to diagnose WORKSPACE
dependency problems.
Minimal Version Selection principles¶
Simon Stewart inspired this blog topic in a Bazel Slack chat on 2025-04-02, echoing the principles behind Bzlmod's Minimal Version Selection mechanism:
As someone who maintains rulesets, it’s dawning on me that I should probably be listing the lowest possible versions of my ruleset’s
bazel_dep
s so that I don’t force projects who use them to update unless they want to. That is, it feels that the root module should be the one that specifies the highest version of anybazel_dep
, and non-root modules should be specifying the lowest version that they work with.The reasoning is that this means that the root module can then pick more recent versions without being forced to take an update it doesn’t want.
How are other people thinking about this?
Chuck Grindel noted that there's no official consensus, but that in bazel-contrib/SIG-rules-authors#82: Renovate Preset for Bazel Rulesets he proposed:
Do not update dependencies defined in the root
MODULE.bazel
. The idea is that a ruleset should strive to require the lowest viable version of an external dependency so that clients can dictate the version of a dependency to use.Find and update dependencies defined in child workspaces (i.e., in a repository subdirectory). While a ruleset should require the lowest viable version of an external dependency, ensuring that the ruleset works with the most recent version of an external dependency is desirable. Hence, example and test workspaces should be updated to the latest version.
While I'd carefully ensured both WORKSPACE
and Bzlmod compatibility for
rules_scala
from the beginning, I confessed that:
I haven't thought much about this yet, but I have been cranking the
bazel_deps
in myrules_scala
work up to eleven as time has gone on. It did occur to me not everyone will want to jump toprotobuf
v30.2 out of the box, and won't necessarily like having to use asingle_version_override
to avoid it.Guess I'll have to prep a branch to roll the volume down to seven after bazelbuild/rules_scala#1722 lands.
Indeed, I took Chuck's advice to heart, and eventually landed:
- bazel-contrib/rules_scala#1726: Establish minimum compatible Bazel, dep versions
- bazel-contrib/rules_scala#1729: Add test_dependency_versions, update test_runner
The rules_scala README now explicitly documents its minimum dependency requirements as well.
The advice below reflects techniques from those pull requests, as well as
techniques I'd applied to maintain WORKSPACE
compatibility while implementing
Bzlmod support.
Dependency challenges aren't new, but are amplified under Bzlmod¶
Arguably, maintaining compatibility with a range of dependency versions has been
a longstanding problem throughout the history of software development. However,
Bzlmod is less forgiving than WORKSPACE
in many ways, and adopting it may
require significant effort (hence, again, this blog series). Though Bzlmod's
strictness is a feature yielding many benefits, narrow dependency requirements
can further inhibit projects currently working under WORKSPACE
from switching
to Bzlmod.
Elements of compatible design¶
Maintaining compatibility begins with proper design, both of the public interface and the internal structure. Correcting such issues may require fundamental changes that go beyond all the other techniques covered throughout this Bzlmod blog series. This post offers a few suggestions for achieving a broadly compatible design.
Make the WORKSPACE and Bzlmod APIs as similar as possible¶
This is the primary principle driving continued WORKSPACE
support. From a
project's perspective, the WORKSPACE
and Bzlmod models are merely two
different ways of expressing the same essential configuration information. All
the dependency setup macros and ordering sensitivity of the WORKSPACE
model
notwithstanding, the same clusters of actual configuration options remain the
same. With careful design, the APIs supporting each model can prove only
superficially different, despite the radically different dependency resolution
models.
In the Toolchainization post, I mentioned that rules_scala 7.0.0
introduced a new WORKSPACE
API that was very similar to the new Bzlmod API. I
designed both APIs to ensure that the Bzlmod API is essentially a thin layer
sitting on top of the WORKSPACE
API. Specifically:
-
The Bzlmod module extension layer merely collects information from its tag classes instantiated throughout the module graph.
-
It then applies this information as arguments to the same
scala_toolchains()
macro used directly byWORKSPACE
files.
Here's the WORKSPACE
configuration for the rules_scala
v7.0.0 Scala,
Scalafmt, and ScalaTest toolchains (without http_archive
and setup macro
boilerplate):
And here's the equivalent Bzlmod configuration:
The new v7.0.0 API dropped a lot of previous WORKSPACE macros, but all
the previous functionality remained. The new scala_toolchains
macro
consolidated options previously spread across dozens of previous macros imported
from several different files.
The existing test suite, while requiring some modifications to handle slightly
differing log outputs, proved essential to ensuring compatibility between
WORKSPACE
and Bzlmod builds. Then when others wondered whether it was worth
launching Bzlmod separately from WORKSPACE
updates, I could confidently assert
that it was a moot point. No one who remained on WORKSPACE
would have any
contact with the new Bzlmod layer, which already relied upon the same
implementation.
However, such users would have to update to the new WORKSPACE
API, which
closely resembles the Bzlmod API. This meant we could force WORKSPACE
users
to take material steps towards an eventual Bzlmod migration, but without
immediately forcing a complete migration. At the same time, the new API
shrinks WORKSPACE
configuration significantly, and basing Bzlmod functionality
on the same implementation reduces our maintenance burden.
scala_toolchains trades separation of concerns for usability¶
One may wonder if consolidating previously disjoint implementations into a
single scala_toolchains
macro actually degraded the design by mixing
previously separate concerns. Each of the previously load
ed files could've
become its own module extension. However, there's something to be said for
reducing the API surface when it improves usability without noticeably harming
performance. It makes the resulting MODULE.bazel
configuration more compact
and understandable, and thus more maintainable and less error prone.
Also, some of the previous WORKSPACE
toolchain dependency macros weren't as
disjoint as they seemed, as they'd instantiate the same Maven artifact
repositories. WORKSPACE
allows this, while Bzlmod expressly forbids it.
scala_toolchains
ensures the creation of only one instance of each repository
required by multiple toolchains, under both WORKSPACE
and Bzlmod. Having each
toolchain in a separate module extension would've worked, but in addition to
maintaining a broader API, it would've unnecessarily duplicated several
repositories.
scala_toolchains
also dynamically generates the @rules_scala_toolchains
repo
containing all configured toolchains. The "@rules_scala_toolchains//...:all"
target specifier replaces separate specifiers for each toolchain in
register_toolchains
calls. This is why scala_register_toolchains
replaced
all the WORKSPACE
registration macros, and why the rules_scala
module can
make this call automatically. Having separate toolchain repos would've
complicated this automatic registration under Bzlmod, and would've required
keeping the separate registration macros under WORKSPACE
.
See the Toolchainization blog post for much more detail.
Separate configuration .bzl files from rule and aspect .bzl files¶
WORKSPACE
and module extensions never need access to rule
or aspect
implementations, and BUILD
files never need access to repository_rule
s or
module extensions. Therefore it's good practice to keep .bzl
files used by
WORKSPACE
or module extensions separate from those used by BUILD
files.
Future Bazel versions may break .bzl
files loaded by WORKSPACE
and module
extensions that directly or transitively load
files containing rule
or
aspect
implementations. Older Bazel versions provided many symbols required
by such implementations, which are now provided by separate repositories (e.g.,
JavaInfo
now resides in rules_java
). Newer Bazel versions inject
nonfunctional versions of these symbols into the WORKSPACE
environment for
backwards compatibility.
Of course, it's best practice not to rely on backwards compatibility
workarounds. From the perspective of WORKSPACE
and module extension files,
it's best not to rely on files referencing rule
or aspect
definitions at
all. When Bazel drops the compatibility workarounds one day, such properly
partitioned .bzl
files will continue to work. This means projects using your
module won't be held back from updating Bazel one day because of your module's
intermingled .bzl
files.
Separate Bazel module compatible dependencies from non-module dependencies¶
You may need to define (at least) two macros: one to instantiate repositories
also available as Bazel modules, and one for repositories that aren't.
WORKSPACE
will invoke both macros. Under Bzlmod, you'll import the Bazel
module dependencies using bazel_dep, and wrap the macro instantiating
non-module dependencies in a module extension.
Here's a summary of how to instantiate the two kinds of dependencies under
WORKSPACE
and Bzlmod. module_deps
and nonmodule_deps
are placeholders for
whatever macro names you choose for your project.
Mode | Module dependencies | Non-module dependencies |
---|---|---|
WORKSPACE |
Invoke module_deps in WORKSPACE |
Invoke nonmodule_deps in WORKSPACE |
Bzlmod | Invoke bazel_dep in MODULE.bazel |
Invoke nonmodule_deps in a module extension |
Though the dependency version duplication between module_deps
and bazel_dep
is unavoidable, nonmodule_deps
keeps non-module versions synchronized between
WORKSPACE
and Bzlmod.
As a concrete example, here's how rules_scala
v7.0.0 instantiates its
dependencies:
Mode | Module dependencies | Non-module dependencies |
---|---|---|
WORKSPACE |
Invokes rules_scala_dependencies in WORKSPACE to instantiate bazel_skylib , rules_java , protobuf , et. al. |
Invokes scala_toolchains in WORKSPACE to instantiate the Maven dependency repos used by the builtin toolchains |
Bzlmod | Invokes bazel_dep in MODULE.bazel to instantiate bazel_skylib , et. al. |
Invokes scala_toolchains via the scala_deps module extension |
Create deps.bzl, latest_deps.bzl, and dev_deps.bzl for WORKSPACE¶
Providing macros to instantiate your repository's required dependencies, in a
file often named something like deps.bzl
, is a common WORKSPACE
convention.
In the examples from the previous section:
-
module_deps
would reside in a//:deps.bzl
file or similar.nonmodule_deps
could reside in the same file or a separate one. -
rules_scala_dependencies
resides in //scala:deps.bzl, andscala_toolchains
resides in //scala:toolchains.bzl.
Nothing to add here, other than to reiterate the point that it should contain
the minimum supported version of each required dependency. If users wish to
use a later version, instruct them to import those versions before invoking
your deps.bzl
macros.
However, you should also create a latest_deps.bzl
containing the maximum
version of each dependency supported by your project, for development use only.
load
this file into your project's WORKSPACE
file instead of deps.bzl
to
ensure your project remains compatible with current dependency versions during
development. (Remember, a future post will cover writing a backwards
compatibility test suite for older dependency versions.)
You can also create dev_deps.bzl
with a macro instantiating all of your
development only dependencies that are not also published as Bazel modules.
These dependencies can use whichever version you prefer. Although leaving
development dependencies in your WORKSPACE
file won't affect users, you can
wrap a dev_deps
macro in a module extension. As with the
nonmodule_deps
macro described in the previous section, this keeps dependency
information synchronized between WORKSPACE
and Bzlmod builds.
latest_deps.bzl and dev_deps.bzl are for development use only.
latest_deps.bzl
is the WORKSPACE
analog for the
single_version_override
directives described later in this post.
dev_deps.bzl
is the WORKSPACE
analog for Bzlmod's dev_dependency =
True
parameter for bazel_dep
, use_extension
, and register_toolchains
.
Specify the minimum supported dependency versions with bazel_dep¶
As we all know, Bzlmod automatically resolves dependency versions across all Bazel modules. Therefore, you should specify the minimum supported version of each dependency directly in each bazel_dep. This allows Bazel to select the latest compatible versions from across the entire dependency graph, which the main module can easily (and deterministically) override.
Note that bazel_dep
instances marked as dev_dependency = True
are only
included when building the project itself as the main module. Consumers of the
project shouldn't be affected by them at all. As with dev_deps.bzl
, this means
you can set these dependencies to any version you want: oldest, newest, or in
between.
Module resolution applies only to bazel_dep repositories¶
More accurately, Bazel only resolves the versions of other Bazel modules;
http_archive
repositories and the like aren't included in the version
resolution process. At the same time, http_archive
repositories and the like
are encapsulated within the module extensions that instantiate them. This also
means that each module can use its own version of such repos without
interference from any others. Under WORKSPACE
, there would be only one version
of each repo of the same name, determined by the order of WORKSPACE
statements.
Specify the latest dependency versions with single_version_override¶
As with latest_deps.bzl
, to ensure compatibility with the latest dependency
versions, use single_version_override in your MODULE.bazel
file. Like
bazel_dep instances marked with dev_dependency = True
, these overrides
only take effect when the project itself is the main module.
For example, here's how the combination of bazel_dep
and
single_version_override
looks in rules_scala
:
A nice feature of this arrangement is having working code express the minimum
and maximum supported versions of each dependency right next to each other.
(Note that the Temporarily required
comment actually applies to the patches
and patch_strip
properties, not the entire override in this case. In the
nested testing modules, which I'll describe in the next post, the entire
override is temporary.)
Encapsulate non-module development dependencies in a dev_dependency extension¶
As mentioned earlier, wrapping non-module development dependencies in a macro in
a dev_deps.bzl
file makes them convenient to encapsulate in a module
extension. In fact, scala/private/extensions/dev_deps.bzl in rules_scala
v7.0.0 includes both the WORKSPACE
macro and the module extension.
Here's how it's used in WORKSPACE
:
Using dev_deps.bzl in WORKSPACE | |
---|---|
In MODULE.bazel
, we call use_extension
with dev_dependency = True
to
ensure it doesn't impact user builds:
Keep the WORKSPACE and Bzlmod dependency versions the same¶
In case it's not immediately obvious, the minimum and maximum dependency
versions for both WORKSPACE
and Bzlmod builds should be identical. This makes
it easier for both users and maintainers to reason about, and helps avoid
unexpected version changes when switching from WORKSPACE
to Bzlmod. It might
not avoid all unexpected dependency version changes, but it should minimize
them, at least.
Create a new release when minimum dependency versions change¶
A change to the minimum supported version of any dependency necessitates a new release. This is because your module must have changed in a way to require a new minimum version of that dependency. Changes to the maximum supported version of a dependency only require a new release if the update required changes to your module as well.
Per the semantics of semantic versioning:
-
When an updated dependency doesn't produce a user visible change in your module's API, you should be able to make a minor version release. This indicates that, as long as all dependencies are within their supported ranges, existing builds should continue to work after an upgrade without further action.
-
If updating a dependency would force users to change their code after updating to your module's new version, that's cause for a major version release. This indicates that the new version of your module contains at least one known change that could potentially break existing builds.
Major version releases should include a compatibility_level bump as well.
Since major version and compatibility_level
increases can prove disruptive,
they should be relatively few and far between.
Conclusion¶
Requiring users to switch from WORKSPACE
to Bzlmod immediately, or to upgrade
multiple dependencies in addition to your own, disincentivizes progress. They
may (rightly) fear that upgrading to your latest release will unleash chaos,
motivating them to stay put and remain stuck on WORKSPACE
.
While we can't support all older dependency versions indefinitely, we can
achieve a balance that helps minimize the friction involved in pulling the
community forward. We've seen that we can keep WORKSPACE
builds intact, gently
encourage movement towards Bzlmod adoption, and accommodate many dependency
versions at the same time. When your users eventually decide to migrate to
Bzlmod, or to update Bazel or other dependencies, your project will have already
helped prepare them. What's more, it's possible to minimize the maintenance
burden of supporting both WORKSPACE
and Bzlmod by sharing a core
implementation and aligning their APIs.
Of course, it's important to actually test that your project maintains this
flexibility, instead of making unverified assumptions. The next two posts will
summarize testing approaches to validate compatibility with WORKSPACE
builds,
Bzlmod builds, and the vast majority of existing users' dependencies:
-
The next post will summarize elements of Bazel tests that allow for flexibly switching between build modes and Bazel versions, while using newer dependency versions.
-
The post after that will describe how to write a dependency version compatibility smoke test, and strategies for running tests locally and in continuous integration.
As always, I'm open to questions, suggestions, corrections, and updates relating to this series of Bzlmodification posts. It's easiest to find me lurking in the #bzlmod channel of the Bazel Slack workspace. I'd love to hear how your own Bzlmod migration is going—especially if these blog posts have helped!