Designing with Interfaces

Updated

September 13, 2026

Designing with Interfaces

An interface is a set of methods. A type satisfies it by having those methods — no implements keyword. The boring default is a small interface at the point of use: one or two methods, a function that accepts the interface and returns a concrete struct.

Mental model

type Payer interface {
    Pay(cents int) string
}

Anything with Pay(int) string is a Payer. Card does not mention Payer. That is implicit satisfaction. Adding a method to Card does not break anyone; adding a method to Payer does, because every existing implementation must grow.

io.Reader is the standard-library version of this idea: one method, Read([]byte) (int, error). strings.NewReader and bytes.Buffer both satisfy it. Your function takes io.Reader and does not care which.

A nil interface is a later chapter. Here: pass real values, keep interfaces small, return the type you actually built.

Worked examples

Case 1: Two types, one method, no registration

Save as take_pay.go. take prints whatever Pay returns. Card and Tab never name Payer.

// take_pay.go
package main

import "fmt"

type Payer interface {
    Pay(cents int) string
}

type Card struct{ Last4 string }

func (c Card) Pay(cents int) string {
    return fmt.Sprintf("card %s: %d cents", c.Last4, cents)
}

type Tab struct{ Table int }

func (t Tab) Pay(cents int) string {
    return fmt.Sprintf("table %d tab: %d cents", t.Table, cents)
}

func take(p Payer, cents int) {
    fmt.Println(p.Pay(cents))
}

func main() {
    take(Card{Last4: "4242"}, 450)
    take(Tab{Table: 12}, 180)
}

Run:

go run take_pay.go

Output:

card 4242: 450 cents
table 12 tab: 180 cents

The interface earned its keep because two implementations exist. One implementation is a function that takes Card.

Case 2: io.Reader with a string and a buffer

Save as slurp.go. slurp reads any io.Reader. strings.NewReader holds a string. bytes.Buffer is a growable buffer that also implements Read.

// slurp.go
package main

import (
    "bytes"
    "fmt"
    "io"
    "strings"
)

func slurp(r io.Reader) (string, error) {
    b, err := io.ReadAll(r)
    if err != nil {
        return "", err
    }
    return string(b), nil
}

func main() {
    note, err := slurp(strings.NewReader("hold the onions"))
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    fmt.Println(note)

    var buf bytes.Buffer
    buf.WriteString("two coffees")
    drink, err := slurp(&buf)
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    fmt.Println(drink)
}

Run:

go run slurp.go

Output:

hold the onions
two coffees

slurp does not import a file type. Tests can pass a strings.NewReader. Production can pass an os.File. That is the whole payoff of a one-method interface.

bytes.Buffer’s Read has a pointer receiver, so you pass &buf. strings.NewReader already returns a pointer.

Case 3: Accept an interface, return a struct

Save as new_ticket.go. The input can be any reader. The output is a Ticket you can field-select, JSON-encode, and store. Callers do not get a NoteSource interface they have to type-assert.

// new_ticket.go
package main

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

type Ticket struct {
    ID   int
    Note string
}

func NewTicket(id int, r io.Reader) (Ticket, error) {
    b, err := io.ReadAll(r)
    if err != nil {
        return Ticket{}, err
    }
    return Ticket{ID: id, Note: string(b)}, nil
}

func main() {
    t, err := NewTicket(41, strings.NewReader("soup"))
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    fmt.Printf("ticket %d: %s\n", t.ID, t.Note)
}

Run:

go run new_ticket.go

Output:

ticket 41: soup

NewTicket would be weaker as func NewTicket(...) (io.Reader, error) or func NewTicket(...) (Payer, error). You built a ticket. Return a ticket.

The trap

Returning an interface from a constructor that only ever builds one type hides the fields and forces every caller into the interface, including tests that wanted Last4.

Save as new_payer.go:

// new_payer.go
package main

import "fmt"

type Payer interface {
    Pay(cents int) string
}

type Card struct{ Last4 string }

func (c Card) Pay(cents int) string {
    return fmt.Sprintf("card %s: %d cents", c.Last4, cents)
}

func newPayer() Payer {
    return Card{Last4: "4242"}
}

func main() {
    p := newPayer()
    fmt.Println(p.Pay(450))
}

Run:

go run new_payer.go

Output:

card 4242: 450 cents

It works. p.Last4 does not compile. The fix is func newCard() Card (or *Card if you need a pointer). Let callers store it in a Payer if they need to.

The boring rule

  • Define interfaces with the methods you call, not the methods you might one day call.
  • Two real implementations (or a test double you cannot avoid) justify an interface. One does not.
  • Accept interfaces, return structs.
  • Steal small shapes from the standard library (io.Reader, io.Writer, fmt.Stringer) instead of inventing TicketReader.
  • Pointer vs value receivers still decide the method set. Pass &buf when Read is on *bytes.Buffer.

Try this

  1. Add a Cash type to take_pay.go with Pay that prints cash: N cents. Call take(Cash{}, 90).
  2. In slurp.go, pass os.Stdin instead of the string reader and run echo extra napkins | go run slurp.go. You will need import "os".
  3. Change NewTicket to return *Ticket. Keep the io.Reader parameter. Confirm t.Note still works.
  4. In new_payer.go, change newPayer to return Card. Print p.Last4 in main.