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.
Learning outcomes
Define CQRS as separating command/write concerns from query/read concerns without requiring separate databases.
Use EF projections/keyless/raw SQL for read models before adding another data-access library.
Integrate optional Dapper 2.1.79 with EF through the same DbConnection/DbTransaction when atomic coordination is required.
Choose ExecuteUpdate/ExecuteDelete or provider-native bulk APIs according to semantics and measurement.
Compare tools with generated SQL, rows/bytes, tracking, plans and transaction evidence.
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
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
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
dotnet add package Dapper --version 2.1.79
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
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 |
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
-
Write a WorkOrder change through the aggregate + EF and verify
the
Revisionpredicate. -
Implement the same bounded queue read with EF projection and
SqlQuery. - Optionally install Dapper 2.1.79 and implement the same read using the same SQLite connection.
- Compare returned rows, SQL, parameterization, allocations/timing in a controlled local benchmark; do not invent universal winners.
- Run a mixed EF+Dapper transaction and force the Dapper statement to fail; prove EF changes roll back.
- 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
- Does CQRS require separate databases?
- Why is AsNoTracking projection often a strong read default?
- What does Dapper not inherit from EF?
- How do EF and Dapper participate in one atomic transaction?
- Why is ExecuteUpdate not an aggregate method?
- 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
- CQRS pattern — command/query separation, benefits and tradeoffs
- CQRS read implementation — Microsoft example of independent read DTOs and Dapper
- SQL queries in EF Core — FromSql/SqlQuery parameterized escape hatches
- ExecuteUpdate and ExecuteDelete — set-based DML semantics and tracker boundaries
- Transactions in EF Core — sharing connections/transactions across data-access APIs
- Dapper 2.1.79 — current optional micro-ORM package and license/version information