Lesson 1 of 9 · 20m
A Go Project Layout That Scales Past main.go
Where code goes when a service outgrows one file: cmd/, internal/, and why Go's compiler enforces your boundaries.
You inherit a repo that started life as a single main.go at the root. It's now 4,000 lines, someone bolted on a second binary, and the utils package imports half the codebase. Nothing in the Go toolchain forced this to be a mess — but nothing stopped it either. Layout is a choice you make once and live with for years.
The shape that works
For a service (not a library), this is the layout that holds up:
myservice/
go.mod
cmd/
server/main.go
migrate/main.go
internal/
config/
store/
handler/
migrations/Two rules do the real work here. First, cmd/<binary>/main.go is thin — parse config, wire dependencies, call Run(). Second, everything the service owns lives under internal/. That directory name is not a convention: the compiler refuses to let code outside the module import anything under internal/. Your implementation details are private by enforcement, not by politeness.
A thin main.go looks like this:
package main
import (
"log"
"os"
"github.com/acme/myservice/internal/app"
"github.com/acme/myservice/internal/config"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if err := app.Run(cfg); err != nil {
log.Printf("run: %v", err)
os.Exit(1)
}
}Testability note: main() can't be tested, but app.Run(cfg) can. Keep the untestable surface as small as possible.
Package names and boundaries
Name packages for what they are (store, handler, auth), not what they contain (utils, common, helpers). A utils package is a dependency magnet: everything imports it, it imports everything, and one day the compiler reports an import cycle — because Go forbids those outright, which is a feature masquerading as an annoyance. When you hit a cycle, the design is wrong, usually a shared type that wants its own package.
On pkg/: it's a reasonable place for code you intend external modules to import. It is not a junk drawer. If nobody outside your module will import it, it belongs in internal/. Every exported name is an API contract you now maintain.
Field notes
go.mod's module path is your repo URL (module github.com/acme/myservice). All imports derive from it.- Resist creating packages before you need them. Flat is fine until it isn't.
- Exported identifiers are forever. When in doubt, don't export.
Try this
Take a service you know (or sketch one) with handlers, a DB layer, and config all in main. Restructure it into cmd/server plus internal/{config,store,handler}, and verify go build ./... still passes. Then try importing internal/store from a different module in /tmp and watch the compiler refuse — that's the boundary working.