Go Embedding: Composition Over Inheritance

Go deliberately omits class-based inheritance. The language designers recognized that deep inheritance hierarchies create fragile, tightly-coupled code that's difficult to refactor. Instead, Go...

Key Insights

  • Go’s embedding mechanism promotes fields and methods from inner types to outer types, providing composition-based code reuse without classical inheritance hierarchies
  • Interface embedding composes behaviors declaratively, while struct embedding provides implementation reuse with explicit control over method shadowing
  • Embedding works best for “has-a” relationships where you want to extend functionality; use explicit fields when the relationship needs to be clear or when you need multiple instances of the same type

Why Go Chose Composition

Go deliberately omits class-based inheritance. The language designers recognized that deep inheritance hierarchies create fragile, tightly-coupled code that’s difficult to refactor. Instead, Go provides embedding—a mechanism that achieves code reuse through composition while maintaining clear, flat structures.

Embedding isn’t just syntactic sugar. It’s a fundamental design choice that encourages you to think about what objects do (interfaces) rather than what they are (class hierarchies). This shift produces more flexible, testable code.

Struct Embedding Basics

Struct embedding works by declaring a field without a name—just a type. The embedded type’s fields and methods are “promoted” to the outer struct, making them accessible as if they belonged to the outer type directly.

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func (p Person) Introduce() string {
    return fmt.Sprintf("Hi, I'm %s and I'm %d years old", p.Name, p.Age)
}

type Employee struct {
    Person  // embedded struct
    EmployeeID string
    Department string
}

func main() {
    emp := Employee{
        Person: Person{
            Name: "Alice",
            Age:  30,
        },
        EmployeeID: "E12345",
        Department: "Engineering",
    }

    // Promoted fields - accessed directly
    fmt.Println(emp.Name)        // "Alice"
    fmt.Println(emp.Age)         // 30
    
    // Promoted method
    fmt.Println(emp.Introduce()) // "Hi, I'm Alice and I'm 30 years old"
    
    // Original embedded struct still accessible
    fmt.Println(emp.Person.Name) // "Alice"
}

The Employee struct gains all of Person’s fields and methods without explicitly defining them. You can still access the embedded Person explicitly using emp.Person, but the promoted access pattern (emp.Name) is cleaner and more idiomatic.

Interface Embedding and Composition

Interface embedding composes multiple interfaces into a single interface. This is how Go’s standard library builds complex interfaces from simple, focused ones.

package main

import (
    "fmt"
    "io"
    "os"
)

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

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

type Closer interface {
    Close() error
}

// Composed interface
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

// Custom implementation
type FileHandler struct {
    file *os.File
}

func (fh *FileHandler) Read(p []byte) (n int, err error) {
    return fh.file.Read(p)
}

func (fh *FileHandler) Write(p []byte) (n int, err error) {
    return fh.file.Write(p)
}

func (fh *FileHandler) Close() error {
    fmt.Println("Closing file with cleanup")
    return fh.file.Close()
}

func ProcessData(rwc ReadWriteCloser) error {
    defer rwc.Close()
    
    data := []byte("Hello, World!")
    _, err := rwc.Write(data)
    return err
}

func main() {
    file, _ := os.Create("example.txt")
    handler := &FileHandler{file: file}
    
    ProcessData(handler)
}

Interface embedding is purely declarative—it doesn’t provide implementation. Any type that satisfies all embedded interfaces automatically satisfies the composed interface. This enables powerful abstraction without coupling.

Method Promotion and Shadowing

When you embed a type, its methods are promoted to the outer type. But if the outer type defines a method with the same signature, it shadows the embedded method.

package main

import (
    "fmt"
    "time"
)

type Logger struct {
    prefix string
}

func (l Logger) Info(msg string) {
    fmt.Printf("[INFO] %s: %s\n", l.prefix, msg)
}

func (l Logger) Error(msg string) {
    fmt.Printf("[ERROR] %s: %s\n", l.prefix, msg)
}

func (l Logger) Debug(msg string) {
    fmt.Printf("[DEBUG] %s: %s\n", l.prefix, msg)
}

type Service struct {
    Logger  // embedded logger
    name    string
}

// Shadow the Error method to add custom behavior
func (s Service) Error(msg string) {
    // Add timestamp and service context
    timestamp := time.Now().Format("2006-01-02 15:04:05")
    fmt.Printf("[ERROR] %s [%s] %s: %s\n", timestamp, s.name, s.Logger.prefix, msg)
    
    // Could add additional error tracking here
}

func main() {
    svc := Service{
        Logger: Logger{prefix: "UserService"},
        name:   "user-api",
    }

    // Uses promoted method from Logger
    svc.Info("Service started")
    svc.Debug("Processing request")
    
    // Uses shadowed method from Service
    svc.Error("Database connection failed")
    
    // Can still access original if needed
    svc.Logger.Error("Direct logger call")
}

Output:

[INFO] UserService: Service started
[DEBUG] UserService: Processing request
[ERROR] 2024-01-15 10:30:45 [user-api] UserService: Database connection failed
[ERROR] UserService: Direct logger call

Shadowing gives you fine-grained control: use embedded behavior by default, override only where you need customization.

Real-World Pattern: Building Flexible Components

Embedding shines in middleware and decorator patterns. Here’s an HTTP handler pattern that composes functionality:

package main

import (
    "fmt"
    "net/http"
    "time"
)

// Base handler
type BaseHandler struct{}

func (h BaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

// Logging middleware via embedding
type LoggingHandler struct {
    http.Handler  // embed interface, not concrete type
}

func (h LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    fmt.Printf("[%s] %s %s\n", start.Format("15:04:05"), r.Method, r.URL.Path)
    
    h.Handler.ServeHTTP(w, r)  // delegate to embedded handler
    
    fmt.Printf("Completed in %v\n", time.Since(start))
}

// Auth middleware
type AuthHandler struct {
    http.Handler
    requiredToken string
}

func (h AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("Authorization")
    
    if token != h.requiredToken {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    
    h.Handler.ServeHTTP(w, r)
}

func main() {
    base := BaseHandler{}
    
    // Compose handlers through embedding
    logged := LoggingHandler{Handler: base}
    authenticated := AuthHandler{
        Handler:       logged,
        requiredToken: "secret-token",
    }
    
    http.Handle("/", authenticated)
    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", nil)
}

This pattern creates a chain of responsibility where each layer adds functionality without modifying existing code. You can reorder, add, or remove middleware without changing the handlers themselves.

Common Pitfalls and Best Practices

Pitfall 1: Pointer vs Value Embedding

// Problematic: value embedding with pointer receiver methods
type Database struct {
    connected bool
}

func (db *Database) Connect() error {
    db.connected = true
    return nil
}

type Repository struct {
    Database  // value embedding
}

func main() {
    repo := Repository{}
    repo.Connect()  // Compiles but doesn't work as expected!
    // The method operates on a copy, repo.Database.connected stays false
}

Better: Consistent pointer embedding

type Repository struct {
    *Database  // pointer embedding
}

func main() {
    repo := Repository{Database: &Database{}}
    repo.Connect()  // Now it works correctly
    fmt.Println(repo.connected)  // true
}

Pitfall 2: Overusing Embedding for Clarity

// Confusing: what does Employee "do" with Person?
type Employee struct {
    Person
    Salary float64
}

// Clearer: explicit relationship
type Employee struct {
    PersonalInfo Person
    Salary       float64
}

Use embedding when you want the outer type to behave as the inner type. Use explicit fields when the relationship is containment or when you need multiple instances of the same type.

Best Practice: Embed Interfaces, Not Concrete Types

Embedding interfaces provides maximum flexibility:

type Service struct {
    storage Storage  // interface, not *PostgresDB
    logger  Logger   // interface, not *FileLogger
}

This keeps your code testable and allows swapping implementations without changing the Service struct.

Composition’s Advantages

Go’s embedding mechanism delivers the benefits of inheritance—code reuse and polymorphism—without the drawbacks. You avoid the fragile base class problem where changes to parent classes break child classes. You sidestep the diamond problem where multiple inheritance creates ambiguity.

Composition through embedding produces flatter, more maintainable structures. When you need to understand a type’s behavior, you look at its definition and the embedded types—no traversing inheritance chains. When you need to modify behavior, you can shadow methods selectively or create new types that embed different combinations of existing types.

Most importantly, embedding encourages interface-based design. By embedding interfaces rather than concrete types, you create loosely coupled systems where components interact through contracts rather than implementations. This is the foundation of testable, flexible software.

The next time you reach for inheritance in another language, consider whether composition would serve you better. In Go, you don’t have a choice—and that constraint produces better designs.

Liked this? There's more.

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