An interface seam in your Go library's hot path costs an allocation per call, but other teams substitute fakes through it. How do you decide whether to remove it?
answer
- size it on the real workload first
- prefer the win nobody has to pay for
- the seam is somebody else's test
- count consumers before proposing removal
- write down what would change the answer
basics
~20 sDecide with numbers and with the consumers in view. Measure what the seam costs end to end, then prefer specialising the internals while keeping the published interface, over removing a seam other teams already build on.
solid answer
~50 sI start by sizing the prize: what share of the service's CPU time and allocation rate this call site is, measured on a representative workload rather than a microbenchmark that flatters the change. Then I look for the version of the win that costs no one anything — keeping the exported interface and moving the loop onto the concrete type underneath it, or letting a profile-guided build devirtualise the hot site — because those are decisions I own alone. Only if the remaining win is large do I change the published shape, and then the cost is not mine: every consumer replacing a fake is work I am spending on their behalf, so I owe them a lightweight real implementation for tests and a migration window. I write the measurement and the alternatives down, so the reviewer who wants the seam back argues with evidence.
go deeper
Know that an interface in a library is often there so other code can substitute an implementation, and that removing it is a change to other people's code, not only to yours.
Be able to argue both sides with evidence: what the seam costs per call, and what it buys in substitutability, and to name the internal-only fixes that keep both.
Show the sequencing — measure on the real workload, take the invisible wins first, and only then discuss an API change — and know what a profile-guided build can and cannot rescue.
Own the cross-team cost and the reversibility: count consumers, supply a replacement for their test doubles, set a migration window, and record the measurement so the decision can be overruled on new evidence.
## The decision, not the technique The technical facts are usually settled quickly: an interface call cannot be inlined, arguments passed through it are assumed to escape, and boxing a value into it may allocate. What makes this a judgment call is that the seam is not a mistake — someone put it there so other teams could substitute an implementation, and they did. Removing it converts your performance problem into their work. ## Step one: size the prize on the real workload A microbenchmark comparing a concrete call to an interface call will show a large relative difference and almost no absolute one. The number that decides anything is the share of the *service's* time and allocation rate attributable to this call site under a representative load. Two allocations per request on a service that already allocates a thousand is noise. Two allocations per pixel row is a different conversation. Get the profile before writing the change, not after. ## Step two: exhaust the changes that cost no one anything Ranked by how little they cost other people: 1. **Keep the exported interface; move the loop underneath it.** The seam stays exactly where consumers see it, and the per-iteration work moves into an unexported function over the concrete type. Nobody outside the package can tell, and most of the win is usually here. 2. **Stop the per-call allocation from the other side.** Let the caller own and reuse the buffer, or pool it, so the loop stops manufacturing values to box. Again invisible outside. 3. **Let a profile-guided build devirtualise the hot site.** No API change at all, contingent on the profile staying representative. Treat it as a real option, and as one whose input ages. If these get you the number you need, you are done and no cross-team conversation is required. This is the outcome to aim for. ## Step three: if the API must change, price it honestly Removing an interface from a published surface has costs that do not show in your profile: - **Migration work multiplied by consumers.** Every team with a fake behind that interface has to replace it. You are spending their sprint capacity, so it is your job to count the consumers before proposing it, not after. - **Testability.** If you take the seam away, you owe them something else — a genuine lightweight implementation shipped in the package, an exported test helper, or a narrower interface at a different level where the cost is once-per-request instead of once-per-item. - **Reversibility.** Adding an interface back later is easy; retracting a concrete type you exposed is not. Prefer the change you can undo. - **Whose budget.** In a repository where the library and its consumers ship together, this is a same-change edit. Across teams and release trains it is a migration with a window, an announcement and a deadline, and it should be planned as one. ## Step four: make the decision inspectable Write down, in the package or the design note, what you measured, on what workload, which alternatives you rejected and why, and what would change the answer. Two reasons. The reviewer who thinks the seam is worth the allocation should be arguing with a number, and the person who arrives in a year to ask why this package has an odd concrete signature should find the answer without re-running your profile. Then guard it. A benchmark in the package that asserts allocations per operation is what stops the next well-intentioned refactor from putting the interface back in the loop. ## The failure modes to name - **Optimising the API for a benchmark.** The change wins in the microbenchmark and moves nothing in production. - **A blanket rule.** *No interfaces on hot paths* is not a policy; it is a way of losing seams nobody measured. - **Silent removal.** Deleting a seam consumers depend on and finding out in their build. If they cannot be told, they cannot plan. - **Never deciding.** The mirror image: a seam that measurably costs a fifth of the service's CPU and stays because changing it would require a conversation. Owning the call includes being willing to make the expensive one.
- The team argues that dropping the interface makes the package untestable. What do you offer instead?Something concrete to replace the fake with: a real lightweight implementation shipped alongside the fast one, an exported test helper, or the seam relocated to a coarser level where it costs one indirect call per request rather than one per item. If I cannot offer any of those, the removal is not ready and the seam stays.
- How much do you trust a profile-guided build as the answer here?As a real option with a maintenance obligation. It buys the inlining back with no API change, which is exactly what you want, but it depends on a build input that must stay representative as traffic shifts. I would take it as part of the answer while still moving the inner loop off the interface, so the win does not evaporate when the workload changes.
- What would make you refuse to make this change even with good numbers?A large consumer count with no migration path, or a win concentrated in a workload that is not the one we are actually sized for. Also plain reversibility: if the change means exposing a concrete type I would struggle to retract, I would rather take a smaller win that stays inside the package.
- How do you stop the win from silently regressing?A benchmark in the package that asserts allocations per operation on that path, run in the same pipeline as the tests, plus a short comment at the signature saying why the parameter is concrete. Without both, the next refactor restores the interface for good stylistic reasons and nothing notices.
saying these in an interview costs you the question
- Removes the interface without measuring the real workload
- Adopts a blanket no-interfaces-on-hot-paths rule
- Ignores that consumers must replace their test doubles
- Optimises for a microbenchmark rather than production
- Leaves a measured, expensive seam untouched to avoid a conversation