Thinking in Interfaces: Go's Most Underrated Superpower

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.