Go Service Scaffolding
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/go-scaffolding.md |
| Description | Not specified |
Source Content
Go Service Scaffolding
Use this reference when starting a new Go HTTP service or retrofitting the standard architecture onto an existing skeleton. The goal: every new service should show up to ArgoCD looking identical to the last one.
This skill emits a complete, runnable repo so the bikeshed never opens. See the parent SKILL.md for the scaffolding workflow and elicitation points.
The deliverable
A production-ready Go service repo with:
- Layout: clean-architecture
internal/{handler,service,repository,domain,config,observability}/ - Entry point:
cmd/api/main.go - HTTP: Fiber v2
- Database: pgxpool + sqlc (preferred) or GORM
- Migrations: Atlas (declarative)
- Observability: Zap logger with trace_id/span_id injection, OpenTelemetry + Prometheus, pprof on an internal port
- Configuration: Viper
- Code generation:
oapi-codegenfromapi/openapi.yaml(when present) - Testing: table-driven with
testify, integration tests viatestcontainers-goagainst real Postgres - Container: multi-stage Dockerfile → distroless image, nonroot user
- Helm: chart under
.p3/helm/, with Kyverno-ready security context - CI: GitHub Actions workflow that signs (cosign) and scans (Trivy) the image
- Build: Taskfile with standard
dev,test,lint,build,prodtasks
The architecture
service/├── cmd/api/main.go — entry point; wires everything├── internal/│ ├── handler/ — HTTP handlers; parses, validates, calls service│ ├── service/ — business logic, orchestration, transactions│ ├── repository/ — persistence layer; implements interfaces from service│ ├── domain/ — entities, value objects, domain errors, pure logic│ ├── config/ — configuration via Viper│ └── observability/ — Zap, OTel, Prometheus setup├── api/openapi.yaml — OpenAPI 3.1 spec (if applicable)├── migrations/ — Atlas migration files (schema.hcl, versioned SQL)├── .p3/helm/Chart.yaml — Helm chart for k8s deployment├── Dockerfile — multi-stage, distroless, nonroot├── Taskfile.yml — standard tasks (dev, test, lint, build, prod)└── .github/workflows/ — CI that builds, tests, signs, scansMiddleware order (load-bearing)
The order of middleware in Fiber matters because each layer depends on the one before:
app.Use(recover.New()) — catches and logs panicsapp.Use(otel_middleware) — injects trace IDs into contextapp.Use(zap_middleware) — structured logging with trace injectionapp.Use(prometheus_middleware) — RED metricsapp.Use(auth_middleware) — checks JWT/session/API keyapp.Router(routes) — your handlersNever reorder these — each layer’s output feeds the next.
Middleware routing order (also load-bearing)
recover → otel → zap → metrics → auth → routesIf you add more middleware, insert it at the right level of trust:
- Public-facing, low-trust: recover (catches 500s before logging)
- Observability: otel, zap, metrics (need clean context)
- Guarded access: auth (only trusted after earlier middleware runs)
- Business logic: routes (only reached if all earlier checks pass)
Database choice: sqlc vs GORM
Pick sqlc if:
- The SQL is non-trivial (complex joins, CTEs, window functions)
- You want strict type checking on SQL parameters and results
- Query performance and control matter
- You’re willing to write SQL by hand
Pick GORM if:
- Speed-to-CRUD matters more than control
- The SQL is simple (straightforward inserts, updates, deletes)
- You want a model-based abstraction
- You don’t mind a small ORM overhead
Question to ask: “How non-trivial is the SQL?” The answer changes the choice.
OpenAPI codegen workflow
- Write or receive
api/openapi.yaml(spec at the root ofapi/) - Run
oapi-codegen -config api/oapi-codegen.yaml api/openapi.yaml > internal/handler/openapi.gen.go - Implement the generated
ServerInterfaceininternal/handler/— one handler per operation - Never edit
*.gen.gofiles by hand; regenerate when the spec changes - Commit generated files to git so CI can detect drift
- Add a
task openapistep that regenerates andgit diff --exit-codeto fail CI if drift is detected
The spec is the source of truth. Code is generated from it, never the reverse.
Observability wiring (first commit)
Not a later milestone — wire it on day one. The pattern:
Zap logger setup (in internal/observability/logger.go):
logger, _ := zap.NewProduction()defer logger.Sync()// Log with trace ID injected:logger.Info("request processed", zap.String("trace_id", traceID))OTel trace setup (in internal/observability/tracer.go):
tp := tracesdk.NewTracerProvider( tracesdk.WithBatcher(otlptracehttp.NewClient()),)otel.SetTracerProvider(tp)Prometheus metrics (in internal/observability/metrics.go):
httpDuration := prometheus.NewHistogramVec( prometheus.HistogramOpts{Name: "http_request_duration_seconds"}, []string{"method", "path", "status"},)prometheus.MustRegister(httpDuration)pprof on a separate internal port (in cmd/api/main.go):
go func() { if os.Getenv("DEBUG") == "true" { log.Printf("pprof listening on :6060") log.Printf("%v", http.ListenAndServe("127.0.0.1:6060", nil)) }}()Helm chart essentials
The chart under .p3/helm/ must include:
Chart.yamlwith version and appVersionvalues.yamlwith sensible defaults (replicas, resources, image)templates/deployment.yamlwith:runAsNonRoot: truereadOnlyRootFilesystem: true- No
:latestimage tags (use the git SHA or a semantic version)
templates/service.yamlexposing the servicetemplates/configmap.yamlfor non-secret config (Viper integration)
Example security context:
securityContext: runAsNonRoot: true runAsUser: 65534 # nobody user readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: - ALLDockerfile essentials
Multi-stage build:
# Stage 1: buildFROM golang:1.25-alpine AS builderWORKDIR /buildCOPY . .RUN go build -o api ./cmd/api
# Stage 2: runtimeFROM gcr.io/distroless/static-debian12:nonrootCOPY --from=builder /build/api /EXPOSE 8080ENTRYPOINT ["/api"]Key rules:
- No
:latestbase image tags — use exact versions distrolessimage (no shell, no package manager — smaller attack surface)nonrootuser (runs as UID 65534)- Minimal copying (only the final binary)
CI workflow essentials
GitHub Actions workflow in .github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '1.25' - run: go build ./... - run: go test -race ./...
build: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v2 - run: docker build -t api:${{ github.sha }} . - uses: sigstore/cosign-installer@v3 - run: cosign sign --key ${{ secrets.COSIGN_KEY }} api:${{ github.sha }} - uses: aquasecurity/trivy-action@master with: image-ref: api:${{ github.sha }}Key checks:
- Build succeeds
- Tests pass with
-race - Image is signed (cosign)
- Image is scanned (Trivy) for vulnerabilities
Taskfile.yml essentials
version: '3'
tasks: dev: cmds: - go run ./cmd/api env: DEBUG: "true"
test: cmds: - go test -race ./...
test-cover: cmds: - go test -cover ./domain/... ./service/...
lint: cmds: - scripts/lint_go.sh
build: cmds: - go build -o bin/api ./cmd/api
prod: cmds: - docker build -t api:latest . - docker push api:latestTesting patterns
Unit tests (against mock repository):
func TestInvoiceService_GetByID(t *testing.T) { tests := []struct { name string id string setupFake func(*FakeRepository) want *domain.Invoice wantErr error }{ { name: "found", id: "inv-001", setupFake: func(f *FakeRepository) { f.invoices["inv-001"] = &domain.Invoice{ID: "inv-001"} }, want: &domain.Invoice{ID: "inv-001"}, }, { name: "not found", id: "missing", wantErr: domain.ErrNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fake := &FakeRepository{} tt.setupFake(fake) svc := NewInvoiceService(fake) got, err := svc.GetByID(context.Background(), tt.id) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) return } require.NoError(t, err) require.Equal(t, tt.want, got) }) }}Integration tests (against real Postgres via testcontainers):
func TestInvoiceRepository_Create(t *testing.T) { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "postgres:16", Env: map[string]string{ "POSTGRES_PASSWORD": "test", "POSTGRES_DB": "test", }, } postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) require.NoError(t, err) defer postgres.Terminate(ctx)
// get DSN from container, connect, test...}Verification
Run scripts/verify_scaffold.sh [repo-dir] from this skill to confirm:
- Go module builds
- Six-layer
internal/layout is present cmd/entrypoint exists.p3/helm/Chart.yamlpresent- Dockerfile references distroless and no
:latesttags .github/workflows/has CI (checks for cosign and Trivy references)- Taskfile.yml present
api/openapi.yamlexists (if applicable) and generated *.gen.go existstask testorgo test ./...passes- No naked
go func()ininternal/
All checks must pass before the scaffolded repo is handed off.
How to use this reference
- Follow the workflow in the parent SKILL.md’s “How I work (scaffolding)” section
- Refer back here for architecture details, middleware order, database choice, observability wiring
- Run
scripts/verify_scaffold.shbefore submission - Every repo that exits this skill should pass verification