Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20268 min read

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 DbContext per module
Reads
Dapper SQL through IDbConnectionFactory
Dispatch
IMessageBus.InvokeAsync for both
Paging
PaginationFilter, at most 100 rows

Both start at an endpoint and go through Wolverine. A command ends in SaveChangesAsync, and a query ends in a SQL statement.

Endpointbus.InvokeAsync(command or query)
Wolverine handlerchecks the permission, gets CompanyId
CommandEF loads the aggregate, the slice changes it, SaveChangesAsync
Querya Dapper repository runs SQL and maps rows to a DTO
Figure 1. One bus, two data paths.

The query side looks like this from the endpoint. The command side is the same call with a command object:

src/CRM/AppointMe.Crm/Customers/GetCustomers/GetCustomersEndpoint.csView source ↗
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

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.

src/CRM/AppointMe.Crm/Customers/UpdateCustomer/UpdateCustomerCommandHandler.csView source ↗
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:

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.csView source ↗
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).

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:

src/CRM/AppointMe.Crm/Customers/Database/CustomersRepository.csView source ↗
private const string CustomersSqlTemplate =
"""
SELECT
[Id],
[FirstName],
[LastName],
[DateOfBirth],
[Gender],
[Email],
[RegistrationDate]
/**totalcount**/
FROM [crm].[Customers]
/**where**/
ORDER BY [RegistrationDate] DESC, [Id] DESC
/**pagination**/
""";
src/CRM/AppointMe.Crm/Customers/Database/CustomersRepository.csView source ↗
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.

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
src/AppointMe.Shared/Pagination/PaginationFilter.csView source ↗
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:

src/AppointMe.Shared/Utilities/SearchExtensions.csView source ↗
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:

src/CRM/AppointMe.Crm/Customers/Database/CustomerTypeConfiguration.csView source ↗
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.

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])
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.