Skip to content

Go Service Scaffolding

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/go-scaffolding.md
DescriptionNot 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-codegen from api/openapi.yaml (when present)
  • Testing: table-driven with testify, integration tests via testcontainers-go against 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, prod tasks

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, scans

Middleware 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 panics
app.Use(otel_middleware) — injects trace IDs into context
app.Use(zap_middleware) — structured logging with trace injection
app.Use(prometheus_middleware) — RED metrics
app.Use(auth_middleware) — checks JWT/session/API key
app.Router(routes) — your handlers

Never reorder these — each layer’s output feeds the next.

Middleware routing order (also load-bearing)

recover → otel → zap → metrics → auth → routes

If 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

  1. Write or receive api/openapi.yaml (spec at the root of api/)
  2. Run oapi-codegen -config api/oapi-codegen.yaml api/openapi.yaml > internal/handler/openapi.gen.go
  3. Implement the generated ServerInterface in internal/handler/ — one handler per operation
  4. Never edit *.gen.go files by hand; regenerate when the spec changes
  5. Commit generated files to git so CI can detect drift
  6. Add a task openapi step that regenerates and git diff --exit-code to 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.yaml with version and appVersion
  • values.yaml with sensible defaults (replicas, resources, image)
  • templates/deployment.yaml with:
    • runAsNonRoot: true
    • readOnlyRootFilesystem: true
    • No :latest image tags (use the git SHA or a semantic version)
  • templates/service.yaml exposing the service
  • templates/configmap.yaml for non-secret config (Viper integration)

Example security context:

securityContext:
runAsNonRoot: true
runAsUser: 65534 # nobody user
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL

Dockerfile essentials

Multi-stage build:

# Stage 1: build
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY . .
RUN go build -o api ./cmd/api
# Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/api /
EXPOSE 8080
ENTRYPOINT ["/api"]

Key rules:

  • No :latest base image tags — use exact versions
  • distroless image (no shell, no package manager — smaller attack surface)
  • nonroot user (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:latest

Testing 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.yaml present
  • Dockerfile references distroless and no :latest tags
  • .github/workflows/ has CI (checks for cosign and Trivy references)
  • Taskfile.yml present
  • api/openapi.yaml exists (if applicable) and generated *.gen.go exists
  • task test or go test ./... passes
  • No naked go func() in internal/

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.sh before submission
  • Every repo that exits this skill should pass verification