Folder Structure
Option A — Domain-Oriented (recommended for growing apps)
Project structure
Details
.github/
Repository automation, CI workflows, and GitHub configuration.
Contains
- workflows/ — GitHub Actions workflow definitions.
Why this works
- Encapsulation:
internal/keeps internals private so only your service packages can import them. - Bounded contexts: each domain owns its own models, interfaces, repositories, services, and transports, which reduces cross-cutting dependencies.
- Testability: domain code stays close to the behavior it implements, making unit tests and integration tests easier to write and maintain.
- Maintainability: feature boundaries are explicit, so you can evolve one domain without destabilizing others.
- Scalability: this structure supports multiple teams and multiple services by keeping domain responsibilities isolated.
Option B — Layered (simple start, okay for small apps)
Project structure
Details
cmd/api/
Single API command entrypoint for the service.
Contains
- main.go — Bootstraps the service, HTTP server, and dependency graph.
When to use
- Quick prototypes, small APIs, or services where speed of delivery matters more than long-term structure.
- Good when your team is small and the domain is not yet fully understood.
- Works well for a first iteration, with the option to refactor into domain-oriented layout once the product matures.
- Keep the migration path in mind: move feature folders into
internal/and adopt domain boundaries when the service grows.
Minimal wiring (Gin + pgx) — where things live
cmd/api/main.go
func main() {
cfg := config.Load() // internal/platform/config
log := logger.New(cfg.Log) // internal/platform/logger
db := db.NewPool(cfg.DB) // internal/platform/db (pgxpool)
srv := httpserver.New(cfg.HTTP) // internal/platform/httpserver
container := app.Wire(cfg, log, db) // internal/app/wiring.go
router := httpserver.BuildRouter(container) // registers domain routes
srv.Start(router) // graceful shutdown inside
}
internal/app/wiring.go
type Container struct {
UserService *user.Service
AuthService *auth.Service
// ...
}
func Wire(cfg config.Config, log *slog.Logger, pool *pgxpool.Pool) *Container {
userRepo := userpg.New(pool) // internal/domain/user/repo/pg
userSvc := user.NewService(userRepo) // internal/domain/user/service.go
authRepo := authpg.New(pool)
authSvc := auth.NewService(authRepo, userRepo)
return &Container{UserService: userSvc, AuthService: authSvc}
}
internal/domain/user/transport/http/routes.go
func Register(r *gin.RouterGroup, svc *user.Service) {
r.GET("/users/:id", getUser(svc))
r.POST("/users", createUser(svc))
}
Testing layout
internal/
└─ domain/
└─ user/
├─ service_test.go # unit tests (use memory repo)
├─ repo/
│ └─ memory/
│ └─ repo.go
└─ transport/http/
└─ handler_test.go # httptest + gin engine
- Enable race detector in CI:
go test -race ./... - Add integration tests hitting a test DB (spin with docker-compose).
Config strategy
- 12-factor: env vars first, file as default.
- Validate on boot; fail fast.
- Consider
koanforviperif you need merges.
CONFIG_*
DB_DSN=postgres://...
HTTP_ADDR=:8080
LOG_LEVEL=info
Migrations
- Keep SQL in
/migrations. - Tooling options:
golang-migrate,goose,atlas. - Run on boot (optional) or via Make target.
make migrate-up
make migrate-down
Observability
- Logging: structured (slog/zap).
- Metrics: Prometheus
/metricsendpoint. - Tracing: OpenTelemetry exporters (OTLP).
- pprof: gated in non-prod or behind auth.
Makefile (starter)
.PHONY: run test lint tidy migrate-up
run: ## Start API
\tgo run ./cmd/api
test: ## Run tests with race
\tgo test -race ./...
lint: ## Static checks
\tgolangci-lint run
tidy:
\tgo mod tidy
migrate-up:
\tmigrate -path migrations -database $$DB_DSN up
Naming & import boundaries (rules that save you later)
- Domains never import each other’s
transport/httporrepo/pgpackages. They talk via interfaces in their own domain (e.g.,user/repo.go). - Cross-domain collaboration happens in services; if a cycle appears, extract an interface into a neutral place or create a new domain for shared concerns.
- Keep handlers thin (bind/validate → call service → present response). Keep repositories dumb (SQL only), no business logic.
Quick picks
- New service, long runway → Option A (domain-oriented)
- Tiny API / short-lived → Option B (layered)
- Monorepo with multiple services → keep each service under
cmd/<svc>with its own wiring; share platform libs underinternal/platformor a dedicated shared module.