When should an exported Go type forward to a named field explicitly instead of embedding it?
answer
- promotion is an API promise
- what did you not choose to export
- a promoted mutex is a public lock
- hook methods change behaviour, not just names
- widening later is easy, narrowing is not
basics
~20 sEmbed only when the embedded type's whole surface belongs in your API. Promotion publishes all of it, including surprises such as Lock and Unlock or a MarshalJSON that hijacks encoding. Otherwise keep an unexported field and forward the methods you mean.
solid answer
~50 sEmbedding is an API decision, not a spelling convenience: everything the embedded type exports becomes part of your exported type, and it keeps doing so as that type grows a method. Embedding `sync.Mutex` in an exported struct hands every caller `Lock` and `Unlock`; embedding `time.Time` promotes its `MarshalJSON`, so the outer struct encodes as a bare timestamp and its own fields vanish. Explicit forwarding — an unexported named field plus the two or three methods you actually want — costs a few lines and keeps the surface deliberate, which matters most on a package boundary you cannot retract. I embed when the type genuinely is the embedded thing plus more and I want the full method set, typically when wrapping an interface I am decorating; I forward when the embedded type is an implementation detail, when only part of its behaviour is safe to expose, or when it comes from a dependency whose future methods I do not want appearing in my API.
code
go · 17 lines// Embedded: callers get Lock, Unlock and every Grid method.
type PublicDashboard struct {
sync.Mutex
*Grid
}
// Forwarded: the mutex and the grid stay implementation details.
type Dashboard struct {
mu sync.Mutex
grid *Grid
}
func (d *Dashboard) Refresh() {
d.mu.Lock()
defer d.mu.Unlock()
d.grid.Draw()
}go deeper
Know that embedding a type into an exported struct makes that type's exported methods callable by anyone using yours, so it is not just a way to save typing.
Explain the concrete consequences: a promoted Lock and Unlock, or a promoted MarshalJSON that changes how the whole struct encodes, and how a named field avoids both.
Argue the tradeoff on a real boundary: which method set you are willing to promise, what a dependency's next release can add to it, and where forwarding earns its boilerplate.
Own the convention across a codebase — when teams may embed a dependency in an exported type, how wrapper types are shaped, and how you keep an accidental promotion from becoming a contract you cannot withdraw.
## Embedding publishes, and keeps publishing When an exported struct embeds a type, every exported field and method of that type becomes part of the outer type's surface. That is the feature. The consequence is that you have made a promise about a method set you do not control, and the promise renews itself every time the embedded type gains a method. The two classic surprises are worth memorising because they show up in review: **A promoted mutex.** `type Cache struct { sync.Mutex; ... }` gives every importer `c.Lock()` and `c.Unlock()`. Locking is now part of your contract: a caller can hold your lock across their own code, and you cannot change your internal synchronisation without breaking them. The fix is one line — `mu sync.Mutex` — and the lock stays yours. **A promoted MarshalJSON.** `time.Time` has a `MarshalJSON` method. Embed it and that method is promoted to your type, so the encoder calls it and serialises your struct as a single timestamp string; the other fields silently disappear from the output. The same hazard applies to any embedded type carrying `String`, `Error`, `MarshalJSON` or similar hook methods, because those change how your type behaves in formatting, error handling and encoding rather than merely adding a callable name. ## What explicit forwarding buys Forwarding means an ordinary named field, usually unexported, plus hand-written methods: ```go type Dashboard struct { mu sync.Mutex grid *Grid } func (d *Dashboard) Refresh() { d.mu.Lock() defer d.mu.Unlock() d.grid.Draw() } ``` What you get: - **A chosen surface.** Only `Refresh` is exported. `Draw`, `Resize` and whatever else `Grid` grows stay inside. - **Stability against upstream change.** A new method on `Grid` cannot appear in your API, and cannot collide with another embedded type to break a caller's build. - **Freedom to change the inside.** You can replace `*Grid` with something else and keep `Refresh` identical. - **A place to put behaviour.** Locking, validation, metrics and error translation live in the forwarder; with promotion there is no such place. What it costs is real but small: a few lines per method, and boilerplate that grows with the width of the surface you are exposing. ## When embedding is the right call Embedding is not a smell. Reach for it when: - **You are decorating an interface.** Embedding the interface in your wrapper means you write only the methods you intercept and the rest forward correctly, including methods added later. For a wide interface this is the difference between one method and twenty. - **The full method set is the point.** A type that genuinely is a `Grid` plus a title, whose users should have all of `Grid`, is honestly expressed by embedding. - **The embedded type is yours.** Within one package or one module you control both sides, so the coupling is visible and cheap to change. - **Inside an unexported type.** Promotion into a type importers cannot name publishes nothing. ## How to decide in review The question to ask about every embedded field in an exported struct is: *would I have written each of these methods on this type by hand?* If the answer is no for any of them, embedding published something you did not choose. A second, sharper check: does the embedded type carry `String`, `Error`, `MarshalJSON`, `UnmarshalJSON` or `Lock`? Any of those means promotion changes your type's behaviour rather than just its surface, and it should be a named field until proven otherwise. ## The direction of the ratchet Both choices are easy to make and hard to undo, but they are asymmetric. Going from a named field to embedding only adds names, so it rarely breaks anyone. Going from embedding to a named field removes every promoted name from your API at once, and every caller who used one has to change. On a package other teams import, that asymmetry is the whole argument: start narrow, widen deliberately, and let a promoted surface be something you decided rather than something you inherited from a dependency's next release.
- What exactly goes wrong when an exported struct embeds sync.Mutex?`Lock` and `Unlock` become exported methods of your type, so importers can lock it, hold it across their own code, or double-unlock it. Your synchronisation strategy is now part of your contract and cannot change without breaking them. An unexported `mu sync.Mutex` field gives identical internal use with none of that exposure.
- Why does embedding time.Time break a struct's JSON output?`time.Time` declares `MarshalJSON`, which promotion makes a method of the outer struct. The encoder sees a type implementing the marshaler interface and calls it instead of walking the fields, so the whole value encodes as one timestamp string and the sibling fields vanish. Using a named `At time.Time` field restores normal object encoding.
- How do you get the brevity of embedding without publishing the whole surface?Embed inside an unexported type, or embed an interface you have deliberately kept narrow. Promotion into a type importers cannot name publishes nothing, and a narrow interface bounds what can ever be promoted. Both keep the boilerplate down while leaving you in control of the exported surface.
- Which direction is cheaper to change later, embedding or forwarding?Forwarding to embedding, by far. Adding an embedded field only adds names, so existing callers keep compiling. Removing one deletes every promoted name from the API at once and breaks every caller that used any of them, which is why a package other teams import should start with the narrower shape.
saying these in an interview costs you the question
- Treats embedding as purely a typing shortcut
- Embeds sync.Mutex in an exported struct without noticing
- Cannot explain why an embedded time.Time changes JSON output
- Says forwarding methods are always needless boilerplate
- Assumes removing an embedded field is a safe refactor
- Ignores that the embedded type may add methods later