# Struct Embedding in Go: Composition That Bites When You Reach for Inheritance

> Source: <https://dev.to/gabrielanhaia/struct-embedding-in-go-composition-that-bites-when-you-reach-for-inheritance-29le>
> Published: 2026-06-13 21:54:11+00:00

You come to Go from a language with classes. You see struct

embedding for the first time, and it reads like inheritance. A field

with no name, methods that "carry over" to the outer type, a base

struct that your type extends. So you write code the way you always

have, and most of it works. Then a method does something you did not

ask for, a type satisfies an interface you never meant to implement,

or two embedded types fight over a name and the compiler shrugs until

the exact line that calls it.

Embedding is not inheritance. It is composition with a syntax that

promotes methods and fields up one level. Once you hold that

distinction, the surprises stop being surprises. Here is where they

come from.

Write an embedded field by giving a type with no field name:

```
type Engine struct {
    Horsepower int
}

func (e Engine) Start() string {
    return "vroom"
}

type Car struct {
    Engine // embedded
    Brand  string
}
```

`Car`

now has a `Start`

method and a `Horsepower`

field, both

promoted from `Engine`

. You can write `car.Start()`

and

`car.Horsepower`

as if they were declared on `Car`

.

```
car := Car{Engine: Engine{Horsepower: 300}, Brand: "Fiat"}
fmt.Println(car.Start())      // vroom
fmt.Println(car.Horsepower)   // 300
```

This is where the inheritance illusion starts. `car.Start()`

is

sugar. The compiler rewrites it to `car.Engine.Start()`

. The

receiver of `Start`

is still an `Engine`

, never a `Car`

. There is no

base class, no `super`

, no virtual dispatch. `Engine`

does not know

`Car`

exists.

That last point is the one that bites. A promoted method runs against

the embedded value, not the outer struct.

Say you want a stringer on the embedded type that reads outer fields.

This is the move that feels like overriding a base method.

```
type Base struct {
    Name string
}

func (b Base) Describe() string {
    return "base: " + b.Name
}

type User struct {
    Base
    Role string
}
```

You construct a `User`

, set the role, and call `Describe`

, expecting

the role to show up somehow.

```
u := User{Base: Base{Name: "ana"}, Role: "admin"}
fmt.Println(u.Describe()) // base: ana
```

There is no path from `Describe`

to `Role`

. `Describe`

has a `Base`

receiver. `Base`

has no idea what `Role`

is. In a class hierarchy a

method on the parent can be overridden by the child and dynamic

dispatch picks the override. Embedding has no dispatch. If you want

`User`

to describe itself, you declare the method on `User`

:

```
func (u User) Describe() string {
    return "user: " + u.Name + " (" + u.Role + ")"
}
```

Now `User.Describe`

shadows the promoted `Base.Describe`

. Calling

`u.Describe()`

runs the `User`

version. Calling `u.Base.Describe()`

still runs the base one. Both exist. You chose which by the selector,

not by runtime type.

This is the surprise that ships to production. Embedding a type drags

its whole method set into yours, and that method set can satisfy

interfaces you never intended to implement.

```
type Closer interface {
    Close() error
}

type Conn struct{}

func (c *Conn) Close() error {
    fmt.Println("closing real connection")
    return nil
}

type Service struct {
    *Conn // embedded for the Query method, say
}
```

You embedded `*Conn`

because you wanted its query helpers. But

`*Conn`

has `Close`

, so `*Service`

now satisfies `Closer`

too. A

pool that ranges over `Closer`

values and calls `Close`

will close

your service's underlying connection, possibly one shared across the

program.

```
func closeAll(cs []Closer) {
    for _, c := range cs {
        c.Close()
    }
}

// *Service slides into []Closer with no warning,
// and closeAll shuts its connection.
```

Nothing in the type declaration says "I am a Closer." The compiler

infers it from the promoted method set. If you did not mean to expose

`Close`

, embedding was the wrong tool. Use a named field instead:

```
type Service struct {
    conn *Conn
}

func (s *Service) Query(q string) error {
    return s.conn.query(q)
}
```

Now `*Service`

exposes only what you wrote. `Close`

stays private to

the connection. The rule: embedding is a public promise. Everything

the embedded type exports, your type exports too.

Whether you embed `Conn`

or `*Conn`

decides which methods get

promoted, because of how Go builds method sets.

A method with a pointer receiver belongs to the method set of the

pointer type, not the value type. So if `Conn`

has `func (c *Conn)`

, then:

Close()

`Conn`

(value) into `Service`

promotes `Close`

only when
you hold a `*Service`

, because `Close`

needs an addressable
receiver.`*Conn`

(pointer) into `Service`

promotes `Close`

onto
both `Service`

and `*Service`

.

``` js
type Service struct {
    Conn // value embed
}

var s Service
var _ Closer = &s // works: &s is addressable, Close promoted
var _ Closer = s  // compile error: Service does not implement Closer
```

The fix when you want the value type itself to satisfy the interface

is to embed the pointer, or to keep all receivers as values. Mixing

value and pointer receivers on the same type is where this gets hard

to reason about. Pick one receiver style per type and the method-set

rules stop fighting you.

Embed two types that both export the same method or field name, and

the outer struct compiles. The conflict is only an error at the

selector that uses the ambiguous name.

```
type Reader struct{}

func (Reader) Read() string { return "read" }

type Writer struct{}

func (Writer) Read() string { return "also read" }

type Pipe struct {
    Reader
    Writer
}
```

`Pipe`

declares fine. You can construct it, store it, pass it around.

The Go spec says a name promoted from two embedded types at the same

depth is not promoted at all. So `Pipe`

has no `Read`

method.

``` js
var p Pipe
p.Read() // compile error: ambiguous selector p.Read
```

The collision sleeps until a caller writes `p.Read()`

. If no caller

ever does, the ambiguity stays invisible. Then someone adds a call,

or assigns `Pipe`

to an interface that needs `Read`

, and the build

breaks far from where the embedding lives.

Depth matters too. A name at a shallower embed level wins over a

deeper one. So this resolves with no error:

```
type WriterHolder struct {
    Writer // Read sits at depth 2 from Pipe
}

type Pipe struct {
    Reader       // depth 1
    WriterHolder // depth 2 path to Writer.Read
}
```

The shallow `Reader.Read`

is promoted; the deeper one is shadowed.

The asymmetry between "same depth is an error" and "different depth

silently picks the shallow one" is exactly the kind of rule that

reads fine and behaves surprisingly.

Resolve a real collision by promoting explicitly:

```
func (p Pipe) Read() string {
    return p.Reader.Read()
}
```

None of this means avoid embedding. It means use it for what it is:

composition where you genuinely want the embedded type's surface to

become part of yours.

Embedding fits when:

`http.ResponseWriter`

to add logging while
forwarding everything else.`sync.Mutex`

so `s.Lock()`

reads cleanly. Watch the value-copy trap
there: embed it and use pointer receivers.

```
type loggingWriter struct {
    http.ResponseWriter // promote the whole interface
    status int
}

func (w *loggingWriter) WriteHeader(code int) {
    w.status = code
    w.ResponseWriter.WriteHeader(code) // forward
}
```

That is embedding doing its job. You wanted the full

`ResponseWriter`

surface, and you overrode one method by shadowing.

Reach for a named field instead when you want the inner type's data

without its public method set, when you do not want to promise the

inner type's interfaces, or when "has-a" describes the relationship

better than "is-a-surface-of." Most domain types fall here. A

`User`

has an `Address`

; it does not want every `Address`

method

showing up on `User`

.

Before you embed, ask one question: do I want every exported method

and field of this type to become part of my type's public surface,

and to satisfy whatever interfaces that surface satisfies? If yes,

embed. If you only want the data, or only some behavior, give it a

field name and forward what you need by hand. The extra lines are

cheaper than an accidental `Closer`

.

Embedding is one of those Go features that looks like a port of a

class concept and turns out to be its own thing with its own rules.

*The Complete Guide to Go Programming* walks through method sets,

promotion, and interface satisfaction the way the spec defines them,

so the surprises in this post become the behavior you expect.

*Hexagonal Architecture in Go* shows where composition over

inheritance pays off when you are wiring ports and adapters.
