Skip to content

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

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — 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-6

Test command variants

Scenariotest_command
All packagesgo test ./... -v -count=1
Single packagego test ./internal/catalog/... -v -count=1
With race detectorgo test -race ./... -v -count=1
With timeoutgo test ./... -v -count=1 -timeout 60s
With testifygo test ./... -v -count=1 (no change — testify is a library)
Short mode (skip slow tests)go test ./... -v -count=1 -short

3 — Set credentials

Terminal window
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

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with Go

FORGE’s tool-calling loop will:

  1. Read go.mod and go.sum to understand module paths and dependencies
  2. Read existing source files to learn package structure, naming conventions, and interfaces
  3. Write new .go files using idiomatic patterns: table-driven tests, error wrapping, context propagation
  4. Run go build ./... to catch compile errors
  5. Run go test ./... and read --- FAIL output 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.Open or http.ServeFile with unvalidated user input
  • Cryptography: use of math/rand instead of crypto/rand for 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:

Terminal window
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

Install 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:

Terminal window
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: exported

FORGE 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:

Terminal window
go install go.uber.org/mock/mockgen@latest
mockgen -source=internal/catalog/repository.go -destination=internal/catalog/mock_repository.go

FORGE 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/rand for token generation — fix: use crypto/rand
  • _ = someFunc() ignoring errors in security-sensitive paths — fix: handle or explicitly log
  • os.Open(userInput) without path sanitisation — fix: use filepath.Clean and check prefix
  • Shared mutable state without mutex — fix: use sync.RWMutex or sync/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-build

The 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.