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.