Go Project Structure & Best Practices
Go Project Structure & Best Practices (2026)
When to use it: starting a new Go project, structuring/refactoring a Go codebase, deciding
package boundaries, setting up a Go monorepo, or reviewing Go architecture. Keyword bait: "Go
project structure", "Go layout", "cmd/internal/pkg", "Go monorepo", "where does this package go".
Opinionated guide for large, high-throughput Go services. Bias toward boring, explicit, testable.
1. Core principles
- Organize by domain/layer, not by type. No top-level
models/,controllers/,services/buckets. Group by what the code does (billing/,ingest/,monitors/), each owning its handlers, store, types. - Default to
internal/. Code there cannot be imported externally — enforces boundaries. Only promote topkg/when you have a real external consumer. When unsure,internal/. - Start as a modular monolith, not microservices. One deployable, clean bounded contexts inside. Split a context into its own service only when scaling/ownership demands it. Premature microservices = distributed debugging hell.
- Accept interfaces, return structs. Define interfaces at the consumer, not next to the implementation. Keep them small.
mainis thin.cmd/x/main.goonly wires config + dependencies + starts; logic lives ininternal/.- Flat until it hurts. Don't nest packages speculatively. Go rewards shallow trees.
2. Standard layout (single service)
myservice/
├─ cmd/
│ └─ myservice/main.go # entrypoint: parse config, build deps, run
├─ internal/ # everything private to this module
│ ├─ ingest/ # a bounded context / domain
│ │ ├─ service.go # business logic
│ │ ├─ handler.go # HTTP/gRPC adapter
│ │ ├─ store.go # persistence (ClickHouse/Postgres) behind interface
│ │ └─ ingest_test.go
│ ├─ monitors/
│ ├─ platform/ # cross-cutting: db, kafka, http server, telemetry
│ │ ├─ clickhouse/
│ │ ├─ redpanda/
│ │ └─ telemetry/
│ └─ config/config.go # typed config, env-loaded
├─ pkg/ # ONLY if shared externally (often empty — that's fine)
├─ api/ # OpenAPI/protobuf specs (source of truth)
├─ migrations/
├─ go.mod
├─ Makefile
└─ README.md
Reference: golang-standards/project-layout (a community convention, not official — adapt, don't worship). Layer internals by domain, not by type.
3. Monorepo with multiple services
Use Go workspaces (go.work, Go 1.18+) — work on multiple modules with local resolution, no replace-directive hacks.
backend/
├─ go.work # go work use ./services/* ./libs/*
├─ services/
│ ├─ ingest/ # own go.mod, own cmd/internal
│ └─ control-plane/ # own go.mod
└─ libs/ # shared modules, imported explicitly
├─ telemetry/ # own go.mod
└─ proto/ # generated gRPC/Connect stubs
- One
go.modper service + per shared lib. Share code explicitly vialibs/, never reach into another service'sinternal/. - Keep each service's business logic in its own
internal/so it can't be imported across service boundaries. - Modular monolith variant: single module, bounded contexts as
internal/<context>/, wired in onemain.
4. Architecture inside a context (hexagonal / ports-and-adapters)
domain (pure types + rules) ← service (use cases) ← adapters (http, db, queue)
- Domain types have no framework imports.
- Service depends on interfaces (ports) it defines; adapters implement them.
- Dependency direction always points inward. Enables testing the service with fakes, swapping ClickHouse/Postgres without touching logic. (This is Clean Architecture's Dependency Rule in Go.)
5. Error handling
- Wrap with context:
fmt.Errorf("ingest batch: %w", err). Inspect witherrors.Is/errors.As. - Sentinel errors (
var ErrNotFound = errors.New(...)) for expected branches; typed errors for carrying data. - Don't
panicin library code. Don't log-and-return (double reporting) — handle once, at the boundary.
6. Config & dependencies
- Typed config struct, loaded from env (12-factor). Validate at startup, fail fast.
- No global state / no
init()side effects. Construct deps inmain, pass explicitly (constructor injection). Avoids hidden coupling and makes tests trivial. - Prefer the stdlib + a few sharp deps over frameworks.
net/http+chi/echofor routing;pgxfor Postgres; sqlc for type-safe queries.
7. Concurrency (hot-path services)
context.Contextfirst arg on anything that does IO; honor cancellation.- Bounded worker pools + channels for fan-out; never spawn unbounded goroutines per request.
errgroupfor coordinated parallel work.sync.Poolfor hot allocations.- Batch DB writes (ClickHouse: large async inserts, not row-by-row). Backpressure via buffered channels / queue lag.
- Always have a goroutine-leak story: every goroutine has a clear exit on
ctx.Done().
8. Testing
- Table-driven tests,
t.Parallel()where safe. - Test against interfaces with hand-written fakes (avoid mock-framework magic).
testcontainers-gofor real ClickHouse/Postgres integration tests. go test -racein CI, always. Benchmark hot paths (testing.B), guard against regressions.- Golden files for serialization;
testify/requireis acceptable but stdlib-first is fine.
9. Observability
- OpenTelemetry for traces/metrics; structured logging via
log/slog(stdlib). Trace IDs in every log line. - Expose
/healthz,/readyz, Prometheus metrics. RED metrics (Rate, Errors, Duration) on every handler.
10. Tooling / CI
golangci-lint(gofmt, govet, staticcheck, errcheck).gofumptfor stricter format.maketargets:build test lint run migrate. Reproducible builds, pinned Go version.- Multi-stage Docker, distroless/scratch final image. Build with
CGO_ENABLED=0for static binaries unless a C dep requires otherwise.
Anti-patterns (reject in review)
utils/,helpers/,common/,base/grab-bags → name by purpose.- Everything dumped in
pkg/→ useinternal/. - Interfaces defined next to the single implementation → define at consumer.
- Package-level mutable globals,
init()doing real work. - One giant
modelspackage shared by all → couples everything. - Microservices before a domain boundary is proven.
See also
- Clean Architecture — the language-agnostic principles this layout applies.
- Push Compute to the Datastore — keep the store behind an interface; push the data work into it.