Interface Best Practices
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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) beatManagerandService. - 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
anyin your core types.fmtandencoding/jsonhave reasons; your till does not. - Do not invent
Ticketerin the tickets package so that “someone might mock it.” Mock (or stub) at the consumer.
Try this
- In
send_kitchen.go, add aNotetype and changesendto takeLabeleronly after both types exist. That is the right moment, not the first commit. - In
interface_check.go, changePrintLabelto take an argumentfunc (t *Ticket) PrintLabel(p string) string. Observe the exact compiler error from thevar _ Printercheck. - In
notify_fake.go, add a second calldesk.PlaceOrder("tea")and assertlen(fake.Messages) == 2. - Move
type Labelerinannounce.goto sit directly aboveannounceif you placed it elsewhere. The name should live with the function that needs it. - In
too_soon.go, returnTicketfromNewSender(rename itNewTicket). Printt.Tableinmain.