Go Project Structure & Best Practices

History

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 to pkg/ 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.
  • main is thin. cmd/x/main.go only wires config + dependencies + starts; logic lives in internal/.
  • 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.mod per service + per shared lib. Share code explicitly via libs/, never reach into another service's internal/.
  • 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 one main.

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 with errors.Is / errors.As.
  • Sentinel errors (var ErrNotFound = errors.New(...)) for expected branches; typed errors for carrying data.
  • Don't panic in 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 in main, pass explicitly (constructor injection). Avoids hidden coupling and makes tests trivial.
  • Prefer the stdlib + a few sharp deps over frameworks. net/http + chi/echo for routing; pgx for Postgres; sqlc for type-safe queries.

7. Concurrency (hot-path services)

  • context.Context first arg on anything that does IO; honor cancellation.
  • Bounded worker pools + channels for fan-out; never spawn unbounded goroutines per request.
  • errgroup for coordinated parallel work. sync.Pool for 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-go for real ClickHouse/Postgres integration tests.
  • go test -race in CI, always. Benchmark hot paths (testing.B), guard against regressions.
  • Golden files for serialization; testify/require is 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). gofumpt for stricter format.
  • make targets: build test lint run migrate. Reproducible builds, pinned Go version.
  • Multi-stage Docker, distroless/scratch final image. Build with CGO_ENABLED=0 for 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/ → use internal/.
  • Interfaces defined next to the single implementation → define at consumer.
  • Package-level mutable globals, init() doing real work.
  • One giant models package shared by all → couples everything.
  • Microservices before a domain boundary is proven.

See also

Sources