cd /news/developer-tools/struct-embedding-in-go-composition-t… · home topics developer-tools article
[ARTICLE · art-26528] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer explains that Go's struct embedding is composition, not inheritance, and warns that promoted methods can cause unintended interface satisfaction and method shadowing. The post details how embedded methods run against the embedded value, not the outer struct, and recommends using named fields to avoid exposing unintended methods.

read7 min publishedJun 13, 2026

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

.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/struct-embedding-in-…] indexed:0 read:7min 2026-06-13 ·