ToolPopToolPop
Go · Lesson 13 of 20

Interfaces deep, method sets, and composition over inheritance

8 min readUpdated 25 Jun 2026

Go's interfaces are the most underrated feature in the language. They are structurally typed (no implements keyword), satisfied at compile time, and the entire standard library is built on small, single-method ones (io.Reader, io.Writer, error, fmt.Stringer). Senior Go interviews live in this territory.

This lesson is the deep view: method sets, the value-vs-pointer receiver rule everyone gets wrong on first try, how to size interfaces, and why "no inheritance" is a feature.

Interfaces are structural, satisfied implicitly

There is no implements keyword in Go. A type satisfies an interface if it has all the methods. That is the entire test.

go
type Stringer interface {
    String() string
}
 
type User struct{ Name string }
func (u User) String() string { return u.Name }
// User now satisfies Stringer. Nobody had to declare it.

This is huge in practice. You can write an interface in package A and have types from package B satisfy it without B ever importing A. This is how the standard library composes so cleanly.

Method sets: the rule that trips everyone

The rule, stated carefully: the method set of a type T contains all methods declared with a value receiver (t T). The method set of *T contains both value-receiver methods AND pointer-receiver methods (t *T).

go
type Counter struct{ n int }
 
func (c Counter) Value() int { return c.n }    // value receiver
func (c *Counter) Inc()      { c.n++ }         // pointer receiver
  • Counter has Value() only.
  • *Counter has both Value() and Inc().

If an interface requires Inc(), only *Counter satisfies it, never Counter.

Diagram
rendering diagram...
Method sets: pointer type gets everything, value type only gets value-receiver methods

Interview trap: "Why does this not compile?" The code is something like passing a Counter{} to a function taking an interface that requires Inc(). The answer: Inc is on *Counter, not Counter, so the value does not satisfy the interface. Pass &Counter{} instead.

The discipline rule: pick one receiver style per type. If any method needs a pointer receiver (because it mutates or because the struct is large), make all methods pointer receivers. Mixing is the most common source of "does not satisfy interface" errors in real codebases.

Accept interfaces, return structs

The single most repeated piece of senior Go advice. Functions take interface parameters (small, specific) and return concrete types (structs).

go
// good: caller can pass anything that can be read from
func Load(r io.Reader) (*Config, error) { ... }
 
// less good: caller must hand you a real *os.File
func Load(f *os.File) (*Config, error) { ... }

Returning a concrete type means the caller sees the full method set, can rely on specific behaviour, and can later wrap it themselves if they need an interface. Returning an interface throws information away and makes mocking harder, not easier.

Define interfaces where you USE them

This is the Go ethos and it is genuinely different from Java or C#.

In Java, the producer of a type defines the interface and the consumer imports it. In Go, the consumer defines the interface they need, and any type from any package can satisfy it. The interface lives next to the function that takes it.

go
// in package payments, where you USE the interface
type ledger interface {
    Credit(amt int64) error
}
 
func Settle(l ledger, amt int64) error { return l.Credit(amt) }

The ledger interface is one method, lowercase, unexported, defined exactly where it is consumed. The Settle function does not care which package the concrete ledger comes from.

Senior rule: define interfaces where you USE them, not where you implement them. The consumer owns the interface, not the producer.

Concretely: do not define type UserService interface { ... } in your userservice package and then implement it in the same package. Define the interface in the package that needs it (the HTTP handler, the worker, the test), with only the methods that consumer needs.

Interface composition

Interfaces can embed other interfaces. The standard library does this constantly.

go
type Reader interface { Read(p []byte) (int, error) }
type Closer interface { Close() error }
type ReadCloser interface {
    Reader
    Closer
}

io.ReadCloser is exactly that. Any type with both Read and Close satisfies it. This is how http.Response.Body is typed: a ReadCloser because you read it and then close it.

Compose small interfaces into bigger ones. Never define a 12-method "service" interface that callers only use 2 methods from. Split it.

The empty interface (any) trap

interface{} (now spelled any) is the interface with zero methods. Every type satisfies it. It is also the loophole that turns your code into dynamically-typed JavaScript.

go
func process(x any) {
    s, ok := x.(string) // type assertion, runtime check
    if !ok { return }
    fmt.Println(s)
}

You lose compile-time type safety. You pay an allocation (interface boxing puts the value on the heap). You make the caller's life worse because they have to read the function body to know what types are accepted.

Use any for genuinely heterogeneous data (JSON unmarshalling into unknown shape, logging structured fields, middleware pipelines). Otherwise prefer generics (next lesson) or a concrete interface.

Type assertions and type switches

When you have an interface value and need the underlying type.

go
switch v := err.(type) {
case *net.OpError:
    log.Println("network op:", v.Op)
case *url.Error:
    log.Println("url:", v.URL)
default:
    log.Println("other:", err)
}

This is the idiomatic pattern in error handling, request routers, and anywhere you take any but expect a small set of real types. Always have a default case, even if it is just to log "unexpected type".

No inheritance, only composition

Go has no class hierarchy. It has struct embedding, which is composition with a syntactic sugar that promotes the embedded type's methods to the outer struct.

go
type Logger struct{}
func (Logger) Log(msg string) { fmt.Println(msg) }
 
type Server struct {
    Logger // embedded
    Addr string
}
// s.Log("ok") works; the method is promoted

This is not inheritance. There is no "Server is a Logger" relationship. There is no polymorphism through the embedded type. It is just method forwarding. Treat it as a convenience, not as a design pattern import from Java.

Interface concepts

method set
The set of methods callable on a type. *T has all methods of T plus pointer-receiver methods.
structural typing
Interface satisfaction by shape, not by declaration. No 'implements' keyword in Go.
interface composition
Embedding interfaces inside larger interfaces. io.ReadCloser is Reader + Closer.
type assertion
Runtime check that extracts the concrete type from an interface value: v, ok := x.(T).
empty interface
interface{} or any. Satisfied by every type. Loses compile-time type safety; use sparingly.
struct embedding
Including a type by name in a struct. Promotes its fields and methods. Composition, not inheritance.

Senior rule: define interfaces where you USE them, not where you implement them. The consumer owns the interface, not the producer.

Common questions

Q.Should I define interfaces in the package that implements them or the package that uses them?
A.In the package that USES them. The consumer knows what behaviour it needs. The producer just defines concrete types. This keeps coupling loose and interfaces small. The standard library is full of this pattern (io.Reader, io.Writer).
Q.What is the difference between value and pointer receivers for satisfying an interface?
A.A *T value satisfies any interface whose methods are defined on T or *T. A T value only satisfies interfaces whose methods are all on T (value receivers). So pointer receivers restrict where the type can be used. Pick value receivers when possible.
Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict both outputs (each on its own line).

    hint

    Pointer receiver requires &Cat{...} to satisfy the interface.

    show answer
    // already in the editor