PACE with Go
This tutorial shows how to configure PACE for a Go project. FORGE will write idiomatic Go code, GATE will run go test, and SENTINEL will audit for Go-specific security risks.
Prerequisites
- Go 1.22 or later
- An existing Go module (
go.mod) with at least a minimal test file - An API key for your LLM provider
Project layout assumed by this tutorial
my-service/├── pace/ ← PACE subdirectory│ └── pace.config.yaml├── cmd/│ └── server/│ └── main.go├── internal/│ ├── catalog/│ └── auth/├── go.mod├── go.sum└── .pace/1 — Install PACE
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — Configure for Go
Edit pace/pace.config.yaml:
framework_version: "1.0"
product: name: "My Service" description: > A Go HTTP service using net/http and chi router. Handles product catalog, inventory, and order management for an e-commerce backend. Uses PostgreSQL via pgx driver. No ORM — raw SQL with sqlx. github_org: "my-org"
sprint: duration_days: 14
source: dirs: - name: "internal" path: "internal/" language: "Go" description: "Domain packages: catalog, auth, orders. Each package has its own *_test.go files." - name: "cmd" path: "cmd/" language: "Go" description: "Application entry points (HTTP server, CLI tools)"
tech: primary_language: "Go 1.22" ci_system: "GitHub Actions" test_command: "go test ./... -v -count=1" build_command: "go build ./..."
platform: ci: local
llm: provider: anthropic model: claude-sonnet-4-6Test command variants
| Scenario | test_command |
|---|---|
| All packages | go test ./... -v -count=1 |
| Single package | go test ./internal/catalog/... -v -count=1 |
| With race detector | go test -race ./... -v -count=1 |
| With timeout | go test ./... -v -count=1 -timeout 60s |
| With testify | go test ./... -v -count=1 (no change — testify is a library) |
| Short mode (skip slow tests) | go test ./... -v -count=1 -short |
3 — Set credentials
export ANTHROPIC_API_KEY="sk-ant-..."4 — Write a Go sprint plan
Create plan.yaml at your repo root:
release: v1.0
stories: - id: story-1 title: "Product struct, ProductRepository interface, and InMemoryRepository" status: pending acceptance_criteria: - "Product struct has ID, Name, Price float64, and StockQuantity int fields" - "ProductRepository interface has Create, GetByID, Update, Delete, and List methods" - "InMemoryRepository implements ProductRepository with a sync.RWMutex-protected map" - "Tests cover concurrent read/write safety (t.Parallel)" - "go test ./internal/catalog/... exits 0" out_of_scope: - "HTTP handlers" - "Database persistence"
- id: story-2 title: "CatalogService with validation and error types" status: pending acceptance_criteria: - "CatalogService.Create() returns ErrInvalidPrice for price <= 0" - "CatalogService.Restock() returns ErrProductNotFound for unknown ID" - "Custom error types implement the error interface" - "Table-driven tests cover all error and success paths" - "go test ./internal/catalog/... exits 0"
- id: story-3 title: "CatalogHandler with GET, POST, PUT endpoints" status: pending acceptance_criteria: - "GET /products returns 200 with JSON array" - "POST /products returns 201 with Location header and product JSON" - "POST /products returns 422 for invalid price" - "Tests use httptest.NewRecorder() — no real HTTP server" - "go test ./... exits 0"5 — Run Day 1
cd pacepython pace/orchestrator.py --day 1What FORGE does with Go
FORGE’s tool-calling loop will:
- Read
go.modandgo.sumto understand module paths and dependencies - Read existing source files to learn package structure, naming conventions, and interfaces
- Write new
.gofiles using idiomatic patterns: table-driven tests, error wrapping, context propagation - Run
go build ./...to catch compile errors - Run
go test ./...and read--- FAILoutput to self-correct
A typical FORGE run for Day 1:
[FORGE] Reading go.mod ...[FORGE] Reading internal/catalog/ (directory listing) ...[FORGE] Writing internal/catalog/product.go ...[FORGE] Writing internal/catalog/repository.go ...[FORGE] Writing internal/catalog/memory_repository.go ...[FORGE] Writing internal/catalog/memory_repository_test.go ...[FORGE] Running build: go build ./... ...[FORGE] Running tests: go test ./... -v -count=1 ...[FORGE] All tests pass. Calling complete_handoff.What SENTINEL checks for Go
SENTINEL applies Go-specific checks:
- SQL injection: raw string interpolation in database queries instead of parameterized queries
- Path traversal:
os.Openorhttp.ServeFilewith unvalidated user input - Cryptography: use of
math/randinstead ofcrypto/randfor security tokens - Race conditions: shared state without mutex protection (flagged as advisory when tests pass without
-race) - Error handling: silently ignored errors (
_ = someFunc()) in security-sensitive code paths - HTTP security: missing TLS configuration, overly permissive CORS headers
- Secrets: hardcoded tokens or private keys in source files
6 — Projects with CGO or external dependencies
If your project requires CGO or build tags, add them to the test command:
tech: test_command: "go test -tags integration ./... -v -count=1" build_command: "CGO_ENABLED=1 go build ./..."For projects with integration tests that require a running database, use build tags to separate unit from integration tests:
tech: test_command: "go test -tags unit ./... -v -count=1"Then annotate integration tests with //go:build integration so GATE only runs the fast unit suite.
7 — Monorepo with multiple Go modules
If your repo has separate go.mod files per service:
source: dirs: - name: "catalog" path: "services/catalog/" language: "Go" description: "Catalog microservice — chi router, pgx, domain model"
tech: test_command: "cd services/catalog && go test ./... -v -count=1" build_command: "cd services/catalog && go build ./..."PACE runs commands from the repo root, so cd within the command is the simplest approach.
8 — FORGE hints for Go
Add your project’s conventions to product.description so FORGE writes idiomatic Go without requiring corrections:
product: description: > A Go HTTP service using net/http and chi router. Use table-driven tests with t.Run() — every test case has a name, input, and expected output. All errors wrapped with fmt.Errorf("context: %w", err). Custom sentinel errors declared as package-level vars (var ErrNotFound = errors.New(...)). Inject all dependencies via struct fields using interface types — no global state. context.Context is the first argument in every IO-touching function. No third-party test frameworks — use standard testing package with testify/assert.9 — Test coverage
Go’s built-in coverage tool enforces a minimum threshold:
tech: test_command: "go test ./... -v -count=1 -coverprofile=coverage.out && go tool cover -func coverage.out | tail -1 | awk '{if ($3+0 < 80) exit 1}'"Or use a simpler form with go test -cover:
tech: test_command: "go test ./... -v -count=1 -cover -covermode=atomic"For HTML reports in CI:
go test ./... -coverprofile=coverage.outgo tool cover -html=coverage.out -o coverage.htmlInstall goverage or gocover-cobertura for CI coverage artifact formats.
10 — Linter and formatter (golangci-lint)
tech: build_command: "golangci-lint run ./..." test_command: "go test ./... -v -count=1"Install golangci-lint locally:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest.golangci.yml:
linters: enable: - errcheck - govet - staticcheck - gosec - revive
linters-settings: revive: rules: - name: exportedFORGE hint: "Code must pass golangci-lint with errcheck, govet, staticcheck, and gosec enabled. All exported types and functions must have godoc comments."
11 — Database testing
Interface-based mocking (no database needed)
Go’s interface system makes pure unit tests the default — define a repository interface and inject it:
type ProductRepository interface { Create(ctx context.Context, p Product) (Product, error) FindByID(ctx context.Context, id uuid.UUID) (Product, error)}Tests use an in-memory implementation:
type inMemoryRepo struct{ mu sync.RWMutex; data map[uuid.UUID]Product }
func (r *inMemoryRepo) Create(_ context.Context, p Product) (Product, error) { r.mu.Lock(); defer r.mu.Unlock() p.ID = uuid.New(); r.data[p.ID] = p; return p, nil}pgxmock (PostgreSQL driver mock)
For tests that must go through the SQL layer:
import "github.com/pashagolub/pgxmock/v3"
mock, _ := pgxmock.NewPool()mock.ExpectQuery("SELECT").WillReturnRows( pgxmock.NewRows([]string{"id","name"}).AddRow(id, "Widget"))Testcontainers Go
func TestWithRealPostgres(t *testing.T) { ctx := context.Background() container, _ := postgres.Run(ctx, "postgres:15", postgres.WithDatabase("testdb")) defer testcontainers.CleanupContainer(t, container)
connStr, _ := container.ConnectionString(ctx, "sslmode=disable") pool, _ := pgxpool.New(ctx, connStr) // ... use pool}Mark with t.Skip when INTEGRATION_TESTS env var is not set.
12 — Mock patterns (testify/mock)
import "github.com/stretchr/testify/mock"
type MockProductRepo struct{ mock.Mock }
func (m *MockProductRepo) FindByID(ctx context.Context, id uuid.UUID) (Product, error) { args := m.Called(ctx, id) return args.Get(0).(Product), args.Error(1)}
func TestServiceReturnsNotFound(t *testing.T) { repo := new(MockProductRepo) repo.On("FindByID", mock.Anything, testID).Return(Product{}, ErrNotFound)
svc := NewCatalogService(repo) _, err := svc.GetProduct(context.Background(), testID)
assert.ErrorIs(t, err, ErrNotFound) repo.AssertExpectations(t)}For generated mocks, use mockgen:
go install go.uber.org/mock/mockgen@latestmockgen -source=internal/catalog/repository.go -destination=internal/catalog/mock_repository.goFORGE hint: "Use testify/mock for manual mocks. Generated mocks with mockgen for large interfaces. Always call repo.AssertExpectations(t) at the end of every test."
13 — Multi-day sprint (Day 2 builds on Day 1)
After Day 1 ships, SCRIBE records the new package structure in engineering.md. Day 2’s FORGE reads it:
[FORGE] Reading .pace/context/engineering.md ...[FORGE] Found: internal/catalog/product.go (Product struct, ProductRepository interface)[FORGE] Found: internal/catalog/memory_repository.go (InMemoryRepository)[FORGE] Writing internal/catalog/service.go ...[FORGE] Writing internal/catalog/service_test.go ...Story-2 criteria can reference ProductRepository and ErrNotFound by name — FORGE already knows their signatures.
14 — Advisory clearance story
- id: story-5 title: "Security and DevOps advisory clearance" status: pending acceptance_criteria: - "All open SENTINEL advisories resolved or escalated with written justification" - "All open CONDUIT advisories resolved or escalated" - "go test ./... exits 0 after any remediation changes"Common Go SENTINEL advisories:
math/randfor token generation — fix: usecrypto/rand_ = someFunc()ignoring errors in security-sensitive paths — fix: handle or explicitly logos.Open(userInput)without path sanitisation — fix: usefilepath.Cleanand check prefix- Shared mutable state without mutex — fix: use
sync.RWMutexorsync/atomic
15 — CI build caching
Cache Go module downloads and build cache:
- uses: actions/setup-go@v5 with: go-version: '1.22' cache: true # caches ~/go/pkg/mod and ~/.cache/go-buildThe cache: true flag in actions/setup-go handles both the module cache and the build cache automatically using go.sum as the cache key.
16 — Windows notes
Go works natively on Windows. go test and go build commands are identical across platforms. The cd services/catalog && go test pattern in test_command uses the shell — on Windows, use PowerShell or WSL2 for multi-command test_command values. Alternatively, write a Makefile target and use make test as test_command.
Common issues
go: command not found
Ensure Go is on PATH in the shell that runs the orchestrator. If using asdf or mise for version management, activate the correct version before running PACE.
Tests pass individually but fail under ./...
This usually means package-level init() or TestMain functions conflict when run together. Add a note to product.description describing any global test setup so FORGE avoids conflicting initialization.
FORGE generates code with wrong module path
The module path is in go.mod (e.g. module github.com/my-org/my-service). FORGE reads this file, but if it generates imports with the wrong path, verify that go.mod is inside one of the directories listed in source.dirs or at the repo root where FORGE can read it.
Race detector failures
If you want GATE to run with -race, add it to test_command. Note that race conditions found this way will cause GATE to issue HOLD, which is the intended behavior — FORGE must fix them before the day ships.