How do you structure Go projects for scale and maintainability?
Go (Golang) Developer
answer
I architect Go projects with a layered, dependency-inverted structure: domain-first packages, small composable interfaces, and adapters for I/O. Modules pin versions with go.mod + checksums; internal boundaries use internal/ and clear import rules. Packages expose minimal APIs, keep types local, and depend on interfaces, not implementations. Configuration and wiring live in cmd/ mains; business logic is framework-agnostic and fully testable with table tests and fakes.
Long Answer
Scalable Go project structure balances clarity, testability, and stable dependency flow. My approach starts from the domain and constrains how code depends on other code, so teams can add features without tangling modules or leaking details.
1) High-level layout: domain-first, adapters at the edges
I separate core behavior from delivery and infrastructure. The domain owns entities, services, and policies. Adapters live at boundaries: HTTP, gRPC, CLI, DB, cache, and message brokers. The compile-time rule is one-way: domain imports no adapters. This yields a clean dependency graph and makes the core portable.
Typical layout
- cmd/<app>/ — thin mains that parse flags/env, build dependencies, and run.
- internal/<area>/ — implementation details not exported outside the module.
- pkg/<lib>/ — intentionally reusable packages (rare and stable).
- app/ or internal/app/ — composition roots (wiring).
- internal/adapters/ — http, grpc, persistence, cache.
- internal/domain/ — entities, services, and ports (interfaces).
- configs/, migrations/, deploy/ — non-code assets.
2) Package design: cohesion, minimal surface, no cyc import
Each package has one reason to change (cohesion) and exposes the smallest public API possible. I avoid “god” packages; instead, I carve by domain capability (e.g., billing, auth, catalog). Types and helpers default to lowercase (unexported) until proven reusable. Cycles are prevented by keeping arrows pointing inward: leaf packages have zero imports within the repo; higher layers depend on them via interfaces.
3) Ports and adapters via small interfaces
Interfaces belong near the consumer, not the implementer. I define tiny, behavior-focused interfaces (“ports”) in domain packages and implement them in adapters:
type TokenIssuer interface { Issue(ctx context.Context, sub string, ttl time.Duration) (string, error) }
Concrete adapters (JWT, KMS, stub) satisfy the port. This keeps the domain decoupled and testable. I avoid “kitchen-sink” interfaces; small ones reduce mocking and version churn.
4) Modules and dependency control
One repo may contain one or multiple Go modules. I default to a single module to simplify versioning, adding more only for truly independent publishable units. I pin versions with semantic tags, go mod tidy, and go mod vendor only when air-gapped builds are required. For internal boundaries between teams, I may split into modules to enforce API stability via semantic import versioning (v2+ paths) and publishing rules.
5) Configuration, wiring, and lifecycle
Configuration is loaded in cmd/ (flags, env, files), validated once, and passed as typed structs. Construction and wiring use plain Go, not reflection-heavy containers; factories and options patterns are sufficient. Long-lived processes implement graceful shutdowns: contexts with deadlines, errgroup for goroutines, and health probes. Start-up contracts (open DB, warm caches) are explicit so tests can replace them with fakes.
6) Data and persistence boundaries
Repositories are defined as domain ports; adapters encapsulate SQL/NoSQL details. I keep query builders or migrations within the adapter package and expose domain models unaffected by DB quirks. For transactions, I use per-request contexts and unit-of-work abstractions that do not bleed into use cases. Marshaling types (DTOs) stay in adapters to shield the domain from wire formats.
7) Testing strategy: table tests, fakes, and contract tests
Unit tests live next to code with table-driven patterns; domain packages test pure logic with no I/O. Adapter implementations have integration tests guarded by build tags (e.g., //go:build integration). Ports get contract tests shared across adapters to guarantee behavioral parity (e.g., any TokenIssuer must handle clock skew). For HTTP/gRPC, golden tests validate stability of public APIs.
8) Concurrency, context, and errors
Public methods accept context.Context. Goroutines are owned; no “fire-and-forget.” For reliability, I propagate errors with %w, define sentinel errors only when matching is required, and prefer typed errors sparingly. Retries and backoff live near adapters; domain remains deterministic. Time and randomness are injected via interfaces so tests can freeze time and seed RNGs.
9) Tooling, checks, and documentation
I use golangci-lint (staticcheck, govet, revive), go test -race, and codegen where needed (protobuf, oapi-codegen). Makefiles or Taskfiles encode repeatable workflows. README.md at repo and package level explains ownership and import rules. API docs are generated from comments; examples show expected usage.
10) Versioning and API stability
I tag releases, maintain a CHANGELOG.md, and follow semantic versioning. Breaking changes are front-loaded into major releases; minor ones add functionality without breaking contracts. Public packages under pkg/ have explicit stability policies to protect consumers; everything under internal/ can evolve freely.
Result: a maintainable codebase where domain logic is stable and testable, adapters can swap without ripples, and dependencies are controlled by compile-time boundaries—not conventions alone.
Table
Common Mistakes
- Putting everything in pkg/ and exporting too much, freezing the design.
- Letting frameworks dictate structure; domain imports web or DB packages.
- Overusing interfaces; wrapping every type instead of defining ports only at boundaries.
- Creating many Go modules prematurely, multiplying versioning work.
- Global state and singletons that block testing and parallelism.
- Leaky DTOs: wire or DB schemas bleed into domain models.
- Complex DI containers; reflection-heavy wiring that hides dependencies.
- No import rules: cycles appear, packages gain multiple reasons to change.
- Mixing concerns in cmd/ (business logic in main.go), making reuse and testing hard.
Sample Answers
Junior:
“I keep business logic in internal/domain and I/O in adapters. cmd/<app> wires dependencies. I define small interfaces in the domain and have adapters implement them. I avoid exporting symbols unless needed and write table tests.”
Mid:
“I enforce one-way dependencies with consumer-owned ports. Repos implement persistence behind interfaces; DTOs stay at the edge. I keep a single module, tag releases, and use golangci-lint, -race, and contract tests across adapters to ensure parity.”
Senior:
“I design a domain-first architecture with strict import rules, small public surfaces, and context-aware APIs. Modules are minimal; semantic versioning protects consumers. Wiring is explicit in mains; adapters manage retries/backoff. Contract and integration tests, plus CI lint/race gates, keep changes safe and scalable.”
Evaluation Criteria
Look for:
- Domain-centric structure with adapters at edges and one-way dependencies.
- Small, consumer-owned interfaces defining ports; adapters satisfy them.
- Minimal exports and cohesive packages without cycles.
- Pragmatic modules with semantic versioning and internal/ boundaries.
- Explicit wiring in cmd/ and typed configuration.
- Testing depth: table tests, fakes, integration, and contract suites.
- Operational maturity: context usage, graceful shutdown, lint/race CI.
Red flags: framework-driven layering that leaks into domain, many unnecessary modules, large interfaces, global state, and DTOs polluting core types.
Preparation Tips
- Sketch a small service with cmd/, internal/domain, internal/adapters/http, internal/adapters/postgres.
- Move all I/O behind ports; implement two adapters for one port and run shared contract tests.
- Reduce exports: convert public types to unexported until needed.
- Add golangci-lint, go test -race, and a Makefile task for consistent builds.
- Practice table-driven tests and golden tests for HTTP.
- Add graceful shutdown with context + errgroup.
- Experiment with single vs multi-module; tag a release and change logs.
- Document import rules and ownership in the repo README; enforce with code reviews.
Real-world Context
A payments platform tangled HTTP and SQL in handlers, blocking tests and adding risk. We introduced a domain + ports/adapters split: ChargeService in domain, PostgresChargeRepo and StripeAdapter at edges. Handlers became thin. Unit tests covered the domain with fakes; contract tests ensured both repos behaved the same. We collapsed four ad-hoc modules into one, tagged versions, and moved volatile code to internal/. Import cycles vanished, build times dropped, and onboarding time halved. Later, swapping Postgres for Cockroach only touched the adapter; the domain did not change.
Key Takeaways
- Structure Go projects domain-first; keep adapters at edges and imports one-way.
- Define small interfaces owned by consumers; implement them in adapters.
- Minimize exports and keep packages cohesive; prefer internal/ for freedom.
- Use pragmatic modules, semantic tags, and explicit wiring in cmd/.
- Invest in tests (unit, integration, contracts) and CI gates (lint, race).
Practice Exercise
Scenario:
Design a small order service in Go that can persist to either Postgres or in-memory storage and expose both HTTP and gRPC without changing domain logic.
Tasks:
- Create internal/domain/order with Order, Service, and ports: OrderRepo and IDGenerator.
- Implement two adapters for OrderRepo: postgres and mem. Keep SQL and migrations inside the postgres package; no SQL types in domain.
- Implement idgen adapters: uuid and snowflake.
- Expose HTTP and gRPC adapters that depend only on domain ports.
- Wire two binaries in cmd/: orders-http and orders-grpc. Config is typed and loaded once.
- Write table tests for domain service, contract tests shared by both repos, and integration tests for Postgres guarded by build tags.
- Add golangci-lint, go test -race, and a Makefile with build, test, lint targets.
- Document import rules in README.md and keep all non-API code under internal/. Tag v0.1.0 and produce a changelog.
Deliverable:
A repo with clean domain boundaries, swappable adapters, explicit wiring, strong tests, and clear docs—demonstrating scalable, maintainable Go project structure.

