| name | contracts |
| description | Using forge's contract system — when to use contract.go, how to write one, what forge generate produces, integrating with tests. |
Forge Contracts
Internal Go packages in a forge project expose a Go interface in contract.go. The contract is the public surface of the package; everything else is implementation detail. From one contract.go forge generate produces the test seam (mocks) and the cross-cutting middleware (logging, tracing, metrics) for free.
This is always-applicable advice — for greenfield packages and for porting existing ones. For the migration-specific story see migration and its sub-skills.
When does a contract.go belong?
Three categories of internal package, three answers:
| Category | Has contract.go? | Why |
|---|
| Single-impl service (DB-backed, calls external API, holds state) | Yes | Test seam + auto-generated middleware/tracing/metrics. The mock is the value. |
| Multi-impl with strategy pattern (pluggable backends — local vs S3 storage, JWT vs Clerk auth) | Yes | The interface IS the abstraction; multiple implementations satisfy it. |
| Pure utility (string formatters, math helpers, no I/O / time / state) | Optional — see "Pure-utility packages" below | Nothing meaningful to mock. |
The contract linter enforces this. By default, every internal/ package with exported methods needs contract.go.
Set the linter floor
In forge.yaml:
contracts:
strict: true
allow_exported_vars: false
allow_exported_funcs: true
exclude:
- "internal/buildinfo"
contracts.strict: true makes the analyzer mandatory. Run via:
forge lint --contract
Add this to your per-phase gate alongside go build ./... && go test ./.... Catching a contract gap at the moment it's introduced is cheap; backfilling six phases later is not.
Scope: internal/ and pkg/
The contract analyzer scans both internal/ and pkg/ by default. The conceptual difference is intent:
internal/<name>/ — business logic. The contract pattern is mandatory here; this is where the test seam matters.
pkg/<name>/ — library code (utilities, clients, shared types). The contract pattern is optional but the linter still scans. Use contracts.exclude: - pkg/<name> for genuine library packages where the contract.go shape adds noise without value (e.g. pkg/httputil, pkg/crypto, packages that wrap a third-party library's idiomatic API).
If you keep finding yourself adding pkg/<X> entries to contracts.exclude, that's a signal — either the package belongs in internal/ (it has real business state and should follow the contract pattern), or it's genuinely library code and the exclusion is correct. Don't reflexively exclude every pkg/* package; ask which side of that line it falls on.
Strategy registries: //forge:strategy
A strategy-pattern package (one interface, multiple impls each with its own constructor, often a Register()/Registry map) doesn't have a single canonical Service/Deps/New(Deps) Service trio — each strategy is independently constructed. The canonical-names lint can't bind to anything sensible here.
Mark the package with //forge:strategy at the package declaration to opt out of the canonical-shape check explicitly:
package algos
type Strategy interface {
Name() string
Run(ctx context.Context, input []float64) (float64, error)
}
var Registry = map[string]Strategy{}
func Register(s Strategy) { Registry[s.Name()] = s }
Other lint rules (exported-vars, naming, banner enforcement) still apply — the directive only suppresses the Service/Deps/New(Deps) Service enforcement. It's opt-in for a reason: a package that looks strategy-shaped but really is an incomplete Service scaffold should still surface the gap.
Prefer narrow per-aggregate interfaces over wide facades
The contract pattern works best when Service is focused — one cohesive responsibility, methods that belong together by domain. A 100+ method Repository interface (one struct exposing every DB operation in the system) defeats the pattern:
- Every consumer depends on every method, even the ones it doesn't use.
- Mocks become unusable in tests — you can't easily fake just the 3 methods a handler calls.
interfacebloat will fire, correctly.
The remedy depends on your shape:
| Source shape | Port-time fix |
|---|
Hand-written wide Repository interface + a concrete *PostgresRepository that satisfies it | Drop the wide Repository. Define narrow per-aggregate interfaces (UserRepository, OrgRepository, BillingRepository). The same concrete *PostgresRepository satisfies all of them structurally — Go does that for free. Each handler depends on the 10-method interface it actually needs. |
| Multi-aggregate package with wide internal surface | Split into per-aggregate packages: internal/db/user/, internal/db/org/, internal/db/billing/. Each gets its own narrow contract.go. Larger refactor; cleanest long-term. |
sqlc-generated Queries struct (one method per query, all in one struct) | Generated code. Add a path-based exclude for the sqlc output dir AND document it ("generated by sqlc; cannot split"). This is the one case where interfacebloat is a false positive — sqlc's output shape is non-negotiable. |
| Wide interface but only one or two callers | Either narrow it inline (delete the methods nobody calls) or split per-caller. Don't keep a 50-method interface that two functions use. |
The wide-facade pattern is convenient when you're writing the implementation. Forge's contract pattern asks you to design for the callers instead. The split-or-narrow conversation belongs at port time, not after lint failures pile up.
Writing a contract.go
One file per package, defining one (or a few cohesive) interfaces. Keep methods focused — every method becomes a mock entry, a middleware wrapper, a tracing span.
package email
import "context"
type Service interface {
Send(ctx context.Context, to, subject, body string) error
SendTemplated(ctx context.Context, to, templateID string, data map[string]any) error
}
Naming convention: Service for single-impl, <Name>er for multi-impl (Storer, Authenticator, Validator). Forge templates assume Service for the canonical case.
The implementation lives in a sibling file:
package email
import (
"context"
"github.com/sendgrid/sendgrid-go"
)
type sendgridSvc struct {
client *sendgrid.Client
}
func New(apiKey string) Service {
return &sendgridSvc{client: sendgrid.NewSendClient(apiKey)}
}
func (s *sendgridSvc) Send(ctx context.Context, to, subject, body string) error {
}
func (s *sendgridSvc) SendTemplated(ctx context.Context, to, templateID string, data map[string]any) error {
}
The constructor returns the interface type (Service), not the concrete struct. This forces consumers to depend on the abstraction.
What forge generate produces
For every contract.go it finds, you get one sibling file:
| File | What it gives you |
|---|
mock_gen.go | A MockService struct implementing Service. Each method has a XxxFunc field you assign in tests; if unset, the method returns the canonical "MockService.XxxFunc not set" error. The mock embeds contractkit.Recorder, so every call is captured for m.Calls("Send") / m.CallCount("Send") assertions. |
mock_gen.go is regenerated on every forge generate. Never
hand-edit it. If the contract changes, edit contract.go and re-run.
The generated file carries the canonical forge ownership header — a
// Code generated by forge. DO NOT EDIT. line, a // Source: pointer
at contract.go, and a // To customize: pointer back at the contract.
forge lint --scaffolds enforces both halves: hand-editing the _gen.go
file (or stripping its header) is a build-gating error.
Where the middleware/tracing/metrics wrappers went
Pre-1.7 forge also emitted middleware_gen.go, tracing_gen.go and
metrics_gen.go per package. Those were removed in favour of:
-
Connect interceptors at the handler boundary — forge/pkg/observe
exposes LoggingInterceptor, TracingInterceptor,
MetricsInterceptor, RecoveryInterceptor, RequestIDInterceptor
plus a one-call canonical chain observe.Chain(observe.Deps{…}).
The generated cmd serve.go builds that chain (handing in the
already-built Auth/Audit/RateLimit interceptors by name) and
applies it as handler options; the explicit composition root
(internal/app/compose.go, NewComponents) constructs the components
it mounts.
-
Opt-in per-method helpers — for the rare case where one Service
calls another and you want a child span / log line / metric, use
observe.LogCall / observe.TraceCall / observe.NewCallMetrics
at the explicit call site:
func (s *svc) DoThing(ctx context.Context, req Req) (Resp, error) {
return observe.TraceCall(ctx, s.tracer, "userstore.Get", func(ctx context.Context) (User, error) {
return s.userStore.Get(ctx, req.UserID)
})
}
For projects upgrading from the old shape, see the
v0.x-to-observe-libs migration skill — forge generate removes the
stale wrappers. The interceptor chain is assembled in the generated
cmd serve.go via observe.Chain; the owned composition seam is
internal/app/providers.go (OpenInfra) for infra and the regenerated
internal/app/compose.go (NewComponents) for component construction.
Using mocks in tests
Mocks come from mock_gen.go. They are designed to be drop-in for the real service in handler unit tests:
package user
import (
"context"
"testing"
"github.com/example/myproject/internal/email"
)
func TestCreateUser_SendsWelcomeEmail(t *testing.T) {
mockEmail := &email.MockService{
SendFunc: func(ctx context.Context, to, subject, body string) error {
return nil
},
}
svc := New(Deps{Email: mockEmail})
_, err := svc.CreateUser(context.Background(), &usersv1.CreateUserRequest{
Email: "alice@example.com",
})
assert.NoError(t, err)
if mockEmail.CallCount("Send") != 1 {
t.Errorf("expected 1 Send call, got %d", mockEmail.CallCount("Send"))
}
calls := mockEmail.Calls("Send")
if calls[0].Args[1] != "alice@example.com" {
t.Errorf("Send to = %v, want alice@example.com", calls[0].Args[1])
}
}
The mock satisfies the Service interface, so the handler under test does not know it's running against a mock — it talks to its dependency the same way it does in production.
For integration tests that need the real implementation, use the harness in internal/app/ (NewTestHarness(t)) — it calls the owned composition root (Build) to wire real services against a test database.
Pure-utility packages — three options
For packages that are exclusively pure functions on no state (naming, format, math helpers):
(A) Skip the contract
contracts:
allow_exported_funcs: true
exclude:
- "internal/naming"
Honest about the lack of test seam. Best for string-case conversions and pure formatters.
(B) Wrap functions in a Service struct
Expose the functions as methods on a struct, define a Service interface in contract.go. Mockable, but verbose at every call site (naming.New().ToPascalCase(s) instead of naming.ToPascalCase(s)).
(C) Hybrid
Keep free functions for ergonomics, ALSO expose a Service interface. Service methods delegate to free functions. Best of both, slightly redundant. Pick this when consumers want mockability but you don't want to break call sites.
Decision rule: pure utility with no I/O / time / randomness / external state → (A). Anything that touches one of those → (B) or (C). Don't apply this rule to your DB layer, HTTP clients, or anything that calls time.Now() — those are stateful and need a mock.
Greenfield: forge package new
For new packages in an existing forge project:
forge package new <name>
Scaffolds internal/<name>/:
contract.go with a starter Service interface
<name>.go with a stub implementation
mock_gen.go via forge generate
It will refuse if the directory already exists. For ports of existing packages, hand-write contract.go first (extracted from the source's exported surface), run forge generate, then copy the implementation behind the interface. See migration-cli and migration-service for the porting flow.
Per-phase lint gate
forge lint --contract --exported-vars && go build ./... && go test ./...
If any of those three fail, the phase isn't done. This catches contract gaps at introduction time, not 6 phases later when you've forgotten the package shape. For migrations, this is the difference between a smooth port and a 5-hour backfill at the end.
Rules
- One
contract.go per package. The interface IS the public surface.
- Constructors return the interface, not the concrete struct.
- Never hand-edit
mock_gen.go. Edit contract.go and re-run forge generate. (Pre-1.7 projects also had middleware_gen.go / tracing_gen.go / metrics_gen.go; those are removed by the next forge generate.)
- Naming:
Service for single-impl, <Name>er for multi-impl pluggable backends.
- Pure utilities: pick (A), (B), or (C) explicitly. Don't leave the package half-mocked.
contracts.strict: true from day one. Backfilling later is the most expensive option.
When this skill is not enough
- Designing proto-level contracts (the external API surface) — see
proto and services.
- Choosing what generated middleware to apply (auth, rate-limit, audit) — see
auth, packs, observability.
- Test patterns beyond unit-level mocking (integration, e2e) — see
testing and its sub-skills.
- Migrating an existing codebase that doesn't yet have contracts everywhere — see
migration.