I’ve heard it from people new to Bazel but also from people very familiar with the Bazel ecosystem: BUILD files must go away. And they must go away because they are redundant: they just repeat the dependency information that’s already encoded in the in-code import/use statements.

Hearing this from newcomers to Bazel isn’t surprising: after all, most newcomers are used to build tools that provide zero facilities to express dependencies across the sources of your own project. Hearing it from old-timers, however, is disappointing because it misses the point of what BUILD files can truly offer.

In my opinion: if that’s how you are writing BUILD files, you are holding them wrong. There is much more to BUILD files than mindlessly repeating import statement dependencies. Let’s see why.

The problem

Suppose you are given the following change to review:

--- a/src/main/com/example/compiler/parser/Parser.java
+++ b/src/main/com/example/compiler/parser/Parser.java
@@ -1,8 +1,10 @@
 package com.example.compiler.parser;
 
 import com.example.compiler.ast.Ast;
+import com.example.compiler.ast.Statement;
 import com.example.compiler.lexer.Lexer;
 import com.example.compiler.lexer.Token;
+import com.example.compiler.utils.FileReader;
 
 // Parser for a simple language.
 class Parser {

By looking at this diff, possibly from a Pull Request (PR) review, you can guess the following:

  • The com.example.compiler.parser Java package already depends on the com.example.compiler.ast package.

  • The com.example.compiler.parser Java package already depends on the com.example.compiler.lexer package.

  • The addition of the import com.example.compiler.ast.Statement line does not modify the dependency graph: the edge from the parser package to the ast package existed beforehand, and this new import statement is just leveraging it.

  • The addition of the import com.example.compiler.utils.FileReader line is… uh, well, given this limited context, you just can’t tell! Is it OK or is it not? Did com.example.compiler.parser already depend on com.example.compiler.utils via some other file in the same package—in which case this new import changes nothing dependency-wise—or did it not—in which case this new import deserves questioning from a high-level architecture perspective?

Software architecture

The snippet I presented above is for Java but, in reality, the problem I described applies to every other language: all languages out there have some sort of import/use statements and all languages have some sort of mechanism to group code in module-like entities. By inspecting standalone changes at the file level, we cannot tell whether new cross-module dependencies are being introduced or not.

And being able to reason about modules is critical: we humans work best when we can reason about higher level relationships than files. We think of software as a collection of modules with layered dependencies and constraints that should not be violated. Enforcing these conceptual models via import/use statements is impossible, but the build graph—the very thing that BUILD files define—is the best place to encode them in a programmatic manner.

So: my point is that BUILD files give you a chance to encode the high-level architecture of your software project as a graph of dependencies that lives outside of the code. If you keep your BUILD files lean and human-managed, you have a good chance of detecting invalid dependencies from a layering perspective as soon as they are introduced.

The word “lean” in the previous paragraph is doing a lot of the heavy lifting though because by “lean” I mean simple BUILD files that define targets that map to concepts. This bypasses “best practices” that dictate one BUILD file per directory because you may need to use recursive globs to group sources into larger conceptual units, and this can also result in reduced build performance because you end up with fewer, larger targets. And that’s fine.

For one, if recursive globs are a problem because they end up bundling too many related concepts in one target, you have got a problem with your directory structure and you should fix that. And for another, if larger targets end up hurting build performance, you have got a problem with your modularity and you should work towards breaking those big targets apart. At the end of the day, these two issues are symptoms of having too many unrelated concepts in one module. Simplifying the build structure may result in a transient performance regression, but working towards breaking those apart will help everyone in your organization.

None of this is novel though, as these ideas can be found outside of Bazel. Think about shared libraries in large C or C++ projects, multiple Maven modules in a large Java code base, or multiple crates in a large Rust project. If you have ever done any of these, you know that manually defining modules is useful because it forces you and your fellow developers to think in terms of APIs at the module boundaries.

Changing the module-level architecture of a project is something that happens infrequently and, when it does, you want the more senior people in the team to question and review such changes. And, for that, you must make these changes visible as soon as they happen.

Reverse dependencies

Expressing modules in your build graph is great, but people seem to like having tools to automatically update dependencies based on code changes. This is not incompatible with what I have said so far, but in order to keep a clean software architecture, you will need to have a strong code review culture because any undesirable new edges introduced in a change will have to be vetted up at code review. But… what if they aren’t? Can we do better?

Of course we can! Bazel gives us a way to express restrictions via reverse dependencies: aka visibility rules. When you maintain a conceptual dependency graph by hand, you will find cases where you want to express things like:

  • com.example.compiler.utils can be consumed from com.example.compiler.lexer, which is the lowest level layer of the compiler.

  • com.example.compiler.utils cannot be consumed from any other layer unless we discuss the implications.

Visibility rules allow you to express these restrictions programmatically. The difference with forward dependencies is that, if you ever wanted to use com.example.compiler.utils from a module that has not been pre-declared as an allowed consumer, you would need to modify the BUILD file definitions in com.example.compiler.utils to widen the visibility rules. This would require talking to the owners of such module, either in person or via the code review, to be allowed as a consumer of those APIs.

Putting it all together

Now that we know the theory behind my proposal, let’s revisit the utils package from the earlier example. To enforce our desired architecture, the BUILD file in src/main/com/example/compiler/utils/BUILD might look like this:

java_library(
    name = "utils",
    srcs = glob(["*.java"]),
    visibility = [
        "//src/main/com/example/compiler/lexer:__pkg__",
    ],
)

This is a “lean” BUILD file. It defines a single, conceptual utils library, and doesn’t bother about specifying source files: it trusts that whatever you throw into the com/example/compiler/utils/ directory truly belongs to that module. Most importantly, the visibility attribute declares that only code within the lexer package is allowed to depend on this utils library.

With this rule in place, the problematic code change we saw earlier (adding an import of FileReader to the parser) would no longer be a silent, ambiguous change. The moment the developer (or the CI system) tries to build the code, they would get an immediate, explicit error from Bazel stating that the parser target is not allowed to see the utils target.

The architectural violation is caught automatically. The desired conversation with the module owners is now forced to happen, exactly as intended.

Helping AI models

Finally, we get to the most hyped topic of all times: AI agents. Remember when I said above that a clean conceptual module-based architecture is critical for humans to understand how a project works? Well, guess what, the same applies to AI models.

If you try to use AI agents on an existing codebase, you will notice that they try to reason about the current architecture by reading individual file names and their contents, and then chasing through their file-level dependencies.

But what if you could make these AI agents to follow your conceptual dependency chain by teaching them, via an MCP server, to follow your build graph? Presumably, their ability to reason would increase because they’d be faced with cleaner concepts that explain the story behind your codebase in big blocks.

Your turn

I hope to have convinced you that manually managing your BUILD files in a Bazel project is a good idea for long-term maintainability and for the successful use of AI tools. For this to be possible, you have to forego the “standard practice” of having very small BUILD targets and instead capture your conceptual modular architecture in the build graph. And once you do that, BUILD files magically become manageable by humans, without the need for fancy automation that pushes complexity under the rug.

But that’s just my opinion.