Sparse Mapping
Override Exceptions, Don’t Rewrite the DTO
Abstract
In many backends, “get one record and return a DTO” starts simple and slowly becomes expensive: load a deep entity graph, run a generic mapper, then patch display fields in code. This article shares a practical pattern from day-to-day API work: treat mapping as default coverage + sparse exceptions, not as a second implementation of your domain. The goal is one clear decision model other developers can reuse when choosing between load-and-map, SQL projection, and post-query enrichment.
The problem we keep rediscovering
A Get-by-id endpoint usually needs more than raw columns. UIs want:
translated or localized titles
composed labels (
Parent / Child)ordered nested lines
a few fields that come from another service or module
The common path looks like this:
Load the entity (often with many includes)
Auto-map entity → DTO
Patch awkward fields in an
OnMap-style hook
It works. It also trains a bad habit: when one field is hard, rewrite the whole mapping story for that handler.
Over time you get:
repeated translation / navigation logic across handlers
queries that load more than the response needs
handlers that become a second source of truth for DTO shape
The engineering question is not “mapper vs hand-written LINQ.” It is:
Where does coverage live, and where do exceptions live?
A better mental model
Split mapping into three layers of responsibility:
Default map
• Coverage: same-named fields, FKs, amounts, simple renames
• Lives in: mapper profile / conventions
SQL-friendly exceptions
• Coverage: names, translations, ordered collections, conditional display fields
• Lives in: sparse projection expression
Non-SQL enrichment
• Coverage: cross-module lookups, HTTP/service calls, non-SQL logic
• Lives in: post-query hook
Rule of thumb:
Put coverage in one place.
Put exceptions only where they are forced.
Never turn an exception into a full DTO rewrite.
That restraint is the whole pattern.
Decision tree (use this in code reviews)
Ask these three questions in order:
1) Does the default map already satisfy the UI?
If yes → enable a pure projection path (or empty custom initializer).
Do not invent custom mapping for ceremony.
2) Are the missing fields expressible in SQL?
Examples:
preferred translation for a language
composed branch/parent label
ordered nested items with a few computed names
If yes → write a sparse custom projection: assign only those members; leave the rest to the default map.
3) Do you need services or non-SQL work?
If yes → enrich after the query.
Anything that needs runtime services is enrichment, not mapping.
If mapping is trivial and load cost is acceptable, staying on load-entity + map is still valid. Projection is a preference for new work and for handlers you are actively cleaning up—not a religion.
Scenario catalog (abstract recipes)
Scenario A — Empty / mapper-only
When: profile + conventions already produce a correct DTO.
Do: enable projection with no custom members.
Avoid: enabling projection if you silently depended on post-map display-name helpers that projection will not invent.
Scenario B — Sparse header fields
When: most fields are fine; a few display names need joins/translations/composition.
Do: override only those fields in a projection expression.
Avoid: reconstructing the entire DTO “just to be safe.”
Scenario C — Nested collections
When: line items need ordering, filtered lookups, or differently shaped display fields.
Do: project nested Select(... new ItemDto { ... }) with only computed/different members; keep ids/amounts/FKs on the default path.
Benefit: one query shape, less in-memory patching, less duplicated item mapping.
Scenario D — Operational documents
When: documents have shared base enrichment (status, permissions, workflow metadata, etc.).
Do: use sparse projection for SQL fields, then always run the shared post-projection base enrichment.
Avoid: copying base enrichment into every handler.
Scenario E — Stay on load + OnMap
When: mapping is complex, non-SQL, not yet migrated, and performance is acceptable.
Do: keep the old path intentionally.
Avoid: half-migrating: projection enabled but still relying on OnMap (that path usually never runs).
What “hybrid / sparse” really buys you
Without a merge strategy, teams face a false binary:
use generic projection, then patch missing fields later → extra work / extra round-trips
hand-write the entire DTO in LINQ → duplicate the mapper profile forever
Sparse hybrid mapping keeps both strengths:
Mapper owns the majority
Handler owns only SQL-expressible exceptions
Post-query hook owns service lookups
In practice, the benefit shows up as:
Smaller cognitive load — readers see what is special, not a wall of identical assignments
Better query shape — select what the response needs instead of hydrating a deep graph “because mapping might need it”
Safer evolution — changing a common field mapping happens once in the profile, not in every handler
Clearer ownership — SQL vs service logic stops mixing in one overloaded
OnMapmethod
This is experience talking more than theory: most bugs I have seen in this area were not “wrong SQL.” They were wrong layer—display logic stuffed into the wrong hook, or a full rewrite that drifted from the shared map.
Hard rules that survive real projects
Sparse overrides only. If you assign every property, you abandoned the pattern.
Capture request/language locals before the expression. Don’t close over unstable request state carelessly.
Deep-merge nested item projections. Item exceptions should not erase default item mapping.
Projection path skips classic
OnMap. If you need post-work, use the projected enrichment hook and callbasewhen a shared base exists.Do not enable pure projection if the old empty handler secretly depended on map-time helpers the new path does not provide.
Prefer boring defaults. Custom mapping is an admission that a field is special—not a license to be clever.
A migration checklist you can reuse
When cleaning an old Get-by-id handler:
List DTO fields the UI actually needs.
Mark which are covered by the existing map.
Mark which are SQL-friendly exceptions.
Mark which need services.
Enable projection / sparse expression only for (3).
Move (4) to post-projection enrichment.
Compare response parity with the old path before deleting
OnMaplogic.Delete duplicated assignments that the profile already owns.
If step 7 fails, the failure usually tells you which layer you misclassified—not that “projection is bad.”
Pitfalls worth sharing
Binary thinking: “either full ProjectTo or full hand map.” Sparse merge exists to kill that trap.
Silent dependency on
OnMap: enabling projection and wondering why display names disappeared.God expression: a “sparse” projection that secretly rebuilds the whole graph.
Non-translatable logic in the expression: service calls, random in-memory algorithms, or anything EF cannot translate.
Nested overwrite: custom item
Selectthat drops default item fields because merge rules were ignored.Premature purity: migrating a stable, cheap handler just to look modern.
What I would tell another developer tomorrow morning
Start every mapping discussion with coverage vs exception.
If AutoMapper (or your convention layer) already covers the DTO, stop.
If a few fields need SQL-friendly shaping, override only those.
If something needs a service, enrich after the query.
Custom mapping for a scenario is not a rewrite.
It is a small, honest admission: this field is special—and the discipline to leave the rest alone.
When the exception grows into a full second implementation, you have not customized the map. You have abandoned the pattern.
Closing
Software engineering patterns earn their keep when they reduce repeated judgment calls. Sparse mapping does that: one decision tree, a short scenario catalog, and a few hard rules that keep handlers readable as systems grow.
Share the decision model, not the proprietary domain. Other teams can apply the same structure to invoices, catalogs, tickets, or any Get-by-id surface where DTO shape outgrows naïve load-and-map.




