원클릭으로
contracts
Using forge's contract system — when to use contract.go, how to write one, what forge generate produces, integrating with tests.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Using forge's contract system — when to use contract.go, how to write one, what forge generate produces, integrating with tests.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Visual-design discipline for forge frontends — brief the work before building (never invent an aesthetic without user input), declare the design system before coding, lean on the component library, use restrained color/type systems, never hand-draw complex SVGs, and verify visually before declaring done.
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Outbound boundary translators. One adapter per third-party system / queue / storage backend; narrow interface, vendor-neutral callers.
Write Connect RPC handlers — proto-driven codegen, the thin-translation handler pattern (validate, extract auth, convert proto↔internal, call service, wrap errors via `svcerr.Wrap`), middleware, and testing. Business logic lives in `internal/handlers/<svc>/contract.go`, never in handlers.
Forge project conventions and architecture — project structure, generated vs hand-written code, the generate pipeline, proto annotations, contracts, wiring, and naming.
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
| name | contracts |
| description | Using forge's contract system — when to use contract.go, how to write one, what forge generate produces, integrating with tests. |
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.
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.
In forge.yaml:
contracts:
strict: true # default true — require contract.go
allow_exported_vars: false # default false — no globals
allow_exported_funcs: true # default true — allow free functions
exclude: # opt-out for genuine pure utilities
- "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.
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.
//forge:strategyA 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:
// Strategy-registry package. Each algorithm registers itself in init().
//
//forge:strategy
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.
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:
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.
contract.goOne 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.
// internal/email/contract.go
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:
// internal/email/email.go
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.
forge generate producesFor 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.
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.
Mocks come from mock_gen.go. They are designed to be drop-in for the real service in handler unit tests:
// internal/user/service_test.go
package user
import (
"context"
"testing"
"github.com/example/myproject/internal/email"
)
func TestCreateUser_SendsWelcomeEmail(t *testing.T) {
// Set the func-field for the method you want to fake. Unset
// methods return "MockService.XxxFunc not set".
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)
// The mock embeds contractkit.Recorder. Use it to assert call
// patterns directly:
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.
For packages that are exclusively pure functions on no state (naming, format, math helpers):
contracts:
allow_exported_funcs: true
exclude:
- "internal/naming"
Honest about the lack of test seam. Best for string-case conversions and pure formatters.
Service structExpose 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)).
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.
forge package newFor 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 implementationmock_gen.go via forge generateIt 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.
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.
contract.go per package. The interface IS the public surface.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.)Service for single-impl, <Name>er for multi-impl pluggable backends.contracts.strict: true from day one. Backfilling later is the most expensive option.proto and services.auth, packs, observability.testing and its sub-skills.migration.