How do you decide whether an exported Go struct in a shared library may embed a type at all?
answer
- what you embed, you publish
- you cannot take a method back
- their next minor release edits your API
- would you write this method by hand
- the rule is about the exported boundary
basics
~20 sAsk whether you would hand-write and document every promoted method as your own API. If not, hold the dependency in a named field and forward the calls you mean. Embedding publishes a surface you cannot withdraw.
solid answer
~50 sMy rule is that an exported struct may embed a type only when that type's entire method set is deliberately part of my contract, and I own the type. Everything else goes in a named field with hand-written forwarding. Two arguments carry it. Promotion is irreversible: importers compile against promoted methods, so removing one is a breaking change costing a new major version and a migration every consumer funds. And embedding delegates my API to someone else's release process - a method the embedded type gains in a minor release silently joins my surface, can collide with a second embedded type, or can change how my values print and serialise. Inside a package or an `internal` tree I relax the rule, because there I see every caller and fix them atomically. The constraint is about the exported boundary, and I write it down rather than argue it per pull request.
go deeper
Understand that anything promoted onto an exported struct can be called by anyone who imports the package, so embedding is visible far beyond the file it appears in.
Be able to explain why removing a promoted method later is a breaking change for importers, and what a hand-written forwarding method buys you in exchange for the extra lines.
Argue the concrete risk: a dependency's next minor release can add a method that silently joins your type's API, collides with another embedded field, or changes how your values print and serialise.
Own the policy and its cost. Say who writes the forwarding boilerplate, where the rule stops applying, when a monorepo lets you be looser, and how you would defend it to a team that finds it pedantic.
## What you embed, you publish - and cannot unpublish Embedding a type in an exported struct promotes its exported methods onto your type. From the moment your module is tagged, those methods are your API: importers compile against them, and there is no marker that says "this one was not really mine". The decision therefore belongs at API review, not to whoever is typing. ### The three costs, in the order they bite **1. Irreversibility.** Removing an exported method is a breaking change under Go's module rules, and a breaking change means a new major version with a new module path ending in `/v2`. That is not a line of code, it is a migration you are asking every consumer to fund and schedule. A method that arrived by accident costs exactly as much to remove as one you designed. **2. You inherit the dependency's release process.** This is the argument most teams have not thought through. Whoever owns the embedded type can add a method in an ordinary minor release, and that method silently becomes part of *your* exported surface on your next bump. Three ways that hurts: - It collides with something you already promoted from a second embedded type, and the ambiguous selector breaks importers who were calling a name that had nothing to do with the upgrade. - It changes behaviour at a distance. Gaining a `String() string` method makes your values print differently under `fmt`; gaining a `MarshalJSON` method changes what your callers put on the wire. Neither shows up as a compile error. - It widens interface satisfaction, so your type can be passed to places you never designed for and now must keep working. **3. It removes your freedom to change the collaborator.** The promoted method set is a mirror of a dependency's API. Swapping it for a different implementation with a different shape means deleting exported methods, which loops back to cost one. ### The rule that survives contact with reviewers Embed in an exported type only when **both** hold: - The embedded type's *entire* method set is deliberately part of the contract - typically because you are decorating something and want the untouched methods to pass through by design. - You own that type, in the same module, so its API cannot move under you. Otherwise: named field, plus explicit forwarding for the two or three calls that belong in your surface. The one-line test to settle an argument is *would you write and document this method by hand?* If nobody wants to stand behind `Lock` as an API commitment, `Lock` should not be on the type. ### Where the rule deliberately does not apply Inside a package, and inside an `internal/` tree, embedding is cheap. You can see every caller, and unembedding is a single atomic change with a compiler to find the fallout. Applying the exported-boundary rule everywhere is how a sensible policy gets a reputation for pedantry and stops being followed. Say explicitly that the constraint is about the module boundary. A monorepo where every consumer builds from head is a middle case: breaking changes are real work but they are *your* work, done in one commit, so the calculus tilts back towards convenience. An externally published module with unknown consumers is the strict end. ### Who pays, and how you make the argument The complaint against the rule is fair: forwarding methods are boilerplate, and boilerplate is written by the team that owns the library, repeatedly, while the benefit is spread over consumers who never notice it. The honest framing is that this is a transfer of cost from an unpredictable future to a predictable present. A forwarding method is ten seconds now; a major-version migration is quarters of other people's time, spent at whatever moment the collision happens to land. Two things make the rule stick where argument alone will not: - **Write it in the package's contributing notes**, phrased as the test rather than the prohibition, so a reviewer can point at it in one line instead of re-deriving the reasoning on every pull request. - **Make "an embedded field was added" a review trigger**, in the same bucket as adding a method to an exported interface. Both change what compiles for someone else, and neither looks like an API change in a diff. ### What you should be willing to concede If the promoted surface really is the contract - a wrapper that must behave as the thing it wraps - insist on the forwarding methods anyway and you have written a worse library, more code and more drift for no benefit. The rule is a default that a reviewer can be talked out of with a specific reason, which is what separates a policy from a lint rule.
- What actually breaks if you later remove a promoted method from an exported type?Every importer that called it stops compiling. Under Go's module rules that is a breaking change, so the honest path is a new major version and a new module path ending in `/v2` - a migration you are asking every consumer to fund. That asymmetry is why the decision belongs at review time, not at cleanup time.
- Does the same rule apply to unexported and internal types?No, and saying so is what keeps the rule credible. Inside a package or an `internal/` tree you can see every caller, so unembedding is one atomic change with the compiler finding the fallout. The constraint exists only where other modules compile against your surface.
- How do you answer someone who says forwarding methods are pointless boilerplate?Agree that it is boilerplate, then price both sides. Forwarding is a known cost paid once by the library's owners; the alternative is an unknown cost paid later by every consumer, at whatever moment a dependency adds a colliding method or you need to withdraw one. And concede the exception: if the whole method set really is the contract, embed.
- What review signal tells you an embedded field deserves scrutiny?The embedded type is owned by another team or module, the outer type is exported, or a second embedded field is being added. Any of those changes what compiles for importers without looking like an API change in the diff, which is exactly the class of change a review process should catch.
Embedding a type you do not own is co-signing another team's API: their next release is added to your surface, and your consumers are the ones who pay if it goes wrong.
saying these in an interview costs you the question
- Treats embedding as free because it saves typing
- Assumes a promoted method can be dropped in a minor release
- Ignores that a dependency's new method joins your exported surface
- Applies the same strictness to internal and exported types
- Cannot name a case where embedding is the right call