In Go, what breaks when you add a method to an interface your package already exports?
answer
- structs and interfaces evolve in opposite directions
- Go has no default method bodies
- the consumer's build is what breaks
- assert for the extra capability instead
basics
~20 sEvery type outside your package that satisfied the old interface stops satisfying it and fails to compile. Go interfaces have no default method bodies, so widening an exported interface breaks implementers; adding a method to a struct breaks nobody.
solid answer
~50 sAdding a method widens the method set the interface demands, and satisfaction is checked at compile time, so every outside type that implemented the old interface now fails wherever it is used as that interface. Go has no default method bodies to fill the gap, so there is no non-breaking way to widen an interface others implement. The usual escape hatch is to leave the published interface alone and declare the new capability as a separate small interface, then type-assert for it at the call site and fall back when it is absent -- the way `io.Copy` checks whether its argument also satisfies `io.WriterTo` or `io.ReaderFrom`. This asymmetry is a strong argument against exporting an interface for your own single implementation in the first place: a struct absorbs new methods without breaking anyone, an exported interface cannot.
code
go · 17 lines// package payments -- exported months ago, implemented in other repos
type Store interface {
Save(ctx context.Context, authID string, cents int64) error
}
// the new capability, declared on its own instead of added to Store
type Refunder interface {
Refund(ctx context.Context, authID string) error
}
func RefundAuth(ctx context.Context, s Store, authID string) error {
r, ok := s.(Refunder)
if !ok {
return errors.ErrUnsupported
}
return r.Refund(ctx, authID)
}go deeper
Remember the direction of the rule: adding a method to a struct is safe for callers, adding one to an interface others implement is not, because Go has no default method bodies.
Explain where the breakage appears -- at the point of use in the consumer's build, not in the implementing type -- and name the optional-interface workaround with a standard-library example.
Weigh the options in a real migration: assert for a separate capability interface with a defined fallback, publish a second interface, or fix every implementer because they are all in your build. Say which you would pick and what it costs consumers.
Treat an exported interface as a promise not to widen it. Decide which interfaces the organisation is willing to make that promise about, and who reviews additions to them.
## The mechanic An interface names a set of methods. A type satisfies it when its method set contains all of them, and the compiler verifies that at each point of use: an assignment, an argument, a return, a channel send. Add a method to the interface and the required set grows. Any type that does not have the new method no longer satisfies the interface, and every place that used it as that interface stops compiling. The type itself still compiles -- nothing about the struct changed. The failure appears at the boundary, which means it appears in the *consumer's* build, often in another repository, at whatever moment they next upgrade your module. ## Why Go offers no softening Some languages let an interface carry a method with a body, so widening the interface supplies an implementation to every existing implementer. Go has nothing equivalent. An interface declares signatures only; there is no default, no optional member, and no way to mark a method as "implement it if you like". Embedding does not help either -- `type StoreV2 interface { Store; Refund(...) }` is a new, wider interface, and any function that takes `StoreV2` has the same requirement. The contrast with a struct is the point worth carrying into design discussions. Adding an exported method to a struct is backward compatible: existing callers keep working and simply gain a capability. Adding a method to an exported interface is backward incompatible for anyone implementing it. So the decision to export an interface, rather than a concrete type, is a decision about which changes you may safely make later. ## The options when you must add the capability **Declare a second, narrow interface and assert for it.** Leave the published interface untouched, put the new method in an interface of its own, and at the call site ask whether the value also satisfies it: ```go if r, ok := s.(Refunder); ok { return r.Refund(ctx, authID) } return errors.ErrUnsupported ``` Every existing implementer keeps compiling and simply does not get the fast path or the extra behaviour. This is exactly how the standard library grows capabilities: `io.Copy` uses `io.WriterTo` or `io.ReaderFrom` when its arguments happen to provide them and otherwise falls back to a plain read-write loop, and an HTTP handler asks whether its `http.ResponseWriter` also satisfies `http.Flusher` before flushing. The cost is that the capability is now optional, so the caller needs a defined behaviour when it is absent -- a fallback, or a clear error such as `errors.ErrUnsupported`. **Publish a new, wider interface beside the old one** and migrate consumers deliberately. Honest, but it doubles the vocabulary and every consumer must eventually choose. **Change all the implementers.** Only available when you can see them all -- a single repository, or a module nobody outside your organisation depends on. Inside one repo this is often simply the right answer: change the interface, change the implementations, one commit. **Take the interface out of the exported surface.** If the interface existed only because the package wanted to hand back an abstraction it did not need, the real fix is to return the concrete type and let each consumer declare the narrow interface it uses. A consumer-declared interface can be widened freely, because the only package that must satisfy it is the one that also uses it, and both sides are in the same build. ## The design lesson The rule that follows is not "never export an interface" -- `sort.Interface` and `io.Writer` are exported interfaces others implement, and they are stable precisely because they were designed to be small and final. The rule is that an exported interface is a promise you cannot widen. If you cannot say who implements it besides you, and you cannot say that its method set is finished, do not export it.
- Why does adding a method to an exported struct not break callers the same way?Callers of a struct use the methods they know about; a new one is simply available and nothing requires them to provide it. An interface inverts the direction -- it states what implementers must supply -- so widening it imposes work on every implementer rather than offering something to every caller.
- Where does the standard library use the optional-interface trick?`io.Copy` type-asserts its arguments for `io.WriterTo` and `io.ReaderFrom` and uses them when present, falling back to a buffered loop otherwise. HTTP handlers assert their `http.ResponseWriter` for `http.Flusher` before flushing. In both cases the published interface stayed small and the capability arrived as a separate one.
- When is it fine to just widen the interface and fix everything?When every implementer is inside a build you control -- one repository, or a module with no outside consumers. Then the change is a single commit touching the interface and its implementers, and no compatibility window exists. Pre-production code with no external importers is exactly this case.
saying these in an interview costs you the question
- Add a default implementation to the interface
- Nothing breaks, since Go interfaces are checked at run time
- Embedding the old interface in a new one is backward compatible
- Only the implementing type fails to compile, not its users
- Interfaces are safer than structs to expose because they can grow