all_lessons/Go/12 · Packages & moduleslesson 13 / 18

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.

The thesis, here
"Complexity must be earned" stops being abstract the moment your package imports another package — because you have just made your code's correctness depend on code you don't control, written by someone you may never meet, that can change underneath you. Every import is a line on a bill that the whole team pays forever: it slows builds, widens the surface that can break, and binds your release to someone else's. Go's package and module rules are not bureaucracy; they are the language refusing to let that bill be hidden. The proverb that captures it — "a little copying is better than a little dependency" — is the bargain applied to the largest unit of complexity there is.
Linear position
Prerequisite: Lesson 05 (interfaces — a package exports behavior, and the smallest export surface is an interface) and Lesson 11 (you now know how a single package's code behaves; this lesson is about composing many of them).
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.
The plan
Four moves. (1) The package as the unit of compilation and encapsulation, with capitalization as the entire access-control system. (2) Why the import graph must be acyclic, and what that single rule buys for builds and reasoning. (3) Modules, 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:

The export surface is grep-able
To find a package's entire public API you scan for capitalized top-level names. The fields 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.
The package is also the compile unit
Go compiles a whole package at once and produces one object with a compact export data summary of its public API. Importers read that summary, not the package's source — which is half of why Go builds fast (§2). The boundary the compiler cares about and the boundary you reason about are the same boundary.
Files are not a boundary; the directory is
A package is a directory; its files share one namespace, so splitting a package across files is free and carries no visibility meaning. You organize files for the reader, not the compiler. The decision that matters is where to draw the package line.

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:

What a cycle costs (the pain Go forbids)
  • 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.
What forbidding it buys
  • 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.
The spine: a DAG, not a knot
cmd/server (main: wires everything, imported by no one) | +----+-----+ | | report httpapi (features: depend on store, not on each other) | | +----+-----+ | store (leaf: concrete data access, depends on nothing local) Arrows point ONE way and never return. Build leaves first, cache each package's export summary, walk up once. Forbidding back-edges is what makes "build" and "understand" both O(code touched), not O(code^2).

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:

Semantic Import Versioning
A breaking change (major version 2+) must change the import path: 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.
Minimal Version Selection (MVS)
When several packages in your build require different versions of the same dependency, Go selects the highest version anyone explicitly asked for — and nothing newer. There is no SAT solver searching a space, no "latest compatible" guesswork. It is the maximum of a set, computed in one deterministic pass. The build you get today is the build you get next year from the same files.

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.

ConcernMechanismWhat it buys / costs
Which version of a dep?MVS: max of explicitly-required versionsReproducible 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 databaseTamper-evident builds; cost is a file you commit
Trimming what's actually usedgo mod tidyDrops 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:

Name the package by what it provides, not what it contains
A package called 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?)
Export the minimum; accept interfaces, return structs
Every exported name is a promise you maintain forever. Export a small surface — ideally functions taking the smallest interface that does the job (Lesson 05) and returning concrete types — so callers couple to behavior, not to your internals. The unexported remainder stays free to change. (Question 4: depend on a behavior, not a concretion.)
A little copying is better than a little dependency
If you need 8 lines from a library, copying those 8 lines may be cheaper over the decade than importing a package that drags transitive deps, slows builds, and binds your release to its maintenance. Go's proverb is the bargain at module scale: a dependency is permanent complexity — earn it. (Question 5: what did it cost, and who pays — every importer, forever.)

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

Try it
Take a project you have (any language) and draw its import graph: a node per module/package, an arrow per dependency. First, find a cycle — almost every non-Go codebase has at least one — and ask what shared concept, extracted into its own node, would break it (in Go this is forced; elsewhere it's optional, which is exactly why the cycle survived). Second, pick your most-imported internal package and list its public surface; for each exported name ask "could a caller break if I changed this?" and mark the ones that should have been unexported. Third, find one dependency you pulled in for fewer than ~20 lines of use and decide honestly whether copying those lines would have cost the team less over five years. You have just run questions 1, 4, and 5 over your architecture.

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.

Takeaway
A package is Go's unit of both compilation and encapsulation, and capitalization is its entire access-control system: an identifier is exported only if it starts with an uppercase letter, so a package's whole public surface is grep-able and its lowercase internals are yours to change forever (with 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