Interfaces in Go: Small, Implicit, and Done Right

1. Overview

Go interfaces differ from Java or C# in one fundamental way: there’s no implements keyword. A type automatically satisfies an interface simply by having all the required methods. This leads to a distinctive design pattern: small interfaces, declared where they’re used, not where they’re implemented.

After reading this, you’ll know how to write interfaces the Go way, use them for testing without mock libraries, and avoid common pitfalls like the “nil but not nil” bug.

2. Implicit Interface Satisfaction

Let’s start with an interface that sends notifications to customers:

package main

import "fmt"

type Notifier interface {
	Notify(customer, message string) error
}

type EmailNotifier struct {
	From string
}

func (e EmailNotifier) Notify(customer, message string) error {
	fmt.Printf("[email from %s] to %s: %s\n", e.From, customer, message)
	return nil
}

type SMSNotifier struct{}

func (SMSNotifier) Notify(customer, message string) error {
	fmt.Printf("[sms] to %s: %s\n", customer, message)
	return nil
}

func main() {
	notifiers := []Notifier{
		EmailNotifier{From: "shop@nonla.dev"},
		SMSNotifier{},
	}
	for _, n := range notifiers {
		_ = n.Notify("An", "Order #42 has been confirmed")
	}
}

Output:

[email from shop@nonla.dev] to An: Order #42 has been confirmed
[sms] to An: Order #42 has been confirmed

Notice that EmailNotifier and SMSNotifier never mention Notifier. The compiler checks: any type with a Notify(string, string) error method can be assigned to a Notifier variable.

A key consequence is that the implementation side doesn’t need to know the interface exists. You can declare a new interface for an existing type from the standard library or a third-party library without modifying their code.

3. Keep Interfaces Small

Go’s standard library is the best example. io.Reader and io.Writer each have exactly one method:

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

Because it’s so minimal, many types satisfy it: *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… A function accepting io.Writer works with all of them:

package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

type Order struct {
	ID       int
	Customer string
	Total    int64
}

func WriteReceipt(w io.Writer, o Order) error {
	_, err := fmt.Fprintf(w, "Order #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
	return err
}

func main() {
	order := Order{ID: 42, Customer: "An", Total: 350000}

	_ = WriteReceipt(os.Stdout, order) // print to terminal

	var sb strings.Builder
	_ = WriteReceipt(&sb, order) // write to memory
	fmt.Printf("Receipt length: %d bytes\n", sb.Len())
}
Order #42 - An - 350000 VND
Receipt length: 26 bytes

WriteReceipt only needs the ability to write bytes, so it asks for exactly that. To write to a file, HTTP response, or compressed file, you don’t need to modify the function.

When you need multiple behaviors, compose small interfaces instead of writing one large interface from the start:

type OrderFinder interface {
	FindOrder(id int) (Order, error)
}

type OrderSaver interface {
	SaveOrder(o Order) error
}

type OrderStore interface {
	OrderFinder
	OrderSaver
}

A function displaying an order accepts only OrderFinder. A function updating status accepts OrderStore. Each function declares clearly what it needs, and writing fakes for testing is much lighter.

4. Declare Interfaces at the Point of Use

In Java, you’d typically write PaymentGateway and then StripePaymentGateway implements PaymentGateway, both side by side. In Go, it’s the opposite: the implementation package returns a concrete struct, and the using package declares the interface it needs.

Suppose the payment package provides a client with many methods:

// package payment
type Client struct {
	apiKey string
}

func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }

func (c *Client) Charge(customer string, amount int64) (string, error) {
	return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}

func (c *Client) Refund(txnID string) error         { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }

The checkout package only needs to charge, so it declares a single-method interface:

// package checkout
var ErrInvalidTotal = errors.New("order total must be greater than 0")

type PaymentGateway interface {
	Charge(customer string, amount int64) (string, error)
}

type Service struct {
	gateway PaymentGateway
}

func NewService(gw PaymentGateway) *Service {
	return &Service{gateway: gw}
}

func (s *Service) Checkout(o Order) (string, error) {
	if o.Total <= 0 {
		return "", ErrInvalidTotal
	}
	txnID, err := s.gateway.Charge(o.Customer, o.Total)
	if err != nil {
		return "", fmt.Errorf("payment for order #%d: %w", o.ID, err)
	}
	return txnID, nil
}

When initializing in main:

svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>

*payment.Client implicitly satisfies checkout.PaymentGateway. The payment package doesn’t import checkout and doesn’t even need to know it exists.

This principle is often summarized as “accept interfaces, return structs”: functions accept interfaces for flexibility, but return concrete types so callers can use any method and field.

5. Testing with Fakes

Small interfaces at the point of use make testing very clean. You only need to write a struct with exactly one method:

package checkout

import (
	"errors"
	"testing"
)

type fakeGateway struct {
	charged []int64
	err     error
}

func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
	if f.err != nil {
		return "", f.err
	}
	f.charged = append(f.charged, amount)
	return "txn-test", nil
}

func TestCheckout_ChargesOrderTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if txn != "txn-test" {
		t.Errorf("txn = %q, want %q", txn, "txn-test")
	}
	if len(gw.charged) != 1 || gw.charged[0] != 200000 {
		t.Errorf("charged = %v, want [200000]", gw.charged)
	}
}

func TestCheckout_RejectsZeroTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})

	if !errors.Is(err, ErrInvalidTotal) {
		t.Fatalf("err = %v, want ErrInvalidTotal", err)
	}
	if len(gw.charged) != 0 {
		t.Errorf("Charge should not be called, but charged = %v", gw.charged)
	}
}

func TestCheckout_WrapsGatewayError(t *testing.T) {
	gwErr := errors.New("card declined")
	svc := NewService(&fakeGateway{err: gwErr})

	_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})

	if !errors.Is(err, gwErr) {
		t.Fatalf("err = %v, want to wrap %v", err, gwErr)
	}
}

Run tests:

go test ./checkout -v
=== RUN   TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN   TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN   TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS

If PaymentGateway had ten methods, the fake would need all ten too. That’s the clearest sign an interface is too large.

6. Type Assertions, Type Switches, and Compile-Time Verification

6.1. Optional Interfaces Using Type Assertions

Sometimes you want to use an extra capability if the concrete type supports it. Go uses the two-value form of type assertion:

type Refunder interface {
	Refund(txnID string) error
}

func (s *Service) Cancel(txnID string) error {
	r, ok := s.gateway.(Refunder)
	if !ok {
		return errors.New("payment gateway does not support refunds")
	}
	return r.Refund(txnID)
}

With *payment.Client, ok is true because it has a Refund method. With fakeGateway, ok is false and the function returns an error instead of panicking. The standard library uses this technique extensively—for example, io.Copy checks whether a parameter is io.WriterTo to choose a faster path.

Note: The single-value form r := s.gateway.(Refunder) will panic if the type doesn’t match. Always use the r, ok := ... form unless you’re absolutely certain.

6.2. Type Switches

When you need to handle multiple different types, use a type switch:

func describe(n Notifier) string {
	switch v := n.(type) {
	case EmailNotifier:
		return "email from " + v.From
	case SMSNotifier:
		return "sms"
	default:
		return fmt.Sprintf("unknown: %T", v)
	}
}

If you find yourself writing type switches on business types in many places, you might be missing a method in the interface.

6.3. Ensuring Interface Satisfaction at Compile Time

Because satisfaction is implicit, if someone changes a method signature, the error only appears at assignment. To catch errors early in the implementation file, add this line:

var _ checkout.PaymentGateway = (*payment.Client)(nil)

This line creates no value at runtime. It only forces the compiler to check that *payment.Client satisfies PaymentGateway. Use it only when the implementation package truly wants to commit to the interface. Otherwise, it creates an unnecessary backwards import.

7. Common Mistakes

7.1. Interfaces Containing Nil Pointers Are Not Nil

This is Go’s most classic mistake:

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string {
	return "field invalid: " + e.Field
}

func validate(o Order) error {
	var verr *ValidationError
	if o.Total <= 0 {
		verr = &ValidationError{Field: "Total"}
	}
	return verr // WRONG
}

func main() {
	err := validate(Order{Total: 100})
	fmt.Println(err == nil)
}
false

An interface value has two parts: type and value. An interface is only nil when both are empty. Here, the type is *ValidationError while the value is a nil pointer, so err != nil. The fix is to explicitly return nil:

func validate(o Order) error {
	if o.Total <= 0 {
		return &ValidationError{Field: "Total"}
	}
	return nil
}

7.2. Pointer Receivers and Method Sets

type AuditNotifier struct{ count int }

func (a *AuditNotifier) Notify(customer, message string) error {
	a.count++
	return nil
}

var n Notifier = AuditNotifier{} // compile error
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)

Methods with pointer receivers only belong to the method set of *AuditNotifier. You need to assign &AuditNotifier{}. Conversely, methods with value receivers work for both values and pointers.

7.3. Writing Interfaces Too Early

Code with Java habits often looks like this:

type OrderService interface {
	Create(o Order) error
	Cancel(id int) error
	Find(id int) (Order, error)
	// … 10 more methods
}

type orderServiceImpl struct{ /* … */ }

func NewOrderService() OrderService { return &orderServiceImpl{} }

This has three problems. The interface has only one implementation. It lives on the implementation side instead of the usage side. The constructor returns an interface, so callers lose access to the concrete type. Instead, return *OrderService as a struct, and let each using package declare the small interface it actually needs to replace—like for testing.

7.4. Overusing any

any (alias for interface{}) accepts any type, so the compiler can’t help anymore. From Go 1.18 onwards, if you need a function to work on multiple types, consider generics first:

func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
	var sum int64
	for _, it := range items {
		sum += it.GetTotal()
	}
	return sum
}

Only use any when the data truly doesn’t have a known type upfront, like when unmarshaling arbitrary JSON.

8. Conclusion

Interfaces in Go are powerful precisely because they’re simple. Key points to remember:

  • Types implicitly satisfy interfaces, no implements keyword needed.
  • Interfaces should be small, ideally one or two methods. When you need more, compose small interfaces.
  • Declare interfaces where they’re used. Functions accept interfaces, constructors return structs.
  • Small interfaces make hand-written fakes easy, no mock library needed.
  • Watch out for interfaces holding nil pointers and pointer receiver method sets.

Next time you’re about to write an interface, ask yourself: “Who uses it, and what methods do they really need?” The answer is usually fewer than you think.

golanginterfacedesigntesting