name: v0.x-to-strict-contract-names
description: Internal-package contract.go files must declare type Service interface, type Deps struct, and func New(Deps) (Service, error) (or the legacy single-result func New(Deps) Service). The convention is now lint-enforced; non-canonical names previously produced silently-broken bootstrap codegen.
relevance: migration
Migrating to strict internal-package contract names
Canonical New signature. The current scaffold and the canonical shape is the two-result form:
func New(Deps) (Service, error). The single-result formfunc New(Deps) Serviceis still accepted by the linter for backward compatibility, but theforge add packageandforge generatescaffolds emit the two-result form and thecontract_test.goauto-scaffold targets it. If you write the single-result form, you keep ownership ofcontract_test.go(forge will skip the auto-scaffold to avoid emitting a non-compiling_, err := pkg.New(...)call).
Use this skill when forge generate or forge lint reports a
forgeconv-internal-package-contract-names violation, or when upgrading
across the version that introduced the strict-naming check.
1. What changed
Pre-strict, forge would happily run forge generate against an
internal-package contract.go that declared, say:
package email
type Sender interface { Send(to, body string) error }
type Config struct{}
func NewSender(_ Config) Sender { return &svc{} }
The bootstrap codegen template (pkg/app/bootstrap.go) hardcodes
references to <pkg>.Service, <pkg>.Deps, and <pkg>.New(...) for
every entry under Packages, so the rendered bootstrap would emit:
import emailpkg "<module>/internal/email"
// ...
emailpkg.New(emailpkg.Deps{...})
…which doesn't compile against the Sender/Config/NewSender
contract. The error pointed at generated code (pkg/app/bootstrap.go)
rather than the user's contract.go, making the failure mode confusing.
Post-strict, both forge lint --conventions and forge generate
short-circuit BEFORE bootstrap codegen runs and report:
[forgeconv-internal-package-contract-names]
internal-package contracts must declare
'type Service interface', 'type Deps struct', and 'func New(Deps) Service'.
Found 'Sender' at internal/email/contract.go:3 — rename to 'Service'
(or move out of contract.go) so the bootstrap template can wire it.
The deprecation cycle policy doesn't apply here because the broken state was always wrong — it just failed silently. There is no "old shape works for N versions with warnings" period; non-canonical names never produced a working project.
2. Detection
# Run from the project root.
forge lint --conventions
# Or scan for the four canonical pieces by hand.
for f in $(find internal -name contract.go -not -path '*/testdata/*'); do
echo "=== $f ==="
grep -E '^type (Service interface|Deps struct)|^func New\(' "$f" || echo "MISSING canonical pieces"
done
If the project sits in contracts.exclude (analyzer sub-packages,
embed-only packages, the cli surface itself), the check skips it —
those packages aren't bootstrap-managed and are allowed to declare
alternate shapes.
3. Migration (deterministic part)
For each violating package:
Rename the interface to
Service.-type Sender interface { +type Service interface { Send(to, body string) error }Rename the dependency struct to
Deps. Usestruct{}if there are no dependencies yet — the bootstrap template emits<pkg>.New(<pkg>.Deps{})either way.-type Config struct{} +type Deps struct{}Rename the constructor to
New(Deps) (Service, error)(canonical two-result form) orNew(Deps) Service(legacy single-result form, still accepted by the lint). Pointer receivers for the parameter are intentionally rejected (func New(*Deps) Service) — the bootstrap template emits a value, not a pointer.Canonical two-result form (matches the
forge add packagescaffold):-func NewSender(_ Config) Sender { return &svc{} } +func New(_ Deps) (Service, error) { return &svc{}, nil }Legacy single-result form (still lint-clean, but disables the
contract_test.goauto-scaffold — you own that file by hand):-func NewSender(_ Config) Sender { return &svc{} } +func New(_ Deps) Service { return &svc{} }Update call-sites in user code (typically
pkg/app/setup.goor tests) that referenced the old names. The bootstrap template itself is regenerated byforge generateand doesn't need hand-editing.Re-run
forge generate && go build ./....
forge upgrade will run the lint pass automatically and surface the
list of files that need renaming, but the renames themselves are
manual — they touch the interface's method set, which forge can't
safely rewrite.
4. Migration (manual part)
Things that may need attention beyond the mechanical rename:
- Multiple interfaces in one contract.go. The strict naming rule
only requires that ONE interface be named
Service— it's fine to declare additional interfaces alongside (e.g.Service+Cache). The bootstrap codegen wiresServiceonly; other interfaces are for in-package use. The contract scaffold's mock generator handles multi-interface files by emitting one mock per interface. - The package legitimately has multiple peers. If your package
exposes two equally-important interfaces (e.g.
internal/cachewithReadCacheandWriteCache), pick the one bootstrap should wire asServiceand either rename the second to a name unrelated toServiceor split into two packages. - The package isn't bootstrap-managed. Packages that ship via
contracts.exclude(analyzer sub-packages, embed wrappers, the cli itself) can keep alternate names — the check skips excluded paths. If you add a new analyzer-style package, add it tocontracts.excludeinforge.yaml.
5. Verification
forge lint --conventions # must report ✓
forge generate # must run to completion
go build ./... # must compile clean
go test ./... # no contract-related test should regress
6. Rollback
Not really applicable — the broken state (non-canonical names) was
always wrong. There's no version where a Sender/Config/NewSender
contract produced a working project; the lint just makes the existing
failure mode loud and early.
If you must temporarily silence the lint to land an unrelated change,
add the package's path to contracts.exclude in forge.yaml. That
opt-out is permanent for analyzer-style packages and a temporary
band-aid for everything else — the package's bootstrap entry will
still be broken until the rename happens.