|
| 1 | +# Go Package Layout Guide |
| 2 | + |
| 3 | +Default: **one flat `pkg/` directory**. Split into subpackages only when the flat layout gets too big to navigate. |
| 4 | + |
| 5 | +## Default Layout Rule |
| 6 | + |
| 7 | +Put all production code into `pkg/<repo-name>` (or just `pkg/` with a single package name) as a flat package. Files are organized by file-name convention (`<type>.go`, `<type>_test.go`), not by directory. |
| 8 | + |
| 9 | +**Two conventional exceptions** (these always split, even when they only have 1-2 files): |
| 10 | + |
| 11 | +- **`pkg/factory/`** — every project gets one. Centralizes `Create*` wiring per [go-factory-pattern.md](go-factory-pattern.md). The factory's whole job is to be the one place that depends on every other package, so it MUST live in its own package to avoid making everything else depend on it. |
| 12 | +- **`pkg/handler/`** — every HTTP service gets one. Per [go-http-handler-refactoring-guide.md](go-http-handler-refactoring-guide.md), HTTP handlers always live in `pkg/handler/`, never inline in `main.go`. Even a single `/healthz` handler goes here. |
| 13 | + |
| 14 | +Both exist by convention regardless of file count. The rest of the code is flat in `pkg/` until a real trigger fires. |
| 15 | + |
| 16 | +```text |
| 17 | +notification-service/ |
| 18 | +├── main.go |
| 19 | +└── pkg/ |
| 20 | + ├── inventory.go # was pkg/schedule/inventory.go |
| 21 | + ├── tasks-for-date.go # was pkg/schedule/tasks_for_date.go |
| 22 | + ├── publisher.go # was pkg/publisher/publisher.go |
| 23 | + ├── render.go # was pkg/publisher/render.go |
| 24 | + ├── tick.go # was pkg/tick/tick.go |
| 25 | + ├── factory/ # always its own package (see Rule) |
| 26 | + │ └── factory.go |
| 27 | + └── handler/ # always its own package (see Rule) |
| 28 | + ├── trigger.go |
| 29 | + └── healthz.go |
| 30 | +``` |
| 31 | + |
| 32 | +One Go package. One import path. Files grouped by name, not directory. |
| 33 | + |
| 34 | +### GOOD: flat `pkg/` with conventional exceptions split |
| 35 | + |
| 36 | +```go |
| 37 | +// [GOOD] pkg/publisher.go |
| 38 | +package notification |
| 39 | + |
| 40 | +type Publisher struct { /* ... */ } |
| 41 | + |
| 42 | +func (p *Publisher) Publish(ctx context.Context, msg Message) error { /* ... */ } |
| 43 | +``` |
| 44 | + |
| 45 | +```go |
| 46 | +// [GOOD] pkg/render.go — same package, free function call |
| 47 | +package notification |
| 48 | + |
| 49 | +func Render(tmpl string, data any) (string, error) { /* ... */ } |
| 50 | +``` |
| 51 | + |
| 52 | +```go |
| 53 | +// [GOOD] pkg/factory/factory.go — conventional exception, separate package |
| 54 | +package factory |
| 55 | + |
| 56 | +import notification "github.com/example/notification-service/pkg" // pkg declares `package notification` |
| 57 | + |
| 58 | +func CreatePublisher(/* deps */) *notification.Publisher { /* ... */ } |
| 59 | +``` |
| 60 | + |
| 61 | +### BAD: premature type-bucket split |
| 62 | + |
| 63 | +```go |
| 64 | +// [BAD] pkg/service/publisher.go |
| 65 | +package service |
| 66 | + |
| 67 | +type Publisher struct { /* ... */ } |
| 68 | +``` |
| 69 | + |
| 70 | +```go |
| 71 | +// [BAD] pkg/repository/store.go — now publisher.go must import a sibling |
| 72 | +package repository |
| 73 | + |
| 74 | +import "github.com/example/notification-service/pkg/service" |
| 75 | +// every feature touches pkg/service + pkg/repository + pkg/handler |
| 76 | +``` |
| 77 | + |
| 78 | +The BAD layout invents three packages before any of the five split triggers fire — cross-cutting churn replaces cohesion. |
| 79 | + |
| 80 | +## When to split |
| 81 | + |
| 82 | +Extract a subpackage **only** when the flat layout hits a real friction point — never preemptively. Triggers, any one of these: |
| 83 | + |
| 84 | +1. **Independent reuse** — another binary in the same module (or a different repo) needs to import the chunk without dragging the rest. |
| 85 | +2. **Independent versioning** — the chunk has its own release cadence different from the parent. |
| 86 | +3. **Cycle break** — a circular import between two clusters of files in `pkg/` can only be broken by splitting. |
| 87 | +4. **Too many files to navigate** — ≥30 files in `pkg/`, where naming convention alone no longer keeps `cd pkg && ls` readable. Even then, split by *natural seam* (the cluster with the highest internal cohesion), not by *type bucket* (one dir per "model", "handler", "factory" is anti-pattern — see [go-architecture-patterns.md](go-architecture-patterns.md)). |
| 88 | +5. **Build-tag isolation** — a chunk uses `//go:build linux` or similar and contaminates the parent's portability. |
| 89 | + |
| 90 | +If none of the above hold, **keep it flat**. A 5-file extracted package costs more in import-path noise + cross-file refactoring friction than it ever saves. |
| 91 | + |
| 92 | +## Why |
| 93 | + |
| 94 | +- Flat `pkg/` minimizes import-path churn during refactors. Moving a function between files is free; moving it between packages rewrites every import. |
| 95 | +- Flat layout makes "is this exported?" obvious — every lower-case identifier is package-internal; every upper-case is the public API of the module. Subpackages multiply the public-surface decision per package. |
| 96 | +- Premature subpackage split locks in an architectural shape before the code has revealed its real shape. Flat first, split when forced. |
| 97 | +- Renaming files is cheap. Renaming directories is cheap. Renaming a package is expensive (imports, godoc, tests, mocks, downstream callers). |
| 98 | + |
| 99 | +## Antipatterns |
| 100 | + |
| 101 | +- **One dir per "concern"** — `pkg/handler/`, `pkg/service/`, `pkg/repository/`. These create cross-cutting churn (every feature touches three dirs) and obscure cohesion. See [go-architecture-patterns.md](go-architecture-patterns.md). |
| 102 | +- **One dir per "domain entity"** — `pkg/user/`, `pkg/order/`. Often correct for large products; premature for a single-binary service. |
| 103 | +- **Splitting because "it feels cleaner"** — feelings are not a trigger. Run the five-rule check above. |
| 104 | +- **Splitting to satisfy a linter (`funlen`, `gocognit`)** — extract a function/method instead. Splitting a package is the wrong knob. |
| 105 | +- **Flattening `pkg/factory/` or `pkg/handler/` into `pkg/`** — these are the two conventional exceptions (see Rule). Even with one file, they stay in their own package. |
| 106 | + |
| 107 | +## Migration: subpackages → flat |
| 108 | + |
| 109 | +When a service has prematurely split into `pkg/foo`, `pkg/bar`, `pkg/baz` and none of the five triggers apply: |
| 110 | + |
| 111 | +1. `git mv pkg/foo/*.go pkg/` (rename the package declaration in each file to match `pkg/`'s package name) |
| 112 | +2. Rewrite imports — `find . -type f -name '*.go' -exec sed -i '' 's|module/pkg/foo|module/pkg|g' {} +` |
| 113 | +3. Resolve symbol collisions (rename internal helpers if two subpackages each had `func helper()`) |
| 114 | +4. `make precommit` |
| 115 | +5. Delete the empty subpackage directories: `rmdir pkg/foo pkg/bar pkg/baz` |
| 116 | + |
| 117 | +Do this in one PR per service. Don't split the migration across multiple PRs — partial state (some imports rewritten, some not) is a worse problem than the original premature split. |
| 118 | + |
| 119 | +## Related |
| 120 | + |
| 121 | +- [go-architecture-patterns.md](go-architecture-patterns.md) — Interface → Constructor → Struct → Method (works inside one flat package or many) |
| 122 | +- [go-factory-pattern.md](go-factory-pattern.md) — `pkg/factory/` is the canonical home for `Create*` wiring (always a separate package, see Rule) |
| 123 | +- [go-http-handler-refactoring-guide.md](go-http-handler-refactoring-guide.md) — `pkg/handler/` is the canonical home for HTTP handlers (always a separate package, see Rule) |
0 commit comments