1. Why you need a dependency policy
When a project is small, dependencies usually look like an innocent impulse buy at the checkout: “well, let’s grab a library for X; it’ll be faster.” But as the project grows, every external library starts behaving like a new team member: you have to trust it, you have to update it, and sometimes you also have to figure out why it suddenly broke the build on Friday night.
A dependency policy is a set of pre-agreed rules by which a team (or you, working solo) makes decisions: whether you can add external packages, under what conditions, how to update versions, how to review changes, and how to keep the build reproducible. This isn’t bureaucracy for bureaucracy’s sake; it’s a way to make sure the project builds the same way on your machine, on a colleague’s machine, and on CI.
What reproducibility means in everyday terms
Reproducibility is when “the same source code” (and pinned dependencies) produces “the same result” regardless of the machine and the mood of the universe. In Go, the module system helps a lot: dependency versions are pinned, and go.sum acts like a list of checksum “fingerprints” for downloaded modules.
It’s important to understand that reproducibility isn’t only about the binary and go build. It’s also about tests, tool output, and the behavior of your commands. If your CLI prints tasks in one order today and in a different order tomorrow (because you accidentally iterated a map), then the build is “reproducible,” but the program’s behavior is not. And the user doesn’t care: it hurts here and now.
In Go, the very idea of determinism and reproducibility is part of the toolchain and ecosystem philosophy: version and build management has long been considered one of the key responsibilities of the tooling.
2. How to keep dependencies under control
A dependency policy isn’t one “magic bullet,” but a set of habits: check the standard library first, treat go.mod/go.sum carefully, “clean up” regularly with tidy, pin tool versions separately, and use replace cautiously.
Standard library first
When you’re looking for a solution, the first question should be boring and pragmatic: “Is this in the standard library?” And yes, it sounds like advice from the “drink water and get enough sleep” category, but it works.
Go’s standard library is large, stable, and compatible. It’s updated along with Go itself, follows the same compatibility rules, and—crucially—doesn’t bring along a transitive zoo of twenty more dependencies.
Imagine we’re developing our learning app—a small task manager (a hypothetical todo/tasks) that can already store tasks in memory and print them to the console. At this stage we regularly need things like sorting, formatting, time handling, JSON/CSV, and the file system. All of that exists in the standard library.
Sometimes you want to grab an external library “for pretty table output,” “for configs,” “for logs,” “for everything.” But in practice it often turns out the standard library covers 80% of your needs, and the remaining 20% can be implemented with a small amount of your own code that you understand and control.
A small “sanity table” for deciding “do we take a dependency or not”
| Situation | What’s usually more reasonable | Why |
|---|---|---|
| You need sorting, strings, JSON, files, HTTP | Standard library | Less risk, fewer updates, fewer surprises |
| You need a narrow algorithm/format that’s genuinely complex | An external dependency may be appropriate | Sometimes it’s cheaper to “buy” a maintained solution |
| “I want a library because that’s what articles use” | Usually you don’t need it | An article optimizes for “nice,” and you end up living with the dependency |
go.mod and go.sum are part of the contract
When you see go.mod, don’t think “a file for Go”; think “a document: which dependencies the project needs to build.” When you see go.sum, think “a document: which exact versions were downloaded and verified.” And that’s directly related to reproducibility.
At some point Go became noticeably stricter about module state: instead of “quietly fixing” a problem in go.mod, the tool more often reports an error and suggests the command that fixes it. This is done for predictability and explicitness: it’s better for you to see the problem and fix it intentionally than for the tool to start “healing” your project behind your back.
go mod tidy as daily hygiene
It’s very easy to let go.mod and go.sum drift into a state of “there’s something in there, but nobody knows why.” For example, you added an import, then removed it, then renamed a package, and go.sum ballooned. Or the other way around: you use a new package, but it’s not in go.mod, and the build “suddenly” doesn’t work on another machine.
go mod tidy is exactly about bringing dependencies into alignment with the actual code: add what’s truly used and remove what’s no longer needed. It’s not magic; it’s cleanup.
A “gentle” working scenario to keep in your head
Imagine you added a new package to our todo project (even an internal one, not an external dependency). The right sequence of thoughts is: “I changed imports → I should tidy up the module files → I should verify the changes are understandable and expected.”
In newer versions of Go, additional conveniences around this discipline appear as well—for example, modes that let you preview the planned changes first and only then accept them and commit.
Tool dependencies: pin versions the same way as libraries
There’s a subtle but important distinction: dependencies can be “runtime” (needed by your code) and “dev dependencies” (needed by you as a developer). A dev dependency example: a linter, a generator, a formatter, a development utility.
If a tool is installed “however it happens,” then one machine might check the code with one golangci-lint version and another machine with a different one. The result is like two English teachers arguing over punctuation because one uses a 1998 textbook and the other uses a 2024 edition.
At some point Go explicitly highlighted the right practice: install binary tools with an explicit version via go install pkg@version, while not touching your project’s go.mod. This is designed so that installing tools doesn’t turn into accidental editing of your application’s dependencies.
replace: powerful, but dangerous for reproducibility
replace is useful, but it’s like super glue: it holds tight, but tearing it off later hurts. The main problem with replace, especially with local paths, is that the project stops being self-contained: it “builds” for you, but not for a colleague, because they don’t have your local folder ../some-lib.
If you use replace, you should have a policy: why it’s done, how long it’s acceptable, and what counts as the “signal to remove replace.” In learning projects it’s often tolerated as a temporary crutch, but in a real project it’s better to treat it as an exception, not the norm.
3. Reproducibility of results: determinism and process
Build reproducibility is often broken not by a “package version” but by “behavioral randomness.” And here we move to very practical things that suddenly turn out to be part of the same quality policy: identical inputs should produce identical outputs.
map iteration order and stable output
In Go, the iteration order of a map is not guaranteed. If you print tasks “however it comes out,” users and tests will suffer.
In our app, suppose we have tag statistics for tasks—a map[string]int counter. We want to print it stably.
package report
import (
"sort"
"strconv"
"strings"
)
func StablePairs(m map[string]int) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(strconv.Itoa(m[k]))
b.WriteString("\n")
}
return b.String()
}
This code is small and boring—which means it’s reliable. You “pay” with a few lines, but you get stable output, and with it: sane tests, sane diffs, and fewer surprises.
Time (time.Now()) as a source of non-reproducible tests and logic
The second classic source of randomness is the current time. If your code calls time.Now() inside business logic, tests can become either complicated or brittle.
A simple pattern is to introduce a Clock interface and a real implementation.
package audit
import "time"
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
func AgeInDays(c Clock, created time.Time) int {
d := c.Now().Sub(created)
return int(d.Hours() / 24)
}
This separates “pure logic” from the time source. This directly relates to quality: you control the runtime environment, and results become predictable.
A mini-protocol for changing dependencies
When you add (or remove) a dependency, it’s very important not to do it in the “well, it builds on my machine” style. It’s better to keep a short, repeatable protocol in mind—like a quality gate, just for dependencies.
A healthy sequence looks like this: you add an import or a package, then bring the module files in line with the real code via tidy, then review the changes as a meaningful diff (“what exactly was added and why”), then run tests. If the project prints data, you also verify the output is deterministic and doesn’t depend on map order or time.
The habit matters here: a dependency is an architectural change to the project, even if it looks like a single import line.
Where dependency risks live in a project
To cement the mental model, it helps to imagine dependencies as layers, where each layer adds its own risks:
flowchart TD
A[Your code] --> B[Standard library]
A --> C[External libraries]
C --> D[Transitive dependencies]
A --> E[Development tools]
E --> F[Tool versions on machines/CI]
B --> G[Minimal risk and stability]
D --> H[Growing risk surface area]
F --> I[Drift in check quality]
The meaning of the diagram is simple: the farther you move away from the standard library, the larger the “risk surface.” This doesn’t mean “external libraries are evil”; it means “every library has to earn its keep.”
4. Typical mistakes
Mistake #1: “let’s add a library and figure it out later.”
That’s how it usually starts: one dependency for logs, one for configs, one for JSON (even though it’s in the standard library), and then suddenly go.mod looks like a wedding guest list you didn’t personally invite. The right move is to first ask yourself what exactly the standard library can’t do, and only then decide.
Mistake #2: not running go mod tidy regularly and intentionally.
A project can “accidentally” build on your machine because you already have the needed modules in cache, or the IDE pulled something in automatically. But on a clean machine, everything falls apart. tidy is a way to synchronize the reality of the code with the reality of the modules, and that’s exactly why it’s part of the quality gate.
Mistake #3: using replace as the project’s permanent state.
Local replace directives are convenient, but they almost always break reproducibility for other people and CI. If you can’t avoid replace, it should be temporary, documented, and have a clear exit plan. Otherwise you’re “signing up” for perpetual build instability.
Mistake #4: not pinning development tool versions.
One person runs linter version X, another runs version Y, and they argue about who wrote the code “correctly.” Usually nobody is right: the tools are simply different. If you agree that a tool matters for quality, then its version must be managed; otherwise the quality gate turns into a lottery.
Mistake #5: confusing “the build is reproducible” with “the result is deterministic.”
You can pin dependencies perfectly and still get different results if you print a map without sorting or use time.Now() inside your logic. Reproducibility isn’t only about module versions; it’s also about predictable program behavior.
GO TO FULL VERSION