You own a Go package many teams import. Should you export an interface for their fakes, or a concrete type?
answer
- who is allowed to change what later
- a test fake is an implementation too
- breakage lands at their upgrade
- a concrete type can gain methods for free
- adding one later is still possible
basics
~20 sDefault to returning the concrete type and letting each consumer declare the one or two methods it needs. An exported interface that downstream teams implement in tests is frozen API: every method you add breaks their stand-ins, and you cannot withdraw it.
solid answer
~50 sI return concrete types and let consumers declare their own narrow interfaces, because that keeps the ability to evolve on my side of the boundary. The moment I export an interface that other teams implement — and hand-written test fakes are implementations — its method list is a compatibility contract: adding a method breaks every fake in every downstream repo, at their next upgrade, on their schedule, and I own the migration I just caused. A concrete type can grow methods freely and still satisfies every consumer-declared view. I export an interface only when my package genuinely accepts implementations from callers, and then I keep it minimal, document it as frozen, and if it must widen, do it through a new interface or a new major version rather than an edit. The decision is reversible in only one direction, which is why it belongs in an API review rather than in a single pull request.
go deeper
Know the default: a package returns its concrete type, and code that needs to swap it declares its own small interface. You rarely need to export an interface just so somebody can test around your package.
Explain the mechanics behind the rule: satisfaction is structural, so a concrete type can gain methods without breaking anyone, while adding a method to an exported interface breaks every outside implementation, test doubles included.
Show how you would evolve an interface you already exported — an additive second interface with a type assertion, a major version, or a deliberate break — and how you sequence it so consumers are not surprised at upgrade time.
Own the boundary policy and the cost it moves. Be ready to argue reversibility, say who pays for a downstream migration, and defend the exception list for cases where your package genuinely accepts implementations.
## The decision You are writing a package other teams import — a client, a store, a service wrapper. They will need to test their code without your real dependency. Two shapes are available: 1. **Export an interface** (`type Client interface { ... }`) and have your constructor return it, so consumers can implement it in their tests. 2. **Return a concrete type** (`*Client`) and let each consumer declare, in its own package, the one or two methods it calls. Both are testable. They differ in who can change what afterwards, and that is the whole question. ## Why exporting the interface is the expensive choice An exported interface that others implement is API in the strongest sense. Its method *list* is part of the contract, not just each method's signature. Adding a method — the most ordinary thing that happens to a growing package — breaks every implementation outside your module. Test fakes are implementations, so the blast radius is not "a few adapters", it is every consumer's test suite. The timing is what makes it an organisational problem rather than a technical one. The breakage does not land when you merge; it lands when each team upgrades, weeks apart, in repositories you cannot see or fix. You have created work you cannot schedule, on teams you do not manage, and the failure they see is a compile error in *their* test file with your package's name on it. You also cannot take it back. Un-exporting the interface, or narrowing it, breaks the consumers who now name it in their own signatures. The choice is irreversible in the direction that matters. ## Why the concrete type keeps your options open Return `*Client`, and you can add methods whenever you like: no consumer implements your type, so nothing downstream can break from a method being added. Each consumer declares its own view of what it uses: ```go // in the consumer's package type userFetcher interface { UserByID(ctx context.Context, id int64) (User, error) } ``` Your concrete type satisfies it without knowing it exists. Two consumers can hold different views of the same type. When you add a method, their interfaces are unchanged, their fakes are unchanged, and nobody upgrades in a hurry. The cost is real but small: consumers write a couple of lines each, and there is no single canonical name for the abstraction. The saving is that you keep the right to evolve. ## When exporting an interface is correct Three cases justify it, and they share a property: your package is the one *consuming* the abstraction. - **You accept implementations.** A parameter or a registration function takes something callers provide. Then the interface is genuinely yours to declare, and it should be tiny. - **You legitimately ship several implementations** behind one name and callers choose among them at runtime. - **A standard shape already exists.** Accepting an `io.Reader` or a `context.Context` is not you inventing API; those method sets are fixed by convention and will not grow. Even then, keep the method count near one or two. A small exported interface can survive a long time precisely because there is little reason to widen it. ## Managing it when you must widen If an exported interface has to grow, you have choices that differ in who pays: - Declare a **second, additive interface** and type-assert for it at the point of use, so old implementations keep working and new capability is opt-in. This is how the standard library handles this problem. - Ship a **struct consumers can embed** that provides implementations of everything, so adding a method does not break embedders. This shifts the guarantee but also removes the compile error that told them to look. - Cut a **new major version** of the module. Honest, and expensive for everyone. - Simply break it and **own the migration**: announce it, and send the changes to the affected repositories yourself rather than filing tickets. Sometimes correct — but it should be a decision made deliberately, with the cost acknowledged, not the side effect of a merged pull request. ## How to defend the call The argument that persuades a reviewer is about reversibility and cost ownership, not about style. Returning a concrete type is the choice that can be changed later — you can always add an exported interface once you know its shape, and existing consumers are unaffected. Exporting an interface early commits you to a method list before anybody has used the package, on behalf of teams who have not written their code yet. The counter-argument worth taking seriously is discoverability: consumers unfamiliar with the idiom may not realise they can declare their own view, and may reach for your concrete type in their signatures, taking the coupling anyway. That is answered with documentation and an example in the package docs, not by exporting an interface you cannot withdraw. ## What it looks like as a standard Write it down as a rule with an escape hatch: packages return concrete types; exported interfaces are declared only where the package accepts implementations; any exported interface that others implement is treated as frozen, and widening it is a compatibility event that goes through review with a named owner for the downstream migration.
- Consumers say declaring their own interface is boilerplate. How do you answer?It is one or two signature lines per package, and it buys them independence: their view never changes because I added a method for someone else. I make it easy rather than mandatory — an example in the package documentation showing the shape, and a concrete type whose methods read the same way they would in a caller's interface.
- You must add a method to an interface you already exported and others implement. What do you do?Prefer an additive second interface and a type assertion at the call site, so existing implementations keep compiling and the new capability is opt-in. If that is not workable, either a new major version or a deliberate break where I own the migration and send the fixes — chosen openly, with the cost stated, not discovered by consumers at upgrade time.
- Does returning a concrete type stop consumers from testing your package's callers?No. They declare an interface naming the methods they call and pass a stand-in in tests; my type satisfies it unchanged. The only thing they cannot do is substitute my type where they insisted on naming it concretely, which is a choice they made in their own signatures.
- Where does the internal versus external distinction change the answer?Inside one repository where I can change every caller in the same commit, exporting an interface is cheap: breakage is a build failure I fix immediately. Across module boundaries and teams, the same edit becomes work scheduled by other people, which is what makes it a governance decision rather than a refactor.
Exporting an interface others implement is like publishing a form everyone must fill in: adding a field means reprinting every copy in the field. Publishing a filled-in document instead lets readers quote the two lines they care about.
saying these in an interview costs you the question
- Exports an interface for every concrete type by reflex
- Thinks adding a method to an exported interface is backwards compatible
- Forgets that consumers' test fakes are implementations too
- Says consumers can just regenerate or update their doubles
- Ignores that an exported interface cannot be withdrawn
- Treats an interface-per-struct convention as dependency inversion