Interface Best Practices

Updated

September 13, 2026

Interface Best Practices

Interfaces belong to the consumer — the function that calls the methods — not to the type that happens to have them. The boring default is a concrete struct until a second implementation appears. any (interface{}) is not a junk drawer for the desk.

Mental model

A package that stores tickets should export Ticket, not Ticketer. The kitchen package that prints labels can declare:

type Labeler interface {
    Label() string
}

next to announce, because announce is what needs Label. Ticket in another file grows a Label method and satisfies Labeler without importing the kitchen.

Premature interfaces (one implementation, a constructor that returns the interface, a file named interfaces.go) make the code harder to read and harder to change. Empty interface (any) throws the type checker away. Use a real type. When many types need the same function, part 09 (generics) is the tool — not func f(v any).

Worked examples

Case 1: Concrete first

Save as send_kitchen.go. One type, one function, no interface. This is the whole design until a second label shape exists.

// send_kitchen.go
package main

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

func (t Ticket) Label() string {
    return fmt.Sprintf("ticket %d → table %d", t.ID, t.Table)
}

func send(t Ticket) {
    fmt.Println("kitchen:", t.Label())
}

func main() {
    send(Ticket{ID: 41, Table: 12})
}

Run:

go run send_kitchen.go

Output:

kitchen: ticket 41 → table 12

If you feel the urge to extract KitchenService now, wait. A second type is a better reason than a slogan.

Case 2: Interface at the consumer, when there are two types

Save as announce.go. Labeler sits next to announce, which is the consumer. Ticket and Note do not import it; they just have Label().

// announce.go
package main

import "fmt"

type Labeler interface {
    Label() string
}

type Ticket struct {
    ID    int
    Table int
}

func (t Ticket) Label() string {
    return fmt.Sprintf("ticket %d → table %d", t.ID, t.Table)
}

type Note struct {
    Text string
}

func (n Note) Label() string { return n.Text }

func announce(items []Labeler) {
    for _, it := range items {
        fmt.Println("up:", it.Label())
    }
}

func main() {
    announce([]Labeler{
        Ticket{ID: 41, Table: 12},
        Note{Text: "86 soup"},
    })
}

Run:

go run announce.go

Output:

up: ticket 41 → table 12
up: 86 soup

The slice type is []Labeler because announce needs to range mixed values. That is a real consumer need. A []Ticket would have been enough for Case 1.

Case 3: any erases the desk

Save as print_any.go. It compiles. The function cannot add cents, cannot call Label, cannot tell a table from a soup. You pushed the type check to every caller, or into a type assertion (next chapter).

// print_any.go
package main

import "fmt"

func printAny(v any) {
    fmt.Println(v)
}

func main() {
    printAny(12)
    printAny("soup")
    printAny([]int{1, 2})
}

Run:

go run print_any.go

Output:

12
soup
[1 2]

fmt.Println itself takes ...any because printing truly is “whatever.” Your order total is not “whatever.” Write func add(cents int) or, later, a generic func Sum[T int | int64](xs []T) T. Do not store tickets in []any.

Case 4: Compile-time interface verification

Save as interface_check.go. If a struct is intended to implement an interface, how do you prevent someone from renaming a method and breaking callers silently? You declare a blank identifier assignment: var _ Interface = (*ConcreteType)(nil).

// interface_check.go
package main

import "fmt"

type Printer interface {
    PrintLabel() string
}

type Ticket struct {
    ID int
}

func (t *Ticket) PrintLabel() string {
    return fmt.Sprintf("ticket #%d", t.ID)
}

// Compile-time check: ensures *Ticket satisfies Printer at build time.
var _ Printer = (*Ticket)(nil)

func main() {
    t := &Ticket{ID: 42}
    fmt.Println(t.PrintLabel())
}

Run:

go run interface_check.go

Output:

ticket #42

This line costs zero memory and runs zero instructions at runtime. If you later change PrintLabel() to PrintLabel(prefix string), the compiler refuses to build immediately on the line where _ Printer = (*Ticket)(nil) sits.

Case 5: Consumer-side fakes for testing

Save as notify_fake.go. The caller defines what it requires (Notifier). In production, this might send network notifications. In tests, you implement a lightweight 5-line slice recorder right where the test lives.

// notify_fake.go
package main

import "fmt"

type Notifier interface {
    Notify(msg string)
}

type DeskService struct {
    notifier Notifier
}

func (s *DeskService) PlaceOrder(item string) {
    s.notifier.Notify("order placed: " + item)
}

// In unit tests, a fake recorder implementation:
type FakeNotifier struct {
    Messages []string
}

func (f *FakeNotifier) Notify(msg string) {
    f.Messages = append(f.Messages, msg)
}

func main() {
    fake := &FakeNotifier{}
    desk := &DeskService{notifier: fake}

    desk.PlaceOrder("toast")
    fmt.Printf("recorded %d notification: %s\n", len(fake.Messages), fake.Messages[0])
}

Run:

go run notify_fake.go

Output:

recorded 1 notification: order placed: toast

No heavyweight mocking framework, code generators, or dynamic proxies required.

The trap

An interface with one implementation, defined next to that implementation, is a costume. It looks like flexibility. It is a detour every time you need a field.

Save as too_soon.go:

// too_soon.go
package main

import "fmt"

type Sender interface {
    Send()
}

type Ticket struct {
    ID    int
    Table int
}

func (t Ticket) Send() {
    fmt.Printf("kitchen: ticket %d → table %d\n", t.ID, t.Table)
}

func NewSender(id, table int) Sender {
    return Ticket{ID: id, Table: table}
}

func main() {
    s := NewSender(41, 12)
    s.Send()
}

Run:

go run too_soon.go

Output:

kitchen: ticket 41 → table 12

s.ID does not compile. Tests cannot set Table without a type assertion. The boring version is Case 1: func NewTicket(id, table int) Ticket and send(t Ticket).

When a test needs a fake, define a small interface in the test’s package or next to the function under test — still the consumer — not on Ticket.

The boring rule

  • Concrete structs until a second implementation is real.
  • Declare interfaces where they are used, with the methods that are used.
  • One-method interfaces named after the method (Labeler, Payer, Reader) beat Manager and Service.
  • Use compile-time checks (var _ Interface = (*Concrete)(nil)) when exporting a type meant to satisfy a contract.
  • Keep test fakes tiny and local to the test. Do not import heavy mocking libraries.
  • Do not put any in your core types. fmt and encoding/json have reasons; your till does not.
  • Do not invent Ticketer in the tickets package so that “someone might mock it.” Mock (or stub) at the consumer.

Try this

  1. In send_kitchen.go, add a Note type and change send to take Labeler only after both types exist. That is the right moment, not the first commit.
  2. In interface_check.go, change PrintLabel to take an argument func (t *Ticket) PrintLabel(p string) string. Observe the exact compiler error from the var _ Printer check.
  3. In notify_fake.go, add a second call desk.PlaceOrder("tea") and assert len(fake.Messages) == 2.
  4. Move type Labeler in announce.go to sit directly above announce if you placed it elsewhere. The name should live with the function that needs it.
  5. In too_soon.go, return Ticket from NewSender (rename it NewTicket). Print t.Table in main.