Skip to content
Codeloom
Go

Struct Embedding and Composition Patterns in Go

Learn Go composition over inheritance with struct embedding, interface embedding, and practical patterns for clean architecture.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How struct embedding works and how it differs from inheritance
  • Interface embedding for composing behaviors
  • Practical composition patterns for services and middleware

Prerequisites

  • Basic Go knowledge
  • Familiarity with structs and interfaces

Go does not have inheritance. It has composition, and struct embedding is the mechanism that makes composition feel natural. This guide explains how embedding works, when to use it, and common patterns for production code.

How Struct Embedding Works

When you embed a type inside a struct, its methods and fields are “promoted” to the outer struct. You can call them directly without going through a field name.

type Logger struct {
    Prefix string
}

func (l *Logger) Log(msg string) {
    fmt.Printf("[%s] %s\n", l.Prefix, msg)
}

type Server struct {
    Logger // embedded, not a named field
    Port   int
}

func main() {
    s := Server{
        Logger: Logger{Prefix: "HTTP"},
        Port:   8080,
    }

    s.Log("starting server") // promoted method
    // equivalent to s.Logger.Log("starting server")
}

Output: [HTTP] starting server

Embedding Is Not Inheritance

The key difference: when you call a promoted method, the receiver is still the embedded type, not the outer type.

type Base struct{}

func (b *Base) Name() string { return "Base" }
func (b *Base) Greet() string { return "Hello from " + b.Name() }

type Derived struct {
    Base
}

func (d *Derived) Name() string { return "Derived" }

func main() {
    d := &Derived{}
    fmt.Println(d.Greet()) // "Hello from Base", not "Hello from Derived"
}

There is no virtual method dispatch. Base.Greet() calls Base.Name(), not Derived.Name(). If you need polymorphism, use interfaces.

Interface Satisfaction via Embedding

Embedding a type that implements an interface makes the outer type also satisfy that interface.

type ReadCloser struct {
    io.Reader // has Read method
    io.Closer // has Close method
}

// ReadCloser now implements io.ReadCloser without writing any methods

This is how the standard library builds complex interfaces from simple ones:

// From the io package
type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

Pattern: Embedding for Default Behavior

Provide a base implementation and let the outer type override specific methods.

type Notifier interface {
    Notify(ctx context.Context, event Event) error
    Format(event Event) string
}

type BaseNotifier struct{}

func (b *BaseNotifier) Format(event Event) string {
    return fmt.Sprintf("[%s] %s", event.Type, event.Message)
}

type SlackNotifier struct {
    BaseNotifier
    WebhookURL string
}

func (s *SlackNotifier) Notify(ctx context.Context, event Event) error {
    msg := s.Format(event) // uses BaseNotifier.Format
    return postToSlack(ctx, s.WebhookURL, msg)
}

type EmailNotifier struct {
    BaseNotifier
    SMTPAddr string
}

func (e *EmailNotifier) Format(event Event) string {
    // Override the default format for email
    return fmt.Sprintf("Subject: %s\n\n%s", event.Type, event.Message)
}

func (e *EmailNotifier) Notify(ctx context.Context, event Event) error {
    msg := e.Format(event) // uses EmailNotifier.Format (overridden)
    return sendEmail(ctx, e.SMTPAddr, msg)
}

Pattern: Composition with Dependency Injection

Instead of embedding, hold dependencies as named fields. This is the most common pattern for services.

type OrderService struct {
    repo     OrderRepository
    payments PaymentGateway
    notifier Notifier
    logger   *slog.Logger
}

func NewOrderService(
    repo OrderRepository,
    payments PaymentGateway,
    notifier Notifier,
    logger *slog.Logger,
) *OrderService {
    return &OrderService{
        repo:     repo,
        payments: payments,
        notifier: notifier,
        logger:   logger,
    }
}

func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
    if err := s.payments.Charge(ctx, order.Total); err != nil {
        return fmt.Errorf("charge payment: %w", err)
    }

    if err := s.repo.Save(ctx, order); err != nil {
        return fmt.Errorf("save order: %w", err)
    }

    s.logger.Info("order placed", "order_id", order.ID, "total", order.Total)
    return nil
}

Named fields (not embedding) are preferred for dependencies because:

  • The API is explicit. Callers know exactly what OrderService needs.
  • No accidental method promotion. The service does not accidentally expose repo.Delete at the top level.
  • Easier to test. Swap dependencies with mocks.

Pattern: Embedding Mutex for Protected State

Embed a sync.Mutex to protect a struct’s fields. This is idiomatic for small, self-contained types.

type SafeMap struct {
    sync.RWMutex
    data map[string]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{data: make(map[string]int)}
}

func (m *SafeMap) Get(key string) (int, bool) {
    m.RLock()
    defer m.RUnlock()
    v, ok := m.data[key]
    return v, ok
}

func (m *SafeMap) Set(key string, value int) {
    m.Lock()
    defer m.Unlock()
    m.data[key] = value
}

Embedding promotes Lock, Unlock, RLock, and RUnlock directly onto SafeMap. Some teams prefer this; others prefer a named mu sync.RWMutex field to keep the locking methods private.

Pattern: Wrapping with Embedding

Add behavior around an existing type by embedding it and overriding specific methods.

type LoggingRoundTripper struct {
    http.RoundTripper
    Logger *slog.Logger
}

func (l *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    start := time.Now()

    resp, err := l.RoundTripper.RoundTrip(req)

    duration := time.Since(start)
    if err != nil {
        l.Logger.Error("http request failed",
            "method", req.Method,
            "url", req.URL.String(),
            "duration", duration,
            "error", err,
        )
        return nil, err
    }

    l.Logger.Info("http request",
        "method", req.Method,
        "url", req.URL.String(),
        "status", resp.StatusCode,
        "duration", duration,
    )
    return resp, nil
}

Usage:

client := &http.Client{
    Transport: &LoggingRoundTripper{
        RoundTripper: http.DefaultTransport,
        Logger:       slog.Default(),
    },
}

This is the decorator pattern via embedding.

Pattern: Embedding Interfaces in Structs

Embed an interface in a struct to create a partial mock or a decorator that only overrides some methods.

type MockStore struct {
    Store // embed the interface
    GetFunc func(ctx context.Context, id string) (*Item, error)
}

func (m *MockStore) Get(ctx context.Context, id string) (*Item, error) {
    if m.GetFunc != nil {
        return m.GetFunc(ctx, id)
    }
    return m.Store.Get(ctx, id) // delegate to embedded
}

This is useful in tests when an interface has many methods but you only want to override one or two.

When to Embed vs When to Compose

Embed when:

  • You want the outer type to satisfy an interface automatically.
  • You are building a decorator that wraps and extends behavior.
  • The promoted methods make sense as part of the outer type’s API.

Use named fields when:

  • The inner type is a dependency (database, logger, HTTP client).
  • Promoting methods would leak implementation details.
  • You want to keep the outer type’s API surface small.

Avoiding Ambiguity

If two embedded types have a method with the same name, the compiler reports an ambiguity error.

type A struct{}
func (a A) Hello() string { return "A" }

type B struct{}
func (b B) Hello() string { return "B" }

type C struct {
    A
    B
}

// c.Hello() is ambiguous and will not compile
// Fix by defining Hello() on C explicitly:
func (c C) Hello() string {
    return c.A.Hello() // or c.B.Hello()
}

Summary

  • Embedding promotes methods and fields. It is composition, not inheritance.
  • There is no virtual dispatch: promoted methods use the embedded type as the receiver.
  • Embed interfaces to compose behaviors (io.ReadWriteCloser).
  • Use named fields for dependencies to keep APIs explicit and testable.
  • Embed sync.Mutex for self-protecting types.
  • Embed an interface in a struct for partial mocks and decorators.
  • When two embeddings conflict, resolve ambiguity with an explicit method.