Go Interface Composition: Combining Interfaces

Go doesn't have inheritance. Instead, it embraces composition as a first-class design principle. Interface composition is one of the most powerful manifestations of this philosophy—you build complex...

Key Insights

  • Interface composition in Go allows you to build complex contracts from smaller, focused interfaces without inheritance, following the Unix philosophy of doing one thing well
  • Types automatically satisfy composed interfaces by implementing all embedded methods, enabling gradual capability detection through type assertions at runtime
  • Keep individual interfaces small (1-3 methods) and compose them at the point of use rather than creating large monolithic interfaces upfront

Introduction to Interface Composition

Go doesn’t have inheritance. Instead, it embraces composition as a first-class design principle. Interface composition is one of the most powerful manifestations of this philosophy—you build complex interfaces by embedding simpler ones, creating flexible contracts that types can satisfy incrementally.

The standard library demonstrates this pattern extensively. The most recognizable example is io.ReadWriter:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
    Reader
    Writer
}

This composition is declarative and explicit. ReadWriter doesn’t inherit from Reader and Writer—it embeds them. Any type implementing both Read and Write methods automatically satisfies ReadWriter without explicitly declaring it. This is interface satisfaction through structural typing, not nominal inheritance.

Basic Interface Embedding

When you embed an interface within another, all methods from the embedded interface become part of the embedding interface. The syntax is straightforward: list the interface names without field names.

type Validator interface {
    Validate() error
}

type Persister interface {
    Save() error
    Load() error
}

type Auditor interface {
    LogAction(action string) error
}

// Entity combines all three interfaces
type Entity interface {
    Validator
    Persister
    Auditor
}

Any type satisfying Entity must implement all five methods: Validate(), Save(), Load(), and LogAction(). Here’s a concrete implementation:

type User struct {
    ID    int
    Email string
}

func (u *User) Validate() error {
    if u.Email == "" {
        return errors.New("email required")
    }
    return nil
}

func (u *User) Save() error {
    // Database save logic
    fmt.Printf("Saving user %d\n", u.ID)
    return nil
}

func (u *User) Load() error {
    // Database load logic
    fmt.Printf("Loading user %d\n", u.ID)
    return nil
}

func (u *User) LogAction(action string) error {
    fmt.Printf("User %d: %s\n", u.ID, action)
    return nil
}

// User now satisfies Entity without explicitly declaring it
func ProcessEntity(e Entity) error {
    if err := e.Validate(); err != nil {
        return err
    }
    e.LogAction("processing started")
    return e.Save()
}

The beauty here is implicit satisfaction. User doesn’t declare implements Entity anywhere. It just implements the methods, and the compiler handles the rest.

Real-World Composition Patterns

The standard library uses composition patterns extensively. io.ReadWriteCloser combines three interfaces:

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

This pattern appears throughout networking and file I/O code. You can build similar patterns for your domain:

type DataReader interface {
    ReadData(id string) ([]byte, error)
}

type DataWriter interface {
    WriteData(id string, data []byte) error
}

type DataValidator interface {
    ValidateData(data []byte) error
}

// DataStore composes all operations
type DataStore interface {
    DataReader
    DataWriter
    DataValidator
}

// EncryptedStore adds encryption capability
type Encryptor interface {
    Encrypt(data []byte) ([]byte, error)
    Decrypt(data []byte) ([]byte, error)
}

type SecureDataStore interface {
    DataStore
    Encryptor
}

For HTTP handlers, composition enables middleware patterns:

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

type Authenticator interface {
    Authenticate(r *http.Request) (bool, error)
}

type RateLimiter interface {
    AllowRequest(r *http.Request) bool
}

type SecureHandler interface {
    Handler
    Authenticator
    RateLimiter
}

Type Assertions and Interface Composition

Composed interfaces shine when you need optional capabilities. Accept minimal interfaces but check for extended functionality at runtime:

type BasicStorage interface {
    Store(key string, value []byte) error
    Retrieve(key string) ([]byte, error)
}

type CacheableStorage interface {
    BasicStorage
    InvalidateCache(key string) error
}

type MetricsStorage interface {
    GetMetrics() map[string]int64
}

func SaveWithOptimizations(storage BasicStorage, key string, value []byte) error {
    // Always works with BasicStorage
    if err := storage.Store(key, value); err != nil {
        return err
    }

    // Optional: invalidate cache if supported
    if cacheable, ok := storage.(CacheableStorage); ok {
        cacheable.InvalidateCache(key)
    }

    // Optional: record metrics if supported
    if metrics, ok := storage.(MetricsStorage); ok {
        m := metrics.GetMetrics()
        fmt.Printf("Storage metrics: %v\n", m)
    }

    return nil
}

This pattern—accepting a minimal interface and checking for optional capabilities—is idiomatic Go. It’s how io.Copy checks for WriterTo and ReaderFrom to optimize copying.

Type switches work similarly when you have multiple optional behaviors:

func ProcessStorage(storage BasicStorage) {
    switch s := storage.(type) {
    case SecureDataStore:
        // Handle encrypted storage
        fmt.Println("Using secure storage with encryption")
    case CacheableStorage:
        // Handle cached storage
        fmt.Println("Using cached storage")
    default:
        // Handle basic storage
        fmt.Println("Using basic storage")
    }
}

Empty Interface Composition and Constraints

Interface composition has limits. You cannot embed overlapping interfaces with conflicting method signatures. This won’t compile:

type ReaderA interface {
    Read() string
}

type ReaderB interface {
    Read() int
}

// Compile error: duplicate method Read
type BadComposition interface {
    ReaderA
    ReaderB
}

Avoid interface pollution—the anti-pattern of creating massive composed interfaces “just in case”:

// BAD: Kitchen sink interface
type EverythingStore interface {
    DataReader
    DataWriter
    DataValidator
    Encryptor
    CacheableStorage
    MetricsStorage
    Auditor
    // ... 10 more interfaces
}

Instead, compose at the point of use:

// GOOD: Compose only what you need
func BackupData(store interface {
    DataReader
    Encryptor
}, id string) error {
    data, err := store.ReadData(id)
    if err != nil {
        return err
    }
    encrypted, err := store.Encrypt(data)
    // ... backup logic
    return nil
}

With Go 1.18+ generics, you can use interface constraints similarly:

func ProcessSecure[T interface {
    DataStore
    Encryptor
}](store T, id string, data []byte) error {
    if err := store.ValidateData(data); err != nil {
        return err
    }
    encrypted, err := store.Encrypt(data)
    if err != nil {
        return err
    }
    return store.WriteData(id, encrypted)
}

Testing with Interface Composition

Interface composition simplifies testing by letting you implement only what you need:

// Production code expects composed interface
func SyncData(store DataStore, id string, data []byte) error {
    if err := store.ValidateData(data); err != nil {
        return err
    }
    return store.WriteData(id, data)
}

// Test mock implements only required methods
type MockStore struct {
    validateErr error
    writeErr    error
}

func (m *MockStore) ValidateData(data []byte) error {
    return m.validateErr
}

func (m *MockStore) WriteData(id string, data []byte) error {
    return m.writeErr
}

func (m *MockStore) ReadData(id string) ([]byte, error) {
    return nil, errors.New("not implemented in test")
}

func TestSyncData(t *testing.T) {
    mock := &MockStore{validateErr: errors.New("invalid")}
    err := SyncData(mock, "test", []byte("data"))
    if err == nil {
        t.Error("expected validation error")
    }
}

The mock doesn’t need to implement ReadData even though it’s part of DataStore, because SyncData doesn’t call it.

Best Practices and Conclusion

Follow these guidelines for effective interface composition:

Keep interfaces small. The best interfaces have 1-3 methods. io.Reader has one. io.ReadWriter has two (by composition). Small interfaces are easier to implement, test, and compose.

Accept interfaces, return structs. Function parameters should use interfaces (often composed), but return concrete types. This gives you flexibility in what you accept while maintaining control over what you provide.

Compose at the boundary. Don’t create composed interfaces in your core domain package. Compose them where you need them—in function signatures or local scopes.

Here’s a refactoring example showing these principles:

// BEFORE: Monolithic interface
type Repository interface {
    Create(entity interface{}) error
    Read(id string) (interface{}, error)
    Update(entity interface{}) error
    Delete(id string) error
    Validate(entity interface{}) error
    Cache(id string, entity interface{})
    InvalidateCache(id string)
}

// AFTER: Composable interfaces
type Creator interface {
    Create(entity interface{}) error
}

type Reader interface {
    Read(id string) (interface{}, error)
}

type Updater interface {
    Update(entity interface{}) error
}

type Deleter interface {
    Delete(id string) error
}

// Compose only what each function needs
func SaveNew(repo interface {
    Creator
    Validator
}, entity interface{}) error {
    if err := repo.Validate(entity); err != nil {
        return err
    }
    return repo.Create(entity)
}

Interface composition is Go’s answer to complex type hierarchies. By combining small, focused interfaces, you build flexible contracts that types can satisfy incrementally. This approach reduces coupling, improves testability, and makes your code more adaptable to change. Start with small interfaces, compose them where needed, and let the type system work for you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.