Designing with Interfaces
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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 inventingTicketReader. - Pointer vs value receivers still decide the method set. Pass
&bufwhenReadis on*bytes.Buffer.
Try this
- Add a
Cashtype totake_pay.gowithPaythat printscash: N cents. Calltake(Cash{}, 90). - In
slurp.go, passos.Stdininstead of the string reader and runecho extra napkins | go run slurp.go. You will needimport "os". - Change
NewTicketto return*Ticket. Keep theio.Readerparameter. Confirmt.Notestill works. - In
new_payer.go, changenewPayerto returnCard. Printp.Last4inmain.