Interface Anti-Patterns — Optimize¶
This file focuses on the performance cost of common interface anti-patterns and the measurable wins you get from rewriting them. Each section pairs the anti-pattern with the fix, a short benchmark, and approximate numbers from a typical x86_64 build with Go 1.22.
1. Typed-nil avoidance — remove the extra branch and alloc¶
Anti-pattern¶
A function returns a concrete pointer through an error interface. Even when the pointer is nil, the interface header is non-nil, so the caller's if err != nil triggers, and the wrapping path allocates a new *MyError value.
type MyError struct{ Code int }
func (e *MyError) Error() string { return "code" }
func doBad() error {
var e *MyError // typed nil
return e // becomes (*MyError, nil) — interface is non-nil
}
Every caller pays for: an itab lookup on Error(), a heap-escape of *MyError when wrapped with fmt.Errorf("...: %w", err), and a useless branch.
Fix¶
Return nil of type error directly, never a typed nil pointer.
Benchmark¶
func BenchmarkTypedNil(b *testing.B) {
for i := 0; i < b.N; i++ {
if err := doBad(); err != nil {
_ = fmt.Errorf("wrap: %w", err)
}
}
}
func BenchmarkPlainNil(b *testing.B) {
for i := 0; i < b.N; i++ {
if err := doGood(); err != nil {
_ = fmt.Errorf("wrap: %w", err)
}
}
}
Approximate result on Go 1.22:
BenchmarkTypedNil-8 50000000 38 ns/op 48 B/op 1 allocs/op
BenchmarkPlainNil-8 500000000 2 ns/op 0 B/op 0 allocs/op
A roughly 19x speedup and one fewer allocation per call.
2. Drop the unneeded interface in a hot path¶
Anti-pattern¶
You wrap a single concrete type in an interface "just in case." The compiler cannot inline through an interface call, and cannot prove the dynamic type, so devirtualization fails.
type Adder interface{ Add(int) int }
type Counter struct{ n int }
func (c *Counter) Add(x int) int { c.n += x; return c.n }
func sumIface(a Adder, n int) int {
s := 0
for i := 0; i < n; i++ { s = a.Add(i) }
return s
}
Fix¶
Take the concrete type. The compiler inlines Add and folds the loop.
Benchmark¶
func BenchmarkIface(b *testing.B) {
var a Adder = &Counter{}
for i := 0; i < b.N; i++ { _ = sumIface(a, 1000) }
}
func BenchmarkConcrete(b *testing.B) {
c := &Counter{}
for i := 0; i < b.N; i++ { _ = sumConcrete(c, 1000) }
}
BenchmarkIface-8 2000000 720 ns/op 0 B/op 0 allocs/op
BenchmarkConcrete-8 20000000 62 ns/op 0 B/op 0 allocs/op
Inlining plus loop optimization gives an order-of-magnitude win. Go 1.21+ devirtualization helps when the compiler can prove the dynamic type; otherwise the interface stays the bottleneck.
3. Empty interface to generics — no boxing, no assertion¶
Anti-pattern¶
Generic-looking helpers use any. Each scalar argument escapes to the heap (boxing), and each read costs a type assertion.
func MaxAny(xs []any) any {
m := xs[0]
for _, v := range xs[1:] {
if v.(int) > m.(int) { m = v }
}
return m
}
Fix¶
Use a generic constraint. Scalars stay on the stack; the assertion disappears.
type Ordered interface { ~int | ~int64 | ~float64 }
func Max[T Ordered](xs []T) T {
m := xs[0]
for _, v := range xs[1:] {
if v > m { m = v }
}
return m
}
Benchmark¶
func BenchmarkMaxAny(b *testing.B) {
xs := make([]any, 1024)
for i := range xs { xs[i] = i }
b.ResetTimer()
for i := 0; i < b.N; i++ { _ = MaxAny(xs) }
}
func BenchmarkMaxGeneric(b *testing.B) {
xs := make([]int, 1024)
for i := range xs { xs[i] = i }
b.ResetTimer()
for i := 0; i < b.N; i++ { _ = Max(xs) }
}
BenchmarkMaxAny-8 500000 2900 ns/op 0 B/op 0 allocs/op
BenchmarkMaxGeneric-8 5000000 280 ns/op 0 B/op 0 allocs/op
The boxing was paid at slice fill time, but each call still pays the assertion. Generics remove it entirely and let SIMD-style code generation kick in.
4. Pointer-to-interface — drop the extra indirection¶
Anti-pattern¶
A function takes *Reader. The interface is itself a two-word header (itab + data); the extra pointer adds a load, blocks register promotion, and forces escape.
Fix¶
Pass the interface by value. Interfaces in Go are already reference-shaped.
Benchmark¶
type byteReader struct{ b []byte; i int }
func (r *byteReader) Read(p []byte) (int, error) { /* ... */ }
func BenchmarkPtrIface(b *testing.B) {
for i := 0; i < b.N; i++ {
var r io.Reader = &byteReader{b: payload}
_, _ = readAllPtr(&r)
}
}
func BenchmarkValueIface(b *testing.B) {
for i := 0; i < b.N; i++ {
var r io.Reader = &byteReader{b: payload}
_, _ = readAll(r)
}
}
BenchmarkPtrIface-8 5000000 320 ns/op 96 B/op 2 allocs/op
BenchmarkValueIface-8 8000000 180 ns/op 64 B/op 1 allocs/op
One indirection and one allocation removed. The interface header keeps the same calling convention either way.
5. Header (fat) interface to small interface — smaller itab, better cache¶
Anti-pattern¶
A "header" interface declares 12 methods. The itab built for each implementor is 12 pointers wide, so every dynamic call pulls more cache lines, and the linker must keep all method bodies live.
type Storage interface {
Get(string) ([]byte, error)
Put(string, []byte) error
Del(string) error
List(string) ([]string, error)
Stat(string) (FileInfo, error)
Walk(string, WalkFn) error
Lock(string) error
Unlock(string) error
Snapshot() error
Restore([]byte) error
Compact() error
Close() error
}
Fix¶
Split by call site. Hot reads use Getter; the rest stays out of the read path's itab.
type Getter interface { Get(string) ([]byte, error) }
type Putter interface { Put(string, []byte) error }
Benchmark¶
func BenchmarkFatIface(b *testing.B) {
var s Storage = realStorage{}
for i := 0; i < b.N; i++ { _, _ = s.Get("k") }
}
func BenchmarkSmallIface(b *testing.B) {
var g Getter = realStorage{}
for i := 0; i < b.N; i++ { _, _ = g.Get("k") }
}
Difference comes from itab size and L1 hit rate, not call count. The win grows with the number of distinct implementors that must coexist in cache.
6. Single-implementation interface to concrete type¶
Anti-pattern¶
Production has exactly one implementation; the interface exists only "for symmetry." The compiler must assume any type may satisfy it, so it cannot inline the body.
type UserRepo interface { Find(id int) (*User, error) }
type pgRepo struct{ db *sql.DB }
func (r *pgRepo) Find(id int) (*User, error) { /* ... */ }
func handle(repo UserRepo, id int) (*User, error) { return repo.Find(id) }
Fix¶
Use the concrete type. If a test needs a fake, define the interface at the consumer boundary, not the producer.
Benchmark¶
func BenchmarkSingleImplIface(b *testing.B) {
var r UserRepo = &pgRepo{db: nil}
for i := 0; i < b.N; i++ { _, _ = handle(r, 1) }
}
func BenchmarkSingleImplConcrete(b *testing.B) {
r := &pgRepo{db: nil}
for i := 0; i < b.N; i++ { _, _ = handleConcrete(r, 1) }
}
Once Find is inlinable the call cost collapses to a memory load.
7. Reflection-heavy API — generics or codegen¶
Anti-pattern¶
A "universal" decoder uses reflect.Value.SetXxx for every field. Each call walks the type table, does bounds-checking, and allocates reflect.Value wrappers.
func DecodeInto(src map[string]any, dst any) error {
v := reflect.ValueOf(dst).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if x, ok := src[f.Name]; ok {
v.Field(i).Set(reflect.ValueOf(x))
}
}
return nil
}
Fix A — generics¶
type Decoder[T any] func(map[string]any) (T, error)
func DecodeUser(m map[string]any) (User, error) {
return User{
ID: m["ID"].(int),
Name: m["Name"].(string),
}, nil
}
Fix B — codegen (go generate produces the body)¶
//go:generate gen-decoders -type=User
func decodeUser(m map[string]any) (User, error) { /* generated */ }
Benchmark¶
func BenchmarkDecodeReflect(b *testing.B) {
m := map[string]any{"ID": 1, "Name": "x"}
var u User
for i := 0; i < b.N; i++ { _ = DecodeInto(m, &u) }
}
func BenchmarkDecodeGeneric(b *testing.B) {
m := map[string]any{"ID": 1, "Name": "x"}
for i := 0; i < b.N; i++ { _, _ = DecodeUser(m) }
}
BenchmarkDecodeReflect-8 1000000 1850 ns/op 240 B/op 6 allocs/op
BenchmarkDecodeGeneric-8 20000000 90 ns/op 0 B/op 0 allocs/op
Reflection is fine for one-off CLI tooling; never put it in a per-request hot path.
8. Mock-driven interface sprawl — fewer itabs at runtime¶
Anti-pattern¶
Every collaborator gets a XxxIface so a mock can be plugged in. The binary now carries dozens of itabs; the runtime must materialize each at first use, and method calls go through dynamic dispatch even though only one impl ever runs in production.
type ClockIface interface{ Now() time.Time }
type LoggerIface interface{ Log(string) }
type CacheIface interface{ Get(string) ([]byte, bool) }
// ... 30 more
Fix¶
Reduce surface to the few seams that are actually swapped. Inject functions for one-method seams; keep concrete types for the rest.
type Service struct {
now func() time.Time // one-method seam, no interface
db *pgRepo // concrete
}
Benchmark¶
func BenchmarkManyIfaces(b *testing.B) {
s := newSvcWithIfaces()
for i := 0; i < b.N; i++ { _ = s.Process(req) }
}
func BenchmarkFewIfaces(b *testing.B) {
s := newSvcWithFuncs()
for i := 0; i < b.N; i++ { _ = s.Process(req) }
}
BenchmarkManyIfaces-8 3000000 480 ns/op 32 B/op 1 allocs/op
BenchmarkFewIfaces-8 8000000 160 ns/op 0 B/op 0 allocs/op
First-call itab materialization also disappears from p99 latency — the very first request stops paying a one-time runtime.getitab cost.
9. Setter/getter interface — direct struct field access¶
Anti-pattern¶
Every field has a GetX / SetX method behind a Bean interface. Each access is a dynamic call; the compiler cannot reorder loads or fold constants.
Fix¶
Expose the struct. Methods only when behavior, invariant, or representation needs to be hidden.
Benchmark¶
func BenchmarkBeanGetter(b *testing.B) {
var u Bean = &userBean{id: 7}
for i := 0; i < b.N; i++ { _ = u.GetID() }
}
func BenchmarkDirectField(b *testing.B) {
u := &User{ID: 7}
for i := 0; i < b.N; i++ { _ = u.ID }
}
A 16x gap on a no-op accessor. In a tight loop the compiler can also keep u.ID in a register, which the interface form prevents.
10. Quick decision table¶
ANTI-PATTERN FIX WIN
─────────────────────────────────────────────────────────────────────────
typed-nil error return return plain nil 1 alloc, 19x
interface in hot path, 1 impl pass concrete inlining + DCE
any+assertion generics no boxing
*Interface parameter Interface (value) 1 indirection
12-method "header" interface split by call site smaller itab
single-impl interface concrete + consumer-side iface inline
reflect.* per call generics or codegen ~20x
mock-driven sprawl function injection fewer itabs
getter/setter iface exported field ~16x
11. How to find these in your binary¶
# inlining decisions
go build -gcflags='-m=2' ./... 2>&1 | grep -E 'cannot inline|inlining call'
# escape analysis
go build -gcflags='-m=2' ./... 2>&1 | grep 'escapes to heap'
# itab count (rough): symbols of the form go:itab.*,*
go tool nm ./bin | grep -c '^.* r go:itab\.'
# CPU profile of an interface-heavy hot path
go test -bench=. -cpuprofile=cpu.prof
go tool pprof -list 'YourFunc' cpu.prof
If pprof shows time inside runtime.assertI2I2, runtime.convI*, or runtime.getitab, you are paying for one of the patterns above.
12. Summary¶
Interface anti-patterns hurt performance through five concrete mechanisms:
- Allocations — typed-nil wrapping,
any-boxing of scalars. - Lost inlining — the compiler cannot see through dynamic dispatch.
- Lost devirtualization — multiple impls in scope defeat the 1.21+ pass.
- Itab and cache pressure — fat interfaces and mock sprawl bloat the binary.
- Extra indirection —
*Interfaceparameters and getter chains.
The fixes share one rule: keep interfaces small, define them at the point of use, and reach for generics or concrete types when an interface only exists "for symmetry." Always confirm with a benchmark on the hot path you actually care about — anti-patterns outside that path are not worth rewriting.