Accept Interfaces, Return Structs: 5 Patterns From a Go CLI

go dev.to

"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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode
type Digester interface {
    Digest(components []string, sep byte) kernel.Digest
}

type Verifier interface {
    Verify(data []byte, sig kernel.Signature) error
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)),
}
Enter fullscreen mode Exit fullscreen mode

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)),
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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", ...)
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials