Chapter 25 · Production Architecture, DDD/CQRS Integration, Reliability, and Capstone

CQRS Read Models, Raw SQL/Dapper Interop, Bulk Workloads, and Choosing the Right Data-Access Tool per Path

Separate ServiceHub write invariants from read projections, compare EF LINQ, keyless/raw SQL and optional Dapper 2.1.79, and coordinate mixed tools through the same connection/transaction when correctness requires it.

Advanced210–300 minutesCQRS/tool-choice labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · Dapper 2.1.79 optional · production-like server provider optionalArchitecture/package/platform status reviewed: August 27, 2026

Learning outcomes

01

Define CQRS as separating command/write concerns from query/read concerns without requiring separate databases.

02

Use EF projections/keyless/raw SQL for read models before adding another data-access library.

03

Integrate optional Dapper 2.1.79 with EF through the same DbConnection/DbTransaction when atomic coordination is required.

04

Choose ExecuteUpdate/ExecuteDelete or provider-native bulk APIs according to semantics and measurement.

05

Compare tools with generated SQL, rows/bytes, tracking, plans and transaction evidence.

06

Avoid ideology: choose each path by correctness, maintainability, latency, throughput and provider capability.

1. CQRS starts with responsibility separation, not microservices

Command Query Responsibility Segregation (CQRS) separates operations that change state from operations that return data. ServiceHub can apply CQRS inside one process and one database: aggregate writes use EF tracking/concurrency/invariants, while read endpoints return purpose-built DTOs. Separate databases, event sourcing and asynchronous projections are optional later choices, not prerequisites.

2. EF Core is often already enough for the read side

C# · narrow no-tracking read model
public sealed record WorkOrderQueueRow(    int Id, string Number, string Summary, int Priority, string City);var rows = await db.WorkOrders    .AsNoTracking()    .Where(w => w.Priority >= 3)    .OrderByDescending(w => w.Priority)    .ThenBy(w => w.OpenedUtc)    .Take(100)    .Select(w => new WorkOrderQueueRow(        w.Id, w.WorkOrderNumber, w.Summary,        w.Priority, w.ServiceAddress.City))    .ToListAsync(ct);

This avoids materializing full tracked aggregates and keeps tenant filters. Chapter 17’s measurement rules still apply: inspect columns, rows, bytes, plans and end-to-end latency before introducing another tool.

3. Raw SQL/keyless types are an EF escape hatch before a second ORM

C# · parameterized unmapped read shape
var tenant = db.TenantId;var rows = await db.Database.SqlQuery<WorkOrderQueueRow>($"""    SELECT work_order_id AS Id,           work_order_number AS Number,           summary AS Summary,           priority AS Priority,           service_city AS City    FROM work_orders    WHERE tenant_id = {tenant}      AND is_deleted = 0    ORDER BY priority DESC, opened_utc    LIMIT 100    """).ToListAsync(ct);

Raw SQL is not automatically faster. It trades LINQ translation for hand-authored SQL and provider coupling. Keep parameterization, tenant predicates, bounded results and tests.

4. Optional Dapper 2.1.79: use it where direct SQL mapping is measurably valuable

Optional package
dotnet add package Dapper --version 2.1.79
C# · Dapper read on EF-owned connection
var connection = db.Database.GetDbConnection();await db.Database.OpenConnectionAsync(ct);const string sql = """SELECT work_order_id AS Id, work_order_number AS Number,       summary AS Summary, priority AS Priority, service_city AS CityFROM work_ordersWHERE tenant_id = @tenant AND is_deleted = 0ORDER BY priority DESC, opened_utcLIMIT @take;""";var rows = (await connection.QueryAsync<WorkOrderQueueRow>(    new CommandDefinition(sql,        new { tenant = db.TenantId, take = 100 },        cancellationToken: ct))).AsList();

Dapper is a micro-ORM over ADO.NET. It does not provide EF tracking, model metadata, query filters or migrations. The SQL is your responsibility. Prefer it only if the read path benefits from its direct mapping/SQL control enough to justify a second data-access dependency.

5. Mixed writes must share the same transaction deliberately

C# · EF + Dapper in one relational transaction
await using var tx = await db.Database.BeginTransactionAsync(ct);order.ReviseSummary(summary, clock);await db.SaveChangesAsync(ct);var connection = db.Database.GetDbConnection();await connection.ExecuteAsync(new CommandDefinition(    "UPDATE reporting_watermark SET last_work_order_id = @id",    new { id = order.Id },    transaction: tx.GetDbTransaction(),    cancellationToken: ct));await tx.CommitAsync(ct);

If Dapper opens a different connection or runs without the EF transaction, the operations are not atomic. Cross-tool coordination is a relational connection/transaction problem, not a CQRS slogan.

6. Bulk workloads: choose semantics before throughput

Path Best fit Key caveat
Tracked SaveChanges Aggregate invariants, concurrency, events Materialization/tracking overhead
ExecuteUpdate/Delete Set-based update/delete with SQL predicate Bypasses tracker and aggregate methods
Raw SQL/stored procedure Provider-specific optimized operation Manual SQL/security/provider ownership
Native bulk API Very high-volume ingest/export Provider-specific transaction/error semantics
Third-party bulk library Convenience/performance if justified Commercial/license/version/semantic review required
C# · set-based maintenance with explicit tenant predicate
var affected = await db.WorkOrders    .Where(w => w.TenantId == db.TenantId && !w.IsActive)    .ExecuteUpdateAsync(s => s        .SetProperty(w => w.IsDeleted, true), ct);

Because ExecuteUpdateAsync bypasses tracked aggregate methods, it also bypasses per-entity domain-event creation and manual Revision advancement unless you set those columns explicitly. Use it for operations whose semantics are truly set-based.

7. Deliberate failure: “Dapper is faster, so use it everywhere”

A blanket rewrite can remove tenant filters, concurrency predicates, relationship fixup, migrations and transaction consistency while duplicating mapping. The opposite dogma—“never use SQL outside EF”—can make specialized reports or bulk operations harder than necessary. Measure the path and document the ownership boundary.

8. Mandatory lab: one write path, three read paths, one evidence table

  1. Write a WorkOrder change through the aggregate + EF and verify the Revision predicate.
  2. Implement the same bounded queue read with EF projection and SqlQuery.
  3. Optionally install Dapper 2.1.79 and implement the same read using the same SQLite connection.
  4. Compare returned rows, SQL, parameterization, allocations/timing in a controlled local benchmark; do not invent universal winners.
  5. Run a mixed EF+Dapper transaction and force the Dapper statement to fail; prove EF changes roll back.
  6. Document which path owns tenant filtering and why.

9. Production judgment and bridge

Use EF for aggregate persistence and ordinary projections when it meets the requirements. Add raw SQL, Dapper, stored procedures or native bulk paths only where their explicit SQL/control is worth the added provider/testing/operational surface. The next lesson turns these choices into a deployment and incident model.

Check your understanding

  1. Does CQRS require separate databases?
  2. Why is AsNoTracking projection often a strong read default?
  3. What does Dapper not inherit from EF?
  4. How do EF and Dapper participate in one atomic transaction?
  5. Why is ExecuteUpdate not an aggregate method?
  6. How should a tool choice be defended?
Review the answers

1. No. It can begin as separate command and query models over one database.

2. It selects only required fields and avoids change-tracker overhead when the result will not be updated.

3. EF model metadata, query filters, change tracking, migrations and automatic concurrency semantics.

4. Use the same open DbConnection and pass EF’s current DbTransaction to Dapper/ADO.NET commands.

5. It performs set-based SQL without loading/tracking entities, so domain methods/events are bypassed.

6. With correctness, maintainability, measured latency/throughput, provider capabilities and operational/test cost.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n