Lesson 1: Go Interfaces: Implicit Satisfaction
Why This Matters
Open the Kubernetes source code and you'll see this pattern everywhere:
type Client interface {
Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error
List(ctx context.Context, list ObjectList, opts ...ListOption) error
Create(ctx context.Context, obj Object, opts ...CreateOption) error
Update(ctx context.Context, obj Object, opts ...UpdateOption) error
Delete(ctx context.Context, obj Object, opts ...DeleteOption) error
}
That's not a class hierarchy. It's not an abstract base class. It's a Go interface. And understanding how it works — deeply — is the difference between writing Go and writing idiomatic Go.
After this lesson, you will understand Go's interface model, be able to define and implement interfaces, and recognize interface patterns in real cloud-native code.
The Core Idea: Implicit Satisfaction
A Go type satisfies an interface automatically — just by having the right methods. No declaration. No keyword. No ceremony.
In Java or C#, you write class Foo implements Bar. The type declares that it implements the interface. The relationship is explicit and bidirectional.
Go flips this: the interface defines what it needs, and any type that happens to have those methods automatically satisfies it. The type doesn't even need to know the interface exists.
This is called implicit interface satisfaction, and it's arguably the most important design decision in Go. Effective Go §
A Concrete Example
// Define an interface: anything that can Speak
type Speaker interface {
Speak() string
}
// Dog has a Speak method — it satisfies Speaker automatically
type Dog struct {
Name string
}
func (d Dog) Speak() string {
return d.Name + " says woof!"
}
// Cat also satisfies Speaker
type Cat struct {
Name string
}
func (c Cat) Speak() string {
return c.Name + " says meow!"
}
// This function accepts ANYTHING that can Speak
func MakeItSpeak(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
MakeItSpeak(Dog{"Rex"}) // Rex says woof!
MakeItSpeak(Cat{"Whiskers"}) // Whiskers says meow!
}
Dog and Cat never mention Speaker. They don't know it exists. They just have a Speak() method, and that's enough. This is structural typing — not nominal typing.
Interface Syntax
Declaring an Interface
type InterfaceName interface {
MethodName(param Type) ReturnType
OtherMethod(param1 Type1, param2 Type2) (ReturnType, error)
}
Implementing an Interface
You implement an interface by defining methods on a type. The receiver type matters:
type Counter struct {
value int
}
// Value receiver:
func (c Counter) Value() int { return c.value }
// Both Counter AND *Counter satisfy interfaces with Value()
// Pointer receiver:
func (c *Counter) Increment() { c.value++ }
// Only *Counter satisfies interfaces with Increment()
A method with a pointer receiver is NOT in the method set of the value type. If you have func (c *Counter) Increment(), then Counter{} does NOT satisfy an interface requiring Increment() — only &Counter{} does.
Compile-Time Interface Checks
Use this pattern to verify at compile time that a type satisfies an interface:
var _ io.Reader = (*MyReader)(nil) // *MyReader must implement io.Reader
var _ io.Reader = MyReader{} // MyReader must implement io.Reader
If the type doesn't satisfy the interface, the code won't compile. This is cheap documentation and a safety net.
The Most Important Interface: io.Reader
If you learn one interface in Go, make it this one:
type Reader interface {
Read(p []byte) (n int, err error)
}
That's it. One method. Two return values. And it's the backbone of all I/O in Go.
io.Reader reads up to len(p) bytes into p, returns how many were read and any error. It's used by files, network connections, HTTP bodies, gzip decompressors, crypto hashers, strings, byte buffers — everything.
Implementing Your Own io.Reader
type UpperReader struct {
source string
pos int
}
func (r *UpperReader) Read(p []byte) (int, error) {
if r.pos >= len(r.source) {
return 0, io.EOF
}
n := copy(p, strings.ToUpper(r.source[r.pos:]))
r.pos += n
return n, nil
}
func main() {
r := &UpperReader{source: "hello"}
io.Copy(os.Stdout, r) // Prints: HELLO
}
Because UpperReader implements io.Reader, it works with io.Copy, io.ReadAll, bufio.NewScanner, and every other function in the standard library that accepts an io.Reader.
Define one method, get dozens of functions for free. That's the compounding value of interfaces.
Interface Composition
You can embed interfaces inside other interfaces:
type ReadWriter interface {
io.Reader
io.Writer
}
type ReadWriteCloser interface {
io.Reader
io.Writer
io.Closer
}
A type satisfies ReadWriter if it has both Read and Write methods. Composition, not inheritance.
This is how the standard library builds up complex interfaces from simple ones. io.ReadWriteCloser is just Reader + Writer + Closer.
The Empty Interface (any)
interface{} — now also written as any (since Go 1.18) — is satisfied by every type. It's Go's universal container, but use it sparingly.
var x any
x = "hello"
x = 42
x = Dog{Name: "Rex"}
// To get the value back, use a type assertion:
s, ok := x.(string) // ok=false because x is a Dog now
// Or a type switch:
switch v := x.(type) {
case string: fmt.Println("string:", v)
case int: fmt.Println("int:", v)
default: fmt.Printf("%T: %v\n", v, v)
}
Generics (Go 1.18+) replace many uses of any. If you find yourself doing type switches to handle "string or int", consider a generic function or union constraint instead. We'll cover generics in a later lesson.
Interface Design Principles
1. Accept interfaces, return structs
// GOOD: callers can pass anything that reads
func ProcessData(r io.Reader) error { ... }
// GOOD: returns a concrete type, caller can still treat as interface
func NewStore() *FileStore { ... }
// BAD (usually): returning an interface limits what callers can do
func NewStore() Store { ... }
2. Keep interfaces small
The most powerful interfaces in Go's standard library have 1–3 methods. io.Reader: 1 method. io.Writer: 1 method. fmt.Stringer: 1 method.
3. Define interfaces where they're used, not where they're implemented
This is a direct consequence of implicit satisfaction. The consumer decides what interface it needs — the producer doesn't declare anything.
4. Naming conventions
- Single-method interfaces: method name +
er→Reader,Writer,Stringer,Marshaler - Multi-method interfaces: describe what they do →
ReadWriter,Handler,Client
5. The nil interface trap
var r io.Reader // nil interface (type=nil, value=nil)
var b *bytes.Buffer // nil pointer
r = b // r is NOT nil! (type=*bytes.Buffer, value=nil)
fmt.Println(r == nil) // false!
An interface is nil only when both its dynamic type and dynamic value are nil. A nil pointer stored in an interface makes the interface non-nil.
Interfaces in Cloud-Native Go
Let's look at a real pattern from the Kubernetes controller-runtime library:
// From sigs.k8s.io/controller-runtime/pkg/client
type Client interface {
Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error
List(ctx context.Context, list ObjectList, opts ...ListOption) error
Create(ctx context.Context, obj Object, opts ...CreateOption) error
Update(ctx context.Context, obj Object, opts ...UpdateOption) error
Patch(ctx context.Context, obj Object, patch Patch, opts ...PatchOption) error
Delete(ctx context.Context, obj Object, opts ...DeleteOption) error
DeleteAllOf(ctx context.Context, obj Object, opts ...DeleteAllOfOption) error
Status() StatusWriter
SubResource(subResource string) SubResourceClient
Scheme() *runtime.Scheme
RESTMapper() meta.RESTMapper
GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error)
IsObjectNamespaced(obj runtime.Object) (bool, error)
}
Yes, this is a large interface — a pragmatic exception to the "keep it small" rule when you genuinely need all these operations. But notice the pattern:
- Controllers depend on
Client, not on a concrete implementation. This means you can test a controller with a fake client. - Multiple implementations exist: A real client that talks to the API server, a fake client for tests, a dry-run client, a delegating client.
- The interface is defined at the consumption point — in the controller-runtime library, not in the API server.
When you contribute to Kubernetes or similar projects, you'll write code that depends on interfaces like Client, Object, EventRecorder. Your first PR might be adding a new controller — and you'll test it with the fake client, made possible entirely by interfaces.
Practice
Write Go code in your editor, run it with go run, and verify the output. The exercises build on each other. Ask your teaching agent for help if you get stuck.
Create a type called LoudWriter that implements io.Writer. It should write data to stdout, but in UPPERCASE.
Test it: fmt.Fprintf(&LoudWriter{}, "hello %s", "world") should print HELLO WORLD.
Building on Exercise 1, add a Close() error method to LoudWriter (it can just return nil). Now LoudWriter satisfies both io.Writer AND io.Closer — which means it satisfies io.WriteCloser.
Write a function func WriteAndClose(wc io.WriteCloser, msg string) error that writes the message and closes the writer. Call it with a LoudWriter.
Imagine you're building a metrics collector for a cloud-native tool. Define an interface called MetricStore with two methods: Record(name string, value float64) and Query(name string) (float64, bool).
Implement two concrete types that satisfy MetricStore:
InMemoryStore— stores metrics in a mapLoggingStore— just logs metric records to stdout, returns 0 for queries Then write a functionCollectCPU(store MetricStore)that records a CPU metric and queries it back. Call it with both implementations.
Quick Check
What's Next?
You now understand the most fundamental abstraction in Go. In the next lesson, we'll tackle error handling — because in Go, error is just an interface:
type error interface {
Error() string
}
Everything you learned here applies directly — error wrapping, sentinel errors, custom error types, and the patterns used in production Go code.
Ask Questions
Your teaching agent is here to help. If anything was unclear, ask now. Want to check your exercise code? Paste it and I'll review it. Want to understand why Kubernetes made that Client interface so large? Ask.
1. Q1: Which statement is true about Go interfaces?
2. Q2: What does this print?