Packages, modules & the cost of a dependency
Everything so far has been about code in the small — a value, a method, a goroutine. This lesson zooms out to the unit Go actually cares most about: the boundary between one body of code and the next. Go's thesis — optimize for the team over the lifetime of the code — lives or dies at these boundaries, because a boundary is where complexity either leaks or stays contained. Go enforces just a handful of rules here (capitalization is access control; the import graph may not contain a cycle; a version is selected by a deterministic algorithm, not a solver), and each one is a direct, mechanical answer to a way large codebases rot. The single idea: a dependency is a permanent cost, and Go makes you pay it on purpose.
New capability: design a package boundary that an outsider can use without reading its insides; explain why the import graph is acyclic and what that buys; and read a
go.mod file knowing exactly how Go chose each version and why the choice is reproducible.go.mod, semantic import versioning, and Minimal Version Selection — Go's deliberately boring dependency math. (4) Designing boundaries, and the discipline of not taking a dependency.1 · The package: compilation unit and encapsulation boundary at once
Start with the pain. In a large codebase, the question that costs the most time is not "what does this function do?" — it is "who is allowed to call this, and what may I change without breaking them?" If every identifier in every file is reachable from everywhere, that question has no answer: any change to anything is potentially a change to the public API, because you cannot prove no one depends on it. Languages answer this with visibility keywords — private, protected, public, internal — and the answer fragments into per-class, per-member rules that take real effort to read.
Go collapses the whole problem into one boundary and one rule. The package is the unit of encapsulation, and an identifier is exported — visible to other packages — if and only if its name starts with an uppercase letter. There is no public keyword to forget and no private to add; the capitalization is the access control, checked by the compiler, visible at every use site.
package counter
// Exported: callers in other packages may use these.
type Counter struct {
mu sync.Mutex
total int // lowercase: unexported, invisible outside this package
}
func New() *Counter { return &Counter{} } // exported constructor
func (c *Counter) Add(n int) { c.mu.Lock(); c.total += n; c.mu.Unlock() }
func (c *Counter) Total() int { c.mu.Lock(); defer c.mu.Unlock(); return c.total }
Three consequences fall out of this, all of them buying the same thing — a small, provable surface:
mu and total above are structurally invisible to any other package — a caller literally cannot write c.total, so you may rename, retype, or delete it freely. The unexported part is yours to change forever.There is one more lever: a directory named internal/ can be imported only by code rooted at its parent. Put a package at myapp/internal/store and only code under myapp/ may import it — the compiler refuses everyone else. This is encapsulation one level up: a whole package made private to one subtree, so you can share code across your own packages without it leaking into anyone else's API. The rule is mechanical and enforced at compile time, not a convention you hope people honor.
2 · The import graph is acyclic — one rule, large payoff
Here is a rule that looks arbitrary until you've felt the pain it prevents: package A may import package B, or B may import A, but not both. The directed graph of imports must contain no cycle. Try to create one and the compiler refuses with import cycle not allowed — it is not a lint warning you can suppress; it is a hard error.
Why be so strict? Because a cycle is the precise structural shape of "these two things are actually one tangled thing pretending to be two." Consider what a cycle forces:
- You can't understand either package alone. To read A you must hold B in your head, and B pulls back into A — the "what does this do?" question never bottoms out.
- You can't compile them separately. Neither has a complete public API until the other is built, so the compiler would need a whole-program fixpoint instead of one pass per package.
- You can't test or reuse one without the other. The "small reusable component" is a fiction; you always drag the cycle along.
- A topological build order exists. The graph is a DAG, so the compiler builds leaves first, caches each package's export data, and never revisits one. Combined with §1's export summaries, this is why a Go build is roughly linear in code touched, not quadratic.
- Every package is comprehensible bottom-up. You can always find a leaf with no internal dependencies and understand it completely, then move up.
- The fix is a real design improvement. Breaking a cycle forces you to extract the shared concept — usually an interface (Lesson 05) — into its own package both sides depend on. The rule pushes you toward better structure.
The standard escape from an accidental cycle is the move Lesson 05 set up. If A needs a type from B and B needs one from A, the dependency is almost always on a behavior, not a concretion. Define the small interface where it is used, so the using package owns the abstraction and the providing package never has to import it back:
// package report — needs to read something, doesn't care what.
package report
type Source interface{ Read(p []byte) (int, error) } // defined where it's used
func Summarize(s Source) string { /* ... */ }
// package store — provides a concrete Reader. It does NOT import report.
// store.File satisfies report.Source implicitly (Lesson 05), so the arrow
// points only one way: report -> (uses) -> store, never back.
3 · Modules: versioning made deliberately boring
A package boundary controls your code. The harder boundary is the one to code you didn't write and can't change — third-party dependencies, which change on someone else's schedule and can break you. Pre-2018 Go had no answer (the infamous "GOPATH" era: one global checkout, no versions, "it built on my machine last Tuesday"). The answer since Go 1.11, default since 1.16, is the module: a versioned collection of packages with a go.mod file at its root declaring the module's own import path, the Go version it targets, and its direct dependencies with their versions.
module github.com/acme/billing // this module's import path (the prefix for its packages)
go 1.22
require (
github.com/google/uuid v1.6.0
golang.org/x/sync v0.7.0
)
Two design choices here are pure "the team over the lifetime," and both are worth understanding precisely because they are unusual:
github.com/acme/billing/v2. So v1 and v2 are, to the compiler, different packages that can coexist in one build. This kills "diamond dependency hell" — two of your deps wanting incompatible majors of a third no longer deadlock; both majors are present, each used by whoever asked for it. The cost: the visible /v2 in every import line. Go decided visible-but-honest beats clean-but-ambiguous.MVS deserves a second look because it inverts what most ecosystems do. npm, Cargo, and pip-style resolvers tend to pull the newest version satisfying a constraint range — which means an unrelated upstream release can silently change your build between two runs. Go's MVS does the opposite: it picks the lowest version that still satisfies every requirement, i.e. the maximum of the explicitly-required versions and no higher. New releases of a dependency do not enter your build until you raise a requirement. The trade Go made: you give up automatic upgrades (you must run go get to move forward) and buy a build that is reproducible by construction, with no lockfile needed to pin it — though go.sum still records cryptographic hashes so a published version can never be swapped out from under you.
| Concern | Mechanism | What it buys / costs |
|---|---|---|
| Which version of a dep? | MVS: max of explicitly-required versions | Reproducible without a solver; cost is no auto-upgrade |
| Incompatible majors coexisting? | Semantic import versioning (/v2 in the path) | No diamond deadlock; cost is visible version suffixes |
| Did a published version get tampered with? | go.sum hashes + checksum database | Tamper-evident builds; cost is a file you commit |
| Trimming what's actually used | go mod tidy | Drops unused requires, adds missing ones |
4 · Designing the boundary — and the discipline of not depending
Rules tell you what is legal; taste tells you what is good. The thesis applies most sharply here, because a bad package boundary is invisible until the codebase is large enough that you can't move it. Three habits, each one of the five questions (Lesson 00) applied to the largest unit:
util or common or helpers has no boundary — it's a junk drawer, and junk drawers grow until they import everything and become a cycle magnet. store, auth, ratelimit name a responsibility, so it's obvious what belongs and what doesn't. (Question 1: what can I leave out — and where does this even go?)That last proverb is widely misquoted as "never depend on anything." It says the opposite of that, too: a little copying beats a little dependency — when the thing you'd import is small. A big, well-maintained dependency (the standard library above all, Lesson 13) is almost always worth it; reimplementing TLS or JSON parsing to avoid a dependency is the bargain run backwards. The skill is weighing the import as a cost rather than reaching for it as a free convenience — because to the build, the security surface, and the next engineer's onboarding, it is never free.
Checkpoint exercise
Where this points next
We now know how to draw boundaries and import across them — but the best teacher of where to draw an export surface is code that has gotten it right at enormous scale and stayed stable for over a decade: Go's standard library. Lesson 13 reads the stdlib as a design exemplar, with io.Reader and io.Writer as the canonical case — two one-method interfaces, defined in one tiny package, that compose into files, sockets, buffers, compressors, and HTTP bodies, so that io.Copy works on combinations its authors never imagined. It is §1's "export the minimum" and §3's "depend on the standard library" turned into the most-studied API in the language — the place where Go's idiom is the library.
internal/ extending that privacy to a whole subtree). The import graph must be acyclic — a hard compiler error, not a warning — because a cycle is the structural signature of two tangled things pretending to be two separable ones; forbidding it gives a topological build order (builds roughly linear in code touched, via cached per-package export summaries) and bottom-up comprehensibility, and the standard fix is to extract a small interface where it's used (Lesson 05). At module scale, go.mod plus Semantic Import Versioning (a breaking major changes the import path, so /v2 can coexist with v1 and diamond conflicts dissolve) and Minimal Version Selection (pick the maximum of explicitly-required versions, never newer — deterministic, no solver, reproducible without a lockfile) make dependency math deliberately boring. The transferable habit: a dependency is permanent complexity paid by every future reader and every build, so earn it — name packages by responsibility, export the minimum, and remember that a little copying can beat a little dependency.Interview prompts
- How does Go do access control, and what's the granularity? (§1 — an identifier is exported iff it starts with an uppercase letter; the boundary is the package (a directory), not the file or the type, so a package's public API is the set of capitalized top-level names.)
- What does
internal/do and why use it? (§1 — a package underinternal/may be imported only by code rooted at its parent directory, enforced by the compiler; it lets you share code across your own packages without exposing it as public API.) - Why does Go forbid import cycles, and how do you break one? (§2 — a cycle means two packages aren't separately understandable, compilable, or reusable; the DAG gives a topological build order and bottom-up comprehension. You break it by extracting the shared concept — usually a small interface defined where it's used (Lesson 05) — so the arrow points one way.)
- Why are Go builds fast, structurally? (§1–§2 — each package compiles once into an object plus a compact export-data summary; importers read the summary, not the source, and the acyclic graph gives a one-pass topological order with caching, so build time scales with code touched rather than quadratically.)
- Explain Minimal Version Selection and how it differs from npm/Cargo-style resolution. (§3 — MVS selects the maximum of the versions explicitly required across the build and nothing newer, in one deterministic pass with no solver; newer upstream releases never enter until you raise a requirement. Range-based resolvers pull the newest satisfying version, so builds can drift between runs.)
- What problem does Semantic Import Versioning solve, and what does it cost? (§3 — a major-version bump changes the import path (
/v2), so v1 and v2 are different packages that coexist in one build, dissolving diamond dependency conflicts; the cost is the visible version suffix in import lines.) - Unpack "a little copying is better than a little dependency." (§4 — a dependency is permanent complexity every importer and every build pays for (build time, security surface, release coupling), so for a handful of lines, copying can cost the team less over the long run; it is not "never depend" — a large, well-maintained dep like the stdlib is usually worth it. Question 5 applied at module scale.)