If you’ve cloned an unfamiliar Go repository and spent ten minutes figuring out where to add a feature, you’ve felt the cost of bad structure firsthand. Go doesn’t prescribe a layout — which is a deliberate choice — but that freedom produces a wide spectrum of project shapes. After working across enough codebases, I’ve settled on a small set of conventions that scale predictably without adding ceremony.
The short version #
Put binaries in cmd/, private logic in internal/, and deliberate public helpers in pkg/. Keep main.go under 50 lines. Everything else follows from these three rules.
Why structure matters at all #
A directory tree is a communication tool. Before anyone reads a line of code, it answers: where does X live? When that answer is consistent, onboarding is faster, code review is easier, and refactors are less likely to scatter changes across unrelated packages. The goal isn’t to follow a spec — it’s to make intent visible at a glance.
The three directories #
myapp/
├── cmd/
│ └── myapp/
│ └── main.go ← wire deps, call Run(), nothing else
├── internal/
│ ├── user/ ← domain logic, protected by the compiler
│ └── transport/ ← HTTP / gRPC handlers
├── pkg/
│ ├── retry/ ← genuinely reusable across repos
│ └── config/
├── migrations/
├── Dockerfile
└── go.mod
cmd/ holds one directory per binary. Each main.go is intentionally thin: parse flags, instantiate dependencies, start the process. If it grows beyond ~50 lines, something that belongs in internal/ has leaked in.
internal/ is where your actual logic lives. Go enforces this at compile time — code outside your module cannot import anything under internal/. This is one of Go’s most underused features. Use it freely and don’t reach for pkg/ until you have a concrete reason to share.
pkg/ is a deliberate decision, not a dumping ground. Before promoting something here, ask: would another team actually import this today? If the answer is “maybe someday,” leave it in internal/ and promote it when the need is real.
How dependencies flow #
graph LR cmd["cmd/"] -->|wires| int["internal/"] int -->|uses| pkg["pkg/"] ext["other repos"] -.->|blocked| int ext -->|allowed| pkg
cmd/ depends on internal/. internal/ can use pkg/. External repos can reach pkg/, but the compiler hard-blocks any access to internal/. This asymmetry is the whole point: your business logic is protected by default, and anything you expose via pkg/ is an explicit, considered choice.
Testing #
Unit tests live beside the code they test — user_test.go next to user.go. Use package user_test (external test package) when you want to test only the public API and catch accidental exposure of internals.
Integration tests belong in a top-level test/ directory and reference the composed system, not individual packages. Keep them separate so go test ./... runs fast and integration tests are opt-in.
Write Example functions for anything in pkg/ — they run as part of go test and serve as verified, always-up-to-date documentation.
Multiple binaries and monorepos #
Several binaries in one repo is perfectly normal. Each gets its own cmd/<name>/ directory and shares internal/ freely. Reach for multiple go.mod files only when you need independent release cycles — not for organizational tidiness. The added module complexity rarely pays off early on.
Migrating a messy codebase #
Don’t restructure everything at once. Pick the most critical package, write tests for it, move it into internal/, keep CI green. Repeat. The structure improves incrementally and you never enter a state where nothing builds.
Closing thoughts #
Good structure is invisible when it works. Use cmd/, internal/, and pkg/ with clear intent, keep entry points thin, and let the Go compiler enforce your boundaries. The layout should describe the system — not constrain it.
If you’re ready to take this further — adding module boundaries, event buses, and composition roots within a single deployable — read Designing a Modular Monolith in Go.