When I first started writing Go, I treated interfaces the way I’d used them in Java — big,
feature-heavy contracts that modeled entire subsystems. A PaymentService interface with
twelve methods. A UserRepository with every CRUD operation under the sun.
It worked. The compiler was happy. But something felt off.
The moment it clicked
I was writing tests for a reconciliation pipeline at PLN Icon Plus. The pipeline needed to
fetch transaction data from multiple PSPs, compare settlement amounts, and flag discrepancies.
My initial design had a PSPClient interface:
type PSPClient interface {
FetchTransactions(ctx context.Context, filter TransactionFilter) ([]Transaction, error)
FetchSettlement(ctx context.Context, date time.Time) (Settlement, error)
Reconcile(ctx context.Context, txns []Transaction) (ReconciliationResult, error)
// ... eight more methods
}
Testing this was a nightmare. Every mock needed all twelve methods, even when a test only cared about one.
Then a colleague pointed me to the Go proverb: “The bigger the interface, the weaker the abstraction.”
Interface segregation, Go-style
I refactored. Instead of one monolithic interface, I defined small, focused ones at the point of use:
type TransactionFetcher interface {
FetchTransactions(ctx context.Context, filter TransactionFilter) ([]Transaction, error)
}
type SettlementFetcher interface {
FetchSettlement(ctx context.Context, date time.Time) (Settlement, error)
}
Now each function accepted only what it needed:
func ReconcilePSP(
fetcher TransactionFetcher,
settlement SettlementFetcher,
txns []Transaction,
) (ReconciliationResult, error) {
// ...
}
The immediate payoff was in testing. Mocking became trivial — define only the methods you need. No more twelve-method stubs:
type mockFetcher struct {
txns []Transaction
err error
}
func (m *mockFetcher) FetchTransactions(ctx context.Context, _ TransactionFilter) ([]Transaction, error) {
return m.txns, m.err
}
Accept interfaces, return structs
This rule changed how I think about package boundaries. My packages now accept interfaces as parameters — making them flexible and testable — but return concrete types. The caller knows exactly what it gets, no guessing about which interface methods are actually implemented.
// Good: function is flexible, testable
func ProcessPayment(gateway PaymentGateway, order Order) error { ... }
// Also good: caller knows exactly what it's working with
func NewMidtransGateway(config Config) *MidtransGateway { ... }
The real benefit
The biggest win isn’t testability — it’s clarity. When every function declares its dependencies as minimal interfaces, the code reads like documentation. You can look at a function signature and immediately understand what it needs from the outside world.
No digging through implementations. No scrolling through twelve-method interfaces to find which one is actually called. Just a clean, honest declaration of dependencies.
This is the Go way. Not because it’s clever, but because it’s simple.