Learn · Learning Bazel
seedling
Who Is Allowed to Depend on This?
An attribute you have been copying without reading turns out to be the only architectural boundary in your repository that cannot be violated by accident.
By the end of this chapter you will have watched the build refuse a dependency, and you will be able to explain the choice that refusal forces. You will also know why the answer "just make everything public" is available, cheap, and worth resisting.
The attribute you have been ignoring
Since chapter three, every library declaration has carried a line you have had no reason to think about:
server/greeting/BUILD.bazel
load("@rules_go//go:def.bzl", "go_library")
go_library(
name = "greeting",
srcs = ["greeting.go"],
# go:embed reads this file, so the build has to be told about it.
# Without this line the compiler cannot see it, even though it sits
# right next to greeting.go on disk.
embedsrcs = ["name.txt"],
importpath = "example.com/greet/server/greeting",
visibility = ["//server:__subpackages__"],
)visibility says which packages may depend on this target. //server:__subpackages__ means //server and anything beneath it.
With one consumer, that line does nothing you can observe. It is the kind of boilerplate you copy from the target above and stop seeing.
Give the workspace a second consumer and it wakes up.
A perfectly reasonable mistake
Suppose you want a small command-line tool alongside the server. It should print the same greeting, and there is already a package that builds greetings, so you reach for it:
cmd/hello/main.go
package main
import (
"fmt"
"example.com/greet/server/greeting"
)
func main() {
fmt.Println(greeting.Greet())
}A new command in a new directory, importing the existing greeting package.
cmd/hello/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "hello_lib",
srcs = ["main.go"],
importpath = "example.com/greet/cmd/hello",
deps = ["//server/greeting"],
)
go_binary(
name = "hello",
embed = [":hello_lib"],
)Its declaration, depending on //server/greeting exactly the way //server does.
This is not a contrived error. It is the most natural thing in the world: the code you need exists, you import it, you move on. In most build systems that is the end of the story and the second caller is now a fact.
The build says no
$ bazel build //cmd/hello
ERROR: in go_library rule //cmd/hello:hello_lib: Visibility error:
target '//server/greeting:greeting' is not visible from
target '//cmd/hello:hello_lib'
Recommendation: modify the visibility declaration if you think the
dependency is legitimate. For more info see
https://bazel.build/concepts/visibilityThe build refuses during analysis, before compiling anything. The message names both ends of the edge it rejected.
Notice what the error does not say. It does not say you did something wrong. It says this dependency is not currently allowed, and that if you think it should be, the way to say so is to change the declaration.
The build is not making an architectural judgement. It is telling you that one is required, and that you are the one who has to make it.
Visibility turns an intention into a fact. "This package is internal to the server" stops being a comment that reviewers must notice and becomes a property the build enforces on every edge, every time, without anybody remembering.
The choice
You now have two ways forward, and the difference between them is the actual content of this chapter.
Name the new consumer. The greeting package grows its list to say that //cmd may also use it:
server/greeting/BUILD.bazel
load("@rules_go//go:def.bzl", "go_library")
go_library(
name = "greeting",
srcs = ["greeting.go"],
# go:embed reads this file, so the build has to be told about it.
# Without this line the compiler cannot see it, even though it sits
# right next to greeting.go on disk.
embedsrcs = ["name.txt"],
importpath = "example.com/greet/server/greeting",
# //cmd/hello needs this too, so the package list grows to name it.
# Widening to //visibility:public would work and would also stop the
# build system from ever asking this question again.
visibility = [
"//cmd:__subpackages__",
"//server:__subpackages__",
],
)Two entries instead of one. The boundary moved, deliberately, and the file records who is inside it.
Or widen it to everything. visibility = ["//visibility:public"] also fixes the error, in less typing, and permanently. The build will never ask again — about this consumer or any future one.
Both compile. The second is faster today and costs you the mechanism. Once a target is public, "who depends on this?" becomes a question you answer by searching rather than by reading, and the next person to add a consumer gets no prompt to think about whether they should.
Public is not always wrong. A target genuinely meant for the whole repository — a shared logging library, a generated schema — should say so. The point is that it be a decision somebody made, not the residue of the fastest way past an error message.
What public actually costs
The two options both compile, so the argument for one over the other has to be about something else. Here it is, concretely.
Suppose a second team adds a reporting tool somewhere else in the tree and reaches for the same internal package:
$ bazel build //vendor/report
ERROR: in go_library rule //vendor/report:report_lib: Visibility error:
target '//server/greeting:greeting' is not visible from
target '//vendor/report:report_lib'A new consumer, in a different part of the repository, depending on the server's internal greeting package.
Refused, as expected. Now take the quick way out:
$ bazel build //vendor/report
INFO: Build completed successfully, 15 total actions
$ bazel run //vendor/report
Hello worldOne attribute, and the refusal is gone — for this consumer and every future one.
It works. Nothing is broken, no test fails, and the reviewer sees a one-line diff that makes a build error go away.
Here is what it cost. Ask the file who is allowed to depend on this package:
visibility = ["//visibility:public"],The public version. The question has no answer here any more.
visibility = [
"//server:__subpackages__",
"//vendor/report:__pkg__",
],The scoped version, which also builds, and which answers it.
Same build, same binary, same output. The difference is that one of these files still knows something and the other has forgotten.
Going public does not add a dependency — it removes the question. From then on nobody is asked to justify a new consumer, because there is no longer a place where consumers are named. The erosion is not the first one; it is the ninth, which nobody noticed arriving.
Why the list is the better default
The listing version has a property the public version does not: it tells you something true when you read it later. A visibility list is a record of every part of the repository that was allowed in, and each entry was added by somebody who had to justify it at least to themselves.
That makes the reverse question cheap. Chapter three showed rdeps answering "what depends on this" from the graph. Visibility answers a different question — "what is permitted to depend on this" — and it answers it from a single file you can read in five seconds.
The two together are what makes a large repository navigable. One tells you what the code does; the other tells you what it was meant to do.
A door with a list of who may come in is more useful than a door propped open, even when the same people end up walking through it. The list is the part that survives everybody forgetting.
Boundaries you can move on purpose
The thing worth internalizing is that this boundary is movable. It is not a wall someone built once; it is a line in a file that changes when the design changes, with a build failure marking every moment it does.
That is a good property for architecture to have. It means the design is written down in a form that cannot silently rot, and that the moments it changes are visible in your history rather than lost in the diff of whoever needed one more import.
visibility lists which packages may depend on a target, and the build enforces it during analysis with an error naming both ends. A new consumer forces a choice: name it, and keep a readable record of who is inside the boundary — or widen to public, and never be asked again. Public should be a decision, not the fastest way past an error.
Try this in your own repository
Find a package with more consumers than it was designed for. Something written for one caller that now has six. Ask each caller whether they needed the whole thing or one function — the second answer means the boundary is in the wrong place, not that the package should be public.
Try to write down your intended boundaries. Not the ones the code has; the ones you would defend. Anywhere the two disagree is either a boundary worth enforcing or an intention worth abandoning, and it is worth deciding which.
What you can now do
Read a visibility declaration and say who may depend on the target, diagnose a visibility error, and make the boundary decision deliberately rather than by reflex. The next chapter looks at the BUILD files themselves, which have started to repeat.