Go’s lack of inheritance is deliberate. The Go designers observed that inheritance hierarchies tend to create tight coupling and fragile base classes — problems that composition avoids. Embedding is Go’s tool for composition: you can embed one type in another and promote its methods, without inheritance’s downsides.

It’s more powerful and more subtle than it appears.

What Embedding Does

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Logger struct {
    prefix string
}

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

type Service struct {
    Logger           // embedded — not a named field
    name   string
}

svc := Service{
    Logger: Logger{prefix: "SVC"},
    name:   "order-service",
}

svc.Log("started")  // promoted — calls svc.Logger.Log("started")

Logger’s Log method is promoted to Service. You can call it directly on Service values. The embedded Logger is accessible as svc.Logger for explicit access.

This is not inheritance. Service is not a Logger. There’s no polymorphism — you can’t pass a *Service where a *Logger is expected. Embedding is a syntactic convenience for accessing the embedded type’s fields and methods.

Interface Satisfaction Through Embedding

Where embedding becomes powerful is interface satisfaction:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Writer interface {
    Write([]byte) (int, error)
}

type BufferedWriter struct {
    io.Writer         // embedded — promotes Write method
    buffer []byte
}

// BufferedWriter satisfies Writer because it has Write (promoted from io.Writer):
var w Writer = &BufferedWriter{Writer: os.Stdout}

By embedding io.Writer, BufferedWriter automatically satisfies the Writer interface. You can then override specific methods while keeping the rest:

1
2
3
4
5
6
7
8
func (b *BufferedWriter) Write(data []byte) (int, error) {
    b.buffer = append(b.buffer, data...)
    if len(b.buffer) > 4096 {
        return b.Writer.Write(b.buffer)  // explicit call to embedded method
        b.buffer = b.buffer[:0]
    }
    return len(data), nil
}

Now BufferedWriter buffers writes and flushes when full, delegating to the embedded io.Writer for actual I/O.

The Composition Pattern for Services

A common pattern at the European fintech firm: embedding common infrastructure into service types.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type BaseService struct {
    logger  *slog.Logger
    metrics *prometheus.Registry
    tracer  trace.Tracer
}

func (b *BaseService) Logger() *slog.Logger  { return b.logger }
func (b *BaseService) Metrics() *prometheus.Registry { return b.metrics }
func (b *BaseService) Tracer() trace.Tracer  { return b.tracer }

type OrderService struct {
    BaseService           // embed common infrastructure
    repo OrderRepository
    pricing PricingClient
}

type PositionService struct {
    BaseService
    repo PositionRepository
    risk RiskClient
}

Both services have access to logging, metrics, and tracing through the promoted methods. Updates to BaseService (adding a new infrastructure field, for example) automatically propagate to all embedded types.

Embedding Interfaces (Not Just Structs)

You can embed interfaces in structs to create partial implementations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type ReadOnlyCache interface {
    Get(key string) (Value, bool)
    Keys() []string
}

// A wrapper that adds TTL to any ReadOnlyCache:
type TTLCache struct {
    ReadOnlyCache           // embed the interface
    expiry map[string]time.Time
    mu     sync.RWMutex
}

func (t *TTLCache) Get(key string) (Value, bool) {
    t.mu.RLock()
    exp, ok := t.expiry[key]
    t.mu.RUnlock()

    if ok && time.Now().After(exp) {
        return Value{}, false  // expired
    }
    return t.ReadOnlyCache.Get(key)  // delegate to underlying cache
}

// Keys() is promoted from ReadOnlyCache — not overridden, delegates to underlying

The TTLCache wraps any ReadOnlyCache and adds expiry. It overrides Get to check expiry, and inherits Keys() by delegation to the embedded interface’s value.

The important caveat: if you use this pattern and the embedded interface field is nil, any call to a promoted method that isn’t overridden will panic. Always initialise embedded interfaces:

1
2
3
4
5
6
// Will panic on Keys():
cache := &TTLCache{}
cache.Keys()  // panic: nil pointer dereference

// Correct:
cache := &TTLCache{ReadOnlyCache: newMemoryCache()}

What Embedding Is Not

Not polymorphism: you can’t use a *Service value where a *Logger is expected, even though Service embeds Logger. Embedding promotes methods; it doesn’t establish a subtype relationship.

Not method override with super: calling svc.Log("x") always calls Logger.Log. There’s no mechanism like Java’s super.Log() to call the embedded type’s method from an overriding method — you call it explicitly via svc.Logger.Log("x").

Not always the right tool: if you need polymorphism (pass different concrete types where an interface is expected), use interface composition. Embedding is for method promotion and delegation.

The Rule

Embed when:

  • You want to promote methods to the outer type for ergonomic access
  • You’re building a decorator/wrapper that overrides some methods and delegates others
  • Multiple types share common infrastructure and you want a single definition

Don’t embed when:

  • You need subtype polymorphism (use interfaces)
  • The embedded type is conceptually a field, not a capability the outer type has (named fields are clearer)
  • The embedded type has methods you don’t want to expose (they’ll all be promoted)

Embedding is one of Go’s more elegant design choices — a composition mechanism that’s simple, explicit, and doesn’t require the type system complexity that inheritance brings. Using it correctly produces code that’s easy to read and extend.