CQRS: EF Core writes, Dapper reads
Commands load and save aggregates through EF Core. Queries skip the domain model and run SQL with Dapper. Both go through Wolverine's IMessageBus, so every endpoint has the same shape.
- Writes
- EF Core aggregates, one
DbContextper module - Reads
- Dapper SQL through
IDbConnectionFactory - Dispatch
IMessageBus.InvokeAsyncfor both- Paging
PaginationFilter, at most 100 rows
How a command and a query differ
Section titled “How a command and a query differ”Both start at an endpoint and go through Wolverine. A command ends in SaveChangesAsync, and a query ends in a SQL statement.
bus.InvokeAsync(command or query)CompanyIdSaveChangesAsyncThe query side looks like this from the endpoint. The command side is the same call with a command object:
private static async Task<GetCustomersResponse> GetCustomers([AsParameters] GetCustomersRequest request, IMessageBus bus, CancellationToken cancellationToken){ return await bus.InvokeAsync<GetCustomersResponse>(new GetCustomerQuery { Search = request.Search, Pagination = request.ToPaginationFilter() }, cancellationToken);}| Piece | Where | What it does |
|---|---|---|
Module DbContext |
<Module>/Database/ |
The write model. Tracks aggregates, applies the tenant and soft-delete filters, and hands raised events to Wolverine on save. |
IDbConnectionFactory |
AppointMe.Shared/Database |
Opens a SqlConnection for Dapper. SqlConnectionFactory is the only implementation. |
ExtSqlBuilder |
AppointMe.Shared/Database/Dapper |
Dapper’s SqlBuilder plus AddPagination. The WhereSearch extension is in the same file. |
| Repository | <Feature>/Database/ |
The Dapper queries for one feature: CustomersRepository, AppointmentsRepository, TeamRepository |
| Pagination types | AppointMe.Shared/Pagination |
PaginationRequest in, PaginationFilter to the SQL, PagedResult<T> out |
Writes: load, change, save
Section titled “Writes: load, change, save”A command handler loads the aggregate with EF Core, calls the slice’s operation and saves. Wolverine wraps the handler in a transaction and publishes the aggregate’s events through the outbox.
public async Task HandleAsync(UpdateCustomerCommand command, IPrincipal principal, CancellationToken cancellationToken){ principal.Require(CustomerPermissions.Update);
var customer = await dbContext.Customers.LoadAsync(new CustomerId(command.CustomerId), cancellationToken);
customer.Update( name: PersonName.Create(command.FirstName, command.LastName), dateOfBirth: DateOfBirth.CreateOrNull(command.DateOfBirth, timeProvider), gender: Gender.ParseOrNull(command.Gender), email: Email.CreateOrNull(command.Email) );
await dbContext.SaveChangesAsync(cancellationToken);}| Step | What happens |
|---|---|
LoadAsync |
An EF query in Customers/Database/CustomerQueries.cs. It throws NotFoundException, which the API returns as 404. The company filter applies, so another tenant’s customer is “not found” too. |
customer.Update(...) |
The slice’s extension method changes the properties and raises CustomerUpdatedEvent |
SaveChangesAsync |
Writes the row, checks the Version row version, and lets Wolverine store the raised events in its outbox in the same transaction |
The transaction and the event hand-off come from the host’s Wolverine setup:
options.UseEntityFrameworkCoreTransactions();options.PublishDomainEventsFromEntityFrameworkCore<AggregateRoot>(root => root.Events);options.Policies.UseDurableLocalQueues();options.Policies.AutoApplyTransactions();Every entity configuration in the solution maps a Version column with IsRowVersion(). If someone else saved the row after you loaded it, SaveChangesAsync throws DbUpdateConcurrencyException, and the API answers 409 with code: "concurrency_conflict" (see API conventions).
Reads: SQL with Dapper
Section titled “Reads: SQL with Dapper”A query handler calls a Dapper repository, which fills a SQL template with ExtSqlBuilder and maps the rows to a DTO. There’s no change tracking and no aggregate.
The template has placeholders that the builder fills in:
private const string CustomersSqlTemplate = """ SELECT [Id], [FirstName], [LastName], [DateOfBirth], [Gender], [Email], [RegistrationDate] /**totalcount**/ FROM [crm].[Customers] /**where**/ ORDER BY [RegistrationDate] DESC, [Id] DESC /**pagination**/ """;public async Task<PagedResult<CustomerDto>> GetAll(string[] searchTokens, PaginationFilter pagination, CompanyId companyId, CancellationToken cancellationToken){ var builder = new ExtSqlBuilder() .AddPagination(pagination) .Where("[CompanyId] = @CompanyId", new { CompanyId = companyId.Value }) .Where("[IsDeleted] = 0") .WhereSearch("[SearchKey]", searchTokens);
var template = builder.AddTemplate(CustomersSqlTemplate);
using var connection = await connectionFactory.OpenConnectionAsync(cancellationToken); var (records, totalCount) = await connection .QueryWithPaginationAsync<CustomerRecord>(template.RawSql, template.Parameters); return records.Select(record => record.ToDto()).ToPagedResult(pagination, totalCount);}| Placeholder | Filled by | Becomes |
|---|---|---|
/**where**/ |
Each .Where(...) call |
WHERE … AND …, with parameters |
/**totalcount**/ |
AddPagination |
,COUNT(*) OVER () AS [TotalCount] |
/**pagination**/ |
AddPagination |
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY |
/**orderby**/ |
.OrderBy(...) |
ORDER BY … (used by AppointmentsRepository) |
QueryWithPaginationAsync uses Dapper’s multi-mapping to split the TotalCount column off each row, so one round trip returns the page and the total. Rows land in CustomerRecord, a class of primitives, and ToDto() turns them into CustomerDto. Dapper doesn’t know about typed IDs or value objects, so parameters pass .Value (see IDs & value objects).
AddSharedModule registers two Dapper type handlers once at startup: DateOnlyTypeHandler, and UtcDateTimeOffsetTypeHandler, which reads both datetime2 (Booking) and datetimeoffset (the other modules) back as UTC.
Pagination and search
Section titled “Pagination and search”List endpoints take ?page=&limit= and return a PagedResult<T>. Search splits the text into words, and every word must match.
| Type | Does |
|---|---|
PaginationRequest |
Binds limit and page from the query string, defaulting to 10 and 1. Request classes inherit it: GetCustomersRequest : PaginationRequest. |
PaginationFilter |
Clamps the values and computes the offset (below) |
PagedResult<T> |
Items, TotalCount, Page, Limit, and a computed TotalPages |
public PaginationFilter(int limit, int page){ Limit = Math.Clamp(limit, MinLimit, MaxLimit); Page = Math.Max(page, MinPage);}
public int Limit { get; }public int Page { get; }public int Offset => Limit * (Page - 1);MinLimit is 1 and MaxLimit is 100, so ?limit=500 quietly returns 100 rows. Search text goes through Tokenize() in AppointMe.Shared/Utilities:
public string[] Tokenize(){ if (string.IsNullOrWhiteSpace(search)) { return []; }
return [ .. search .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(token => token.ToLowerInvariant()) .Distinct() .Take(30) ];}WhereSearch then adds one [SearchKey] LIKE '%' + @SearchToken0 + '%' clause per token, joined with AND. SearchKey is a stored computed column that EF defines and Dapper reads:
builder.Property<string>("SearchKey") .HasComputedColumnSql( "LOWER(CONCAT([FirstName], ' ', ISNULL([LastName], ''), ' ', ISNULL([Email], '')))", stored: true);So ann smith finds a customer whose name or email contains both ann and smith, in any order. Organizations’ TeamRepository searches the team list the same way.
When to use which
Section titled “When to use which”Use EF Core when you change state or need the aggregate’s rules. Use Dapper when you shape data for a screen.
| You’re writing | Use | Example |
|---|---|---|
| A command that changes an aggregate | EF Core: load, call the operation, save | UpdateCustomerCommandHandler |
| A check inside a command | EF Core, so the tenant filter applies | ScheduleAppointmentCommandHandler checks the provider exists with AnyAsync |
| A list or detail endpoint | A Dapper repository | CustomersRepository.GetAll |
| A read that joins tables for a screen | Dapper | AppointmentsRepository joins Appointments, ServiceProviders and Attendees |
| A contract another module calls | Either | CRM’s CustomerRehydrationSource uses EF with IgnoreQueryFilters([CustomerFilters.CompanyId]) |
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
| A list shows another company’s rows | The Dapper query has no [CompanyId] = @CompanyId. EF’s filters don’t reach Dapper. |
Add the predicate. See Multi-tenancy. |
| Soft-deleted customers appear in a list | Soft delete is an EF filter too | Add .Where("[IsDeleted] = 0"), as CustomersRepository does |
TotalCount is 0 on a page past the end |
The total comes from COUNT(*) OVER () on the returned rows, and that page has none |
Clamp the page to TotalPages on the client |
409 with code: "concurrency_conflict" |
The row’s Version changed since you loaded it |
Reload and retry |
| A paged query keeps running after the client disconnects | QueryWithPaginationAsync takes no CancellationToken |
Add a token parameter to the extension and pass a CommandDefinition |
| A renamed column breaks a list at runtime | EF owns the schema, and Dapper’s SQL is a string | Search the repositories for the old column name when you change a mapping |
Why two data access libraries?2 min · optional
EF Core is strong on the write side. Change tracking, the query filters, row-version checks, owned value objects, migrations and the event hand-off all hang off the DbContext. Reads that shape data for a screen are a different job: joins across projections, window functions and computed columns are shorter and more predictable as SQL you wrote, and Dapper runs that SQL and maps the rows.
The cost is two views of one schema to keep in step. EF owns it, Dapper reads it by column name, and the safety nets EF gives for free (the tenant filter, soft delete) are manual in SQL.
Why send queries through the bus too?1 min · optional
Wolverine’s handler policies do the cross-cutting work: they supply the CompanyId and the principal (see Identity & principal). A query that goes through InvokeAsync gets those for free, so query handlers and command handlers have the same shape. InvokeAsync runs the handler inline, in the same request, so the cost is one level of indirection, not a network hop.