Go interfaces: accept interfaces, return structs — and when not to

During a code review on ClaudeGate, a colleague asked me: "why isn't your Store interface in the consumer package?" He was citing the Go convention "accept interfaces, return structs". He was right about the convention — and wrong about applying it in this specific case.

It's one of those situations where knowing the rule isn't enough. You also need to understand why it exists to know when to ignore it without guilt.

The convention explained from scratch

In Java or C#, when you write a class, you explicitly declare the interfaces it implements. The producer decides: class SQLiteStore implements Store. The interface typically lives in the same package as the implementation.

Go works the other way around. Interface implementation is implicit: if a type has the right methods, it satisfies the interface — no declaration, no implements keyword. And the convention that follows from this is that it's the consumer that defines the interface it needs, not the producer.

// ❌ Java-style: the producer declares the interface
// package storage
type Store interface {
    Create(ctx context.Context, j *Job) error
    Get(ctx context.Context, id string) (*Job, error)
    List(ctx context.Context, limit, offset int) ([]*Job, int, error)
    Delete(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    MarkProcessing(ctx context.Context, id string) error
    ResetProcessing(ctx context.Context) ([]string, error)
    Count(ctx context.Context) (int, error)
}

type SQLiteStore struct{ db *sql.DB }
func (s *SQLiteStore) Create(...) error { ... }
// etc.
// ✅ Go-style: each consumer declares what it needs

// package api — needs the full API
type JobStore interface {
    Create(ctx context.Context, j *Job) error
    Get(ctx context.Context, id string) (*Job, error)
    List(ctx context.Context, limit, offset int) ([]*Job, int, error)
    Delete(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    MarkProcessing(ctx context.Context, id string) error
}

// package queue — needs less
type QueueStore interface {
    Get(ctx context.Context, id string) (*Job, error)
    MarkProcessing(ctx context.Context, id string) error
    UpdateStatus(ctx context.Context, id, status, result, errMsg string) error
    ResetProcessing(ctx context.Context) ([]string, error)
}

SQLiteStore silently implements both interfaces — no declaration, no explicit coupling between packages. That's the power of Go's type system.

When this approach delivers real value

If packages api and queue have genuinely different needs, splitting into two smaller interfaces delivers three concrete things.

First, lighter mocks. In queue package tests, the mock only needs to implement 4 methods instead of 8. Less noise, less maintenance.

Then, dependency segregation. Each package depends only on what it actually uses — that's the Interface Segregation Principle applied naturally. A change to a method only used by api doesn't force queue to rebuild its mocks.

Finally, easier substitution. If tomorrow you want two different concrete implementations — SQLite for tests, Redis for the queue in production — the contracts are already separated. No architectural surgery needed.

Why we didn't do it in ClaudeGate

ClaudeGate has a single concrete implementation: SQLiteStore. Just one. The Store interface lives in the job package — the central domain package of the project. It's readable, it's natural, any developer opening the code immediately knows where to look.

Splitting into two interfaces across two different packages would have brought:

  • More files to navigate
  • Duplicated method signatures
  • A first-time reader wondering where QueueStore comes from

For zero benefit. The only mock that would exist would be in tests — and with a single implementation, integration tests against SQLite in-memory (:memory:) are more honest than a mock anyway.

The current code is correct as-is. Applying the convention to the letter would have been over-engineering in the name of a rule.

The practical rule

After a few Go projects, I've converged on a simple two-branch rule.

Move interfaces to consumers when:

  • 2+ concrete implementations exist (or are clearly planned in the near term)
  • The global interface is too large and consumers only use a fraction of it
  • Two consumers have sufficiently different needs to justify separate contracts

Keep the interface in the domain package when:

  • A single implementation, a single real use case
  • The code is readable as-is and interfaces would only add indirection
  • The project is still young — the real splitting needs haven't emerged yet

What Go's canonical interfaces teach us

The Go standard library is built around small, one- or two-method interfaces. The canonical example:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

One behavior per interface. os.File, bytes.Buffer, net.Conn, strings.Reader all satisfy io.Reader implicitly — with no coordination between their respective packages. Maximum decoupling.

But watch out for the wrong reading of this example: io.Reader is that small because it models a fundamental and universal behavior, not because we mechanically split a larger interface. The size follows from the real need, not from a style rule.

Composition is the natural next step:

// The stdlib composes its small interfaces when needed
type ReadWriter interface {
    Reader
    Writer
}

// And the consumer declares exactly what it needs
func compress(r io.Reader, w io.Writer) error {
    // r can be a file, a buffer, an HTTP response...
    // w can be a file, a network buffer...
    // This function knows nothing about their concrete type.
}

Conclusion

"Accept interfaces, return structs" is a good rule — but it's a heuristic, not a dogma. It pushes toward a design where consumers define their needs rather than being forced to accept whatever the producer decided. That's healthy.

What isn't healthy is applying the rule mechanically without asking what it actually delivers in the specific context. Splitting an interface in two for a single concrete implementation adds complexity without added value — exactly what Go tries to discourage.

The real enemy isn't a poorly placed interface. It's over-engineering in the name of a convention. The right interface is the smallest one that meets the real need of the consumer — not the one most compliant with a rule you read in a blog post.

Comments (0)