<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Dapper on Russell ‛Russ’ Frith</title><link>https://russfrith.com/tags/dapper/</link><description>Recent content in Dapper on Russell ‛Russ’ Frith</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Tue, 03 Nov 2026 16:09:36 -0500</lastBuildDate><atom:link href="https://russfrith.com/tags/dapper/index.xml" rel="self" type="application/rss+xml"/><item><title>When EF Core Isn't Enough: Adding Dapper Without Breaking Your Domain Model</title><link>https://russfrith.com/posts/ef-core-dapper-light-cqrs/</link><pubDate>Tue, 03 Nov 2026 16:09:36 -0500</pubDate><guid>https://russfrith.com/posts/ef-core-dapper-light-cqrs/</guid><description>A light CQRS pattern for mixing EF Core and Dapper in the same app, with real benchmarks across tracked entities, AsNoTracking, projections, compiled queries, and Dapper</description><content:encoded><![CDATA[<p>I use EF Core almost exclusively for database access in my .NET projects. For the write side of almost any app, I want an aggregate that protects its own invariants, a repository that hides the database, and a unit of work that commits a transaction. EF Core gives me all of that without much ceremony. So when I say &ldquo;here&rsquo;s when I reached for Dapper,&rdquo; I want to be clear up front: this isn&rsquo;t a recommendation to replace EF Core, or even to use Dapper regularly. It&rsquo;s a description of one place where EF Core&rsquo;s own tools stopped being enough, and the rules I set for myself before I let a second data access library into the codebase.</p>
<p>The trigger is almost always a read endpoint. A list screen with filters and paging, a report that aggregates across a hundred thousand rows, a customer history export, something domain-driven design never really promised to be fast at. The order aggregate that protects &ldquo;a submitted order can&rsquo;t be modified&rdquo; has no opinion about &ldquo;show me the last 90 days of orders, by customer name, with a running total.&rdquo; Those are different problems, and forcing one tool to solve both is where the trouble can start.</p>
<p>Our example is an order-management system with an <code>Order</code> aggregate on the write side, a Dapper-backed query layer on the read side, and five ways of fetching the same data (EF tracked, EF <code>AsNoTracking</code>, EF <code>AsNoTracking</code> with a projection, an EF compiled query, and Dapper) benchmarked against each other on 120,000 seeded orders in a containerized Postgres instance. The benchmark result I want to tease now: Dapper won three of the four scenarios, and EF Core won the fourth, cleanly, once I used EF&rsquo;s own tools properly. Both of those facts matter more than &ldquo;Dapper is faster,&rdquo; which is the headline I was tempted to write before I ran the numbers.</p>
<p>The <a href="https://github.com/russfrith/russfrith-blog/tree/master/companion-code/ef-core-dapper-light-cqrs">full companion solution</a> runs entirely in Docker, including the tests and benchmarks, and every code snippet and SQL statement below is pulled directly from it.</p>
<h2 id="the-architecture-light-cqrs">The architecture: light CQRS</h2>
<p>&ldquo;CQRS&rdquo; tends to conjure event sourcing, a separate read database, and a message bus, and that&rsquo;s not what&rsquo;s here. There&rsquo;s no library involved either, no MediatR, no mediator pattern of any kind: this is the smallest version of command/query separation that&rsquo;s still worth naming, built entirely out of plain classes and interfaces I own.</p>
<ul>
<li><strong>Write side</strong>: a command handler calls a repository, which loads an <code>Order</code> aggregate, which enforces its own invariants, saved through EF Core and a unit of work.</li>
<li><strong>Read side</strong>: a query service runs hand-written SQL through Dapper and returns a DTO. No repository, no aggregate, no domain type anywhere in the call stack.</li>
<li><strong>Same database.</strong> Same Postgres instance, same tables, same migrations (owned by EF Core). No separate read store, no projections to keep in sync, no eventual consistency to reason about.</li>
<li><strong>No mediator.</strong> Handlers are plain classes behind plain interfaces, resolved through constructor injection like anything else: <code>IOrderQueries</code>, <code>ICreateOrderHandler</code>, <code>ISubmitOrderHandler</code>.</li>
</ul>
<p>The one rule that makes this specific split work, and the one an architecture test enforces: <strong>repositories never serve reads, and query services never touch aggregates.</strong> That&rsquo;s a rule for this architecture, not a universal law of repositories; an onion or vertical-slice architecture that isn&rsquo;t drawing a write/read line in the first place might reasonably use the same repository for both, and that&rsquo;s a legitimate choice there, not a violation of some broader principle. Here, where the whole point is keeping reads and writes on separate paths, a repository method whose return type ends in <code>Dto</code> is a bug, and so is anything in the read namespace referencing the <code>Domain</code> assembly. Two automated tests in the companion code check exactly this, and I&rsquo;ll show them later.</p>
<p>What it isn&rsquo;t: there&rsquo;s no event store, no separate read database, no projection-rebuild job, and no mediator pattern. If you&rsquo;re picturing a message bus and a read model that&rsquo;s eventually consistent with the write model, that&rsquo;s a different, heavier thing than what&rsquo;s described here. This stays boring on purpose.</p>
<h2 id="when-to-choose-it-and-when-to-skip-it">When to choose it, and when to skip it</h2>
<p><strong>Choose it when:</strong></p>
<ul>
<li>Your read shapes genuinely diverge from your aggregates: list screens, dashboards, reports that join and aggregate across tables the aggregate doesn&rsquo;t know about.</li>
<li>There are real invariants on the write side worth protecting in code, not just in a check constraint. &ldquo;An order can&rsquo;t be modified after submission&rdquo; is the kind of rule I want a unit test on, not a trigger.</li>
<li>Reads dominate your traffic, and the heavy ones are genuinely heavy. Projections save real response time on a hot path, not a cron job nobody&rsquo;s waiting on.</li>
</ul>
<p><strong>Skip it when:</strong></p>
<ul>
<li>The app is mostly CRUD. If every query maps cleanly back to an aggregate or a thin projection of one, you don&rsquo;t need a second data access tool.</li>
<li>EF Core&rsquo;s own projections already meet your performance target. &ldquo;Meet the target&rdquo; is doing the work here; benchmark first, see the next section.</li>
<li>The team is small, and the cognitive cost of a second query language, a second set of conventions, and a second place SQL injection can sneak in isn&rsquo;t worth whatever microseconds you&rsquo;d save.</li>
</ul>
<h2 id="alternatives-i-didnt-pick">Alternatives I didn&rsquo;t pick</h2>
<p><strong>Repositories only, no Dapper.</strong> The default, and right more often than this post might suggest. If <code>AsNoTracking().Select(...)</code> gets you where you need to go, stop there.</p>
<p><strong>EF-only CQRS.</strong> Split the read and write models conceptually (<code>IOrderQueries</code>, DTOs, projections) but implement reads in EF Core too, without ever touching an aggregate. Most of the architectural benefit, no second library. The benchmarks below show it&rsquo;s often fast enough that you should seriously consider stopping there.</p>
<p><strong>Full CQRS with separate read and write stores.</strong> A materialized read database, projections kept current by an event stream or change-data-capture, independent scaling for reads and writes. Solves a different problem (traffic that can&rsquo;t share infrastructure, or read models that reshape data far more than a SQL view can) at a real cost: eventual consistency, a projection pipeline to operate, a second database to back up. Nothing in this post&rsquo;s traffic profile justified that.</p>
<h2 id="start-with-ef-cores-own-fixes">Start with EF Core&rsquo;s own fixes</h2>
<p>Before any of this, there are four things worth trying first, roughly in order of effort:</p>
<ol>
<li><strong><code>AsNoTracking()</code></strong>: skip change tracking on read-only queries. Free, and it&rsquo;s the first lever in the benchmarks below.</li>
<li><strong><code>Select()</code> projections</strong>: project to a DTO shape in the query itself instead of materializing full entities and mapping afterward. This is where most of the gain actually comes from.</li>
<li><strong>Compiled queries</strong> (<code>EF.CompileAsyncQuery</code>): skip LINQ-to-SQL translation on repeat executions of the same query shape. Useful on a hot path, with a real caveat I&rsquo;ll show in the benchmarks: some query shapes simply won&rsquo;t compile.</li>
<li><strong>Split queries</strong> (<code>AsSplitQuery()</code>): avoid a cartesian explosion when a single query includes multiple collection navigations. Not exercised directly in this post&rsquo;s scenarios, but worth knowing before reaching for Dapper on a query that&rsquo;s slow because of a join explosion rather than genuine overhead.</li>
</ol>
<p>Dapper is the last resort on this list, not the first. If <code>AsNoTracking</code> plus a projection gets you to your target, you&rsquo;re done, and that was true for one of the four scenarios benchmarked below.</p>
<h2 id="where-dapper-lives">Where Dapper lives</h2>
<p><img alt="Diagram of the write path versus the read path. Write path: Command handler, to IOrderRepository, to Order aggregate (where invariants are enforced), to EF Core, to IUnitOfWork. Read path: IOrderQueries, to Dapper plus SQL, to DTO, with no aggregate and no repository involved." loading="lazy" src="/posts/ef-core-dapper-light-cqrs/write-read-path-diagram.svg"></p>
<p>Dapper&rsquo;s entire footprint is one namespace: <code>Infrastructure.Reads</code>. <code>IOrderQueries</code> is the only interface the Api project calls for reads, <code>OrderQueries</code> is its only implementation, and nothing in that class imports anything from <code>Domain</code>. Two deliberately contained, documented exceptions let Dapper touch the write side too (covered later), but the default rule holds: Dapper reads, EF Core writes.</p>
<h2 id="sharing-a-connection-and-transaction">Sharing a connection and transaction</h2>
<p>The one place Dapper and EF Core&rsquo;s write side meet is a transaction that must commit or roll back as a unit: submitting an order and writing an audit row. <code>EfUnitOfWork</code> wraps the whole thing in an EF Core transaction:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task ExecuteInTransactionAsync(Func&lt;CancellationToken, Task&gt; action, CancellationToken cancellationToken = <span style="color:#66d9ef">default</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> <span style="color:#66d9ef">using</span> var transaction = <span style="color:#66d9ef">await</span> _context.Database.BeginTransactionAsync(cancellationToken);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> action(cancellationToken);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> transaction.CommitAsync(cancellationToken);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Inside that transaction, <code>OrderAuditWriter</code> borrows EF&rsquo;s own connection and transaction instead of opening its own:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task WriteAsync(Guid orderId, <span style="color:#66d9ef">string</span> eventName, CancellationToken cancellationToken = <span style="color:#66d9ef">default</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> connection = _context.Database.GetDbConnection();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> transaction = _context.Database.CurrentTransaction?.GetDbTransaction();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> sql = <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span>        insert <span style="color:#66d9ef">into</span> order_audit_log (order_id, event_name, occurred_at_utc)
</span></span><span style="display:flex;"><span>        values (@OrderId, @EventName, @OccurredAtUtc)
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;;
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> command = <span style="color:#66d9ef">new</span> CommandDefinition(
</span></span><span style="display:flex;"><span>        sql,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> { OrderId = orderId, EventName = eventName, OccurredAtUtc = DateTimeOffset.UtcNow },
</span></span><span style="display:flex;"><span>        transaction,
</span></span><span style="display:flex;"><span>        cancellationToken: cancellationToken);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> connection.ExecuteAsync(command);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The important detail is what this code doesn&rsquo;t do: it never calls <code>Open()</code>, <code>Close()</code>, or <code>Dispose()</code> on that connection, and it never commits or rolls back that transaction itself. EF owns both for their entire lifetime; Dapper is a guest. An integration test forces the audit insert to fail and asserts the order&rsquo;s status change rolls back with it, which is the whole point of sharing the transaction in the first place.</p>
<h2 id="code-walkthrough-a-command-handler-and-a-query-handler">Code walkthrough: a command handler and a query handler</h2>
<p><strong>Command handler.</strong> <code>SubmitOrderHandler</code> loads the aggregate through the repository, calls its one public method that enforces the invariant, and saves inside the shared transaction described above:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task HandleAsync(SubmitOrderCommand command, CancellationToken cancellationToken = <span style="color:#66d9ef">default</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    Domain.Order order = <span style="color:#66d9ef">await</span> _repository.GetByIdAsync(command.OrderId, cancellationToken)
</span></span><span style="display:flex;"><span>        ?? <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Domain.DomainException(<span style="color:#e6db74">$&#34;Order {command.OrderId} was not found.&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    order.Submit();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> _unitOfWork.ExecuteInTransactionAsync(<span style="color:#66d9ef">async</span> ct =&gt;
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> _unitOfWork.SaveChangesAsync(ct);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> _auditWriter.WriteAsync(order.Id, <span style="color:#e6db74">&#34;OrderSubmitted&#34;</span>, ct);
</span></span><span style="display:flex;"><span>    }, cancellationToken);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>Order.Submit()</code> is where the actual invariant lives, not in the handler:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> Submit()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (Status != OrderStatus.Draft)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> DomainException(<span style="color:#e6db74">$&#34;Only draft orders can be submitted. Order {Id} is {Status}.&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (_lines.Count == <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> DomainException(<span style="color:#e6db74">&#34;An order must have at least one line before it can be submitted.&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Status = OrderStatus.Submitted;
</span></span><span style="display:flex;"><span>    SubmittedAtUtc = DateTimeOffset.UtcNow;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The repository behind <code>_repository.GetByIdAsync</code> is nothing more than an <code>Include</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> Task&lt;Order?&gt; GetByIdAsync(Guid orderId, CancellationToken cancellationToken = <span style="color:#66d9ef">default</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> _context.Orders
</span></span><span style="display:flex;"><span>        .Include(o =&gt; o.Lines)
</span></span><span style="display:flex;"><span>        .FirstOrDefaultAsync(o =&gt; o.Id == orderId, cancellationToken);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Query handler.</strong> The order-detail query is the read-side equivalent, and it never touches <code>Order</code> or <code>OrderLine</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task&lt;OrderDetailDto?&gt; GetOrderDetailAsync(Guid orderId, CancellationToken cancellationToken = <span style="color:#66d9ef">default</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> headerSql = <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">select</span> o.id <span style="color:#66d9ef">as</span> OrderId, o.customer_id <span style="color:#66d9ef">as</span> CustomerId, c.name <span style="color:#66d9ef">as</span> CustomerName,
</span></span><span style="display:flex;"><span>               o.status <span style="color:#66d9ef">as</span> Status, o.created_at_utc <span style="color:#66d9ef">as</span> CreatedAtUtc, o.submitted_at_utc <span style="color:#66d9ef">as</span> SubmittedAtUtc
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">from</span> orders o
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">join</span> customers c <span style="color:#66d9ef">on</span> c.id = o.customer_id
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">where</span> o.id = @OrderId
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;;
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> linesSql = <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">select</span> ol.product_id <span style="color:#66d9ef">as</span> ProductId, p.name <span style="color:#66d9ef">as</span> ProductName, ol.quantity <span style="color:#66d9ef">as</span> Quantity,
</span></span><span style="display:flex;"><span>               ol.unit_price <span style="color:#66d9ef">as</span> UnitPrice, (ol.quantity * ol.unit_price) <span style="color:#66d9ef">as</span> LineTotal
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">from</span> order_lines ol
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">join</span> products p <span style="color:#66d9ef">on</span> p.id = ol.product_id
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">where</span> ol.order_id = @OrderId
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;;
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var connection = _connectionFactory.CreateConnection();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> connection.OpenAsync(cancellationToken);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var multi = <span style="color:#66d9ef">await</span> connection.QueryMultipleAsync(<span style="color:#66d9ef">new</span> CommandDefinition(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">$&#34;{headerSql};{linesSql}&#34;</span>, <span style="color:#66d9ef">new</span> { OrderId = orderId }, cancellationToken: cancellationToken));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> header = <span style="color:#66d9ef">await</span> multi.ReadSingleOrDefaultAsync&lt;OrderHeaderRow&gt;();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (header <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span>)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> lines = (<span style="color:#66d9ef">await</span> multi.ReadAsync&lt;OrderDetailLineDto&gt;()).ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> OrderDetailDto(
</span></span><span style="display:flex;"><span>        header.OrderId, header.CustomerId, header.CustomerName, header.Status,
</span></span><span style="display:flex;"><span>        header.CreatedAtUtc, header.SubmittedAtUtc, lines.Sum(l =&gt; l.LineTotal), lines);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Two round trips, two plain SQL statements, one DTO out. Nothing here could accidentally leak into the write path; the architecture test described below makes sure of it.</p>
<h2 id="the-gray-area-set-based-writes-that-bypass-the-aggregate">The gray area: set-based writes that bypass the aggregate</h2>
<p>Not every write needs an aggregate. A nightly batch job that marks thousands of submitted orders as shipped once they&rsquo;ve aged past a cutoff doesn&rsquo;t protect any invariant that a <code>WHERE</code> clause can&rsquo;t express, and loading each <code>Order</code> into memory just to flip one status column is pure overhead. <code>BulkMarkOrdersShippedHandler</code> does this as a plain <code>UPDATE</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> sql = <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span>    update orders
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">set</span> status = <span style="color:#960050;background-color:#1e0010">&#39;</span>Shipped<span style="color:#960050;background-color:#1e0010">&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">where</span> status = <span style="color:#960050;background-color:#1e0010">&#39;</span>Submitted<span style="color:#960050;background-color:#1e0010">&#39;</span>
</span></span><span style="display:flex;"><span>      and submitted_at_utc &lt; @CutoffUtc
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;;
</span></span></span></code></pre></div><p>This is a real exception to &ldquo;writes go through the aggregate,&rdquo; and I don&rsquo;t want to pretend otherwise. What keeps it from becoming a crack that widens over time:</p>
<ul>
<li>It lives in its own named class, in its own namespace (<code>Infrastructure.WriteExceptions</code>), not folded into <code>OrderRepository</code>.</li>
<li>The one invariant that matters here, that only a <code>Submitted</code> order can become <code>Shipped</code>, is encoded directly in the <code>WHERE</code> clause, and mirrors a domain-level test (<code>MarkShipped_FromDraft_Throws</code>) that checks the same rule on the normal, per-aggregate path.</li>
<li>An architecture test pins the exact, short list of classes allowed to use Dapper for a write. Anything added outside that list fails the build.</li>
</ul>
<p>If this handler ever needed to do something an aggregate would catch and a <code>WHERE</code> clause can&rsquo;t (say, recording a domain event, or validating something more complex than a status comparison), that&rsquo;s the signal to stop and go back through the aggregate, even at the cost of looping over orders one at a time.</p>
<h2 id="guardrails-for-a-team">Guardrails for a team</h2>
<p>Three mechanisms keep this honest as a team grows, enforced by the build rather than by review memory:</p>
<p><strong>Dapper is quarantined to two namespaces.</strong> This NetArchTest finds every type in <code>Infrastructure</code> that references Dapper and fails the build if it&rsquo;s outside <code>Reads</code> or the two named exceptions in <code>WriteExceptions</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> DapperIsOnlyReferencedFromReadsOrDocumentedWriteExceptions()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> typesUsingDapper = Types.InAssembly(InfrastructureAssembly)
</span></span><span style="display:flex;"><span>        .That()
</span></span><span style="display:flex;"><span>        .HaveDependencyOn(<span style="color:#e6db74">&#34;Dapper&#34;</span>)
</span></span><span style="display:flex;"><span>        .GetTypes()
</span></span><span style="display:flex;"><span>        .ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> offenders = typesUsingDapper
</span></span><span style="display:flex;"><span>        .Where(t =&gt; t.Namespace <span style="color:#66d9ef">is</span> not <span style="color:#e6db74">&#34;OrderLightCqrs.Infrastructure.Reads&#34;</span>
</span></span><span style="display:flex;"><span>                    and not <span style="color:#e6db74">&#34;OrderLightCqrs.Infrastructure.WriteExceptions&#34;</span>)
</span></span><span style="display:flex;"><span>        .Select(t =&gt; t.FullName)
</span></span><span style="display:flex;"><span>        .ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.True(offenders.Count == <span style="color:#ae81ff">0</span>, <span style="color:#e6db74">$&#34;Types referencing Dapper outside Reads/WriteExceptions: {string.Join(&#34;</span>, <span style="color:#e6db74">&#34;, offenders)}&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> writeExceptionTypes = typesUsingDapper
</span></span><span style="display:flex;"><span>        .Where(t =&gt; t.Namespace == <span style="color:#e6db74">&#34;OrderLightCqrs.Infrastructure.WriteExceptions&#34;</span>)
</span></span><span style="display:flex;"><span>        .Select(t =&gt; t.Name)
</span></span><span style="display:flex;"><span>        .OrderBy(n =&gt; n)
</span></span><span style="display:flex;"><span>        .ToList();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.Equal(<span style="color:#66d9ef">new</span>[] { <span style="color:#e6db74">&#34;BulkMarkOrdersShippedHandler&#34;</span>, <span style="color:#e6db74">&#34;OrderAuditWriter&#34;</span> }, writeExceptionTypes);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Repositories can&rsquo;t return DTOs, and query services can&rsquo;t return domain types.</strong> Two more architecture tests check both directions: every method on a type implementing <code>IOrderRepository</code> is scanned for a return type ending in <code>Dto</code>, and the <code>Infrastructure.Reads</code> namespace is asserted to have no dependency on <code>Domain</code> at all.</p>
<p><strong>Parameterized queries only.</strong> Every Dapper call passes parameters through <code>CommandDefinition</code> objects (<code>new { OrderId = orderId }</code>), never string interpolation into SQL. Not automated here, but a one-line PR checklist item: reject any Dapper query that builds its <code>WHERE</code> clause with <code>$&quot;...&quot;</code> instead of a parameter.</p>
<p>A short review checklist worth pinning to a PR template: does the query live in <code>Reads</code> (or one of the two named exceptions)? Are all parameters passed, not interpolated? Does it return a DTO, never a domain type? Has its output been diffed against the equivalent EF query on real data?</p>
<h2 id="benchmarks">Benchmarks</h2>
<p>Four scenarios, all run with BenchmarkDotNet, in Release configuration, inside Docker Compose against a warm, seeded PostgreSQL 17 container. Both the Postgres container and the benchmark runner were capped at <code>cpus: &quot;2&quot;</code>, <code>memory: 2G</code>; the host was a 12th-generation Intel Core i5-12600K. These are relative comparisons between query strategies on one machine, not absolute numbers to expect on yours. The seed data was 3,000 customers, 400 products, 120,000 orders, and roughly 426,000 order lines, with customer order counts skewed (one customer pinned to a large, recurring history for scenario 4) rather than evenly distributed. All five implementations in every scenario were verified to return identical results before benchmarking.</p>
<p>Every row in these tables ran against the identical DTO shape, and every Dapper query here is SQL I&rsquo;d actually write by hand for the job, not something contrived to win a benchmark.</p>
<h3 id="scenario-1-order-list-page">Scenario 1: order list page</h3>
<p>A paged, filtered, sorted list: status <code>Submitted</code>, a 90-day date window (matching roughly 60,000 of the 120,000 seeded orders), page 2 of 50, showing customer name, line count, and order total. This is the screen that loads on every visit to an orders list, a real business reason to care about its latency.</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Mean</th>
          <th>Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EF tracked entities</td>
          <td>6.91 ms</td>
          <td>516 KB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code></td>
          <td>4.20 ms</td>
          <td>287 KB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code> + <code>Select</code> projection</td>
          <td>3.43 ms</td>
          <td>126 KB</td>
      </tr>
      <tr>
          <td>EF compiled query + projection</td>
          <td>N/A</td>
          <td>-</td>
      </tr>
      <tr>
          <td>Dapper</td>
          <td><strong>2.39 ms</strong></td>
          <td>27 KB</td>
      </tr>
  </tbody>
</table>
<p>The compiled-query row is N/A for a real reason: <code>EF.CompileAsyncQuery</code> throws <code>InvalidOperationException: ... could not be translated</code> for this shape, a correlated scalar subquery for the customer name combined with a correlated <code>Count()</code>/<code>Sum()</code> over the <code>Lines</code> navigation in the same projection. The identical LINQ, uncompiled, works fine one line above it in the same file; compilation is what breaks it.</p>
<p>There&rsquo;s a second honest result in this scenario. My first Dapper attempt grouped and joined across all ~60,000 matching orders before applying <code>OFFSET</code>/<code>LIMIT</code>, and measured at 34 ms, slower than every EF variant above. The fix was to paginate first in a CTE, then join only the 50 rows on that page:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">with</span> paged_orders <span style="color:#66d9ef">as</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">select</span> o.id, o.customer_id, o.created_at_utc, o.status
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">from</span> orders o
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">where</span> (<span style="color:#f92672">@</span>Status <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> <span style="color:#66d9ef">or</span> o.status <span style="color:#f92672">=</span> <span style="color:#f92672">@</span>Status)
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">and</span> (<span style="color:#f92672">@</span>FromUtc <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> <span style="color:#66d9ef">or</span> o.created_at_utc <span style="color:#f92672">&gt;=</span> <span style="color:#f92672">@</span>FromUtc)
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">and</span> (<span style="color:#f92672">@</span>ToUtc <span style="color:#66d9ef">is</span> <span style="color:#66d9ef">null</span> <span style="color:#66d9ef">or</span> o.created_at_utc <span style="color:#f92672">&lt;</span> <span style="color:#f92672">@</span>ToUtc)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">order</span> <span style="color:#66d9ef">by</span> o.created_at_utc <span style="color:#66d9ef">desc</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">offset</span> <span style="color:#f92672">@</span><span style="color:#66d9ef">Offset</span> <span style="color:#66d9ef">limit</span> <span style="color:#f92672">@</span>PageSize
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">select</span>
</span></span><span style="display:flex;"><span>    po.id <span style="color:#66d9ef">as</span> OrderId,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">c</span>.name <span style="color:#66d9ef">as</span> CustomerName,
</span></span><span style="display:flex;"><span>    po.created_at_utc <span style="color:#66d9ef">as</span> CreatedAtUtc,
</span></span><span style="display:flex;"><span>    po.status <span style="color:#66d9ef">as</span> Status,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">count</span>(ol.id)::int <span style="color:#66d9ef">as</span> LineCount,
</span></span><span style="display:flex;"><span>    coalesce(<span style="color:#66d9ef">sum</span>(ol.quantity <span style="color:#f92672">*</span> ol.unit_price), <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">as</span> Total
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">from</span> paged_orders po
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">join</span> customers <span style="color:#66d9ef">c</span> <span style="color:#66d9ef">on</span> <span style="color:#66d9ef">c</span>.id <span style="color:#f92672">=</span> po.customer_id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">left</span> <span style="color:#66d9ef">join</span> order_lines ol <span style="color:#66d9ef">on</span> ol.order_id <span style="color:#f92672">=</span> po.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">group</span> <span style="color:#66d9ef">by</span> po.id, <span style="color:#66d9ef">c</span>.name, po.created_at_utc, po.status
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">order</span> <span style="color:#66d9ef">by</span> po.created_at_utc <span style="color:#66d9ef">desc</span>
</span></span></code></pre></div><p>That&rsquo;s exactly what EF does internally when it paginates a query with a collection <code>Include</code>: resolve the page first, then join the collection against just that page. The lesson wasn&rsquo;t &ldquo;Dapper is faster,&rdquo; it was &ldquo;EF&rsquo;s query shaping is worth copying,&rdquo; and the SQL above exists because I copied it.</p>
<h3 id="scenario-2-order-detail-view">Scenario 2: order detail view</h3>
<p>A single order with its lines and product names: small result, high call volume, the scenario where per-call overhead should matter most and query complexity least.</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Mean</th>
          <th>Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EF tracked entities</td>
          <td>2,136 µs</td>
          <td>98.2 KB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code></td>
          <td>2,000 µs</td>
          <td>98.5 KB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code> + <code>Select</code> projection</td>
          <td>1,653 µs</td>
          <td>102.5 KB</td>
      </tr>
      <tr>
          <td>EF compiled query + projection</td>
          <td>1,206 µs</td>
          <td>65.2 KB</td>
      </tr>
      <tr>
          <td>Dapper</td>
          <td><strong>627 µs</strong></td>
          <td>6.3 KB</td>
      </tr>
  </tbody>
</table>
<p>This is the one scenario where EF&rsquo;s generated SQL and the hand-written Dapper SQL do nearly the same thing structurally. EF issues two round trips, matching Dapper&rsquo;s <code>QueryMultiple</code>, one for the header and one for the lines:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- header
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> o.id <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Id&#34;</span>, o.customer_id <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;CustomerId&#34;</span>,
</span></span><span style="display:flex;"><span>       (<span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">c</span>.name <span style="color:#66d9ef">FROM</span> customers <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">c</span> <span style="color:#66d9ef">WHERE</span> <span style="color:#66d9ef">c</span>.id <span style="color:#f92672">=</span> o.customer_id <span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">1</span>) <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;CustomerName&#34;</span>,
</span></span><span style="display:flex;"><span>       o.status::text <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Status&#34;</span>, o.created_at_utc <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;CreatedAtUtc&#34;</span>, o.submitted_at_utc <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;SubmittedAtUtc&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders <span style="color:#66d9ef">AS</span> o
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> o.id <span style="color:#f92672">=</span> <span style="color:#f92672">@</span>orderId
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- lines
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> o.product_id <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;ProductId&#34;</span>,
</span></span><span style="display:flex;"><span>       (<span style="color:#66d9ef">SELECT</span> p.name <span style="color:#66d9ef">FROM</span> products <span style="color:#66d9ef">AS</span> p <span style="color:#66d9ef">WHERE</span> p.id <span style="color:#f92672">=</span> o.product_id <span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">1</span>) <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;ProductName&#34;</span>,
</span></span><span style="display:flex;"><span>       o.quantity <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Quantity&#34;</span>, o.unit_price <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;UnitPrice&#34;</span>, o.quantity::numeric(<span style="color:#ae81ff">18</span>,<span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> o.unit_price <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;LineTotal&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> order_lines <span style="color:#66d9ef">AS</span> o
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> o.order_id <span style="color:#f92672">=</span> <span style="color:#f92672">@</span>orderId
</span></span></code></pre></div><p>The only structural difference is EF&rsquo;s correlated scalar subquery for <code>CustomerName</code>/<code>ProductName</code> where Dapper uses a plain <code>join</code>. Both plans do the same real work; the gap here is pure materialization and mapping overhead on EF&rsquo;s side, not a smarter query: the clearest, least confounded result in this post.</p>
<h3 id="scenario-3-sales-report">Scenario 3: sales report</h3>
<p>Revenue by product category and month, aggregated across the last 12 months of orders: heavy aggregation across most of the seeded 120,000 orders, the scenario where I expected either the largest gap or none at all.</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Mean</th>
          <th>Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EF tracked, grouped in memory</td>
          <td>2,100 ms</td>
          <td>788 MB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code>, grouped in memory</td>
          <td>894 ms</td>
          <td>389 MB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code> + <code>Select</code> (<code>GroupBy</code> → SQL)</td>
          <td><strong>759 ms</strong></td>
          <td>128 KB</td>
      </tr>
      <tr>
          <td>EF compiled query + projection (<code>GroupBy</code> → SQL)</td>
          <td><strong>756 ms</strong></td>
          <td>92 KB</td>
      </tr>
      <tr>
          <td>Dapper</td>
          <td>974 ms</td>
          <td>29 KB</td>
      </tr>
  </tbody>
</table>
<p><img alt="Horizontal bar chart of scenario 3 mean times: EF compiled query and projection 756ms, EF AsNoTracking with Select 759ms, EF AsNoTracking grouped in memory 894ms, Dapper 974ms, EF tracked grouped in memory 2,100ms. The two SQL-translated EF variants are the fastest." loading="lazy" src="/posts/ef-core-dapper-light-cqrs/sales-report-benchmark.svg"></p>
<p>This is the scenario to feature, not bury: <strong>both SQL-translated EF variants beat Dapper here</strong>, and by a comfortable margin. The gap isn&rsquo;t about the tool, it&rsquo;s about the plan. EF&rsquo;s generated SQL for the <code>GroupBy</code> wraps the joins in a subquery, then aggregates on the outside:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> s.category <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Category&#34;</span>, s.<span style="color:#e6db74">&#34;Year&#34;</span>, s.<span style="color:#e6db74">&#34;Month&#34;</span>,
</span></span><span style="display:flex;"><span>       COALESCE(<span style="color:#66d9ef">sum</span>(s.quantity::numeric(<span style="color:#ae81ff">18</span>,<span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> s.unit_price), <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Revenue&#34;</span>,
</span></span><span style="display:flex;"><span>       <span style="color:#66d9ef">count</span>(<span style="color:#66d9ef">DISTINCT</span> s.id0)::int <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;OrderCount&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> o.quantity, o.unit_price, o0.id <span style="color:#66d9ef">AS</span> id0, p.category,
</span></span><span style="display:flex;"><span>           date_part(<span style="color:#e6db74">&#39;year&#39;</span>, o0.created_at_utc <span style="color:#66d9ef">AT</span> TIME <span style="color:#66d9ef">ZONE</span> <span style="color:#e6db74">&#39;UTC&#39;</span>)::int <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Year&#34;</span>,
</span></span><span style="display:flex;"><span>           date_part(<span style="color:#e6db74">&#39;month&#39;</span>, o0.created_at_utc <span style="color:#66d9ef">AT</span> TIME <span style="color:#66d9ef">ZONE</span> <span style="color:#e6db74">&#39;UTC&#39;</span>)::int <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Month&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FROM</span> order_lines <span style="color:#66d9ef">AS</span> o
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> orders <span style="color:#66d9ef">AS</span> o0 <span style="color:#66d9ef">ON</span> o.order_id <span style="color:#f92672">=</span> o0.id
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> products <span style="color:#66d9ef">AS</span> p <span style="color:#66d9ef">ON</span> o.product_id <span style="color:#f92672">=</span> p.id
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> o0.created_at_utc <span style="color:#f92672">&gt;=</span> <span style="color:#f92672">@</span>reportFromUtc <span style="color:#66d9ef">AND</span> o0.created_at_utc <span style="color:#f92672">&lt;</span> <span style="color:#f92672">@</span>reportToUtc <span style="color:#66d9ef">AND</span> o0.status <span style="color:#f92672">&lt;&gt;</span> <span style="color:#e6db74">&#39;Cancelled&#39;</span>
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">AS</span> s
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GROUP</span> <span style="color:#66d9ef">BY</span> s.category, s.<span style="color:#e6db74">&#34;Year&#34;</span>, s.<span style="color:#e6db74">&#34;Month&#34;</span>
</span></span></code></pre></div><p>The hand-written Dapper version groups directly over the joined rows in one flat statement, the more &ldquo;natural&rdquo; way to write this by hand. It isn&rsquo;t the faster plan. I don&rsquo;t have a confident explanation for why Postgres&rsquo;s planner prefers EF&rsquo;s subquery-then-aggregate shape over the flatter one, and I&rsquo;d rather say that than invent one. What I can say with confidence: this is a real, reproducible case where <code>AsNoTracking</code> plus a <code>Select</code> projection beat hand-written Dapper SQL on a heavy aggregation. If I&rsquo;d stopped at &ldquo;Dapper is for reporting queries&rdquo; as a rule of thumb, this benchmark proved that rule wrong.</p>
<p>The two in-memory-grouped rows at the top aren&rsquo;t a fair fight; they show what forgetting to translate the <code>GroupBy</code> to SQL costs: 788 MB allocated for tracked entities pulled into memory and grouped by LINQ instead of letting Postgres do it.</p>
<h3 id="scenario-4-customer-order-history-export">Scenario 4: customer order history export</h3>
<p>Every order and line for one customer with a large history (roughly 2,400 lines): a large result set where materialization cost, not query planning, should dominate.</p>
<table>
  <thead>
      <tr>
          <th>Variant</th>
          <th>Mean</th>
          <th>Allocated</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EF tracked entities</td>
          <td>207 ms</td>
          <td>68.8 MB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code></td>
          <td>194 ms</td>
          <td>34.3 MB</td>
      </tr>
      <tr>
          <td>EF <code>AsNoTracking</code> + <code>Select</code> projection</td>
          <td>113 ms</td>
          <td>19.5 MB</td>
      </tr>
      <tr>
          <td>EF compiled query + projection</td>
          <td>118 ms</td>
          <td>19.2 MB</td>
      </tr>
      <tr>
          <td>Dapper</td>
          <td><strong>83 ms</strong></td>
          <td>15.1 MB</td>
      </tr>
  </tbody>
</table>
<p>This is the cleanest apples-to-apples comparison in the whole set, because the generated SQL and the hand-written SQL are nearly identical:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> o.id <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Id&#34;</span>, o.created_at_utc <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;CreatedAtUtc&#34;</span>, o.status::text <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Status&#34;</span>,
</span></span><span style="display:flex;"><span>       o0.product_id <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;ProductId&#34;</span>, p.name <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Name&#34;</span>, o0.quantity <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;Quantity&#34;</span>,
</span></span><span style="display:flex;"><span>       o0.unit_price <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;UnitPrice&#34;</span>, o0.quantity::numeric(<span style="color:#ae81ff">18</span>,<span style="color:#ae81ff">2</span>) <span style="color:#f92672">*</span> o0.unit_price <span style="color:#66d9ef">AS</span> <span style="color:#e6db74">&#34;LineTotal&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders <span style="color:#66d9ef">AS</span> o
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> order_lines <span style="color:#66d9ef">AS</span> o0 <span style="color:#66d9ef">ON</span> o.id <span style="color:#f92672">=</span> o0.order_id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">INNER</span> <span style="color:#66d9ef">JOIN</span> products <span style="color:#66d9ef">AS</span> p <span style="color:#66d9ef">ON</span> o0.product_id <span style="color:#f92672">=</span> p.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> o.customer_id <span style="color:#f92672">=</span> <span style="color:#f92672">@</span>customerId
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> o.created_at_utc <span style="color:#66d9ef">DESC</span>
</span></span></code></pre></div><p>No correlated subqueries, no nested aggregation, plain joins, same filter, same ordering as the Dapper query it&rsquo;s competing against. The database does the same work either way. The remaining ~27% gap (113 ms EF versus 83 ms Dapper) is purely the cost of EF&rsquo;s materialization machinery over roughly 2,400 rows, not a smarter or dumber query plan. If you want one number to justify &ldquo;Dapper has lower per-row overhead on large result sets,&rdquo; this is the scenario to point at, because the SQL on both sides is close enough to rule out plan differences.</p>
<h3 id="what-the-benchmarks-actually-say">What the benchmarks actually say</h3>
<p>Three of four scenarios favor Dapper, by margins from about 1.4x (sales report, where it loses) to about 3.4x (order detail). One favors EF Core outright, once I used <code>AsNoTracking</code> and a projection instead of tracked entities grouped in memory. None of the four scenarios needed Dapper to hit an acceptable number if &ldquo;acceptable&rdquo; means &ldquo;better than tracked entities&rdquo;: <code>AsNoTracking</code> plus a projection alone closed most of the gap in every case. Dapper&rsquo;s remaining advantage, where it has one, is consistently lower allocations and lower per-call overhead, not a fundamentally different query plan; in two of the four scenarios the generated SQL and the hand-written SQL are close enough that the comparison is really &ldquo;ORM materialization cost versus none,&rdquo; not &ldquo;smart SQL versus dumb SQL.&rdquo;</p>
<h2 id="counterarguments-and-where-this-lands">Counterarguments, and where this lands</h2>
<p><strong>&ldquo;EF Core is fast enough.&rdquo;</strong> For most of what most apps do, yes, and the scenario-3 result above is the proof I didn&rsquo;t expect to find in my own benchmark: EF&rsquo;s own tools, used correctly, beat hand-written SQL on a heavy aggregation. The way to check this for your own app isn&rsquo;t to guess, it&rsquo;s to do what this post did: write the EF query with <code>AsNoTracking</code> and a projection, write the Dapper equivalent, run both against production-shaped data, and look at the actual numbers before reaching for a second tool.</p>
<p><strong>The cognitive cost of two tools is real.</strong> A team now needs to know two query languages, two sets of idioms for pagination and parameterization, and one more place a SQL injection vulnerability or an N+1 query can hide. There&rsquo;s a type-safety cost too: an EF LINQ query is checked by the compiler against the model, so renaming a column or changing a property&rsquo;s type breaks the build. A Dapper query is a string; the same rename breaks it silently, and the only thing that catches it is a test actually running that query against real data, or a user hitting it in production. The architecture tests in this post exist specifically to pay down part of that cost: they turn &ldquo;Dapper stays in the read layer&rdquo; from a convention someone has to remember into something the build enforces.</p>
<p>The rule I actually follow: EF Core first, always, for everything on the write side and for most reads. Dapper only after <code>AsNoTracking</code> and a projection have both been tried and measured, only in a query layer that never sees an aggregate, and only with an architecture test making sure it stays that way. That&rsquo;s a narrower rule than &ldquo;use the fastest tool,&rdquo; and it&rsquo;s the one I&rsquo;d actually want a team to follow after I&rsquo;ve moved on to the next project.</p>
<h2 id="references">References</h2>
<ol>
<li>Microsoft. <a href="https://learn.microsoft.com/en-us/ef/core/performance/efficient-querying#use-dapper-when-needed-for-queries">Dapper: query data</a>. <em>EF Core efficient querying, Microsoft Learn</em>.</li>
<li>Microsoft. <a href="https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics#compiled-queries">Compiled queries</a>. <em>EF Core advanced performance topics, Microsoft Learn</em>.</li>
<li>Microsoft. <a href="https://learn.microsoft.com/en-us/ef/core/querying/tracking">AsNoTracking</a>. <em>EF Core tracking vs. no-tracking queries, Microsoft Learn</em>.</li>
</ol>
]]></content:encoded></item></channel></rss>