Every Go project starts the same way: one main.go, it works, it grows, and at some point you have a 1500-line file
and the question “how do I split this up” - followed by “and what is the difference between a module and a package
again”. This post is the answer I give every time. It is short on theory and long on the three commands and one
directory layout you actually need, with links to the official docs for the parts I skip.
Two words, two different things
- A package is a directory of
.gofiles that all start with the samepackageclause. It is the unit of compilation and of visibility: identifiers starting with a capital letter are exported from the package, everything else is private to it. You import packages. - A module is a directory tree with a
go.modfile at its root. It is the unit of versioning and distribution: it has a path (usually a repository URL), a version (a git tag), and a list of other modules it depends on. Yougo getmodules.
A module contains one or more packages. Most projects are one module with several packages, and that is the case this
post is about. The import path of a package is the module path plus the directory: in module github.com/robertwe/notes,
the code in internal/store/ is imported as github.com/robertwe/notes/internal/store.
Official reference, when you need the full rules: the Go Modules Reference.
Creating a module
mkdir notes && cd notes
go mod init github.com/robertwe/notes
That writes a go.mod:
module github.com/robertwe/notes
go 1.25
The module path should be where the code will live, even if you have not pushed it yet, because it is also the
import path prefix for every package inside and the thing go get will fetch later. For something that will never be
published, any path works, but pick the real one anyway, renaming later means touching every import.
Add dependencies by importing them and running:
go get github.com/aws/aws-sdk-go-v2/service/s3@latest
go mod tidy
go get adds a require line with the resolved version and writes checksums to go.sum. go mod tidy adds anything
you import but have not required, and removes anything you require but no longer import. Run tidy before every
commit; most CI setups fail the build if it would change anything (go mod tidy -diff since Go 1.23).
The official walkthrough for this part is the Create a Go module tutorial, and the dependency commands are in Managing dependencies.
Splitting an app into packages
Take a small HTTP service: it reads config, talks to a database, serves a JSON API. As one file it is main.go. The
layout that the Go team recommends for exactly this case, from their
Organizing a Go module page, is:
notes/
├── go.mod
├── go.sum
├── cmd/
│ └── notes-server/
│ └── main.go # package main: wire things together, start the server
└── internal/
├── config/
│ └── config.go # package config: load env/flags into a struct
├── store/
│ ├── store.go # package store: the database layer
│ └── store_test.go
└── httpapi/
├── server.go # package httpapi: handlers, routing, middleware
└── server_test.go
Three rules make this work:
1. internal/ is enforced by the compiler. A package under internal/ can only be imported by code rooted at
the parent of internal/. So github.com/robertwe/notes/internal/store is importable by anything in the notes
module and by nothing outside it. For a service, that is where everything goes. You get to change the API of store
whenever you want, because nobody but you can depend on it. The docs put it plainly: keep packages in internal as
much as possible.
2. cmd/<name>/main.go is tiny. package main builds the pieces and starts them. Nothing else lives there,
which keeps it testable - you test httpapi and store, not main:
package main
import (
"log/slog"
"net/http"
"os"
"github.com/robertwe/notes/internal/config"
"github.com/robertwe/notes/internal/httpapi"
"github.com/robertwe/notes/internal/store"
)
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
st, err := store.Open(cfg.DatabaseURL)
if err != nil {
slog.Error("store", "err", err)
os.Exit(1)
}
defer st.Close()
srv := httpapi.New(st)
slog.Info("listening", "addr", cfg.Addr)
if err := http.ListenAndServe(cfg.Addr, srv); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
If the project later needs a second binary - a migration tool, a CLI - it is cmd/notes-migrate/main.go, sharing
internal/ with the server.
3. Dependencies point inward, and never in a circle. httpapi imports store. store imports nothing of ours.
main imports both. Go refuses to compile an import cycle, so the compiler keeps you honest; when you hit one, the fix
is almost always to move the shared type into the package that is lower in the stack, or into a small new package
both can import.
Naming packages
This is where most of the mess comes from, so the short version of the official Package names advice:
- Short, lowercase, one word, no underscores:
store, notdata_storeorstoreUtils. - The name is part of every call site, so do not repeat it inside:
store.Open, notstore.OpenStore;config.Load, notconfig.LoadConfig. - Name the package after what it provides, not what it contains.
util,common,helpers,modelsare the names you reach for when you have not decided what the package is for yet. A package calledutilgrows forever and everything ends up importing it. - The directory name and the package name should match. They do not have to, but every reader assumes they do.
When one module is not enough
Most apps never need more than one module. The two cases where you do:
You want to publish a library out of the service. internal/ cannot be imported from outside, so to share
store with another project it has to move out of internal/ - and at that point it usually deserves its own
repository and module with its own version tags, because its users will want to pin it independently of your service.
You are developing two modules at once and want the service to use the library from your working copy instead of a published tag. That is what Go workspaces are for:
go work init ./notes ./notes-lib
A go.work file at the parent directory tells the toolchain to resolve github.com/robertwe/notes-lib from the local
./notes-lib directory. No replace directive to forget to remove before committing, and go.work itself is
normally not committed. The workspaces tutorial covers it in ten minutes.
For publishing, the thing that trips people up is major versions: from v2 on, the major version is part of the module
path (github.com/robertwe/notes-lib/v2), both in go.mod and in every import. The reasoning and the mechanics are in
Module version numbering.
The handful of commands worth memorising
go mod init <path> # new module
go get <pkg>@<version> # add or update a dependency (@latest, @v1.2.3, @master, @none to drop)
go mod tidy # fix require/go.sum to match the imports
go mod why <module> # why is this in my go.mod?
go mod graph # full dependency graph, pipe into grep
go list -m -u all # which dependencies have newer versions
go work init ./a ./b # multi-module local development
Everything else - vendoring, private module proxies, GOPRIVATE, GOFLAGS=-mod=mod - exists and is in the
reference, and you will know when you need it.
A Claude Code skill for this
I wrote earlier about skills, the Markdown files Claude Code loads when a task matches. There is no official Go skill in Anthropic’s public skills repository at the time of writing - it has document, design, API and MCP skills, nothing language-specific. The skills documentation explains the format if you want to roll your own, and for this post’s topic the skill is short enough to paste here:
---
name: go-layout
description: Create or restructure a Go module into packages. Use when starting a Go project, splitting a large main.go, or asked where code should live.
---
Follow https://go.dev/doc/modules/layout. Concretely:
- One module per repository unless told otherwise; `go mod init` with the real repository path.
- Binaries in `cmd/<name>/main.go`; `main` only wires dependencies and starts things.
- Everything else under `internal/`, one package per concern (`config`, `store`, `httpapi`, ...).
No `pkg/`, `util`, `common` or `models` packages.
- Package names: short, lowercase, one word, matching the directory; no stuttering (`store.Open`, not `store.OpenStore`).
- Dependencies point inward: `cmd` -> `httpapi` -> `store`. Never introduce an import cycle; if one appears,
move the shared type down the stack.
- After changes run `go build ./... && go vet ./... && go mod tidy -diff` and fix what they report.
- Do not add a dependency for something the standard library does (`net/http`, `log/slog`, `encoding/json`, `flag`).
Project-scoped (.claude/skills/go-layout/SKILL.md), it is a checklist the agent and every human on the team read the
same way. It encodes nothing that is not in the official layout page, which is the point: the layout page is the
source of truth, the skill just makes sure it gets applied at 17:55 on a Friday.
Questions, corrections, war stories? Start a discussion on GitHub (or browse existing ones), or write to me. No comment widget here, on purpose.