Skip to main content
  1. Posts/

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

·1874 words·9 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 1: 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 — the official guidance at go.dev/doc/modules/layout is deliberately light — and 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.

Structure advice is cheap, so this post holds itself to a higher standard: every claim about what the toolchain enforces comes with the actual error message or test run, captured on go1.26.4. All of it is runnable — clone daveamit/go-architecture-demos and run 01-folder-structure/demo.sh to replay every receipt in this post.

The short version
#

Put binaries in cmd/, private logic in internal/, and deliberately shared helpers in pkg/. Keep main.go under 50 lines. These three conventions carry most projects — but they’re conventions, not rules, and small projects need none of them.

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.

When you need none of this
#

If you’re shipping one small binary or a library, the correct structure is a single package at the module root — main.go (or yourlib.go) next to go.mod. That’s where the official guidance starts too. Reach for the directories below when the project earns them: a second binary, or code you actively need to keep other modules from importing. Structure added before it’s needed is just ceremony.

The three directories
#

myapp/
├── cmd/
│   └── myapp/
│       └── main.go        ← build deps, call run(), nothing else
├── internal/
│   ├── user/              ← domain logic, protected by the go command
│   └── transport/         ← HTTP / gRPC handlers
├── pkg/
│   └── retry/             ← genuinely reusable across repos
├── migrations/
├── Dockerfile
└── go.mod

cmd/ holds one directory per binary. Each main.go is intentionally thin — it does nothing but hand off to a run function that returns an error:

func main() {
	if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

run parses flags, wires dependencies, and starts the process; it can live right next to main in the same package. Returning an error keeps os.Exit in exactly one place, which is what makes the binary testable end-to-end. If main.go grows beyond ~50 lines, something that belongs in internal/ has leaked in.

internal/ is where your actual logic lives, and it’s the only directory on this page with teeth. The rule, defined in the Go 1.4 release notes, is directory-based: a package under internal/ can only be imported by code in the directory tree rooted at internal/’s parent. For a root-level internal/ that parent is the module root, so nothing outside your module can touch it. In my experience 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 the contested one, so let’s be honest about it: pkg/ means nothing to the toolchain. Anything outside internal/ is importable by other modules whether or not it sits under pkg/, the official layout doc doesn’t recommend it, and plenty of well-known repos — tailscale, hugo, caddy — put importable packages at the module root instead. The golang-standards/project-layout repo that popularized the pattern was publicly disowned by Russ Cox — it is not affiliated with the Go team, whatever its name suggests. Both styles work. I keep pkg/ for one reason: in a repo whose root is crowded with cmd/, migrations/, a Dockerfile, and CI config, it marks the deliberately shared code at a glance. Treat it as a signal, not a mechanism, and 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.

Watch the boundary get enforced
#

Claims about compiler-adjacent behavior deserve receipts. Scratch module example.com/myapp, go1.26.4. First, a second module trying to import example.com/myapp/internal/user:

package example.com/othermod
	main.go:6:2: use of internal package example.com/myapp/internal/user not allowed

Build failed, before a single line was compiled. Note who’s talking: that error comes from the go command during import resolution, not from the compiler — the compiler never sees the code.

Second receipt, and this one surprises people: the rule is directory-based, not module-based, so a nested internal/ is off-limits even to the rest of your own module. Here other/ and sub/internal/deep live in the same module:

package example.com/myapp/other
	other/other.go:3:8: use of internal package example.com/myapp/sub/internal/deep not allowed

Same module, still blocked — because other/ isn’t inside sub/, the tree rooted at that internal/’s parent. Large codebases use this deliberately: a nested internal/ gives a subsystem its own private parts, hidden even from sibling packages.

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 import pkg/, but the go command hard-blocks any import of internal/. Be clear about which arrows are enforced, though: only the dotted one. Everything outside internal/pkg/, the module root, anything — is importable by default, so exposure requires no choice at all; protection does. That’s the real asymmetry, and it’s why internal/ should be the default home for your logic: put code there and the boundary is guaranteed, leave it anywhere else and it’s public.

Inside internal/: packages by domain, not by layer
#

The tree above puts internal/user/ next to internal/transport/, and that pairing deserves an explanation, because it mixes two philosophies. user/ is a domain package — it owns a concept: the types, the business rules, the storage access for users. transport/ is a layer package — it owns a mechanism.

My rule: group by domain, and allow exactly one layer exception at the edge. Domain packages (user, order, billing) keep everything about a concept in one place, so a feature change lands in one package and the import graph stays a tree. The layer-first alternative — handlers/, models/, services/ — imports the MVC muscle memory from other ecosystems, and it fails in Go the same way every time: every feature touches three packages, models decays into a bag of types that everything imports, and cyclic-import errors start dictating your design instead of your domain.

transport/ earns its exception because it sits at the boundary: it translates the outside world’s shapes (HTTP requests, gRPC messages) into calls on domain packages. It depends on every domain package; nothing depends on it. An edge layer with one-way dependencies doesn’t tangle.

Naming falls out of the same idea. Package names are part of every call site, so make them short, lower-case, singular domain nouns: user.Service, not user.UserService — the Go blog’s package-names post covers the mechanics. And never util, common, or helpers: a package named after what it isn’t attracts everything that lacks a home, and within a year it imports half the repo.

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 can live in a top-level test/ directory and reference the composed system, not individual packages. But placement alone does nothing, and here’s the receipt — a test/ directory with one test in it, and a plain go test ./...:

?   	example.com/myapp/cmd/myapp	[no test files]
?   	example.com/myapp/internal/user	[no test files]
ok  	example.com/myapp/pkg/retry	0.553s
ok  	example.com/myapp/test	0.989s

It ran. ./... matches every package in the module and a directory named test gets no special treatment. What actually makes integration tests opt-in is a guard — add //go:build integration to the file and the package drops out of the default run entirely:

?   	example.com/myapp/cmd/myapp	[no test files]
?   	example.com/myapp/internal/user	[no test files]
ok  	example.com/myapp/pkg/retry	(cached)

…and comes back with go test -tags integration ./.... A testing.Short() check skipped with -short works too; pick one and put it in CI.

The same show-me standard applies to documentation. Write Example functions for anything in pkg/, but know the rule from the testing package docs: examples without an output comment are compiled but not executed. This example passes go test today, in your CI, silently:

func ExampleDo() {
	retry.Do(3, func() error { return nil })
	panic("this example is broken and CI will never know")
}

Add // Output: <nil> to an example and it becomes a real test — go test -v shows exactly which one earns its keep:

=== RUN   ExampleDo_verified
--- PASS: ExampleDo_verified (0.00s)
PASS
ok  	example.com/myapp/pkg/retry	0.140s

One RUN line, two examples. The // Output: comment is the difference between verified documentation and decoration.

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. (One useful side effect if you do split: a nested module is invisible to the parent’s go test ./..., which is the heavyweight version of the integration-test guard above.)

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.

Two Go-specific warnings. Moving a package under internal/ is a breaking change for anyone importing it from outside the module — check for external importers before you move. And rewrite import paths with gopls rename rather than sed; it understands the package graph and your editor already ships it.

Opinionated Takeaway
#

  • Start flat. One package at the module root until the project earns directories. The official layout doc agrees; ceremony is not architecture.
  • internal/ is the default home for logic, because it’s the only boundary the toolchain enforces — you saw the error message it produces. Everything else on this page is convention maintained by code review.
  • Use pkg/ as a signal, not a mechanism — or don’t use it at all; root-level packages are equally idiomatic. Just don’t believe it protects anything.
  • Inside internal/, package by domain, with transport/ as the one edge-layer exception. If you’re typing util, stop.
  • Make your test claims real: build tags for integration tests, // Output: comments for examples. Both have a failure mode that passes CI silently — that’s worse than no test.
  • Keep main.go at ~15 lines with the run() pattern; 50 is the ceiling, not the target.

Good structure is invisible when it works. The layout should describe the system — not constrain it.


Next in the Go Architecture series: Designing a Modular Monolith in Go — module boundaries, in-process events, and composition roots within a single deployable.


Go Architecture series: 1. How to structure Go projects · 2. Designing a Modular Monolith in Go

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

Related

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