As the author of a shared Go library, how do you decide whether to export an interface at all, and how wide?
answer
- an exported interface is a promise to strangers
- structs grow freely, interfaces do not
- who else implements this besides us
- size it by the busiest caller
- one unexported method buys future room
basics
~20 sDefault to exporting concrete types; export an interface only where several real implementations must exist or your own API takes it as a parameter. Size it by what callers call, since an exported interface can never change without breaking implementers.
solid answer
~50 sThe question is who pays. An exported interface is a permanent obligation on everyone who implements it: you cannot add a method without breaking their build, and you cannot remove one without breaking their calls, so in a versioned module both moves cost a major version. That makes the default clear -- export the concrete type from constructors and functions, and let each caller depend on the capability it uses. Export an interface when there genuinely are multiple implementations that you or third parties must supply, such as a driver or plugin seam, or when the implementation type is deliberately unexported. Then keep it to one or two methods and name it for the capability. If you expect the seam to grow, add an unexported method to seal it so only your package can implement it, which keeps future additions non-breaking. That call is reviewable, and it is the one an architecture reviewer will overrule.
code
go · 16 lines// Only this package can implement Option, because apply is unexported.
type Option interface {
apply(*config)
}
type headerOption struct {
key, value string
}
func (o headerOption) apply(c *config) {
c.extraHeaders[o.key] = o.value
}
func WithHeader(key, value string) Option {
return headerOption{key: key, value: value}
}go deeper
Take away the rule of thumb: libraries usually export concrete types, and an exported interface is a bigger commitment than it looks.
Be able to explain why a struct can gain methods safely while an exported interface cannot, and what that means for a module's version number.
Argue the sizing concretely from real call sites, and know the sealing technique of adding one unexported method when you need a named seam you can still extend.
Own the decision and its bill: who implements this besides us, what the freeze costs each team, when a major version is justified, and why mockability alone never earns an exported interface.
## Frame it as a liability, not a feature Every exported identifier in a library is a promise, but an exported *interface* is an unusual one: it is a promise you make to people writing code you will never see. A struct is implemented only by you, so adding a method to it is free forever. An interface is implemented by them, so its method list is frozen in both directions from the moment it ships. That asymmetry is the whole decision. ## The default: don't Most libraries need no exported interface at all. Constructors return concrete types, functions take narrow interfaces as parameters, and consumers depend on the capability they call. This keeps your evolution cheap: you can add methods, add fields, add options and change internals without a compile failure anywhere downstream. The common anti-pattern is a producer-side interface that mirrors one struct method for method, exported "so people can mock it". It buys downstream teams a twelve-method obligation and buys you a frozen surface, and the same testing goal is met by the caller depending on a narrow capability instead. ## When exporting one is right There are honest cases, and they share a shape: **more than one real implementation must exist, and at least one of them is not yours.** - **A driver or plugin seam.** Your package selects an implementation at run time from configuration or registration. `database/sql/driver` and `hash.Hash` are the standard-library examples. - **Your own API takes it as a parameter.** If your exported function accepts a callback or a sink, that parameter type must be exported for callers to name it. - **The concrete type is deliberately hidden.** If the implementation must stay unexported, a small exported interface is the only way to talk about the value at all. - **A stable boundary between organisations.** Two teams shipping independently sometimes need a named contract more than they need flexibility -- but that is a decision to take deliberately, with the freeze understood. ## Sizing it Size by the *call site*, never by the implementation. Write down what a consumer actually invokes; that list is the interface. One or two methods is normal, three is a smell, and beyond that you are exporting an object rather than a capability, and every implementer will pay for it. Name it for the capability -- an agent noun such as `Rewriter` or `Forwarder` -- not after the struct that happens to implement it today. And place it where it costs least: an interface whose signatures mention many of your own types drags all of them into every consumer's import graph, which is visible as fan-in in `go list -deps` and is a real coupling cost, not an aesthetic one. ## The escape hatch: sealing If you want a named seam now but want the right to extend it later, give the interface one unexported method. Only your package can then satisfy it, so adding methods is not a breaking change for anyone -- outside code can still *hold* and *use* values of the type, it simply cannot declare new implementations. This trades third-party extensibility for future room, and it is exactly the tradeoff worth stating out loud in a design review rather than discovering later. ## Who is on the hook Make the cost explicit when you argue the decision: - A **narrow** interface costs your package more types and slightly more code, and costs consumers almost nothing. - A **wide** interface costs consumers a large obligation in every fake and every implementation, and costs you a frozen API. - **No exported interface** costs consumers a little inconvenience when they want to substitute your type, and costs you nothing. - A **sealed** interface costs third-party implementers the ability to participate, and buys you the ability to evolve. The version policy follows directly. In a module using semantic import versioning, widening or narrowing an exported interface is a major version, with a `/v2` import path that every consumer must edit. That is a bill your consumers pay for a decision you made, which is why it belongs to whoever owns the package's public surface rather than to whoever happened to write the type. ## What a reviewer should ask Who implements this besides us? What does the busiest caller actually call? What happens the day we need one more method? If the answers are "nobody", "two of the eight", and "we break everyone", the interface should not be exported in that shape.
- What does adding a method to an exported interface cost in a published Go module?It breaks every outside implementer at compile time, so under semantic import versioning it belongs in a new major version with a `/v2` import path. Every consumer must edit imports and re-satisfy the interface. Adding a method to an exported struct costs nothing, because nobody outside implements a struct.
- How do you keep a named exported seam extensible?Give the interface one unexported method. Outside packages can still hold, pass and call values of the type, but cannot declare their own implementations, so adding methods later breaks nobody. The price is that third parties cannot plug in, which is only acceptable when you never intended to allow that.
- A team asks you to export an interface mirroring your client struct so they can substitute it in tests. What do you propose instead?Keep returning the concrete client, and have their code depend on the one or two methods it calls. They get substitutability sized to their own use, you keep the freedom to add methods, and neither side inherits a frozen multi-method contract that every future fake must satisfy.
- Why does an exported interface's signature list affect the import graph?Every type named in a method signature must be importable by anyone implementing or calling it, so consumers pull in those packages transitively. A capability interface written over basic types and standard-library types keeps fan-in low; one written over a dozen of your own types couples consumers to all of them.
saying these in an interview costs you the question
- Exports an interface mirroring every method of one struct
- Says an exported interface can be extended in a minor release
- Sizes interfaces by the implementation rather than the caller
- Treats mockability as sufficient reason to export an interface
- Cannot name who outside the package implements the interface