Go’s interface system is fundamentally different from Java or C#. There’s no implements keyword. A type satisfies an interface simply by having the right methods — the compiler checks this silently at the point of use. This is structural typing, or what many call duck typing: if it walks like a duck and quacks like a duck, it’s a duck.
This design choice has a profound consequence: you can make any existing type satisfy any interface you define, even if you didn’t write the original type. That’s the key to Go’s flexibility.
For more context see Go interfaces definition, Go best practices, and Go dependency injection.
How Implicit Satisfaction Works
In Go, an interface is just a set of method signatures. Any type that has those methods — regardless of what package it came from — satisfies the interface automatically.
// Define an interface
type Writer interface {
Write(p []byte) (n int, err error)
}
// FileWriter satisfies Writer — no declaration needed
type FileWriter struct {
filename string
}
func (fw *FileWriter) Write(p []byte) (n int, err error) {
fmt.Printf("Writing %d bytes to %s\n", len(p), fw.filename)
return len(p), nil
}
// ConsoleWriter also satisfies Writer
type ConsoleWriter struct{}
func (cw *ConsoleWriter) Write(p []byte) (n int, err error) {
fmt.Printf("[console] %s\n", string(p))
return len(p), nil
}
// This function accepts any Writer — it doesn't care which concrete type
func WriteData(w Writer, data []byte) {
w.Write(data)
}
Neither FileWriter nor ConsoleWriter mentions Writer anywhere. Yet both work with WriteData. The compiler verifies this at compile time — you get type safety without the coupling.
Compare that to Java: you’d need class FileWriter implements Writer, which creates a hard dependency between the type and the interface definition. In Go, the consumer of the interface defines what it needs, and the producer just has to match the shape.
The Standard Library Proves the Point
The most convincing evidence that implicit interfaces work is Go’s standard library. The io.Reader and io.Writer interfaces were defined in the standard library, but they’re satisfied by types in dozens of third-party packages that were written independently, years later.
// From package io — defined once, used everywhere
type Reader interface {
Read(p []byte) (n int, err error)
}
// strings.Reader satisfies io.Reader
// os.File satisfies io.Reader
// bytes.Buffer satisfies io.Reader
// net.Conn satisfies io.Reader
// http.Response.Body satisfies io.Reader
// compress/gzip.Reader satisfies io.Reader
// ... hundreds more
This composes beautifully. Any function that accepts an io.Reader works with all of these, including ones written after the function was published:
func ReadAll(r io.Reader) (string, error) {
buf := make([]byte, 1024)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
return string(buf[:n]), nil
}
You can call ReadAll with a file, a network socket, a string, a gzip stream, or anything else — the function doesn’t need to know.
Polymorphism: Shapes Example
The canonical example for polymorphism is shapes, and Go handles it cleanly:
type Shape interface {
Area() float64
Perimeter() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
// Works with any Shape — current or future
func Describe(s Shape) {
fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}
The key insight: if you later add a Triangle type, Describe works with it immediately — no changes needed to the interface, the function, or any existing code.
Interface Composition
Small interfaces compose into larger ones. This is where Go’s design really shines:
// From package io
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Embedding creates a composed interface
type ReadWriter interface {
Reader
Writer
}
type Closer interface {
Close() error
}
// Common combinations from the standard library
type ReadCloser interface { Reader; Closer }
type WriteCloser interface { Writer; Closer }
type ReadWriteCloser interface { Reader; Writer; Closer }
A custom buffer satisfies ReadWriter automatically by implementing both Read and Write:
type Buffer struct {
data []byte
pos int
}
func (b *Buffer) Read(p []byte) (n int, err error) {
if b.pos >= len(b.data) {
return 0, io.EOF
}
n = copy(p, b.data[b.pos:])
b.pos += n
return n, nil
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.data = append(b.data, p...)
return len(p), nil
}
// Buffer satisfies io.Reader, io.Writer, and io.ReadWriter
var _ io.ReadWriter = &Buffer{} // compile-time check
The blank identifier assignment var _ io.ReadWriter = &Buffer{} is an idiomatic way to assert interface satisfaction at compile time without runtime cost. Add this near the type definition to catch problems early.
The fmt.Stringer and error Interfaces
Two of the most-used interfaces in Go are tiny — one method each:
// fmt.Stringer — controls how a type prints
type Stringer interface {
String() string
}
type Person struct {
Name string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s (age %d)", p.Name, p.Age)
}
p := Person{Name: "Alice", Age: 30}
fmt.Println(p) // Alice (age 30) — fmt.Println calls String() automatically
// error — the built-in error interface
type error interface {
Error() string
}
// Implement it to create typed errors with context
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}
func ValidateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{Field: "email", Message: "must contain @"}
}
return nil
}
// Callers can check the type when they need the detail
err := ValidateEmail("notanemail")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("Bad field:", ve.Field) // Bad field: email
}
Because error is just an interface, you can add methods to your error types, embed them in other errors, and inspect them with errors.As and errors.Is. The interface gives you a common contract without losing any information.
Making Testing Easy
Implicit interfaces are the foundation of testable Go code. You don’t need mocking frameworks — just define a small interface and swap in a fake:
// The real dependency
type UserStore interface {
GetUser(id int) (*User, error)
SaveUser(u *User) error
}
// The service depends on the interface, not a concrete type
type UserService struct {
store UserStore
}
func (s *UserService) UpdateEmail(id int, email string) error {
user, err := s.store.GetUser(id)
if err != nil {
return err
}
user.Email = email
return s.store.SaveUser(user)
}
In tests, you use a simple in-memory fake — no mock generation required:
type fakeStore struct {
users map[int]*User
}
func (f *fakeStore) GetUser(id int) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, fmt.Errorf("user %d not found", id)
}
return u, nil
}
func (f *fakeStore) SaveUser(u *User) error {
f.users[u.ID] = u
return nil
}
func TestUpdateEmail(t *testing.T) {
store := &fakeStore{
users: map[int]*User{1: {ID: 1, Email: "[email protected]"}},
}
svc := &UserService{store: store}
err := svc.UpdateEmail(1, "[email protected]")
if err != nil {
t.Fatal(err)
}
if store.users[1].Email != "[email protected]" {
t.Error("email not updated")
}
}
The real database implementation and the fake implement the same interface. No annotations, no code generation, no reflection.
Practical Pattern: Logger Interface
Rather than coupling your business logic to a specific logging library, define what you need:
// Logger is a minimal interface — easy for callers to satisfy
type Logger interface {
Info(msg string, args ...any)
Error(msg string, args ...any)
}
type OrderService struct {
log Logger
}
func (s *OrderService) PlaceOrder(order *Order) error {
s.log.Info("placing order", "order_id", order.ID, "amount", order.Amount)
if err := s.processPayment(order); err != nil {
s.log.Error("payment failed", "order_id", order.ID, "err", err)
return err
}
s.log.Info("order placed successfully", "order_id", order.ID)
return nil
}
This works with slog.Logger, zap.SugaredLogger, a test logger, or a no-op logger — whichever the caller injects. The service doesn’t import any logging library.
Rules for Good Interface Design
Define interfaces in the consumer package, not the producer package. The package that uses the interface should define what it needs. This avoids unnecessary coupling and lets you define exactly the surface area you rely on.
Keep interfaces small. The Go standard library’s most useful interfaces — io.Reader, io.Writer, error, fmt.Stringer — have one method each. Small interfaces are easier to satisfy, easier to compose, and easier to mock.
Accept interfaces, return concrete types. Functions should take interfaces as parameters (flexible) but return concrete types (informative). Returning an interface hides information that callers might need.
// ✅ Accept interface, return concrete type
func NewBufferedReader(r io.Reader) *bufio.Reader {
return bufio.NewReader(r)
}
// ❌ Avoid returning interfaces unnecessarily
func NewReader(r io.Reader) io.Reader { // caller loses access to bufio-specific methods
return bufio.NewReader(r)
}
Don’t create interfaces speculatively. Go’s philosophy: define the interface when you have two or more concrete types that need to be used interchangeably, or when you need to swap implementations for testing. Creating interfaces “just in case” adds indirection without benefit.
Anti-Patterns to Avoid
Fat interfaces — a single interface with many methods is hard to satisfy and often signals the interface is doing too much:
// ❌ Too large — hard to mock, hard to satisfy, violates ISP
type FileSystem interface {
Open(name string) (*os.File, error)
Create(name string) (*os.File, error)
Remove(name string) error
Rename(old, new string) error
Stat(name string) (os.FileInfo, error)
MkdirAll(path string, perm os.FileMode) error
// ... 10 more methods
}
// ✅ Define only what each consumer needs
type FileOpener interface {
Open(name string) (*os.File, error)
}
type FileCreator interface {
Create(name string) (*os.File, error)
}
Returning interface{} or any — avoid using the empty interface as a catch-all. It bypasses type checking and forces callers to use type assertions. Use generics (Go 1.18+) or typed interfaces instead.
Summary
Go’s implicit interfaces reward thinking about behavior rather than hierarchy. The key points:
- A type satisfies an interface by having the right methods — no declaration needed
- Define interfaces in the consumer package, with only the methods you actually call
- Compose small interfaces into larger ones when needed (
io.ReadWriter,io.ReadCloser) - Use compile-time assertions (
var _ Interface = &Type{}) to catch mistakes early - Implicit interfaces make mocking trivial — just write a struct that matches the shape
- Accept interfaces in function parameters; return concrete types from functions
Resources
- The Go Blog: Laws of Reflection
- Effective Go: Interfaces
- Go standard library io package
- Go by Example: Interfaces
Comments