Skip to main content

Folder Structure

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.goBootstraps 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 koanf or viper if 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 /metrics endpoint.
  • 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/http or repo/pg packages. 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 runwayOption 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 under internal/platform or a dedicated shared module.