Cross-Project Layout Analysis — 51 Go Projects#

Summary#

Across 51 Go projects spanning libraries, CLIs, web frameworks, infrastructure systems, and developer tools, no single layout pattern dominates. The “Standard Go Layout” (cmd/internal/pkg) is the most frequently cited convention but accounts for fewer than 20% of projects in its purest form. The majority of projects deviate meaningfully, often deliberately. What emerges is a rich taxonomy of at least 10 structurally distinct patterns, each with clear contextual motivations. The most significant trend: project type determines layout more reliably than project age or popularity. Libraries converge on flat root-package designs; application monoliths gravitate toward custom domain trees; large distributed systems invent monorepo patterns tailored to their compilation and publication needs.


Taxonomy#

1. Standard Go Layout (cmd/internal/pkg)#

The canonical layout: a cmd/ directory holding one or more binary entry points, an internal/ tree for private implementation packages, and a pkg/ directory for exportable library packages.

Projects: helm, tekton-pipeline, sqlc, delve, gogs (cmd/internal; no pkg/), restic (cmd/internal; no pkg/), frp (dual-binary variant with cmd/[frpc,frps] + client/ + server/ + pkg/)

Characteristics:

  • Binary entry points in cmd/<name>/main.go
  • pkg/ (or equivalent) for public library surface
  • internal/ enforces package visibility at compile time
  • Clean separation between CLI wiring and library logic

When used: Projects that are both a binary and a reusable library (delve, helm), or that evolved from an early convention-following phase and retained structure (gogs, tekton-pipeline). Also common in Kubernetes ecosystem projects where operator tooling conventions are strong.

Tradeoffs: The pattern works well up to mid-scale. pkg/ is often redundant if there are no external consumers — many projects omit it entirely. The forced cmd/ indirection adds a directory of boilerplate for single-binary tools.


2. Flat Library Layout (root-as-core)#

The root package is the public API. No cmd/ directory. Sub-packages handle specializations but the primary import path is the module root.

Projects: gin, echo, fiber, buffalo, beego, gorm, cobra, viper, pop, wireguard-go, pocketbase (root as facade), fyne (variant: root as pure interface contract)

Characteristics:

  • Core types (Engine, Context, Command, etc.) exported from root package
  • Sub-packages for pluggable extensions (binding, render, codec, middleware)
  • internal/ used only for hot-path utilities or platform-specific details
  • No binary entry point in the project itself (or it’s buried under a subdirectory)

When used: Libraries and frameworks where the import path is the product. The root package name is the package name callers write (gin.Context, cobra.Command). Adding a pkg/ indirection would make imports verbose (gin/pkg.Context).

Tradeoffs: The root package can become very large (gin: ~25 files, gorm: 72 files, cobra: all in root). No enforced separation between public API and private impl unless internal/ is used. Ideal for stable APIs; risky if the library is still rapidly evolving.


3. Multi-Module Monorepo (go.work)#

Multiple go.mod files under one repository, coordinated with a go.work file. Each module is independently versioned and publishable.

Projects: etcd (13 modules), grafana (35+ modules), prometheus (5 modules)

Characteristics:

  • go.work at repository root listing all modules
  • Each sub-module can be go get’d independently
  • Module boundaries enforce stricter API contracts than internal/
  • Sub-modules often contain generated code (proto), plugins, or stable client libraries

When used: Projects where different components have distinct release cadences (e.g., the etcd server vs. the etcd client SDK vs. the raft library), or where external consumers need to import only a slice of the project without pulling the whole dependency graph.

Tradeoffs: Module graph management overhead is high. go work sync and inter-module replace directives add complexity. CI must test each module and their combinations. Grafana’s 35+ modules represent an extreme that few teams can maintain.


4. Kubernetes Staging Monorepo#

Kubernetes-invented pattern: co-develop sub-modules in-tree under staging/src/ but copy-publish them to independent repositories for external consumption. Import enforcement via tooling (import-boss) rather than the compiler.

Projects: kubernetes

Characteristics:

  • staging/src/k8s.io/ contains code that gets periodically published to k8s.io/<name> repos
  • No go.work; single go.mod with replace directives pointing at staging
  • import-boss enforces cross-package import policies
  • Vendor directory at root pins all transitive dependencies
  • pkg/ holds the bulk of application logic; no internal/

When used: Only at Kubernetes’s scale, where dozens of sub-libraries (client-go, apimachinery, apiserver) must remain co-developed but independently consumable by the ecosystem. The pattern exists because go.work did not exist when Kubernetes was designed.

Tradeoffs: Extreme complexity. The sync pipeline between staging and downstream repos requires dedicated automation. The absence of internal/ means any package is importable, creating an implicit public API surface for the entire codebase.


5. Custom Domain-Driven Layout (root main.go, no cmd/)#

A single binary whose entry point lives at the repository root (main.go), with the rest of the codebase organized by domain rather than by technical layer.

Projects: consul, vault, nomad, hugo, buildkite-agent, headscale (cmd/ variant), nats-server, fzf

Characteristics:

  • main.go at root — no cmd/ indirection
  • Top-level directories named by domain (server/, client/, agent/, policy/, storage/, etc.)
  • Often no pkg/ (or it’s replaced by domain packages)
  • internal/ present in some (buildkite-agent) but absent in others (consul, nomad)

When used: Application-centric projects producing a single binary where the cmd/ pattern would add boilerplate without benefit. Also characteristic of HashiCorp projects (consul, vault, nomad, terraform) which share a strong internal convention.

Tradeoffs: Makes it immediately obvious that the project is an application, not a library. Reduces navigation overhead for single-binary projects. But the absence of internal/ (in most variants) means all domain packages are technically importable, creating an accidental library surface that can be hard to maintain.


6. All-Internal / Zero Public API#

Everything under internal/. The root package is either main or a thin facade. No exported packages intended for external use.

Projects: terraform, crush

Characteristics:

  • All packages under internal/ — compiler enforces zero external import
  • Root main.go delegates immediately to internal/cmd
  • No pkg/ directory
  • Binary-only intent is structurally enforced, not merely documented

When used: Pure application binaries with no library use case. Terraform’s explicit policy that its Go packages are not a public API. Crush’s intent as an AI coding tool without an embeddable SDK. The internal/ placement communicates this intent forcefully to anyone who reads the repo.

Tradeoffs: Maximum encapsulation — future refactors require no API compatibility decisions. But it can be overly strict: utility code that could benefit the ecosystem (e.g., config loading, auth) is locked away. Also makes the project harder to unit-test from outside the internal/ boundary.


7. Custom Monolith with extracted Sub-modules#

A single repository that is architecturally a monolith but has extracted specific sub-concerns into independently versioned modules. The boundary between “monolith” and “multi-module” is still being drawn.

Projects: moby (sub-module extraction in progress), drone (registry/ as workspace sub-module), consul/vault/terraform (api/, sdk/, proto-public/ have own go.mod)

Characteristics:

  • Core application code in a monolithic package tree
  • One or more specific concerns (client SDK, gRPC API types, stable plugin interfaces) extracted to separate go.mod files
  • These extracted modules are the stable public API; the rest of the monolith is not expected to be imported externally

When used: Projects transitioning from monolith to modular design, or where the client SDK must be independently versioned but the server cannot yet be fully modularized. Consul, vault, and terraform all follow this pattern: api/ is a first-class module, the rest is not.

Tradeoffs: Captures the real versioning need without requiring full modularization. But the boundary between “module” and “not-module” is implicit and can drift. Teams must decide consciously which packages are stable enough to extract.


8. Component Sub-tree Layout#

A monorepo where each service or component has its own mini-cmd/pkg hierarchy. Top-level directories correspond to product components, each internally organized.

Projects: istio (each component has own cmd/pkg), dapr (6 services, single module), temporal (service/frontend, service/history, service/matching, service/worker each with sub-trees)

Characteristics:

  • Top-level cmd/ may still exist for entry points, but logic is organized by component, not by layer
  • Each component sub-tree can be independently navigated
  • common/ or pkg/ holds cross-component shared code
  • Single go.mod (single module) despite the monorepo feel

When used: Distributed systems where the product is a suite of services deployed together but developed by different sub-teams. The layout reflects organizational boundaries more than technical ones.

Tradeoffs: Clearer ownership than a flat pkg/. Makes it easy to find “all code for the history service” vs. “all handlers for any service.” But intra-component structure still varies and there’s no formal API contract between components (no module boundary).


9. Plugin-Registry Layout#

The framework is built around a plugin extension model. A core abstraction (filesystem interface, handler interface) is defined centrally, and plugins register themselves via blank imports at compile time or via explicit registration calls.

Projects: rclone (fs/ kernel + lib/ utilities + blank-import self-registration), caddy (root-as-library + modules/ plugin tree + standard import aggregator), cockroach (CCL features via blank imports in pkg/ccl)

Characteristics:

  • Core abstraction package defines the plugin interface
  • Individual plugins live under a top-level backends/ or modules/ directory
  • A “standard” or “all” package (e.g., modules/standard) imports all plugins via blank imports
  • Entry binaries link the “all” package to get the full set; trimmed binaries can omit specific ones

When used: Tools with many interchangeable backends (rclone has 50+ storage providers; caddy has HTTP handlers, auth, TLS, etc.). The blank-import registration avoids a central registry while keeping the plugin isolation clean.

Tradeoffs: Very extensible and clean. But blank imports are surprising to Go newcomers and the modules/standard aggregator is an unusual pattern. Build tags are sometimes required in addition to blank imports for large configurations.


10. Framework-specific (Interface Root + Internal Implementation)#

The root package is a pure interface/type contract. All concrete implementations live under internal/. Feature domains live as top-level sub-packages between the two. A specific bridge package (e.g., app/) is the only place that instantiates concrete types.

Projects: fyne (the clearest exemplar), caddy (partial variant)

Characteristics:

  • Root package exports only interfaces and value types — no methods with implementations
  • internal/ contains the entire rendering/execution stack
  • Top-level sub-packages (widget/, container/, layout/) are the feature API surface
  • One bridge package links the public interfaces to private implementations via build tags or factory functions
  • Users can swap implementations (e.g., test renderer vs. OpenGL renderer) without changing code

When used: GUI frameworks and platforms where the same API must run on multiple backends (OpenGL, software, WASM) and where the implementation complexity would otherwise bleed into the public API surface.

Tradeoffs: Maximum interface stability. Adding a new platform backend requires no public API changes. Cost: extremely opaque to navigate — understanding how anything works requires tracing through internal/ which is intentionally hidden from IDEs and documentation tools.


11. Inverted Standard / Dominant cmd Package#

The cmd/ package is unexpectedly large, or serves as the application monolith. The “utility” is inverted: internal/ becomes the supporting ring rather than the core.

Projects: minio (~453 files in a single cmd/ package; internal/ as utilities), k3s (two-tier binary where the single binary contains embedded sub-systems), argo-cd (top-level dirs per service-personality of a single binary)

Characteristics:

  • A single cmd/ package contains the vast majority of application logic
  • internal/ or pkg/ packages are small and primarily utility in nature
  • The pattern often emerges from rapid growth in a single binary’s responsibility

When used: Projects that grew quickly into a large application without a planned modular decomposition. Minio’s massive cmd/ package is the most extreme example in the corpus.

Tradeoffs: Simple import graph — everything is in one package, so there are no inter-package API decisions. But it creates a “god package” that is hard to test, understand, and eventually refactor. It is a liability at scale.


12. Language/Toolchain Repository Layout#

The repository is the language toolchain itself. Everything under src/. Standard library and compiler toolchain are co-located. Dual-module split enforced with separate go.mod files.

Projects: go (the Go standard library and toolchain)

Characteristics:

  • src/ contains all Go source (stdlib packages + toolchain binaries)
  • Stdlib in module std; toolchain in module cmd
  • No pkg/ directory — all packages are at src/<package>
  • api/ directory holds machine-readable API surface files per version
  • test/ at root holds language conformance tests (distinct from per-package _test.go files)

When used: Only for the Go toolchain itself. The pattern predates and influenced many Go conventions.


13. Flat Tool / Minimal Single-Binary Layout#

A small, focused tool with main.go at the root and all logic in one or two packages. No cmd/, no internal/, no pkg/.

Projects: air (root main.go; runner/ package), wireguard-go (root-as-main + peer library packages), headscale (cmd/ variant with hscontrol/ as core)

Characteristics:

  • main.go at root
  • Single logic package (often named after the tool’s function: runner/, server/)
  • Build tags handle platform variation within the same package
  • Total package count: 2–5

When used: Small, focused developer tools. Air has ~20 non-test source files and no reason for further decomposition. wireguard-go is a focused daemon + library. At this scale, adding cmd/internal/pkg structure adds ceremony without value.

Tradeoffs: Maximum simplicity. Works fine until the project grows. The moment a second binary is needed, or the logic exceeds ~30 files in a package, the lack of structure becomes a liability.


14. Flat-Domain Monorepo (Vanity Path, Everything Public)#

A large product suite where every package is exported and lives at the module root as a peer directory. No broad internal/. The module path is the product.

Projects: tailscale

Characteristics:

  • Vanity module path (tailscale.com) signals that the module is a first-class public library
  • ~50 binaries in cmd/ but the domain packages (net/, wgengine/, ipn/, derp/) are the product
  • internal/ exists but has only 2 packages
  • feature/buildfeatures/ provides 100+ compile-time feature flags as file pairs
  • tsnet is an explicit embedded-library API built on top of the same packages the daemon uses

When used: Products that are simultaneously a binary and a platform — where third parties are expected to embed the library in their own applications (e.g., embed Tailscale into an app for zero-config networking).

Tradeoffs: Maximum embeddability. But the absence of internal/ means every package is an implicit API commitment. At Tailscale’s scale, this is managed through careful API design and explicit deprecation, not compiler enforcement.


1. go.work adoption is rising but selective. Only etcd, grafana, and prometheus use go.work directly. Most projects that could benefit from multi-module organization instead use a simpler variant: extract one stable module (the client SDK) and keep everything else in the main module. Full go.work monorepos are reserved for projects where multiple sub-libraries have genuinely different release cadences.

2. internal/ is used strategically, not universally. Of the 51 projects, roughly 15 use no internal/ at all. The most common motivation for omitting it: embeddability (tailscale, pocketbase, temporal, wireguard-go). Projects that do use internal/ often have a specific reason — preventing CGo backend leakage (fyne), hiding security-sensitive parsing (nats-server), or enforcing clean boundaries in a layered architecture (delve, sqlc, gin).

3. pkg/ is declining. Out of 51 projects, fewer than 10 use a pkg/ directory, and several of those use it reluctantly or sparsely. The Go community increasingly views pkg/ as an unnecessary indirection — if a package is public, it should have a meaningful name, not be hidden under a generic pkg/ prefix.

4. cmd/ placement is no longer universal for single-binary tools. At least 15 projects have their binary entry point at the root (main.go), not under cmd/. For single-binary tools with no library use case, the cmd/ convention adds friction without payoff.

5. Generated code gets dedicated directories. Across nearly every project using protobuf, SQL codegen, or OpenAPI: gen/, api/ (typed), db/, or swagger/ hold generated artifacts and are explicitly excluded from code review / manual editing. The convention of isolating generated code is nearly universal.

6. Build tags are heavily used for platform variation and feature gating. wireguard-go, tailscale, fyne, air, nats-server — all use OS-suffixed file names or build tags as the primary mechanism for platform differentiation. Runtime switch runtime.GOOS is rare; compile-time file selection is the Go idiom.

7. Code generation is becoming a first-class build concern. Crush (sqlc + swag + go generate), temporal (7 codegen binaries), headscale (buf + protoc), tailscale (dozens of generated feature flag files) — large projects invest heavily in code generation infrastructure. The cmd/tools/ pattern (a cmd/ subdirectory full of codegen and CI tools) is common at scale.

8. Test infrastructure is getting its own first-class directories. integration/ (headscale), test/e2e/ (frp), tests/ (temporal, pocketbase), _fixtures/ (delve) — projects invest in separating unit tests (co-located _test.go) from integration/e2e tests (dedicated top-level directories). The integration test suite often has its own sub-packages, helpers, and Docker orchestration.


Best Practices#

1. Match layout to project type. Libraries should use flat root-package layout (gin, cobra pattern). Single-binary applications should use root main.go or cmd/<name>/main.go. Multi-binary systems need cmd/<name>/main.go for each binary. Forcing a library layout onto an application (or vice versa) creates structural friction.

2. Use internal/ with intent, not reflexively. The compiler-enforced boundary is most valuable when you have a specific reason: hiding a CGo backend, protecting an unstable API, enforcing a layered architecture, or preventing accidental cross-cutting imports. Putting everything under internal/ (terraform, crush pattern) is correct for pure application binaries that explicitly reject library use.

3. Separate generated code into its own directory. Generated files (gen/, api/, db/, swagger/) should be clearly delineated from hand-written code. They are checked in but marked as machine-owned. This makes git diff reviews, linting, and code search significantly cleaner.

4. Name packages for their domain, not their layer. client/, server/, storage/, policy/ are better package names than impl/, core/, utils/. The best packages in the corpus (frp’s client/ and server/, delve’s pkg/proc/, headscale’s hscontrol/db/) are named for what they contain, not where they sit in a generic architectural diagram.

5. One package per well-defined concept at scale. Temporal’s one-package-per-RPC-handler under service/history/api/ is extreme, but the principle scales: as complexity grows, prefer many small packages over few large ones. The server/ package in nats-server (~180 files) and cmd/ in minio (~453 files) are cautionary examples of what happens when a single package absorbs too much.

6. Put the integration test suite at a peer level, not nested under source. headscale (integration/), frp (test/e2e/), temporal (tests/), and delve (_fixtures/) all treat their integration test infrastructure as a first-class concern at the top level. Nesting it under internal/test/ would make it harder to find, scope, and run independently.

7. Co-locate client SDK extraction with the first stable release. consul, vault, terraform, and moby all extracted their client SDKs to separate modules at some point. The lesson: the client SDK is almost always more stable than the server internals. Extract it early to separate version cadences and reduce consumer dependency surface.

8. Use build tags for feature gating, not runtime flags, when binary size matters. tailscale’s feature/buildfeatures/ with 100+ _enabled/_disabled file pairs achieves dead-code elimination at compile time. This enables a single codebase to produce a minimal mobile build and a full-featured server build. caddy’s modules/standard aggregator and rclone’s blank-import backend registration follow the same principle.


Anti-Patterns#

1. The util package.
Packages named util/, utils/, common/, helpers/ are recurring red flags. They become dumping grounds for unrelated code and eventually develop their own hidden coupling. The best projects avoid them: rclone uses lib/, fyne uses internal/ sub-packages, pocketbase uses fine-grained tools/<name>/ packages. When you find yourself writing util.go, it usually means the function belongs in one of the domain packages.

2. Giant single packages.
minio’s cmd/ package (~453 files), nats-server’s server/ package (~180 files), and gorm’s root package (~72 files) all represent the same anti-pattern at different scales: a single package that has absorbed too many responsibilities. The short-term benefit (no inter-package API design) gives way to long-term costs: untestable code paths, slow compilation, difficult onboarding.

3. Using internal/ without a reason.
Some projects put things in internal/ out of reflex rather than intent. If a project is a pure binary with no external consumers (air, nats-server, nomad), everything under internal/ is actually unnecessary — the project will never be imported as a library regardless. The internal/ annotation only matters if external consumers exist or are anticipated.

4. pkg/ as a meaningless wrapper.
A pkg/ directory that mirrors the root directory structure (e.g., pkg/server/, pkg/client/) adds a path segment without adding information. It became a convention from early Java-esque project structures and is now widely recognized as cargo-culted indirection. Projects like prometheus, gin, and hugo deliberately omit it.

5. No separation between generated and hand-written code.
When generated proto stubs, sqlc queries, or mock objects live alongside hand-written source in the same directory, git diff noise increases and developers accidentally modify generated files. Every project of significant scale separates them (headscale’s gen/, crush’s internal/swagger/, temporal’s api/, delve’s auto-generated helphelpers/).

6. Flat monolith with all logic in main package.
Keeping all logic in the binary’s main package (instead of extracting it into a separately-testable package) makes unit testing impossible — Go does not allow importing package main in tests. The runner/ extraction in air, the server/ package in nats-server, and the internal/app/ in crush all exist specifically to make the core logic testable in isolation.

7. Importing implementation details across team boundaries.
gitea and argo-cd both use convention-enforced layering without internal/ enforcement — models can import modules and vice versa. Without the compiler enforcing boundaries, cross-cutting imports accumulate over time, creating hidden coupling that is expensive to untangle. Projects at this scale benefit from at least aspirational internal/ enforcement.


Exemplars#

Most orthodox Standard Go Layout: helm
cmd/helm/main.gointernal/ (private logic) → pkg/ (embeddable API). The pkg/action package is explicitly designed for embedding Helm in other tools. Clean separation of CLI wiring (internal) from library surface (pkg).

Best flat library layout: cobra
The entire library is in the root package with a single doc/ sub-package. No ceremony, no indirection. The root package is the product. Despite being one of the most widely imported Go libraries, the structure fits in a single directory.

Best all-internal application layout: crush
The cleanest example of the “this is an application, not a library” structural commitment. Root main.go + everything under internal/. The Workspace interface as the internal seam between CLI/TUI and the backend demonstrates that internal/ packages can still have clean architecture — they just have no external surface.

Best plugin-registry layout: rclone
The fs/ abstraction kernel + backend/<name>/ plugin directories + blank-import self-registration in backend/all/all.go is the most fully realized plugin architecture in the corpus. 50+ storage backends, all independently testable, all linked by a single blank-import aggregator.

Best multi-module monorepo: etcd
13 modules in a go.work workspace with clean module-boundary enforcement. The etcd client SDK (go.etcd.io/etcd/client/v3) is independently versioned and widely imported. The raft library (go.etcd.io/raft/v3) is separately consumable. Module boundaries are drawn where versioning responsibility genuinely differs.

Best framework-specific layout: fyne
The interface-root pattern — where the root package contains only interfaces and the entire implementation lives in internal/ — is executed with rare discipline in fyne. The 32 internal/ packages vs. 15 public sub-packages ratio illustrates how much complexity can be hidden behind a stable interface contract.

Best component sub-tree layout: temporal
The one-package-per-RPC-handler pattern under service/history/api/ (60+ packages) is extreme but consistent. Each handler is isolated, independently testable, and independently changeable. Combined with the rich codegen toolchain and the temporal/ public embedding API, temporal demonstrates mature large-scale Go monorepo design.

Best custom domain-driven layout: nomad
main.go at root, domain packages (command/, api/, client/, scheduler/, server/, nomad/) at top level. No cmd/ indirection, no pkg/ wrapper. Each directory name describes a real domain concept. nomad/structs/ as the shared domain model prevents circular imports elegantly.


Note on fyne and crush#

fyne is the clearest example in the corpus of the “interface-root + implementation-internal” pattern. The root package (fyne.io/fyne/v2) exports only interfaces and value types — App, Window, Canvas, Widget, Theme. Zero rendering code lives in the root. All concrete implementations (OpenGL renderer, GLFW driver, software painter, animation engine) are in internal/, making them refactorable without any public API breakage. The 32 internal/ packages vs. 15 public sub-packages illustrates deliberate boundary design at framework scale. The deprecated cmd/fyne/ binary is kept in-tree as a transitional artifact while tooling migrates to a separate module — a sign of maturity.

crush is the clearest example in the corpus of the “all-internal application binary” pattern. Root main.go + everything under internal/. There is no pkg/ and no public library surface. The internal/cmd/ placement of the Cobra command tree (rather than the idiomatic cmd/<name>/) removes even the appearance that any package is importable. The Workspace interface inside internal/workspace/ demonstrates that internal/ does not mean “poorly structured” — the layering (CLI → Workspace → App/Server → Agent/TUI → DB) is clean and testable. Crush also exemplifies modern build tooling: go-task instead of make, sqlc for type-safe DB queries, swag for OpenAPI, WASM-based CGO-free SQLite, and GOEXPERIMENT=greenteagc.