Why does adding a method to an exported Go interface break importers, when adding one to a struct does not?
answer
- nobody declares that they implement it
- the method set is the contract
- Go has no default methods
- every hand-written test fake breaks too
- an unexported method seals it
basics
~20 sAn exported Go interface is satisfied implicitly, so any type with the right method set implements it. Adding a method enlarges that set, and every outside implementer stops compiling. A method on a struct affects only that type.
solid answer
~50 sGo interfaces are satisfied structurally — no type declares that it implements one, so the set of implementers is unbounded and includes types in modules you have never seen, plus every hand-written test fake. The interface's method set *is* the contract; adding a method changes it, and every downstream type that satisfied the old set now fails to compile with "missing method" wherever it is used as that interface. Go has no default methods, so there is no compatible way to add one. A method added to your own struct is different: methods attach to that single type, nobody else must change, and no existing call is invalidated. The two ways out are to seal the interface up front by including an unexported method — only your package can then implement it, so you may grow it later — or to ship the new behaviour as a *second*, small interface and detect it with a type assertion.
code
go · 8 linestype Store interface {
Get(ctx context.Context, key string) ([]byte, error)
// Adding this to an already-published interface is a break:
// every outside type, including every test fake, now fails
// with "does not implement Store (missing method Delete)".
Delete(ctx context.Context, key string) error
}go deeper
Recall that Go types implement an interface just by having the methods, with no implements keyword, and that the compiler checks this where the value is used as the interface.
Explain the mechanics: the method set is the whole contract, the implementer set is unbounded and unknowable, and Go offers no default bodies to fill the gap.
Demonstrate the escape routes in a shipped library — a sealed interface where you own every implementation, or a second interface detected by type assertion — and say what each costs consumers.
Argue the policy: which interfaces your organisation exports at all, why they stay one or two methods wide, and why consumer-side interface definitions leave you free to change.
## Why the two cases differ In Go a type satisfies an interface by having the right methods. There is no `implements` clause, no registration, and no list of implementers anywhere. That is the feature that makes small interfaces so pleasant to consume — and it is exactly what makes an exported interface almost impossible to change. When you publish ```go type Store interface { Get(ctx context.Context, key string) ([]byte, error) } ``` you have published a *requirement*: "one method, this signature". Every type in every downstream module that happens to have that method satisfies it. You cannot enumerate them, grep for them, or notify them. Adding `Delete` to the interface enlarges the requirement, and each of those types instantly fails to satisfy it. The failure is a compile error at every point where such a value is passed as a `Store`, typically reading `does not implement Store (missing method Delete)`. Adding a method to an exported **struct** is the mirror image. A method belongs to exactly one type; declaring `func (c *Client) Close() error` adds capability to `Client` and asks nothing of anyone. No existing call site changes meaning, no existing type needs new code. ## Why the blast radius is bigger than it looks The implementers who break are not only "real" ones. Every test fake counts. A downstream team that wrote a fifteen-line `fakeStore` for their unit tests now has a broken test build — and they will have several. In practice one line added to a widely imported interface produces failures across many packages and repositories at once, all of them in code you cannot fix. And the break is not opt-in the way people assume. Nobody is dragged onto your new version by publishing it alone, because Go's minimal version selection picks the highest version any requirement asks for, not the newest that exists. But the moment a team upgrades — or a *third* module they depend on bumps its requirement on you — the break arrives. ## There are no default methods Java added default methods to interfaces and C# added default interface members precisely so that interfaces could grow. Go has neither. An interface declaration in Go contains only method signatures and embedded interfaces; there is no body to supply. So there is no "compatible" way to add a method — the change is a break, always. ## The two real strategies **Seal the interface if you might grow it.** Include an unexported method in the interface: ```go type Event interface { Kind() string sealed() } ``` A type outside the declaring package cannot supply `sealed()` — unexported identifiers are not accessible across package boundaries — so only your package can implement `Event`. Since every implementer is yours, adding methods later breaks nobody outside. The standard library uses this shape; `testing.TB` includes an unexported method for exactly this reason, so that the `testing` package can extend it. The price is that consumers cannot write their own implementations at all, which is a real cost: it forbids test doubles as well as alternatives. Seal a *closed set* (an enum-like union of your own types), not an extension point. **Otherwise, add a second interface and upgrade at the call site.** Keep the published interface frozen and define the new capability separately: ```go type Deleter interface { Delete(ctx context.Context, key string) error } if d, ok := s.(Deleter); ok { // use the richer behaviour } else { // fall back } ``` This is the shape the standard library uses everywhere it needed to grow: `io.Writer` never gained a method, but `io.WriterTo`, `io.ReaderFrom` and `http.Flusher` let a value announce extra capability, and the consumer type-asserts for it. It costs you a branch and a fallback path, and it is the only additive option once an interface is out in the wild. ## Design consequences This is the strongest argument for keeping exported interfaces tiny. A one-method interface is a promise you can keep forever; a ten-method "service" interface is a promise you will want to break within two releases. It is also an argument for defining interfaces in the *consuming* package rather than exporting them from the producer: an interface that only you import is one you can change at will, because the compiler will show you every implementer — they are all in your own build. ## Adding a method is not always free even on a struct Two edge cases are worth knowing. If downstream code embeds your struct in its own type and that outer type already has a member of the same name at the same depth, a new method can make a selector ambiguous. And a new method can make your type unexpectedly satisfy an interface, changing which arm of a downstream type switch matches. Both are rare, and neither is in the same league as changing an interface, but they are the reason the honest answer is "adding a method to a struct is safe in practice" rather than "always safe".
- Your published interface must gain behaviour and it was not sealed. What do you ship instead?A second, small interface holding just the new method, plus a type assertion where you consume it and a fallback for values that lack it. That is additive: existing implementers keep compiling and richer ones opt in. It is the shape `io.WriterTo`, `io.ReaderFrom` and `http.Flusher` exist in — the standard library never grew `io.Writer`.
- What does sealing an interface cost the people who import it?Everything: they cannot implement it at all, including writing a test double. So seal a closed set of your own types — an enum-like union you intend to own — and never seal something you are offering as an extension point. If consumers must be able to implement it, the interface is frozen and new behaviour goes into a separate one.
- Is adding a method to an exported struct ever a break?Almost never, but two edge cases exist. If downstream code embeds your struct and the outer type already has a member of the same name at the same depth, the selector becomes ambiguous and fails to compile. And a new method can make your type satisfy an interface it did not before, changing which case of a downstream type switch matches.
saying these in an interview costs you the question
- Says interface satisfaction is checked at runtime, so old code still runs
- Thinks Go interfaces support default method implementations
- Believes only code calling the new method breaks
- Assumes every implementer can be found by searching your own repository
- Calls a new interface method an additive, minor-version change