Chapter 4: Dependencies — What You Import Is What You Are#

In which we discover that a dependency import is an architectural commitment, not a convenience decision; that the Go ecosystem has converged on four universal dependencies and five visible clusters; and that the discipline with which a project manages its dependency graph is a reliable proxy for the discipline with which it manages everything else.


The Dependency Graph Is a Design Document#

A project’s go.mod file contains its dependency graph, and the dependency graph is a design document. It tells you which problems the team decided not to solve themselves and who they trusted to solve those problems for them. It tells you which libraries the team believed were stable enough to couple their project to. It tells you how old the project is, because the logging library reveals the era better than any comment. And it tells you, sometimes, when a team made a decision and didn’t finish following through.

Before reading a single source file, reading the go.mod gives you: an estimate of the project’s age, its domain category, its operational requirements (metrics? distributed tracing? authentication?), its team’s philosophy toward third-party code, and the migrations that were started and not completed. This is not an abstract claim. It is what fifty-one go.mod files, read in sequence, actually reveal.

The most striking feature of those fifty-one dependency graphs is not what they share, but how sharply the distribution splits. The corpus is bimodal, not continuous. On one side: wireguard-go (five direct dependencies, documented policy), nats-server (ten, with a CONTRIBUTING.md that discourages additions), Cobra (four — a CLI framework used by 35–40 of the other 51 projects), Echo (three), GORM (three). On the other side: Vault (210), CockroachDB (216), Tailscale (145), Consul (111), Kubernetes (110). Between these poles, surprisingly few projects land: the sixteen-to-seventy-five range is occupied by projects in genuine transition — integrating more external systems as they grow — but the bimodal endpoints are stable attractors, each representing a coherent philosophy.

Understanding why the distribution splits this way, and what it means for the teams at each end, is the chapter’s central question.


The Universal Layer#

Before comparing approaches, it is necessary to name the dependencies that are so universally adopted they have ceased to be choices. Three packages function as a de facto extended standard library across the corpus, and treating them as third-party dependencies is both inaccurate and counterproductive.

golang.org/x/ — the extended standard library. x/crypto, x/net, x/sync, x/sys, x/text are co-maintained by the Go team, released alongside Go, and depended on by 49 to 51 of the 51 projects in the corpus. Projects that claim “zero third-party dependencies” almost invariably depend on at least one x/ package. The Go toolchain itself vendors them. x/sync/errgroup, discussed at length in the concurrency chapter, is the most architecturally significant member of this family. These are not third-party risk; they are the standard library with a faster release cadence.

stretchr/testify — the universal assertion library. Forty-seven of fifty-one projects use it. The holdouts are instructive: the Go standard library itself (stdlib), GORM (uses only stdlib testing), PocketBase (stdlib), and wireguard-go (stdlib). Each holdout is a principled choice: either a zero-dependency policy (wireguard-go), a library author’s decision to minimize what callers inherit (GORM, cobra), or a deliberate simplicity preference (PocketBase). The adoption of testify is so universal that it has effectively become the standard Go assertion convention. The assert.Equal, require.NoError, assert.Contains calls in a test file are as idiomatic as table-driven tests themselves.

google/uuid — globally unique ID generation. Used by roughly 35–40 of 51 projects wherever a UUID is needed. No alternative has gained meaningful traction. The ecosystem has fully converged on this package, making it, in practical terms, a utility that is always available.

These three are not the focus of the chapter because they are not decisions — they are defaults. The interesting analysis begins where projects diverge.


The Bimodal Distribution: Philosophy vs. Scope#

The split between minimalist and integrationist projects is real and explainable, but the explanation is not that one approach is better. It is that the two ends of the distribution reflect genuinely different scopes.

WireGuard-go’s five direct dependencies are not evidence of austerity for its own sake. They are a consequence of the project’s scope and its security constraints. A VPN tunnel implementation must process every packet on the host’s network interface. Every dependency is a code path that executes with that access. The five dependencies WireGuard-go accepts — golang.org/x/crypto, x/sys, x/net, gvisor.dev/gvisor (scoped to the WASM TUN adapter), and golang.zx2c4.com/wintun (Windows kernel interface) — all have clear security justifications. The logging framework, the CLI framework, the test assertion library, and the metrics client are all absent. The decision is: if a dependency does not implement a function that WireGuard-go cannot implement itself, it is not worth the supply-chain risk.

NATS server’s ten direct dependencies tell a similar story with a different motivation. NATS’s CONTRIBUTING.md documents its no-new-dependencies policy explicitly. The broker serves millions of messages per second; its binary starts in under a millisecond; it must be deployable to constrained environments where a 200MB binary is unacceptable. All five additional direct dependencies beyond golang.org/x/ are either NATS-ecosystem packages (nkeys, jwt, nuid) or near-stdlib performance utilities (klauspost/compress, x/time). The minimalism is a product requirement.

Kubernetes’s 110 direct dependencies are equally explainable. Kubernetes must talk to every cloud provider, every container runtime, every DNS resolver, every storage system, every monitoring platform, and every auth provider in production. Every dependency is there because Kubernetes’s scope requires it. The AWS SDK, the Azure SDK, the GCP SDK, the Docker socket client, the CNI interface, the CSI interface, the OIDC library, the Prometheus client — each corresponds to a category of systems that Kubernetes must integrate with. A stripped-down Kubernetes is a useless one.

The conclusion that emerges: dependency count is not a quality metric. It is a scope metric. The question is not “fewer is better” but “does each dependency correspond to a capability the project genuinely needs?” The test is individual, not statistical.


The Ecosystems Within the Ecosystem#

Reading the fifty-one dependency graphs reveals three distinct sub-ecosystems, each with its own conventions and its own internal consistency.

The HashiCorp Cluster#

Vault, Consul, Nomad, and Terraform share a tightly coupled family of in-house dependencies that together constitute a parallel Go ecosystem:

  • hashicorp/go-hclog — structured logger (all four)
  • hashicorp/raft — consensus protocol (Consul, Nomad)
  • hashicorp/serf — gossip protocol (Consul, Nomad)
  • hashicorp/go-memdb — in-memory transaction database (Consul, Nomad)
  • hashicorp/go-plugin — subprocess-isolated plugin protocol (Vault, Terraform)
  • hashicorp/hcl/v2 — HCL configuration language (Vault, Terraform, Nomad)
  • mitchellh/cli — CLI framework (all four, instead of Cobra)
  • armon/go-metrics — metrics abstraction (Consul, Nomad)

This cluster is deliberate. HashiCorp built alternatives to Cobra, Viper, Zap, and even the community Raft implementation rather than adopting ecosystem equivalents. The shared conventions produce internal consistency — a developer moving from Vault to Consul encounters the same logger, the same CLI, the same metrics pattern. The cost is visible at the boundaries: every modern library and framework that expects context.Context as the standard cancellation mechanism must be bridged where the HashiCorp tools use shutdownCh chan struct{}. Every new dependency that uses Cobra’s flag parsing requires an adapter when loaded into a mitchellh/cli application. The cluster is coherent within; divergent from community norms without.

The HashiCorp cluster is also the clearest example in the corpus of conscious ecosystem building. The team did not adopt community standards; they created their own, then maintained them across four large codebases simultaneously. The structural uniformity is genuinely impressive. The technical debt accrues exactly where the cluster meets the Go community mainstream.

The CNCF/Kubernetes Cluster#

Kubernetes, Moby, etcd, Prometheus, Traefik, k3s, Helm, Istio, Argo CD, Tekton Pipeline, and Dapr share a different kind of cluster — not one built by a single team, but one that grew through years of Kubernetes adoption:

  • k8s.io/api, k8s.io/apimachinery, k8s.io/client-go — core Kubernetes types
  • sigs.k8s.io/yaml, sigs.k8s.io/controller-runtime — ecosystem tooling
  • k8s.io/klog/v2 + go-logr/logr — Kubernetes logging interfaces
  • go.opentelemetry.io/otel — now the CNCF telemetry standard
  • onsi/ginkgo + gomega — BDD testing framework (Kubernetes and affiliated)
  • gogo/protobuf + google.golang.org/protobuf — both, due to historical migration

This cluster is not deliberate in the HashiCorp sense. It grew from a dependency fact: once you import k8s.io/apimachinery, you inherit approximately twenty further k8s.io/* packages and the entire Kubernetes API machinery. Projects that target Kubernetes — writing operators, controllers, custom resources — inherit this cluster unavoidably. The cluster defines the technological common ground for cloud-native Go development.

The most significant ongoing transition in this cluster is the gogo/protobuf to google.golang.org/protobuf migration. gogo/protobuf was adopted pre-2019 for performance reasons — it was significantly faster than the official implementation. The official implementation has since closed the gap. But the migration is multi-year: Moby, etcd, Consul, Istio, and Argo CD all carry both libraries simultaneously in their go.mod, reflecting a migration that is started but not finished. Any project in this cluster will encounter both libraries for the foreseeable future.

The Charmbracelet/TUI Cluster#

A third, newer cluster is forming around Charmbracelet’s TUI toolkit:

  • charmbracelet/bubbletea — Elm-architecture TUI framework
  • charmbracelet/lipgloss — style/layout for terminal output
  • charmbracelet/bubbles — pre-built UI components
  • muesli/termenv — terminal color and capability detection
  • mattn/go-runewidth, mattn/go-isatty — terminal character width and TTY detection

fzf predates this cluster (it uses tcell directly). gh (GitHub CLI) is partially migrated to it. Crush is fully built on it. The cluster is growing: every new terminal application written in 2024–2025 reaches for bubbletea as the first decision. This cluster is smaller than the HashiCorp or CNCF clusters but represents the clearest directional signal in new application development.


Owning the Critical Path#

One of the most instructive patterns in the corpus is what might be called the “own the critical path” strategy: when a project’s core value proposition depends on a performance-sensitive or correctness-sensitive code path, the project forks and maintains the underlying library rather than accepting an upstream’s release cadence and design decisions.

The instances are precise and traceable:

MinIO owns minio/mux, minio/cli, minio/xxml, and several klauspost/* performance packages. MinIO is an object store; its erasure coding throughput and S3 wire protocol correctness are existential to its value proposition. When the HTTP router or the XML parser at the API boundary adds latency, MinIO can fix it. When klauspost/compress gets a SIMD improvement, MinIO can adopt it without waiting for an ecosystem consensus.

Caddy — specifically its primary maintainer Matt Holt — owns certmagic, acmez, zerossl, and libdns. Caddy’s core feature is automatic TLS certificate issuance and renewal via ACME. If the ACME library has a bug that causes certificate renewal failures, Caddy cannot wait for a third-party maintainer to respond. Holt owns the library; Caddy controls its own certificate fate.

Hugo — its lead maintainer Bjørn Erik Pedersen — owns approximately fifteen peripheral packages: bep/logg, bep/overlayfs, bep/lazycache, bep/simplecobra, bep/imagemeta, bep/godartsass, and others. Hugo generates hundreds of thousands of pages per second in benchmark configurations; the transformation pipeline is the product. When a string processing utility or cache eviction policy creates a hot path, the author can address it on Hugo’s release schedule, not upstream’s.

Tailscale owns strategic forks of wireguard-go, golang.org/x/crypto, netlink, and go-winio. Tailscale’s product is a VPN that must work reliably on every platform, including platforms with quirky kernel interfaces and non-standard network behavior. Owning the kernel interface adapters means Tailscale can fix bugs that affect its users without waiting for upstream acceptance.

The pattern’s lesson: when correctness or performance is existential to a project’s value proposition, you cannot afford to wait for upstream. The fork is not technical debt — it is a product decision. The projects that own their critical paths are willing to accept the maintenance burden because the alternative (blocking on an external maintainer during a production incident) is worse. The projects that do not need this pattern (libraries, projects with less critical dependencies) correctly avoid the maintenance burden by not forking.


The Dual-Library Migration Debt Problem#

The most reliable indicator of project age in a go.mod is the presence of two versions of the same library. These dual-library states appear across the corpus and share a common history: the team started a migration and did not finish it.

LibrariesProjects carrying both
aws-sdk-go v1 + v2Vault, Grafana, BuildKite-Agent
gopkg.in/yaml.v2 + yaml.v3MinIO, pop, Temporal
gogo/protobuf + google.golang.org/protobufMoby, etcd, Consul, Istio, Argo CD
github.com/golang/protobuf + google.golang.org/protobufKubernetes, Argo CD
urfave/cli v1 + v2Temporal
grpc-gateway v1 + v2Argo CD
lib/pq + pgx/v5pop, Temporal

Each entry represents years of ongoing work. Migrating a database driver, a protobuf library, or an SDK is not a weekend refactor — it requires touching every call site on the library’s interface, updating tests, validating behavior with existing data, and navigating any behavioral differences between versions. The AWS SDK v1-to-v2 migration involves API surface changes: v1 is context-free and monolithic; v2 is modular and context-aware. Every AWS API call site must be rewritten.

The cleanest projects in the corpus show none of this debt. Crush, nats-server, wireguard-go, and PocketBase each carry single-version dependencies throughout. This is partly a function of age — newer projects had the luxury of starting with modern dependencies — and partly a function of discipline. A project that accepts a dual-library state as “we’ll clean that up later” creates entropy that grows with every new call site added in the interim.

The practitioner lesson: when you start a library migration, finish it in the same release. If the scope is too large for that, schedule the completion explicitly and track it. Dual-library states that persist beyond two release cycles are effectively permanent.


The Technology Transitions#

Beyond the static picture, the corpus reveals five active transitions — ongoing shifts in which libraries the ecosystem prefers for common problems. Each transition is visible as a before/after pattern in the go.mod files, correlated with project age.

SQLite: CGo → pure Go. mattn/go-sqlite3 was the dominant SQLite library for most of Go’s history. It wraps the C SQLite library via CGo, requiring a C compiler and making cross-compilation painful. Starting around 2022, projects began migrating to modernc.org/sqlite (a C-to-Go transpiler output) and ncruces/go-sqlite3 (WASM-based). PocketBase, Syncthing, Temporal, Crush, Grafana, and Headscale all use the pure-Go variant. The motivation is concrete: Go cross-compilation to ARM, MIPS, and other targets works without a C toolchain. Docker builds do not require installing gcc. CI pipelines simplify. Projects that haven’t migrated (pop, gogs) retain CGo SQLite, creating a two-tier distribution for the same functional requirement.

Distributed tracing: Jaeger/Zipkin → OpenTelemetry. Pre-2020 projects used OpenTracing (Jaeger) or Zipkin directly. OpenTracing is now deprecated; OTel is the CNCF-endorsed successor. Projects started or substantially modernized after 2021 — Headscale, Buildkite-Agent, Temporal, Dapr — use OTel exclusively. Projects from the pre-OTel era carry both: Beego ships Jaeger and OTel simultaneously. The transition is effectively complete for new code; the migration cost for existing code is the residual.

Logging: four generations. The logging library choice is the most reliable dating signal in the corpus:

GenerationEraLibraryRepresentative projects
Gen 12015–2018logrusMoby, Traefik, k3s, Argo CD
Gen 22018–2021go.uber.org/zapKubernetes, etcd, Dapr, Temporal, MinIO
Gen 32020–2023zerologHeadscale, Drone, Caddy (indirect)
Gen 42022+stdlib slogEcho, Viper, Crush

A project’s logger is an architectural artifact that rarely gets replaced even as the codebase evolves in every other dimension. Moby still uses logrus, a decade after the community moved on. The logger is typically one of the first packages imported in a codebase and one of the last to be migrated, because it is imported everywhere and migration requires touching every log call site.

AWS SDK: v1 → v2. The AWS SDK for Go v2 is context-aware, modular, and idiomatic; v1 is monolithic and context-free. The migration is in progress for every project that uses AWS services: Vault, Grafana, BuildKite-Agent all carry both. Projects started after 2021 use v2 only. The migration, when complete, is a meaningful improvement: v2 allows individual AWS services to be imported without the entire SDK monolith.

YAML: gopkg.in/yaml.v3go.yaml.in/yaml/v3. This is a module path migration — the same code, a new canonical path, driven by a change in the upstream project’s governance. The split is now visible: newer projects and recently updated ones use go.yaml.in; older ones use gopkg.in. This migration is lower-stakes than a library API change, but it still creates dual imports during transition.


Reading the Graph: A Practical Framework#

From the corpus, a practical framework emerges for evaluating any project’s dependency graph during code review, due diligence, or onboarding:

Ask the scope question first. Before judging dependency count, answer: what does this project actually need to do? If the answer is “process network packets with minimal attack surface,” five dependencies is correct. If the answer is “integrate with every cloud provider and container runtime,” 110 is justified. The scope predicts the right count range; the count predicts whether the team understood their scope.

Identify which cluster the project belongs to. Is it using HashiCorp conventions (mitchellh/cli, HCL, go-hclog)? It will have the HashiCorp cluster’s maintenance profile. Is it using CNCF tooling (k8s.io/*, OTel, ginkgo)? It will encounter the gogo/google protobuf migration eventually. Is it using Charmbracelet? It is a modern TUI application and will need the full TUI stack. Cluster membership is not a problem statement — it is a context statement.

Date the codebase from the logger. logrus = pre-2019 core design. zap = 2018–2022 infrastructure. zerolog = 2020+. slog = 2022+. The generation gap between the logger and the most recently used Go version reveals whether the project has kept its observability infrastructure current.

Count dual-library instances. Each dual-library state is an incomplete migration. Zero states = recently started or rigorously maintained. One or two = normal for any project over four years old that has active development. Four or more = significant accumulated technical debt that will slow future development.

Look for the critical path owners. Which dependencies are forks? Which modules come from the same author as the project? These signal where the team has decided their product’s value proposition lives. A fork is a claim: “we cannot afford to wait for upstream on this.” Evaluating whether that claim is justified reveals the team’s judgment about their own architecture.


The Through-Line: Imports as Commitments#

Every import decision in a go.mod is a long-term commitment. The logging library chosen on day one is typically still present on year five. The authentication library chosen for v1 of the API is typically still present in v3. The ORM chosen before the team understood the query patterns becomes a refactor that touches every service boundary when the queries prove to be wrong.

The projects that manage their dependency graphs with discipline — wireguard-go’s explicitly justified five, nats-server’s documented no-new-dependencies policy, Cobra’s four carefully chosen utilities, Caddy’s owned critical-path libraries — are not making a statement about minimalism as a virtue. They are acknowledging that imports are irreversible in practice even when they are replaceable in theory. The activation energy to replace a logging library in a twenty-thousand-file codebase is enormous. The logrus entries in Moby, k3s, and Argo CD are not poor hygiene — they are the residue of decisions made correctly for their era that the passage of time has made suboptimal.

The discipline with which a team manages its dependency graph is, in the end, a reliable proxy for the discipline with which it manages every other architectural decision. Teams that import carefully tend to migrate completely. Teams that import promiscuously tend to leave migrations half-finished. The go.mod file does not lie: it carries every decision, every half-decision, and every correction. Reading it well is reading the team’s architectural history.


Chapter Summary#

What we found: A bimodal distribution of dependency counts (2–15 for minimalist tools; 100–220 for platform services) that reflects genuine scope differences, not quality differences. Four universal dependencies that are effectively mandatory. Three distinct ecosystem clusters — HashiCorp, CNCF/Kubernetes, Charmbracelet/TUI — each with coherent internal conventions and visible boundary costs. Five active technology transitions visible in go.mod files.

The key threshold: Dependency count is not a quality metric; it is a scope metric. Every import should correspond to a capability the project genuinely needs. The test for each dependency is not “is this library good?” but “does our scope require this capability, and is this the right source of it?”

Patterns that propagate: Dual-library migration debt grows with every new call site added during an incomplete migration. The critical-path ownership pattern is a product decision, not a code smell. The logging library choice dates a codebase more reliably than any other single indicator.

The exemplars worth studying: wireguard-go (five dependencies, each with a security justification — the bar for what “justified” means), nats-server (ten dependencies with a documented policy — the bar for communicating that philosophy), Caddy (critical-path ownership executed with discipline — owning what you cannot afford to lose control of), Kubernetes (structured vendor with governance process — dependency management as organizational practice).

The next chapter turns from what projects import to how they structure the code they write — and makes the case that how a project handles errors tells you as much about its architecture as how it handles concurrency.