Skip to content
AyoKoding

Composition Patterns

Why Composition Matters

Go favors composition over inheritance, enabling flexible code reuse without complex type hierarchies. Understanding struct embedding, interface composition, and middleware patterns prevents brittle inheritance-like structures and enables powerful abstractions used throughout the standard library and production systems.

Core benefits:

  • Flexibility: Change behavior without modifying types
  • Testability: Easy to mock embedded interfaces
  • Decoupling: Components interact through interfaces, not concrete types
  • Reusability: Compose small interfaces into larger behaviors

Problem: Developers from OOP backgrounds create deep type hierarchies, missing Go's composition model. This leads to tight coupling and difficult refactoring.

Solution: Learn struct embedding from standard library, then apply interface composition and middleware patterns for production systems.

Standard Library: Struct Embedding

Go's struct embedding allows one struct to include another, promoting fields and methods to the outer type.

Pattern from standard library:

package main
 
import (
    "fmt"
    // => Standard library for output
    "time"
    // => Standard library for time operations
)
 
// Base type with common fields
type Entity struct {
    ID        string
    // => ID is unique identifier
    CreatedAt time.Time
    // => CreatedAt is creation timestamp
    UpdatedAt time.Time
    // => UpdatedAt is last modification timestamp
}
 
// User embeds Entity (composition)
type User struct {
    Entity
    // => Entity embedded (not inherited)
    // => Entity fields promoted to User
    // => User "has-a" Entity (not "is-a")
 
    Email string
    // => Email field specific to User
    Name  string
    // => Name field specific to User
}
 
func main() {
    user := User{
        Entity: Entity{
            // => Initialize embedded Entity
            ID:        "user-123",
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
        },
        Email: "alice@example.com",
        Name:  "Alice",
    }
 
    // PROMOTED FIELDS: access Entity fields directly
    fmt.Println(user.ID)
    // => Output: user-123
    // => user.ID is shorthand for user.Entity.ID
    // => Field promoted from embedded Entity
 
    fmt.Println(user.CreatedAt)
    // => Output: 2026-02-04 20:00:00 +0700 WIB
    // => Promoted field accessed directly
 
    // EXPLICIT ACCESS: also valid
    fmt.Println(user.Entity.ID)
    // => Output: user-123
    // => Explicit access to embedded field
    // => Same result as user.ID
}

Method promotion:

package main
 
import (
    "fmt"
    // => Standard library for output
    "time"
    // => Standard library for time operations
)
 
type Entity struct {
    ID        string
    UpdatedAt time.Time
}
 
// Method on Entity
func (e *Entity) Touch() {
    // => Touch updates UpdatedAt timestamp
    // => Pointer receiver modifies Entity
 
    e.UpdatedAt = time.Now()
    // => Sets UpdatedAt to current time
}
 
type User struct {
    Entity
    // => Embeds Entity (gains Touch method)
 
    Email string
}
 
func main() {
    user := User{
        Entity: Entity{ID: "user-123"},
        Email:  "alice@example.com",
    }
 
    // PROMOTED METHOD: call Entity method on User
    user.Touch()
    // => Calls user.Entity.Touch()
    // => Method promoted from embedded Entity
    // => user.UpdatedAt modified
 
    fmt.Println(user.UpdatedAt)
    // => Output: 2026-02-04 20:00:00 +0700 WIB
    // => UpdatedAt set by Touch method
}

Limitations:

  • No polymorphism (embedding is not inheritance)
  • Name collisions resolved by explicit access
  • Cannot override methods (no dynamic dispatch)
  • Embedding multiple types with same field causes ambiguity

Standard Library: Interface Composition

Go interfaces compose from smaller interfaces, following the "accept interfaces, return structs" principle.

Pattern from standard library (io package):

package main
 
import (
    "io"
    // => Standard library I/O interfaces
    "os"
    // => Standard library file operations
    "strings"
    // => Standard library string utilities
)
 
// SMALL INTERFACES (from io package):
// type Reader interface {
//     Read(p []byte) (n int, err error)
// }
// => Reader represents anything that can be read
// => Single method interface (common pattern)
 
// type Writer interface {
//     Write(p []byte) (n int, err error)
// }
// => Writer represents anything that can be written to
// => Single method interface
 
// COMPOSED INTERFACE:
// type ReadWriter interface {
//     Reader
//     Writer
// }
// => ReadWriter embeds Reader and Writer
// => Types must implement both Read and Write
// => Composition creates larger interface from smaller ones
 
func copy(dst io.Writer, src io.Reader) (int64, error) {
    // => dst is anything implementing Write
    // => src is anything implementing Read
    // => Interfaces enable flexibility (files, buffers, networks)
 
    buffer := make([]byte, 32*1024)
    // => 32KB buffer for copying
    // => []byte allocated on heap
 
    var written int64
    // => Total bytes written (accumulator)
 
    for {
        // => Infinite loop (exits on EOF or error)
 
        nr, err := src.Read(buffer)
        // => Read up to 32KB from source
        // => nr is number of bytes read
        // => err is io.EOF when done
 
        if nr > 0 {
            // => Data available in buffer
 
            nw, errW := dst.Write(buffer[:nr])
            // => Write nr bytes to destination
            // => buffer[:nr] slices to actual data read
            // => nw is bytes written
 
            written += int64(nw)
            // => Accumulate total bytes written
 
            if errW != nil {
                // => Write error occurred
                return written, errW
            }
        }
 
        if err == io.EOF {
            // => End of file reached (normal termination)
            break
        }
        if err != nil {
            // => Read error occurred
            return written, err
        }
    }
 
    return written, nil
    // => Success: return total bytes copied
}
 
func main() {
    // src is *strings.Reader (implements io.Reader)
    src := strings.NewReader("hello world")
    // => strings.Reader wraps string as io.Reader
    // => Read method reads from string
 
    // dst is *os.File (implements io.Writer)
    dst := os.Stdout
    // => os.Stdout is standard output file
    // => Write method writes to terminal
 
    copy(dst, src)
    // => Output: hello world
    // => copy works with any Reader/Writer
    // => No concrete type dependencies
}

Composition enables flexibility:

package main
 
import "io"
 
// Small focused interfaces
type Flusher interface {
    Flush() error
    // => Flush writes buffered data
}
 
type Closer interface {
    Close() error
    // => Close releases resources
}
 
// Composed interfaces
type WriteCloser interface {
    io.Writer
    // => Embeds Writer interface
    Closer
    // => Adds Close method
}
// => WriteCloser represents writable closeable resource
// => Files, network connections implement this
 
type ReadWriteCloser interface {
    io.Reader
    io.Writer
    Closer
}
// => ReadWriteCloser combines three interfaces
// => Full duplex communication with cleanup
// => TCP connections implement this
 
func process(rwc io.ReadWriteCloser) error {
    // => rwc must implement Read, Write, Close
    // => Accepts files, network connections, pipes
    // => Interface composition enables polymorphism
 
    defer rwc.Close()
    // => Ensure resources released
    // => Close called on function exit
 
    // Read/write operations...
    return nil
}

Why interface composition matters:

  • Small interfaces easy to implement and test
  • Compose interfaces to express requirements precisely
  • Functions accept minimal interface needed (not concrete types)
  • Standard library uses this pattern extensively

Production Pattern: HTTP Middleware

Middleware wraps http.Handler to add cross-cutting concerns (logging, auth, metrics) without modifying handler code.

Pattern: Middleware Chain:

package main
 
import (
    "fmt"
    // => Standard library for output
    "log"
    // => Standard library for logging
    "net/http"
    // => Standard library HTTP server
    "time"
    // => Standard library for timing
)
 
// Middleware is function that wraps http.Handler
type Middleware func(http.Handler) http.Handler
// => Takes handler, returns wrapped handler
// => Allows chaining multiple middlewares
// => Composition pattern for HTTP handling
 
// Logging middleware
func LoggingMiddleware(next http.Handler) http.Handler {
    // => next is the wrapped handler
    // => Returns new handler with logging behavior
 
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // => http.HandlerFunc adapts function to http.Handler
        // => w is response writer
        // => r is incoming request
 
        start := time.Now()
        // => Record start time
 
        log.Printf("Started %s %s", r.Method, r.URL.Path)
        // => Log request method and path
        // => Output: Started GET /api/users
 
        next.ServeHTTP(w, r)
        // => CRITICAL: call wrapped handler
        // => Without this, request chain breaks
        // => Delegates to next middleware or final handler
 
        duration := time.Since(start)
        // => Calculate request duration
 
        log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, duration)
        // => Log completion with timing
        // => Output: Completed GET /api/users in 15ms
    })
}
 
// Authentication middleware
func AuthMiddleware(next http.Handler) http.Handler {
    // => Wraps handler with auth check
 
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // => Handler function for auth checking
 
        token := r.Header.Get("Authorization")
        // => Extract auth token from header
        // => token is "Bearer xyz123" or empty
 
        if token == "" {
            // => No token provided
 
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            // => Return 401 Unauthorized
            // => Sets response status and body
            // => Request chain stops here
 
            return
            // => CRITICAL: return without calling next
            // => Prevents unauthorized access to handler
        }
 
        // Token validation would happen here
        // In production: verify JWT, check DB, etc.
 
        next.ServeHTTP(w, r)
        // => Auth passed: continue to next handler
        // => Only reached if token present
    })
}
 
// Business logic handler
func helloHandler(w http.ResponseWriter, r *http.Request) {
    // => Final handler (business logic)
    // => Called after all middleware
 
    fmt.Fprintf(w, "Hello, authenticated user!")
    // => Write response body
    // => Output: Hello, authenticated user!
}
 
func main() {
    // Final handler (business logic)
    finalHandler := http.HandlerFunc(helloHandler)
    // => Wrap function as http.Handler
 
    // Compose middleware chain
    handler := LoggingMiddleware(AuthMiddleware(finalHandler))
    // => Execution order: Logging → Auth → finalHandler
    // => Request flows: Logging (before) → Auth (before) → finalHandler → Auth (after) → Logging (after)
    // => Composition wraps handlers like onion layers
 
    http.Handle("/api/hello", handler)
    // => Register wrapped handler at /api/hello
    // => All requests pass through middleware chain
 
    log.Println("Server starting on :8080")
    http.ListenAndServe(":8080", nil)
    // => Start HTTP server on port 8080
    // => Blocks until server stops
}

Middleware chain visualization:

Request → LoggingMiddleware (start) → AuthMiddleware (check) → finalHandler (logic)
                                                                        ↓
Response ← LoggingMiddleware (end) ← AuthMiddleware (done) ← finalHandler (response)

Reusable middleware library pattern:

package main
 
import "net/http"
 
// Chain wraps multiple middlewares
type Chain struct {
    middlewares []Middleware
    // => middlewares is ordered list of wrappers
}
 
func NewChain(middlewares ...Middleware) Chain {
    // => Variadic function accepts any number of middlewares
    // => Returns Chain for fluent API
 
    return Chain{middlewares: middlewares}
    // => Store middlewares in order
}
 
func (c Chain) Then(h http.Handler) http.Handler {
    // => Then applies all middlewares to handler
    // => Returns fully wrapped handler
 
    // Apply middlewares in reverse order
    for i := len(c.middlewares) - 1; i >= 0; i-- {
        // => Loop backwards through middlewares
        // => Ensures correct execution order
 
        h = c.middlewares[i](h)
        // => Wrap h with middleware[i]
        // => Each iteration adds outer layer
    }
 
    return h
    // => Return fully composed handler
}
 
func main() {
    // Create reusable middleware chain
    chain := NewChain(
        LoggingMiddleware,
        AuthMiddleware,
    )
    // => chain wraps any handler with logging + auth
 
    // Apply to handler
    handler := chain.Then(http.HandlerFunc(helloHandler))
    // => Composes: LoggingMiddleware(AuthMiddleware(helloHandler))
    // => Reusable pattern for multiple endpoints
 
    http.Handle("/api/hello", handler)
    // => /api/hello has logging + auth
 
    http.Handle("/api/users", chain.Then(http.HandlerFunc(usersHandler)))
    // => /api/users has same middleware chain
    // => DRY: Define middleware once, apply everywhere
}
 
func usersHandler(w http.ResponseWriter, r *http.Request) {
    // => Another handler using same middleware
    // ...
}

Trade-offs: When to Use Each

Comparison table:

PatternType SafetyFlexibilityUse Case
Struct embeddingCompile-timeLowReuse fields/methods (Entity base)
Interface embeddingCompile-timeMediumCompose behaviors (ReadWriteCloser)
MiddlewareCompile-timeHighHTTP cross-cutting concerns (logging, auth)

When to use struct embedding:

  • Common fields across types (ID, timestamps)
  • Reuse methods without duplication
  • "Has-a" relationships (User has Entity)
  • Promote methods/fields to outer type

When to use interface composition:

  • Define minimal interfaces (Reader, Writer)
  • Compose interfaces for requirements (ReadWriter)
  • Accept interfaces, return structs (standard pattern)
  • Enable polymorphism without inheritance

When to use middleware:

  • HTTP cross-cutting concerns (logging, auth, metrics)
  • Wrap handlers without modifying code
  • Compose behavior chains (pipeline pattern)
  • Reusable request/response processing

Production Best Practices

Prefer small interfaces:

// GOOD: small focused interface
type Notifier interface {
    Notify(message string) error
}
 
// BAD: large interface (hard to implement)
type Service interface {
    Notify(message string) error
    Log(level string, msg string)
    Metrics() map[string]int
    // ... many methods
}

Compose interfaces from smaller ones:

// Small interfaces
type Notifier interface {
    Notify(message string) error
}
 
type Logger interface {
    Log(level string, msg string)
}
 
// Composed interface
type NotifyingLogger interface {
    Notifier
    Logger
}
// => Only require this when both needed

Use middleware for HTTP concerns:

// Separate concerns into middleware
chain := NewChain(
    RecoveryMiddleware,   // Panic recovery
    LoggingMiddleware,    // Request logging
    MetricsMiddleware,    // Prometheus metrics
    AuthMiddleware,       // Authentication
    RateLimitMiddleware,  // Rate limiting
)
 
// Apply to handlers
http.Handle("/api/users", chain.Then(usersHandler))

Embed interfaces for testing:

type UserService struct {
    db     Database
    notify Notifier
}
// => db and notify are interfaces
// => Easy to mock in tests
// => No concrete type dependencies

Summary

Go's composition model enables flexible code reuse without inheritance. Struct embedding shares fields and methods, interface composition builds complex behaviors from small interfaces, and middleware chains wrap handlers for cross-cutting concerns. Prefer composition over concrete types for testable, decoupled production code.

Key takeaways:

  • Struct embedding promotes fields/methods (has-a, not is-a)
  • Interface composition creates larger interfaces from smaller ones
  • Middleware chains wrap http.Handler for reusable behavior
  • Small interfaces easier to implement, test, and compose
  • Composition provides flexibility without inheritance complexity

Last updated February 3, 2026

Command Palette

Search for a command to run...