Where Things Go: Laying Out a Real Repository

The cmd/ and internal/ conventions, the compiler-enforced internal boundary, package naming without stutter, and go.work for multi-module development. Compiled and run against Go 1.26.5.

The service you built at the end of the last series lived in one file. That was the point: the standard library is enough to write real things. But a file grows into a package, a package into a handful, and at some point someone clones the repo and has to guess where the entry point is, what’s safe to import, and which directory holds the thing they came for. Go has almost no framework-mandated structure to answer that — which sounds like a gap and is actually a gift. The conventions are few, they’re enforced by the compiler where it matters, and they’re the same in every Go repo you’ll ever open. This chapter is those conventions, plus the one rule that outranks all of them. Everything here was compiled and run against Go 1.26.5.

The one rule: flat until it hurts

Before any directory names, the governing principle: keep the layout flat until flatness genuinely hurts, and then split by domain, not by layer. A new service does not need a pkg/, a services/, a handlers/, a models/, and a repositories/ on day one — it needs a main.go and maybe a package or two, and it will tell you when it needs more. The signal is real friction: a file you can’t find, a package that does five unrelated things, an import you’re embarrassed by.

When that friction arrives, resist the instinct most engineers carry in from other ecosystems — splitting by technical layer. A models/ directory holding every struct, a handlers/ directory holding every HTTP handler, a services/ directory holding all the logic. It feels tidy and it ages badly — a single feature is now smeared across five directories, and no directory is about anything. Split by domain instead: an order package that holds the order type, its storage, and its handlers together, and a catalog package next to it. Each package becomes a small vertical slice you can reason about, test, and delete as a unit. The standard library is the model here — net/http is not carved into net/http/types and net/http/logic; it’s one coherent thing about one subject.

cmd/ names your binaries

The first convention with a directory name attached is cmd/. A Go program that produces an executable is a package main with a func main, and the convention puts each such entry point in cmd/<binary-name>/main.go:

myservice/
├── go.mod
├── cmd/
│   ├── api/
│   │   └── main.go      // the "api" binary
│   └── worker/
│       └── main.go      // the "worker" binary
└── internal/
    └── ...

go build ./cmd/api produces a binary named api; ./cmd/worker produces worker. The reason to adopt this even for a single binary is that repos rarely stay single-binary — a service acquires a background worker, a migration tool, a one-off admin command, and cmd/ gives each one an obvious home without the root of your repo turning into a pile of main.go variants. The main package itself should stay thin: parse config, wire dependencies, start the server, hand off. All the actual behavior lives in importable packages below it, so it can be tested without spawning a process.

internal/ is a wall the compiler builds

The most useful directory in Go has nothing to do with taste and everything to do with the compiler. Any package whose import path contains an internal/ element may be imported only by code rooted at internal’s parent directory. This is not a convention or a linter rule you can wave away — it is a hard compile error.

Here is a package under internal/, and a main that shares the parent and imports it normally:

// internal/greeter/greeter.go
package greeter

func Hello() string { return "hello from an internal package" }
// main.go, rooted at the parent of internal/
import "example.com/app/server/internal/greeter"
$ go run .
hello from an internal package

Now drop a sibling package elsewhere in the module, reach into that same internal/greeter, and try to build it:

$ go run .
package example.com/app/badimport
	main.go:6:2: use of internal package
	example.com/app/server/internal/greeter not allowed

The build fails. Not a warning, not a lint finding you can suppress — the code does not compile. That single fact is what makes internal/ the backbone of a maintainable repo. Everything under it is yours to refactor freely, because no code outside the subtree was ever allowed to depend on it. Put your whole implementation there, expose a deliberately small public surface at the root, and you have drawn a line the language itself will defend. The practical default for a service is aggressive: put almost everything in internal/, and let only the genuinely reusable, genuinely stable packages live outside it.

Package names: short, lower, no stutter

A package name is part of every call site that uses it — so it earns its brevity. Go’s conventions here are firm and worth internalizing early — names are short, all-lowercase, no underscores, no camelCase: order, not orderService or order_pkg. The name is a noun for the thing the package is about, and it’s usually singular.

The subtle one is stutter. Because callers write package.Symbol, a symbol named after its own package repeats itself out loud. A function order.NewOrder() reads as “order new order” — the idiomatic name is order.New(). A type http.HTTPServer would stutter, so it’s http.Server. Read every exported name as package.Name and cut the redundancy out. The other trap is the generic bucket — util, common, helpers, base, misc. These attract unrelated code precisely because they mean nothing, and a year later util is the second-biggest package in the repo and about nothing at all. If you can’t name a package for what it is, that’s a sign the code inside it doesn’t belong together yet.

go.work, for when one module isn’t enough

Most repos are one module with one go.mod — and that’s the right default. But sometimes you’re developing two modules at once — a service and a library it consumes, in separate module trees — and you want the service to build against your local, uncommitted copy of the library rather than a published version. That’s what a workspace is for.

Here are two modules. example.com/greet is a library; example.com/app requires it, but that version was never published anywhere:

// greet/greet.go   (module example.com/greet)
package greet

func Greeting(name string) string { return "Hello, " + name }

Build the app on its own and it fails, because the require points at nothing the network can find:

$ cd app && go build ./...
main.go:6:2: unrecognized import path "example.com/greet":
	reading https://example.com/greet?go-get=1: 404 Not Found

Now stitch the two together with a workspace file in the directory above both:

$ go work init ./greet ./app
$ cat go.work
go 1.26.5

use (
	./app
	./greet
)
$ cd app && go run .
Hello, workspace

The go.work file lists local module directories under use, and while it’s present the go command resolves example.com/greet to the directory on disk, ignoring the network entirely. One discipline is non-negotiable here: go.work is a local-development tool, and it does not belong in the committed repo — add it to .gitignore. It describes your machine’s layout of checked-out modules, not a fact about the project, and committing it will break everyone whose directories don’t match yours. When the library is finally published and tagged, you delete the workspace and the normal go.mod require takes over.

Final thoughts

There is no src/, no framework scaffold, no config file deciding where handlers live. The layout is a handful of conventions the whole ecosystem shares — cmd/<binary>/main.go for entry points, internal/ for the code you want the compiler to keep private, domain-shaped packages with short unstuttering names, and go.work when you’re genuinely juggling two modules. Above all of it sits one instruction: stay flat until flatness hurts, then cut along domains rather than layers. A repo laid out this way is legible to anyone who’s read another Go repo, which is exactly the property the language keeps optimizing for. Next we replace the log.Printf calls from the last series with something a machine can actually query.

Next: structured logs, in the standard library

Comments