How to lay a Go project out
What a package is, the three layouts you will meet, and what each common folder is for.
- Explain what a package is and how
internal/restricts imports. - Compare the flat, by-layer and by-domain layouts and pick one for a given project.
- Name packages by what they provide, avoiding
utilsandcommon.
First, what a package is
A package is a folder of .go files that are compiled together and share a name. Every file in one folder must declare the same package name, and that name is how other code refers to it.
Packages are the only unit of privacy Go has. A name starting with a capital letter can be used from outside the package. A lowercase name cannot. There is no public, private or protected keyword. The folder boundary plus capitalisation is the whole system.
A module is one level up. It is a whole project: a folder with a go.mod file, containing one or more packages. The module path in go.mod is the prefix for every import inside it.
What "project layout" means
Laying out a project means deciding which folders exist and what goes in each one. In Go the folder structure decides three things: what can import what, what outsiders can use, and how much you have to read to understand one feature.
Go has no official required layout. What it has instead is one enforced rule (internal/, below) and a set of conventions that most teams follow. Below are the three layouts you will meet most, smallest first.
Layout 1 — flat
Everything in one package at the top of the repository. There are no subfolders.
Use it for: a command-line tool, a script, a small library, or the first two days of any project. Under roughly 1,000 lines it is usually the right answer. More structure than this, this early, costs time and buys nothing.
It stops working when: you cannot find things any more, or two unrelated parts of the file start needing the same helper with the same name.
Layout 2 — grouped by layer
The next step most people take: a folder per *kind of file*. All the data types together, all the business logic together, all the HTTP handlers together.
This is the layout most frameworks in other languages push you towards, so it feels familiar. It is worth understanding because you will inherit codebases built this way.
The problem shows up at about ten features. Adding one feature means editing five folders, every package ends up importing every other package, and no feature can be understood, or deleted, on its own. You also hit Go import cycles quickly, because models wants something from services and services already imports models.
Layout 3 — grouped by domain
A folder per *thing your product has*, not per kind of file. Everything about orders lives in the order package: its type, its rules, its SQL, its HTTP handlers, its tests.
This is a common choice for Go services, and what the rest of this track uses. One feature is one folder, so you can read it, test it, hand it to someone else, or delete it without hunting.
Use it for: anything that will grow: a web service, an API, a long-lived tool.
The three side by side
| Layout | Best for | Falls apart when |
|---|---|---|
| Flat — one package | CLIs, scripts, small libraries, day one of anything | you can no longer find things (~1,000 lines) |
By layer — models/, services/ | small apps; codebases you inherit | about ten features, or the first import cycle |
By domain — user/, order/ | services and APIs meant to grow | rarely — but it is overkill for a 200-line tool |
The names you will hear, and where each one appears
Those layouts have names, and people use them loosely. The table shows what each name means and which project in this track builds it, so you finish having written each one rather than only read about it.
| Layout | Also called | Built in | Reach for it when |
|---|---|---|---|
| Flat | single package | Practice 1 | a CLI, a script, or under ~1,000 lines |
| Layered | package-by-layer, n-tier, standard layout | Practice 2, Project 1 — URL shortener | the service has one concept |
| By feature | package-by-domain, vertical slice, DDD-lite | Practice 3, Project 2 — Expense tracker | several concepts, and it will grow — the usual default |
| Ports and adapters | hexagonal, clean, onion | Project 3 — Small shop | you expect to swap an external dependency |
What every folder means
These names are conventions, not rules, except internal/, which the go tool enforces. Learn them because most Go repositories you open will use some of them.
| Folder | What goes in it | Why it exists |
|---|---|---|
cmd/ | One subfolder per runnable program, each with its own main.go. | A project often ships more than one binary: an API, a worker, a seeder. cmd/api/ and cmd/worker/ keep them apart. Put no logic here, only wiring. |
internal/ | Everything private to this project. | The go tool refuses imports of it from outside the project. It is the only folder rule Go enforces, so you get a private API for free. |
pkg/ | Code you intend outsiders to import. | The opposite of internal/. Only add it if you are publishing a library. For a normal service it adds a folder level and nothing else, and many teams skip it. |
internal/<domain>/ | One folder per business concept: user/, order/. | Keeps one feature in one place. This is the folder you delete when a feature goes away. |
internal/config/ | Reading and checking environment variables. | Config is loaded once, at startup, and passed down. Nothing deeper should read the environment. |
internal/database/ | Opening the connection pool, shared database helpers. | One place that knows how to connect, so no domain package repeats it. |
internal/middleware/ | Logging, panic recovery, auth checks, rate limiting. | Cuts across every request, so it belongs to no single domain. |
internal/httpx/ (or web/) | Shared HTTP helpers: JSON writing, error-to-status mapping. | Stops every handler reinventing "how do I return an error". |
migrations/ | Numbered .sql files that build the database schema. | The schema is version-controlled like code, applied by a tool, never by hand. |
api/ | OpenAPI or protobuf definitions. | The contract with your clients, kept where they can find it. Optional. |
testdata/ | Fixture files used by tests. | The go tool ignores any folder called `testdata`, so nothing in it is compiled. |
scripts/ | Build, deploy and helper scripts. | Keeps shell scripts out of the root. Optional. |
vendor/ | Copies of your dependencies. | Created by go mod vendor. Mostly used for builds with no network access, or when a team wants every dependency's source in the repository. |
internal/ is the one that is real
Everything above is a convention you could ignore. internal/ is not. If a package sits anywhere under a folder named internal, only code rooted at that folder’s parent can import it. Anyone else gets a compile error.
The practical advice: put almost everything in internal/. Move something out only when you have decided, on purpose, that outsiders may depend on it. Once they do, you cannot change it freely.
github.com/you/shop/internal/user ← lives heregithub.com/you/shop/cmd/api ✅ can import it (same module)github.com/other/thing ❌ compile error, alwaysNaming packages
- Short, lowercase, one word.
user,order,config. No underscores, no camelCase, no plurals. - The package name is part of every call, so avoid repeating it.
user.Servicereads well;user.UserServicestutters. Same foruser.New()overuser.NewUser(). - Name it for what it provides, not what it contains.
storenotstructs. - Never create `utils`, `helpers`, `common`, `base` or `shared`. They have no meaning, so everything drifts into them and they end up importing everything, which causes import cycles. If you cannot name the package for what it does, you have not decided what it does.
How to choose, in one line
One program and under a thousand lines: flat. A service you expect to grow: one folder per domain, everything under internal/, main.go doing nothing but wiring. Both are easy to defend in a review.
- A package is a folder; capitalisation decides what is visible outside it. A module is a project with a
go.mod. - Three layouts: flat for small things, grouped by layer (common but ages badly), grouped by domain for anything that grows.
cmd/holds runnable programs and no logic.internal/is private and the compiler enforces it.- Never make a
utilsorcommonpackage, and never let a package name stutter.
- 1Open two Go projects on GitHub and work out which of the three layouts each one uses.
- 2Take a small program you have written and lay it out all three ways. Notice how much work the layer version creates for one small change.
- 3Put a package under
internal/and try to import it from a second module. Read the compiler error. - 4Find a
utilspackage in a project you have worked on and write down what is actually in it. That list is the packages it should have been.