Skip to main content
  1. Posts/

How to structure Go projects (practical conventions, not rules)

·647 words·4 mins·
Go Architecture Golang Project-Structure Monolith Best-Practices
Dave Amit
Author
Dave Amit
Principal Architect with nearly two decades designing distributed systems and cloud-native platforms. I bring deep systems expertise, hands-on AI-assisted engineering workflows, and a track record of shipping things that actually scale. Currently writing Go and Rust, running on Kubernetes, and exploring what happens when strong architectural judgment meets modern AI tooling.
Table of Contents
Go Architecture - This article is part of a series.
Part : This Article

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.

Go Architecture - This article is part of a series.
Part : This Article

Related

Stop the Plumbing Hell: Building `bs`
·486 words·3 mins
Go Devops Build-Systems Bs Engineering