"Accept interfaces, return structs" is the most quoted Go proverb and the least applied. Most Go codebases do the opposite: they define interfaces at the implementation site, accept concrete types, and return interfaces.
Over 60 refactorings, I applied this proverb in 5 distinct ways. Each one solved a different coupling problem, but they all followed the same rule: the consumer defines the interface, the producer returns the concrete type.
Pattern 1: Decouple a Component from Its Container
The Problem
Evaluation strategies had a back-pointer to the concrete Runner:
// BEFORE: Strategy depends on the concrete Runner type
type unsafeStateStrategy struct {
runner *Runner // ← concrete dependency
ctl *policy.ControlDefinition
}
func (s *unsafeStateStrategy) Evaluate(t *asset.Timeline, now time.Time) (Row, []*Finding) {
maxUnsafe := s.runner.getMaxUnsafeDurationForControl(s.ctl)
logger := s.runner.Logger
parser := s.runner.PredicateParser
// ...
}
The strategy needed 4 things from the Runner: a max-unsafe duration, a logger, a gap threshold, and a predicate parser. But it depended on the entire Runner type — all 12 fields, all methods, the full dependency graph.
Testing a strategy meant constructing a full Runner with all its dependencies, even though the strategy only used 4 of them.
The Fix
Define a narrow interface at the consumer (the strategy), not at the producer (the Runner):
// AFTER: Strategy depends on a 4-method interface it defines
type strategyDeps interface {
slaThresholdFor(ctl *policy.ControlDefinition) time.Duration
continuityLimit() time.Duration
logger() *slog.Logger
predicateParser() policy.PredicateParser
}
type unsafeStateStrategy struct {
deps strategyDeps // ← interface, not concrete type
ctl *policy.ControlDefinition
}
func (s *unsafeStateStrategy) Evaluate(t *asset.ExposureLifecycle, now time.Time) (ResourceCheck, []*Finding) {
maxUnsafe := s.deps.slaThresholdFor(s.ctl)
// ...
}
The Runner (now Assessor) satisfies the interface implicitly — no implements keyword:
// Runner/Assessor satisfies strategyDeps without declaring it
func (a *Assessor) slaThresholdFor(ctl *policy.ControlDefinition) time.Duration {
return ctl.EffectiveMaxUnsafeDuration(a.SLAThreshold)
}
func (a *Assessor) continuityLimit() time.Duration { return a.ContinuityLimit }
func (a *Assessor) logger() *slog.Logger { return a.Logger }
func (a *Assessor) predicateParser() policy.PredicateParser { return a.PredicateParser }
Testing a strategy now requires a 4-method mock, not a 12-field constructor:
// Test: mock only what the strategy needs
type mockDeps struct{}
func (m mockDeps) slaThresholdFor(_ *policy.ControlDefinition) time.Duration { return 24 * time.Hour }
func (m mockDeps) continuityLimit() time.Duration { return 12 * time.Hour }
func (m mockDeps) logger() *slog.Logger { return slog.Default() }
func (m mockDeps) predicateParser() policy.PredicateParser { return nil }
After the refactor, strategies went from untestable (coupled to Runner) to independently testable.
Pattern 2: Slim a Fat Interface
The Problem
The Control interface had 8 methods:
// BEFORE: Fat interface — every implementor must provide 8 methods
type Control interface {
ID() kernel.ControlID
Description() string
Severity() Severity
ComplianceProfiles() []string
ComplianceRefs() map[string]string
ProfileRationale(profile string) string
ProfileSeverityOverride(profile string) Severity
Evaluate(snap asset.Snapshot) Result
}
Every control implementation had to provide 7 getter methods that all did the same thing: return a field from a Definition struct. The getters were boilerplate — 14 lines per control, identical across all 14 controls.
The Fix
Slim to 2 methods. Return a struct for the metadata:
// AFTER: 2-method interface — return struct for metadata
type Control interface {
Def() Definition // ← returns struct, not 7 methods
Evaluate(snap asset.Snapshot) Result
}
The Definition struct carries all the metadata:
type Definition struct {
id kernel.ControlID
description string
severity Severity
complianceProfiles []string
complianceRefs map[string]string
// ...
}
func (d Definition) ID() kernel.ControlID { return d.id }
func (d Definition) Severity() Severity { return d.severity }
// ... accessor methods on the struct, not the interface
Callers changed from ctrl.ID() to ctrl.Def().ID():
// BEFORE
controlID := ctrl.ID()
severity := ctrl.Severity()
refs := ctrl.ComplianceRefs()
// AFTER
controlID := ctrl.Def().ID()
severity := ctrl.Def().Severity()
refs := ctrl.Def().ComplianceRefs()
One extra .Def() call. But the interface dropped from 8 methods to 2. New controls only implement Evaluate — the metadata comes from the Definition struct built with functional options:
// Construction with functional options — no method boilerplate
type accessBlockPublic struct {
Definition
}
func init() {
ControlRegistry.MustRegister(&accessBlockPublic{
Definition: NewDefinition(
WithID("CTL.S3.CONTROLS.001"),
WithSeverity(Critical),
WithComplianceRef("hipaa", "§164.312(a)(1)"),
),
})
}
func (ctl *accessBlockPublic) Evaluate(snap asset.Snapshot) Result {
// Only the evaluation logic — no getter boilerplate
}
After the refactor — 14 controls lost 7 boilerplate methods each.
Pattern 3: Port Interfaces with Domain-Only Types
The Problem
A port interface in app/contracts imported a type from internal/core/evaluation/remediation:
// BEFORE: Port contaminated with business logic package
package contracts
import "github.com/sufield/stave/internal/core/evaluation/remediation"
type EnrichedResult struct {
Findings []remediation.Finding // ← port imports domain implementation
}
The port (in the app layer) depended on a specific domain package. Adapters that implemented the port had to import remediation even if they only needed the finding's ID and severity. The dependency arrow pointed inward (correct direction) but the coupling was too tight.
The Fix
Define a boundary type in the port package using only types the port already imports:
// AFTER: Port uses its own boundary type
package contracts
import (
"github.com/sufield/stave/internal/core/evaluation"
policy "github.com/sufield/stave/internal/core/controldef"
)
type EnrichedFinding struct {
Finding evaluation.Finding
Remediation policy.RemediationSpec
}
type EnrichedResult struct {
Findings []EnrichedFinding // ← uses only types already in contracts
}
The enrichment pipeline maps at the boundary:
// Boundary conversion in the app layer
func enrich(findings []remediation.Finding) []contracts.EnrichedFinding {
result := make([]contracts.EnrichedFinding, len(findings))
for i, f := range findings {
result[i] = contracts.EnrichedFinding{
Finding: f.Finding,
Remediation: f.RemediationSpec,
}
}
return result
}
Adapters convert back to remediation.Finding only when they need the full type — via local toRemediationFindings helpers.
After the refactor — port boundary no longer imports business logic packages.
Pattern 4: One-Method Interfaces at the Boundary
The Problem
Infrastructure capabilities were injected as concrete function types or large interfaces:
// BEFORE: Bare function type mixed with other concerns
type Runner struct {
Logger *slog.Logger
Clock func() time.Time // ← bare function, no testability signal
Hasher func([]byte) string // ← bare function
Controls []ControlDefinition
MaxUnsafe time.Duration
}
func() time.Time is technically an interface with one method — but it has no name, no documentation, and no discoverable implementations.
The Fix
Named one-method interfaces in a ports package:
// AFTER: Named interfaces with concrete implementations
package ports
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now().UTC() }
type FixedClock time.Time
func (f FixedClock) Now() time.Time { return time.Time(f) }
type Digester interface {
Digest(components []string, sep byte) kernel.Digest
}
type Verifier interface {
Verify(data []byte, sig kernel.Signature) error
}
The consumer declares the dependency as a named interface:
type Assessor struct {
Clock ports.Clock // ← named interface, not func() time.Time
Hasher ports.Digester // ← named interface, not func([]byte) string
}
Testing uses the provided implementations — no mocking framework needed:
// Test: deterministic time
assessor := &Assessor{
Clock: ports.FixedClock(time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)),
}
Each interface has exactly one method. Go's implicit interface satisfaction means any type with a Now() time.Time method satisfies Clock — including FixedClock, RealClock, or any test stub.
Pattern 5: Remove Unnecessary Interface Threading
The Problem
An IdentityGenerator interface was threaded through 4 levels of the call stack:
// BEFORE: Interface threaded through entire chain
func NewPlanner(idGen ports.IdentityGenerator) *Planner { ... }
func NewMapper(idGen ports.IdentityGenerator) *Mapper { ... }
type publicExposurePlanner struct {
idGen ports.IdentityGenerator // ← carried but used once at the end
}
func (p *publicExposurePlanner) Plan(findings []Finding) []RemediationPlan {
for _, f := range findings {
plan := RemediationPlan{
ID: p.idGen.GenerateID("plan", string(f.ControlID), string(f.AssetID)),
// ...
}
}
}
The ID generator was injected into the Planner, carried through the Mapper, and used at one point: generating plan IDs. Four constructors accepted it. Four structs stored it. One call site used it.
The Fix
Generate IDs at the boundary, not inside the domain:
// AFTER: Domain logic is pure — IDs assigned at boundary
func (p *publicExposurePlanner) Plan(findings []Finding) []RemediationPlan {
for _, f := range findings {
plan := RemediationPlan{
ID: "", // ← empty, assigned later at boundary
// ...
}
}
}
// Boundary function assigns IDs after domain logic completes
func AssignPlanIDs(gen ports.IdentityGenerator, plans []RemediationPlan) {
for i := range plans {
plans[i].ID = gen.GenerateID("plan", ...)
}
}
The Planner and Mapper are now pure domain types with no infrastructure dependencies. AssignPlanIDs is called once at the boundary after enrichment. The interface is accepted at the boundary, not threaded through the domain.
After the refactor — removed IdentityGenerator from 4 constructors and 4 struct fields.
The Rules
| Rule | Before | After |
|---|---|---|
| Consumer defines the interface | Strategy imports *Runner
|
Strategy defines strategyDeps (4 methods) |
| Return structs, not interfaces |
Control interface with 8 methods |
Def() returns Definition struct |
| Ports use only domain types | Port imports remediation.Finding
|
Port defines EnrichedFinding boundary type |
| Name your one-method interfaces | func() time.Time |
ports.Clock with Now() time.Time
|
| Don't thread interfaces through domain |
IdentityGenerator in 4 constructors |
AssignPlanIDs at boundary |
How to Find Violations
# Fat interfaces (more than 3 methods)
grep -rn 'type.*interface {' --include='*.go' -A 10 | grep -c 'func\b' | sort -rn
# Concrete type dependencies that should be interfaces
grep -rn '\*Runner\|\*Engine\|\*Service' --include='*.go' | grep 'struct {' | grep -v '_test.go'
# Interface threading (same interface in multiple constructors)
grep -rn 'ports\.' --include='*.go' | grep 'func New' | sort
# Port contamination (ports importing implementation packages)
grep -rn 'import' internal/app/contracts/ --include='*.go' | grep -v 'core/\|kernel\|asset'
The last command finds port packages importing implementation packages — the signal for Pattern 3.
The Payoff
Before these refactorings, testing the evaluation engine required constructing the full Runner with 12 fields. After, testing a strategy requires a 4-method mock. Testing the Planner requires no mocks at all — it's a pure function.
The interface count went down, not up. Fat interfaces were slimmed. Unnecessary interfaces were removed. The remaining interfaces are small (1-4 methods), defined at the consumer, and satisfied implicitly.
That's the Go way: interfaces should be discovered, not designed.
These 5 patterns were applied across 60 refactorings in Stave, an offline configuration safety evaluator.
Top comments (0)