go-project-layout · git:20260825.28a1756 · 2026-08-25 · sha256 b1649095bc3c81d7
go-project-layout git:20260825.28a1756A
Immutable. This exact content is served forever at /api/v1/blob/b1649095bc3c81d7.
---
name: go-project-layout
description: The canonical Go project taxonomy, directory layout, logging discipline, and config loading. Use when starting a Go project, adding a package or directory, deciding where a file belongs, choosing between zerolog and the standard log package, or wiring config. Triggers on go.mod, main.go, cmd/, internal/, pkg/, utils/, internal/server/static/, and on any question of whether a project is CLI Only, Web Only, CLI + Web, a Headless API Service, or a Library.
user-invocable: false
---
# Go Project Layout
**Which of the five Go project types you are in, what its tree looks like, and how it logs and loads config.**
Every other Go convention keys off the project type, so settling the type is the first step of any Go task.
## Project Taxonomy
| Type | What it is | Defining markers |
|---|---|---|
| CLI Only | Terminal tool for users | `cobra`, `utils/`, zerolog, lipgloss/bubbletea/bubbles v2; multi-platform binaries; no Docker |
| Web Only | Web app served from a Go binary, with no real CLI beyond `serve` | `cobra` with a lone `serve` command, embedded frontend at `internal/server/static/`, standard `log`, Docker, no `utils/` |
| CLI + Web | A real CLI tool that also serves a web app from one `serve` subcommand | The full CLI Only stack for the command surface, plus `internal/server/static/` and a `serve` command whose server uses standard `log`; Docker |
| Headless API Service | REST or gRPC backend with no frontend | `internal/server` handlers with no `static/`, standard `log`, Docker, no `utils/`; `cobra` only when it needs more than `serve` |
| Library / Module | Importable package with no entry point | No `main.go`, no `cobra`, no `utils/`; exported packages at the module root or under `pkg/`; consumed via `go get` |
Read the type off the tree before writing anything, because the same file is correct in one type and a defect in another: a `utils/` package belongs in CLI Only and is a defect in Web Only.
## Layout
### CLI Only
```
project-root/
├── main.go # calls cmd.Execute() and nothing else
├── go.mod / go.sum
├── Makefile # build targets, no docker targets
├── README.md
├── .github/
│ ├── assets/logo.png
│ └── workflows/release.yaml # binaries only
├── cmd/
│ ├── root.go # zerolog, --debug, utils
│ ├── command.go # simple commands
│ └── feature-cmd/ # grouped subcommands get their own package
│ ├── parent.go
│ └── child.go
├── internal/ # private packages, where 90% of the logic lives
│ ├── feature1/
│ └── feature2/
├── utils/ # top-level, not inside internal/
│ ├── globals.go
│ ├── printer.go
│ ├── input.go
│ ├── table.go
│ └── config.go
└── pkg/ # rare, only for genuinely reusable packages
```
### Web Only
```
project-root/
├── main.go
├── go.mod / go.sum
├── Makefile # build, assets, and docker targets
├── Dockerfile
├── README.md
├── .github/
│ ├── assets/logo.png
│ └── workflows/release.yaml # docker and binaries
├── cmd/
│ ├── root.go # no debug, no zerolog, no utils
│ └── serve.go # uses log.Printf
└── internal/
├── feature1/
└── server/
├── server.go
└── static/ # embedded frontend
├── css/ fonts/ js/
└── index.html
```
### CLI + Web
Structurally a CLI Only project with a Web Only server grafted on: the full `utils/` package, zerolog, and the CLI Only `cmd/root.go`, plus an `internal/server/` holding the embedded `static/` frontend reached through a single `serve` command. It ships Docker and multi-platform binaries.
```
project-root/
├── main.go
├── Makefile # CLI Only targets plus docker targets and assets
├── Dockerfile
├── cmd/
│ ├── root.go # zerolog, --debug, utils
│ ├── serve.go # the one web command, server logs via log.Printf
│ └── operation.go # CLI subcommands, full utils/zerolog/TUI stack
├── internal/
│ ├── feature1/
│ └── server/
│ ├── server.go
│ └── static/
└── utils/ # present, the CLI surface uses it
```
The two disciplines divide on command boundaries and never mix inside one command, because a user running `serve` reads a server log and a user running `sync` reads styled terminal output, and one binary emitting both formats from one command is unreadable.
### Headless API Service
A Web Only project minus the frontend: standard `log`, no `utils/`, Dockerfile and Docker in CI, and `cobra` only when the service needs subcommands beyond `serve`.
```
project-root/
├── main.go # cmd.Execute() or a direct serve()
├── go.mod / go.sum
├── Makefile # build and docker targets, no frontend assets
├── Dockerfile
├── cmd/serve.go # optional, only when more than serve exists
└── internal/
├── server/server.go # handlers, no static/ subtree
└── feature1/
```
Its HTTP server drops the `embed.FS`, `static/`, and `handleIndex` pieces, since there is no frontend to serve.
### Library / Module
```
module-root/
├── go.mod / go.sum
├── README.md # usage and API docs, since consumers read this
├── <package>.go # exported API at the module root, or
├── pkg/<package>/ # grouped exported packages
└── internal/ # private helpers outside the public API
```
A library configures no global logging and avoids `log.Fatal` and `os.Exit`, because those decisions belong to the program importing it rather than to the package.
## Layout Rules
`main.go` holds an import and a call to `cmd.Execute()`, so the entry point stays free of logic that tests cannot reach.
New packages go under `internal/` by default. `pkg/` is for packages you intend other repositories to import, and putting private code there commits you to an API you never meant to promise.
A group of related subcommands gets its own package under `cmd/` (`cmd/feature-cmd/`), which keeps the flag variables of one group from colliding with another's in the shared `cmd` package.
Frontend assets live at `internal/server/static/` so a single `//go:embed static` directive in the server package picks them all up.
## Logging
### CLI Only, and the command surface of CLI + Web
zerolog behind `--debug`, and the `utils` printers otherwise. Logs stay hidden in normal use because a user running a tool wants the result, not a trace of how it was produced.
```go
func setupLogs() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.DateTime, NoColor: !utils.StdoutIsTerminal}
log.Logger = zerolog.New(output).With().Timestamp().Logger()
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if debugFlag {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
utils.GlobalDebugFlag = true
}
}
```
Log messages stay generic and carry no package-name field, because most logs originate in the shared `utils` package where a package field would be the same value every time.
### Web Only, Headless API Service, and the server layer of CLI + Web
The standard `log` package with manual level prefixes, and `log.Fatalf` for errors that end the process. These projects have no `utils/` package and no `GlobalDebugFlag`.
```go
log.Printf("INFO Starting on port %d", port)
log.Printf("ERROR Failed to validate token: %v", err)
log.Fatalf("ERROR Failed to bind: %v", err)
```
Output is timestamped, sequential, and uncolored, so it survives being piped into a container log collector that strips nothing and interprets nothing.
## Config
Cobra flags alone cover most projects. Reach for a config file only when a project genuinely needs one, because a hierarchy nobody populates is four lookups to answer one question.
### The config directory
A CLI tool that persists anything puts it at `~/.config/[APP_NAME]/`, hardcoded, with no `--config-dir` flag and no XDG lookup. One path means a user, a backup script, and a support answer all name the same place.
```go
func configDir() string {
home, err := os.UserHomeDir()
if err != nil {
u.PrintFatal("cannot resolve home directory", err)
}
return filepath.Join(home, ".config", "[APP_NAME]")
}
```
The directory is created at `0700` and every file inside it at `0600`, because what lives there is credentials: OAuth tokens, session cookies, API keys, and the config file that may hold any of them. A subdirectory per concern keeps them separable, and nothing else in the user's home belongs to the tool.
### Precedence
Highest first: an explicit flag, then the environment, then the config file, then the built-in default. The flag wins because it is the most specific thing the caller said in this invocation, and an environment variable reaches the command as that flag's default value rather than as an override applied afterwards, which is what keeps `--help` honest about what will be used.
A variable the tool owns is namespaced with the tool's name, so `[APP_NAME]_TOKEN` rather than `TOKEN`. A variable belonging to another tool keeps that tool's name, since renaming `GITHUB_TOKEN` only means the user has to set it twice.
### Where the loading lives
| Project type | Where config loading lives |
|---|---|
| CLI Only, CLI + Web | `utils/config.go`, returning a struct passed into functions |
| Web Only, Headless API Service | Cobra flags and environment variables directly, or a config package under `internal/` |
| Library / Module | Nowhere. A library takes its configuration as function arguments |
The loader returns a struct rather than exposing a global, so a caller can construct one in a test without touching the environment.
```go
func LoadConfig(path string) (*Config, error) {
cfg := &Config{Server: ServerConfig{Port: 8080, Host: "0.0.0.0"}}
if path == "" {
path = filepath.Join(configDir(), "config.yaml")
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
```
A missing config file falls back to defaults instead of erroring, because a first run has not written one yet. A file that exists and does not parse is an error, since silently ignoring it hands the user defaults they did not ask for and no way to tell.