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. 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.