<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arijeet Ganguli</title>
    <description>The latest articles on DEV Community by Arijeet Ganguli (@arijeetganguli).</description>
    <link>https://dev.to/arijeetganguli</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F411952%2Fff61245f-8208-4386-a72c-bee58b6fcb77.png</url>
      <title>DEV Community: Arijeet Ganguli</title>
      <link>https://dev.to/arijeetganguli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arijeetganguli"/>
    <language>en</language>
    <item>
      <title>Building the Fastest .NET Object Mapper</title>
      <dc:creator>Arijeet Ganguli</dc:creator>
      <pubDate>Tue, 31 Mar 2026 05:32:14 +0000</pubDate>
      <link>https://dev.to/arijeetganguli/building-the-fastest-net-object-mapper-288g</link>
      <guid>https://dev.to/arijeetganguli/building-the-fastest-net-object-mapper-288g</guid>
      <description>&lt;p&gt;&lt;strong&gt;How compiled expression trees and cycle analysis beat Mapster, AutoMapper, and hand-tuned reflection.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Every .NET project eventually needs object mapping. &lt;code&gt;User&lt;/code&gt; → &lt;code&gt;UserDto&lt;/code&gt;. &lt;code&gt;Order&lt;/code&gt; → &lt;code&gt;OrderResponse&lt;/code&gt;. You've done it a thousand times. And you've probably reached for AutoMapper or Mapster — because why write &lt;code&gt;dest.Name = src.Name&lt;/code&gt; fifty times?&lt;/p&gt;

&lt;p&gt;But there's a problem nobody talks about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Friday Incident
&lt;/h2&gt;

&lt;p&gt;A developer on our team added a &lt;code&gt;Parent&lt;/code&gt; property to a &lt;code&gt;Node&lt;/code&gt; class. Standard tree structure. The mapper tried to map &lt;code&gt;Node.Parent.Parent.Parent...&lt;/code&gt; until the stack overflowed. Production went down. No exception was caught — &lt;code&gt;StackOverflowException&lt;/code&gt; is uncatchable in .NET.&lt;/p&gt;

&lt;p&gt;We looked at every major mapping library. &lt;strong&gt;None of them had cycle detection.&lt;/strong&gt; Not AutoMapper. Not Mapster. Not PanoramicData.Mapper.&lt;/p&gt;

&lt;p&gt;So we built one that does.&lt;/p&gt;

&lt;h2&gt;
  
  
  But We Didn't Want to Be the Slowest
&lt;/h2&gt;

&lt;p&gt;The first version used reflection. It was safe — cycle detection worked, max-depth enforcement worked, configuration validation caught missing mappings at startup. But it was slow. Really slow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;vs Manual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual Mapping&lt;/td&gt;
&lt;td&gt;~17 ns&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mapster&lt;/td&gt;
&lt;td&gt;~23 ns&lt;/td&gt;
&lt;td&gt;1.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AutoMapper&lt;/td&gt;
&lt;td&gt;~63 ns&lt;/td&gt;
&lt;td&gt;3.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mapture v0 (refl.)&lt;/td&gt;
&lt;td&gt;~773 ns&lt;/td&gt;
&lt;td&gt;45x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;45x slower than hand-written code. That's not a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rewrite: Compiled Expression Trees
&lt;/h2&gt;

&lt;p&gt;The insight: &lt;strong&gt;reflection is only slow when you do it on every call.&lt;/strong&gt; What if you did reflection &lt;em&gt;once&lt;/em&gt; — at configuration time — and compiled the result into a native delegate?&lt;/p&gt;

&lt;p&gt;That's exactly what &lt;code&gt;System.Linq.Expressions&lt;/code&gt; lets you do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// At configuration time, Mapture builds this:&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MemberInit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserDto&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destNameProp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;srcNameProp&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destAgeProp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Property&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;srcAgeProp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lambda&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lambda&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;compiled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// On every Map() call, Mapture just does:&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;compiled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiled delegate is essentially the same IL that the JIT would produce for hand-written &lt;code&gt;new UserDto { Name = src.Name, Age = src.Age }&lt;/code&gt;. No reflection. No dictionary lookups. No allocations beyond the destination object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trick: Separate Fast and Slow Paths
&lt;/h2&gt;

&lt;p&gt;Here's the key optimization most mappers miss. &lt;strong&gt;Most types don't have cycles.&lt;/strong&gt; &lt;code&gt;User&lt;/code&gt; → &lt;code&gt;UserDto&lt;/code&gt; will never cause infinite recursion. Only self-referencing types like &lt;code&gt;Node&lt;/code&gt; → &lt;code&gt;NodeDto&lt;/code&gt; (where &lt;code&gt;Node&lt;/code&gt; has a &lt;code&gt;Node Parent&lt;/code&gt; property) can cycle.&lt;/p&gt;

&lt;p&gt;So at configuration time, Mapture runs a graph traversal on your type maps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → UserDto          → acyclic (fast path)
Order → OrderDto        → acyclic (fast path)  
Node → NodeDto          → CYCLIC (needs tracking)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acyclic types get a pure delegate — no &lt;code&gt;HashSet&amp;lt;object&amp;gt;&lt;/code&gt;, no depth counter, zero overhead. Cyclic types get wrapped with a visited set and a depth counter. You get safety where you need it and raw speed everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three More Tricks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Embedded nested delegates.&lt;/strong&gt; When &lt;code&gt;Order&lt;/code&gt; has an &lt;code&gt;Address&lt;/code&gt; property, the compiled delegate for &lt;code&gt;Address → AddressDto&lt;/code&gt; is embedded directly into the &lt;code&gt;Order → OrderDto&lt;/code&gt; expression tree as a constant. No type-pair lookup per nested object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Typed delegates.&lt;/strong&gt; Instead of &lt;code&gt;Func&amp;lt;object, object&amp;gt;&lt;/code&gt; (which boxes value types), Mapture caches &lt;code&gt;Func&amp;lt;TSource, TDestination&amp;gt;&lt;/code&gt; per generic type pair. Zero boxing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Thread-static cache.&lt;/strong&gt; The hot path doesn't even hit a &lt;code&gt;ConcurrentDictionary&lt;/code&gt;. It reads from a &lt;code&gt;[ThreadStatic]&lt;/code&gt; slot in a static generic class &lt;code&gt;TypePairCache&amp;lt;TSource, TDestination&amp;gt;&lt;/code&gt;. That's a single field read — essentially free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;vs Manual&lt;/th&gt;
&lt;th&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;Manual Mapping&lt;/td&gt;
&lt;td&gt;~17 ns&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;Mapture&lt;/td&gt;
&lt;td&gt;~25 ns&lt;/td&gt;
&lt;td&gt;1.5x&lt;/td&gt;
&lt;td&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;Mapster&lt;/td&gt;
&lt;td&gt;~27 ns&lt;/td&gt;
&lt;td&gt;1.6x&lt;/td&gt;
&lt;td&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AutoMapper&lt;/td&gt;
&lt;td&gt;~68 ns&lt;/td&gt;
&lt;td&gt;4.0x&lt;/td&gt;
&lt;td&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;PanoramicData.Mapper&lt;/td&gt;
&lt;td&gt;~283 ns&lt;/td&gt;
&lt;td&gt;16.9x&lt;/td&gt;
&lt;td&gt;272 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From 773 ns to 25 ns. From last place to first.&lt;/p&gt;

&lt;p&gt;And it still has cycle detection, max-depth enforcement, and configuration validation — features none of the faster alternatives offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ~8 ns Gap
&lt;/h2&gt;

&lt;p&gt;Why not zero overhead? Two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delegate invocation&lt;/strong&gt; (~2–3 ns): calling a compiled &lt;code&gt;Func&amp;lt;T,R&amp;gt;&lt;/code&gt; is slightly slower than inlined code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache lookup&lt;/strong&gt; (~3–5 ns): reading the thread-static delegate slot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For context, a single database query takes 500,000–5,000,000 ns. The 8 ns gap is undetectable in any real application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration from AutoMapper
&lt;/h2&gt;

&lt;p&gt;Mapture uses the same API patterns. Most migrations are a find-and-replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;AutoMapper&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAutoMapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Startup&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Mapture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMapture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Startup&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;Profile&lt;/code&gt;, &lt;code&gt;CreateMap&lt;/code&gt;, &lt;code&gt;ForMember&lt;/code&gt;, &lt;code&gt;Ignore&lt;/code&gt;, &lt;code&gt;ReverseMap&lt;/code&gt;. The API was designed so you can migrate in under 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Mapture
dotnet add package Mapture.Extensions.DependencyInjection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/arijeetganguli/Mapture" rel="noopener noreferrer"&gt;github.com/arijeetganguli/Mapture&lt;/a&gt;&lt;br&gt;
NuGet: &lt;a href="https://www.nuget.org/packages/Mapture/" rel="noopener noreferrer"&gt;nuget.org/packages/Mapture&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Targets: .NET Framework 4.8, .NET Standard 2.0, .NET 8, .NET 10.&lt;/p&gt;

&lt;p&gt;MIT licensed. Zero telemetry. 81 tests across 3 frameworks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Benchmarks measured with BenchmarkDotNet on .NET 10.0, X64 RyuJIT AVX2. Source code and benchmark project included in the repository.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>opensource</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
