Your reflect-based row mapper is the hot path of a nightly ten-million-row batch: keep it, generate mappers, or go generic?
answer
- measure the ceiling first
- the contained fix before the structural one
- who pays after you ship it
- stale generated code needs a gate
- scope it to the types with volume
basics
~20 sDecide on measured cost weighed against the obligations you are creating for everyone else. A cached reflective plan is usually enough. Generated mappers buy speed and build-time breakage, and cost a generation step CI must police forever.
solid answer
~50 sI would order it by what each option costs beyond this package. First measure: allocations and time per row for the current mapper against a hand-written one for the same struct, so we know the ceiling before spending anything. Then take the free win — a plan cached per `reflect.Type` — and re-measure; often the batch is no longer mapper-bound and the decision ends there. If it is still short, code generation is the real answer for the hot types, but it is a build-policy commitment: a generator whose version is pinned, generated files checked in and reviewed, and a CI step that regenerates and fails on any diff, or the tree silently drifts. Generics come in alongside either, shrinking the dynamic surface to the field-by-field step. What I would not do is generate mappers for every model to avoid measuring two of them: that is a permanent tax on every contributor for a cost we never demonstrated.
code
text · 2 linesgo generate ./...
git diff --exit-code # non-empty diff means the checked-in output is stalego deeper
You will not lead this call, but be able to say that reflection buys flexibility at the cost of run-time speed and compile-time checking, and that code generation trades the opposite way.
Be ready to explain the mechanics behind each option — a cached per-type plan, generated per-type code, type parameters — and which costs each one actually removes.
Show that you would measure before changing anything, take the contained fix first, and scope any code generation to the types that carry the volume rather than the whole model set.
Own the consequences outside the package: a pinned generator, a CI gate that fails on stale output, a step every contributor inherits, and an API shape other teams program against. Be able to say who can overrule you and why they might be right.
## The decision, not the benchmark All three options work. The question is which obligations you want to own for the next few years, and who else pays them. Framing this as "reflection is slow, generate everything" is the answer that reads well and ages badly. ## Step one: establish the ceiling before spending anything Measure the current mapper against a hand-written mapper for one representative struct — time and allocations per row, on rows already in memory so the database is out of the picture. That number is the *most* any change can buy. If the mapper is fifteen percent of a batch that is otherwise waiting on the database, the whole discussion is over and the right answer is to leave it alone and say so. Half the value of being the person who owns this call is being willing to close it. ## Step two: take the contained win Caching a plan per `reflect.Type` removes the repeated discovery and changes nothing outside the package: no new build step, no generated files, no change to what callers write. Re-measure after it. Very often the remaining gap no longer justifies anything structural, and you have spent one review on it. ## Step three: price the alternatives honestly **Generated mappers (`go generate`).** The runtime win is real: static field assignment, no boxing, and — the underrated part — a struct field renamed makes the *build* fail rather than row one of tonight's batch. The costs are organisational and permanent: - The generator becomes a dependency the module must pin, or two engineers produce different output from the same source. - Generated files are either checked in (reviewable, diffable, but able to go stale) or produced during the build (never stale, but now every build and every editor needs the generator). Checking in is the common choice, and it only holds if CI runs the generator and fails on a non-empty diff. Without that gate, checked-in generated code is a lie waiting to be discovered. - Every contributor now has a step to remember, including the ones who have never heard of this batch. - Generated code shows up in reviews, coverage and diffs, and someone has to own the template when the schema conventions change. **Type parameters.** Free of build-system cost and checked at compile time, but they cannot walk a struct's fields, so they shrink the dynamic surface rather than removing it. They also change the API: `Load[T]()` reads differently from `Load(dst any)`, and if other teams already call the old one, that is a coordinated change, not a refactor. ## Step four: decide the scope, which is where most teams go wrong The decision is rarely all-or-nothing. Two or three model types carry the volume; the long tail is mapped a handful of times a day. Generating for the hot types and leaving the tail reflective gets almost the entire win for a fraction of the obligation — at the price of two mechanisms in one codebase, which needs a written rule for which to use, or it degenerates into whichever the last author preferred. ## Step five: name who can overrule you A code-generation step is a build-policy change, so the platform or build owner has a legitimate veto, and their objection is usually correct: they inherit the CI gate and the support load. If the mapper is a shared library, the teams importing it inherit the API shape and, if generation is not hermetic, the generator too. Bringing them the measurement rather than the conclusion is what makes this a decision rather than an announcement. ## What a good answer sounds like "Measure the ceiling; cache the plan and re-measure; generate only for the types that carry the volume, and only with a CI gate that fails on stale output; keep reflection for the tail; and if the first measurement says the batch is database-bound, do none of it." What a weak answer sounds like: "reflection is slow, so we generate everything" — with no number, no owner for the generator, and no gate keeping the generated code honest.
- The platform team objects to adding a code-generation step to CI. Is that a veto you should accept?Usually yes, unless the measurement is overwhelming. They inherit the gate, the pinned generator version, and every support request from a contributor whose diff is stale. If the cached reflective plan gets most of the win, their objection costs almost nothing to honour. If it does not, the conversation is about who owns the step, not about whether reflection is slow.
- Generated mappers are checked into the repository. What can go wrong that a reviewer will not see?Drift. Someone edits a struct, does not rerun the generator, and the checked-in mapper still compiles because it references fields that still exist — it simply omits the new one. Nothing fails, and rows are silently written with a zero value. The only reliable guard is CI regenerating and failing on a diff; review alone does not catch what is not in the diff.
- How would you justify keeping reflection on the hot path to a reviewer who wants it removed on principle?With the number. If the cached-plan mapper is within a small margin of generated code for this workload, the remaining difference does not pay for a permanent build step and a second mechanism in the codebase. Reflection on a hot path is a smell, not a defect; what makes it defensible is that it was measured and that a cheaper option was tried first.
- If you generate mappers for only the two highest-volume types, what do you owe the codebase?A written rule for which mechanism a new model uses and why, in the package that owns both, so the choice does not become folklore. Without it the next author picks by taste, and in a year half the models are generated for no stated reason and nobody knows whether the generator can be dropped.
saying these in an interview costs you the question
- Generates mappers for every type without measuring one
- Checks in generated code with no CI regeneration gate
- Treats reflection on a hot path as automatically unacceptable
- Ignores that a build step is imposed on every contributor
- Leaves the generator's version unpinned across the team
- Decides alone when the build owner inherits the CI step