A shared Go generic pipeline helper keeps gaining type parameters and two teams copied it back into their own packages. How do you judge whether to keep it?
answer
- count the real call sites
- who copied it, and ask them why
- a rule or just a shape
- a type parameter per caller is the tell
- a little copying beats a little dependency
basics
~20 sJudge it by real call sites and by what it encodes. A helper that gains a type parameter for each new caller captures a coincidence, not a rule, so delete it and let each pipeline keep its own short loop.
solid answer
~50 sThe teams copying it back is the strongest datum I have: they weighed the cost of depending on it against the cost of the code, and the code won. I would read three call sites out loud, count real instantiations, and ask one question — does this helper encode an *invariant* or a *shape*? An invariant is a rule a copy could get silently wrong: an ordering guarantee, an overflow-safe total, a flush-exactly-once. A shape is a ten-line loop filling a map, where two copies diverging is fine. Growing a type parameter per caller is the signature of a shape forced to serve cases that share only an outline. If it is a shape, I delete it and accept the duplication — a little copying is better than a little dependency. If it is an invariant, I keep it but narrow it: one function, inferable type arguments, standard-library constraints.
code
go · 22 linestype Row struct {
Region string
Amount int
}
type Summary struct {
Count int
Total int
}
// One concrete loop per pipeline, instead of a helper that grew
// a type parameter for every new caller.
func summarize(rows []Row) map[string]Summary {
out := make(map[string]Summary, len(rows))
for _, r := range rows {
s := out[r.Region]
s.Count++
s.Total += r.Amount
out[r.Region] = s
}
return out
}go deeper
Know that duplicated code is not automatically wrong in Go. Being able to say why a short, obvious loop can beat a shared helper is already a good answer at this level.
Name the symptoms of an over-stretched generic helper — a type parameter per caller, a constraint that widened, call sites longer than the code they replaced — and say what you would do about each.
Show the criterion, not the opinion: invariant versus shape, a counted bar for keeping shared code, and a staged retreat that keeps the tests while call sites are converted one package at a time.
Own the decision as a policy other reviewers can apply without you, and account for the cross-team cost: an imported helper needs an owner and a release story, while duplication is reversible and locally verifiable.
## Read the copy-back as evidence, not as a mistake When an engineer copies a shared function instead of importing it, the instinct is to treat that as ignorance or laziness. It usually is not. Importing a package costs an import graph edge, a review conversation with its owner, a version to keep in step and, in a generic helper's case, a signature that must be understood before it can be called. Two teams paying twenty lines of duplication to avoid all that have already made the judgment you are now making. Ask them why before you overrule them. ## The symptoms that a generic abstraction has collapsed - **A type parameter per caller.** The helper started with one, has three, and each arrived with a feature request. Nothing about those callers is genuinely shared; the signature is the union of their differences. - **The constraint widened to `any`.** Constraints narrow as an abstraction gets clearer. One that has widened is one that gave up. - **The body grew branches.** A generic function with a boolean parameter deciding behaviour is two functions wearing one signature. - **Call sites got longer than the code they replaced.** Read three of them out loud. If the call is harder to say than the loop, the abstraction has negative value. - **Someone copied it.** The decisive one. ## Invariant versus shape This is the question that actually settles it. A **shape** is a structure that recurs because problems of this kind look alike: iterate a batch, key it, accumulate into a map, return. Two copies of a shape drift apart harmlessly, and drift is often what you want — one pipeline adds a filter and the other does not. Duplication is cheap here because the code is obvious enough that a reader verifies it in place. An **invariant** is a rule that must hold everywhere and that a copy can get silently wrong: elements must be emitted in the order they arrived; totals must not overflow; a batch is flushed exactly once. When the same subtle logic appears twice, the second copy is where the bug will live, unnoticed, because it looks right. Share those, always. Most generic utility helpers people fight over are shapes. That is why the argument keeps happening: neither side is wrong about their own code, and the abstraction was never doing the work its defenders imagine. ## The bar I can defend A rule reviewers can apply without me, and that stops the discussion from restarting each quarter: 1. **Three real instantiations in at least two packages.** Two callers is a coincidence. 2. **All type arguments inferable from the arguments** at every call site. A call that spells out its types is telling you the signature does not fit the use. 3. **Constraints from the standard library** — `any`, `comparable`, `cmp.Ordered` — not a bespoke interface invented so one caller fits. 4. **The helper is deletable.** If retiring it would take a week, it is load-bearing in a way nobody chose. ## Retreating without a flag day Deleting shared code is not a big-bang operation. Leave the helper in place, rewrite call sites package by package, keep the tests: whatever the shared version guaranteed, the concrete loops must still pass. Recent Go helps mechanically — `go fix` is the home of the modernizers since Go 1.26, and a thin wrapper marked `//go:fix inline` has its call sites rewritten for you. Then delete the function once nothing references it, and delete its tests with it if they only ever tested the helper's own plumbing. ## What you lose, honestly Duplication costs something. A bug fixed in one copy is not fixed in the other; a reader who sees both wonders which is canonical; a metric or a log line added in one place goes missing in the other. The way to pay that down is not a generic function but a shared **test**: if both pipelines must produce the same summary shape, assert it in a helper both test files call, and let the production loops stay separate. ## The thing to say out loud in the review "Two teams already voted with their editors. I want to know whether this encodes a rule a copy could get wrong. If it does, we keep it and narrow the signature. If it does not, we delete it and each pipeline owns its loop." That sentence resolves the argument by naming the criterion, which is the part usually missing.
- How many call sites would make you keep it?No number is magic, but a defensible bar is three real instantiations across at least two packages, all of which would carry the same bug if the shared logic were wrong. Two callers is a coincidence. Writing the bar down matters more than its exact value, because it stops the argument being re-litigated in every review.
- How do you retire the helper without a flag day?Leave it in place and convert call sites package by package, keeping the tests that the concrete code must still satisfy. In recent Go you can mark a thin wrapper `//go:fix inline` and let `go fix` rewrite the callers, then delete the function once nothing references it.
- What if deleting it duplicates genuinely subtle logic?Then it was never a candidate for deletion. Duplication is cheap for a shape — a loop filling a map — and expensive for an invariant like an ordering guarantee or an overflow-safe total, where the second copy is where the silent bug lives. Keep that shared and narrow its signature instead.
- How do you stop the two copies drifting into inconsistency?Share the test rather than the implementation. If both pipelines must produce the same summary shape, put that assertion in a helper both test files call. The guarantee lives where it can be checked, and each pipeline keeps a loop its own reader can verify in place.
A shared helper is a shared tool in a workshop. If it is a precision jig that guarantees a cut is square, everybody borrows it. If it is a hammer, everybody buys their own and nobody walks across the building for it.
saying these in an interview costs you the question
- Treats every duplicated loop as debt that must be removed
- Keeps the helper because deleting code feels like a regression
- Adds one more type parameter to satisfy the newest caller
- Counts lines saved instead of call sites and invariants
- Assumes the teams copied it because they misunderstood it