Chapter 3: Project Layout — Structure That Communicates Intent#
In which we discover that where you put your code is not a style choice, but a claim about scope, stability, and trust — and that the “Standard Go Layout” is followed by fewer than one in five production projects.
The Directory Tree Is a Design Document#
Before you read a single line of Go code in an unfamiliar project, you have already received a design document. It is the directory tree. It tells you whether the project is a library or an application. It tells you whether any package is intended for external use. It tells you whether the project has a stable client API, a subprocess boundary for untrusted plugins, or multiple independently versioned components. All of this is legible from a single find . -maxdepth 2 -type d output, if you know how to read it.
The claim is not that directory layout is the most important thing about a codebase. It is that layout is the first thing, and that it propagates consequences into every subsequent decision the project makes. A project that places its core logic in internal/ will find it easy to refactor implementation details without breaking callers. A project that grows a single cmd/ package to 453 files will find every new feature adds another file to a package that already can’t be tested in isolation. These are not incidental outcomes of layout choices; they are structural consequences, as predictable as the load path on a beam.
Fifty-one Go projects — spanning web frameworks, infrastructure systems, CLI tools, databases, networking daemons, and UI applications — yield fourteen structurally distinct layout patterns. This chapter maps those patterns, explains the forces that produce them, and shows where each is appropriate, where it fails, and what the failures look like in production.
The Myth of the Standard Go Layout#
The “Standard Go Layout” is cmd/internal/pkg: a cmd/ directory holding binary entry points, an internal/ tree for private implementation packages, and a pkg/ directory for public library packages. It is documented, widely referenced in tutorials, and followed by fewer than 20% of the projects in this corpus in its purest form.
This is not because the projects are wrong. It is because the “Standard Go Layout” was extracted from a narrow class of projects — primarily multi-component tools that are simultaneously binaries and libraries — and generalized into a convention that does not fit most Go projects. Web frameworks don’t use it. CLIs often don’t use it. Infrastructure tools frequently deviate from it deliberately. Understanding why requires understanding what each element of the pattern actually communicates.
cmd/ says: “this project produces binary entry points that live at cmd/<name>/main.go.” This is correct when the project produces multiple binaries, or when it wants to separate CLI wiring (which knows about flags, signal handling, and exit codes) from library logic (which does not). Helm’s cmd/helm/main.go is the right use of this pattern: it wires together the pkg/action library surface and the internal flag definitions, cleanly separating concerns. But for a single-binary tool with no library surface, the cmd/ directory is a boilerplate indirection — an extra directory that adds ceremony without adding information.
internal/ says: “the packages under this path cannot be imported by code outside this module.” This is the most useful element of the pattern, and the only one that has compiler-level enforcement. The decision of what to put inside internal/ is a real design decision: it is a statement that the enclosed packages are not stable contract surfaces for external consumers. Terraform and Crush, the two clearest examples of the “all-internal application binary” pattern in the corpus, put everything under internal/. This is not excessive caution; it is an explicit policy statement. Terraform’s documentation explicitly warns that its Go packages are not a public API. Crush has no embedded library use case. The internal/ placement communicates this policy to every consumer without requiring a documentation page.
pkg/ says almost nothing. Originally adopted by the Kubernetes project as a staging area for packages being prepared for independent release, it was cargo-culted by the Go community for a decade before it was widely recognized as meaningless in the absence of that specific reason. Of the 51 projects in the corpus, fewer than 10 use a pkg/ directory in 2026, and several use it reluctantly or with a sparse handful of packages. The Go community has largely concluded that if a package is public, it should have a meaningful name — client/, storage/, policy/ — not be hidden under a generic pkg/ prefix that adds a path segment without adding information.
The Fourteen Patterns#
Rather than one standard, the corpus reveals a taxonomy of fourteen structurally distinct layout patterns. They are not equally distributed: some are correct for libraries, others for applications, others for platforms. The pattern a project chooses is determined primarily by three questions.
Is this a library or an application? Libraries that expose their API as the root package (Cobra, Gin, Echo, GORM, Viper) use what this analysis calls the “flat library layout”: the root package is the product, sub-packages handle specializations, and there is no cmd/ directory or binary entry point. The import path is the API: cobra.Command{}, gin.New(), gorm.Open(). Adding a cmd/ or pkg/ indirection would make the primary import path verbose and unusual. Cobra, despite being one of the most-imported Go packages in the ecosystem, maintains its entire API in the root directory with a single doc/ sub-package. The structural minimalism is not a limitation; it is the correct expression of the project’s scope.
Does any package in this project need to be consumed externally? This question determines the use of internal/. If no external consumer will ever import your packages, internal/ is compiler-enforced documentation: it makes the “applications only, no library API” decision visible to anyone who clones the repository. If you have external consumers for some packages but not all, internal/ draws the line between the stable public contract and the private implementation.
Does the project need independent versioning for different components? This question determines whether the project needs a multi-module monorepo. Three of the largest projects in the corpus — etcd (13 modules), Grafana (35+ modules), and Prometheus (5 modules) — use go.work workspaces with multiple go.mod files. The motivation is always the same: different components have genuinely different release cadences. etcd’s server, its client SDK (go.etcd.io/etcd/client/v3), and its raft library (go.etcd.io/raft/v3) are independently versioned because different consumers depend on different slices. The multi-module monorepo is not a code organization choice; it is a version governance mechanism.
Four Patterns Worth Examining in Detail#
The Flat Library Layout: Cobra#
Cobra’s entire API lives in the root package. There are no subdirectories except doc/. The Command struct, the Args validators, the FParseErrWhitelist, the shell completion generators — all in the root, exported directly, with no pkg/cobra/ wrapper, no internal/ hiding the formatter.
This works because Cobra is a library, and the primary user experience of a library is the import path. Every caller writes cobra.Command{}. The structural simplicity is not laziness; it reflects a real constraint that library authors face: callers form dependencies on import paths, and every directory segment you add is a segment callers must type. The flat layout minimizes that friction.
The tradeoff is that the root package can grow large. Cobra has approximately 25 source files in the root directory. GORM has 72. This is manageable because library packages are read more than they are navigated — callers use godoc, not directory listings. The 72-file root package in GORM is less of a problem than it would be in an application package, because callers do not need to understand all 72 files; they need to understand the API surface, which is small and stable.
The pattern fails when the library is still rapidly evolving and the root package must be refactored frequently. At that point, internal/ becomes useful — not to hide the API, but to allow internal restructuring without breaking callers. Fyne, the GUI framework, uses a more sophisticated version of the flat library layout: the root package exports only interfaces and value types, with zero rendering code. All concrete implementations live in 32 internal/ sub-packages. The root package becomes a stable contract; everything underneath it can change without affecting callers. This is the correct evolution of the flat layout when stability of the public API is the primary constraint.
The All-Internal Application: Crush#
Crush is the clearest example in the corpus of a different pattern: root main.go + everything under internal/. This is not the same as the flat library layout with internal/ added. It is a categorical statement: this codebase is an application binary, and no package in it is intended for external consumption.
The compiler enforces this. Any attempt to import crush/internal/workspace from outside the Crush module fails at compile time. This makes the project’s intent legible in a way that documentation cannot match. You cannot accidentally build a tool that imports Crush’s workspace management logic; the language prevents it.
What is architecturally interesting about Crush is that internal/ placement and clean architecture are orthogonal. The internal/ tree has distinct layers: internal/cmd/ → internal/workspace/Workspace (an interface) → internal/app/, internal/server/ → internal/agent/, internal/tui/ → internal/db/. The Workspace interface is the seam between the CLI/TUI presentation layer and the backend processing layer. Packages higher in the stack depend on the interface, not any concrete implementation. This is textbook dependency inversion — inside an internal/ tree that no external caller will ever see.
The lesson: internal/ does not mean “poorly structured.” It means “not for external use.” A well-structured application binary can have clean layering, clear interfaces, and testable packages entirely within internal/.
The Plugin-Registry Layout: rclone#
rclone supports more than 50 cloud storage backends. Its layout reflects this. The fs/ package defines the Fs interface (the core storage abstraction) and the Object interface (a file within a storage system). Each of the 50+ backends lives in its own backend/<name>/ package: backend/s3/, backend/gdrive/, backend/sftp/, and so on. A file called backend/all/all.go contains nothing but blank imports of every backend package.
The registration mechanism is init() self-registration. Each backend package has an init() function that calls fs.Register(&fs.RegInfo{Name: "s3", Description: "Amazon S3", NewFs: NewFs, Options: []fs.Option{...}}). The RegInfo struct declares the backend’s name, description, constructor, and configuration options. The options auto-surface as --s3-* flags via rclone’s flag system.
The binary entry point imports backend/all to get all backends linked in. A hypothetical trimmed build can omit specific backends by importing only the subset it needs. This is compile-time feature selection: no backend code reaches the binary unless the binary explicitly imports it.
The architectural consequence is extensibility without a central authority. Adding a new backend requires no change to rclone’s core. The backend author writes a package, calls fs.Register() from init(), and imports the package from the aggregator. The core code never names the backends. The routing from --s3-access-key-id to the S3 backend’s NewFs constructor happens entirely through the registration mechanism.
This pattern — interface kernel + self-registering backends + blank-import aggregator — is rclone’s most instructive architectural contribution to the corpus. It appears in variant form in Caddy (modules registering with dotted namespace IDs), Prometheus (exporter registration), the Go standard library’s database/sql (driver registration), and Kubernetes (type system registration). The mechanism is always the same; the sophistication of the namespace scheme varies.
The Multi-Module Monorepo: etcd#
etcd’s repository contains 13 go.mod files. The go.work workspace at the root coordinates them. Each module has an independent semantic version, independent dependency graph, and independent release tag.
The modules are not arbitrary. They reflect genuine differences in release cadence and consumer categories. go.etcd.io/etcd/server/v3 (the etcd server) changes frequently with new features and bug fixes. go.etcd.io/etcd/client/v3 (the client SDK) changes less frequently; its users include every project that interacts with etcd programmatically and they cannot afford churn. go.etcd.io/raft/v3 (the Raft consensus library extracted from etcd) changes on yet another cadence; it is used by projects like TiKV that are not etcd clients at all.
The multi-module structure enforces these boundaries at the version level. A bug fix to the etcd server does not force a version bump to the client SDK. A breaking change to the raft library triggers a major version bump for raft consumers without affecting etcd server or client versions.
The cost of this structure is visible. Module graph management requires explicit replace directives during development. CI must test each module in isolation and in combination. Grafana’s 35+ modules represent the upper end of what a team can maintain; the overhead was justified because Grafana’s plugin system requires that plugin authors can depend on a stable, independently versioned SDK without pulling in the entire Grafana monolith.
The lesson: multi-module monorepos are correct when you have genuine differences in release cadence between components. They are unnecessary when all components are always released together. The test is whether different external consumers depend on different slices of the repository at potentially different versions.
What the Anti-Patterns Tell Us#
The corpus contains several examples of layout patterns that grew into liabilities. They are worth naming directly, because they illustrate how layout choices compound over time.
The god package. MinIO’s cmd/ package contains approximately 453 source files. NATS Server’s server/ package contains approximately 180 source files. These packages absorbed responsibilities over years without a planned decomposition. The short-term benefit was real: no inter-package API design, no circular import resolution, no boundary decisions. The long-term cost is also real: packages at this scale become difficult to test in isolation (a test that imports the package compiles all 453 files), hard to navigate, and slow to onboard for new contributors. Both projects are operationally mature and functionally excellent; the structural debt is a tax on further development, not a fundamental flaw. But it demonstrates the pattern: large packages grow; they rarely shrink without intentional effort.
The util package. Packages named util/, utils/, common/, or helpers/ appear in roughly a third of the corpus. They are almost always dumping grounds — the package where code goes when no one is sure where it belongs. The code inside is typically unrelated across files: a string formatter next to a network utility next to a retry wrapper. Over time, this creates hidden coupling between unrelated concerns: callers that need the string formatter import the package and transitively bring in the network utility and retry wrapper. The best projects in the corpus avoid util/ entirely by having a clear domain model that places every function in a package named for its domain.
Premature modularization. The inverse of the god package: applying multi-module structure before the release cadence differentiation actually exists. This creates the overhead of module graph management without the benefit of independent versioning. The signal is a repository with a go.work workspace and two or more modules that are always released together at the same version — which means the module boundary is purely ceremonial.
The Practical Framework#
From the corpus, a practical decision framework emerges. It is not a rigid rule, but a set of questions that produce the right layout for the right project:
If you are writing a library: Use the flat root-package layout. The root package is your product; callers write yourlib.Type{}. Use internal/ if you need to hide implementation details that are likely to change. Do not use cmd/, do not use pkg/. Add sub-packages only when a genuine specialization is needed (codec variants, platform-specific implementations, optional integrations).
If you are writing a single-binary application with no library use case: Use root main.go + everything under internal/. The internal/ placement communicates clearly and enforces structurally that no package is intended for external import. This is the pattern of Terraform, Crush, and every modern Go application that is explicit about its scope.
If you are writing a single-binary application that others might embed: Use the flat-domain layout with no internal/ restriction (or minimal internal/ for genuinely private details). Tailscale’s tsnet API, PocketBase’s embeddable framework, and Caddy’s module system all follow this pattern. Every exported package is an implicit API commitment, so design them carefully.
If you are writing a multi-binary system: Use cmd/<name>/main.go for each binary entry point. The cmd/ directory is justified when there are two or more binaries and the entry points are genuinely different programs. FRP (client + server), Delve (the debugger + its DAP adapter), and Headscale (the server + the control CLI) each use cmd/ correctly.
If you have components with genuinely different release cadences: Introduce multi-module structure with go.work. Extract the component whose version independence matters — typically the client SDK — into its own go.mod. Keep everything else in the main module until a second genuine versioning need appears.
The Through-Line: Layout as Communication#
Every layout decision in this chapter is, at its core, a communication decision. The directory tree is the first thing a new contributor, a dependency scanner, or a security auditor reads. It answers four questions before they open any file:
- Is this a library or an application?
- What packages are intended for external use?
- Does this project have independently versioned components?
- Where are the generated files, and where is the hand-written code?
The projects that answer these questions clearly with their directory structure are faster to onboard, easier to audit, and more tractable to refactor. The projects that allow layout to accumulate without intent — the god packages, the util/ dumping grounds, the pkg/ wrappers that mirror internal structure without adding information — carry structural debt that grows silently until a team decides to address it.
The Go community’s tendency to cargo-cult the “Standard Go Layout” as a universal convention has, paradoxically, made this communication less clear in many projects: teams apply cmd/internal/pkg mechanically to libraries where it is wrong, to single-binary applications where it adds ceremony, and to platform services where it is too simple. The fourteen patterns documented here are not fourteen options in a style guide. They are the outcomes of fifty-one teams making layout decisions under specific constraints. Understanding the constraints produces the correct layout. Copying any specific pattern without understanding its constraints produces a layout that communicates something different from what the team intends.
Layout is the first sentence of an architectural document that runs for thousands of files. Getting the first sentence right does not guarantee the rest is good. But getting it wrong sends false signals from the very first directory listing — and in Go, where the compiler enforces internal/ boundaries and module version graphs flow from go.mod declarations, those false signals have real structural consequences.
Chapter Summary#
What we found: Fourteen distinct layout patterns across 51 projects. The “Standard Go Layout” (cmd/internal/pkg) accounts for fewer than 20% in its pure form. Project type determines layout more reliably than project age or popularity. Libraries converge on flat root-package layouts. Application binaries increasingly use all-internal structures. Distributed systems invent multi-module patterns tailored to their versioning needs.
The key threshold: The decision point between flat-library, all-internal-application, and multi-module-monorepo is not aesthetic. It is determined by three questions: library or application? external consumers for any package? independently releasable components? Answer these questions first; the correct layout follows.
Patterns that propagate: internal/ enforces what documentation can only suggest. Multi-module structure enforces what code review can only police. The god package and the util/ dumping ground are structural anti-patterns that grow silently. Early layout decisions compound across a project’s lifetime.
The exemplars worth studying: Cobra (flat library layout at its simplest), Crush (all-internal application at its cleanest), rclone (plugin-registry layout at its most fully realized), etcd (multi-module monorepo at its most disciplined), Fyne (interface-root layout executed with rare precision), Nomad (custom domain-driven layout that names packages for their domain, not their layer).
The next chapter turns from where code lives to what it depends on — and makes the case that a dependency import is an architectural commitment, not a convenience decision.