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
QueueStorecomes 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.