CQRS with EF Core and Dapper
In this article I want to show you how you can easily implement CQRS pattern using Entity Framework Core and Dapper. But first let’s try to understand what is CQRS and where it comes from. Before we dive into understanding CQRS itself, we should explain the pattern that inspired it: CQS.
CQS vs CQRS
CQS stands for Command-Query Separation and it assumes that that objects should be splitted into two categories:
- Commands, which mutates the system state and return nothing
- Queries, which returns system data without modyfing it or causing any side effects
We can wrap it into sentence: Question should not change the answer.
CQRS (Command-Query Responsibility Segratation) takes this principle and applies it at the system level. Instead of objects and methods responsible for either reading or writing data, we now have read and write model. This means that:
- Any state transition can be expressed as a command, handled in a way optimized for writes and enforcing business rules.
- The read model can be optimized to execute complex queries without affecting the write model.
When searching online, you’ll often find examples of CQRS implementations with two separate databases: one for the command model and one for the query model.
On the diagram it might look like this:

But this isn’t required and often adds unnecessary complexity. CQRS can be implemented using just one database. That’s what we’ll cover in this article, using EF Core for the write model and Dapper for the read model.
Now we can simplify the diagram:

Understanding the domain
Let’s say we’re building a system for managing material-handling equipment.
Our domain objects would be:
- Device, which can have many service tickets
- Customer, who owns the devices and can register service tickets
- WorkOrder, which is created based on service tickets to support the business process
From this, we can extract two aggregates: Device and WorkOrder.
- The write model should allow us to add devices, add service tickets for a device, create work orders from service tickets, and manage the business process (e.g., changing work order state, adding an offer).
- For the sake of the example, the query model will focus on a single use case: get work order details, including the work order itself, the service ticket that caused it, the device, and customer data.
Notice that this query spans across two separate aggregates — a perfect example of why separating read and write models makes sense.
Implementing CQRS with EF Core and Dapper
Implementing CQRS with EF Core and Dapper
Now that we understand the pattern and our example domain, let’s implement it using EF Core for the write model and Dapper for the read model.
To keep it simple, we’ll focus on two write use cases:
- Adding a service ticket to a device
- Creating a work order based on a ticket
Write model
First, let’s take a look at the device aggregate:
namespace MaterialHandling.Domain.Devices;
public class Device
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Customer Customer { get; set; }
public DeviceType DeviceType { get; set; }
public string SerialNumber { get; set; } = string.Empty;
public List<ServiceTicket> ServiceTickets { get; set; } = [];
public void AddServiceTicket(string description, Guid addedBy)
{
ServiceTickets.Add(new ServiceTicket
{
Id = Guid.NewGuid(),
Description = description,
AddedBy = addedBy,
AddedAtUtc = DateTime.UtcNow
});
}
}
public enum DeviceType
{
Forklift,
Crane,
Lift
}
It references the customer and contains a list of tickets. It also exposes AddServiceTicket(string description, string addedBy) to assign ticket to a device.
Next, the WorkOrder aggregate with some example logic:
namespace MaterialHandling.Domain.WorkOrders;
public class WorkOrder
{
public WorkOrder(string number, Guid serviceTicketId, Guid addedBy, string description)
{
Id = Guid.NewGuid();
Number = number;
CreatedAtUtc = DateTime.UtcNow;
AddedBy = addedBy;
Description = description;
ServiceTicketId = serviceTicketId;
Status = Status.Draft;
}
public Guid Id { get; set; }
public string Number { get; set; }
public Guid ServiceTicketId { get; set; }
public ServiceTicket ServiceTicket { get; set; }
public Guid? OfferId { get; set; }
public Offer? Offer { get; set; }
public DateTime CreatedAtUtc { get; set; }
public Guid AddedBy { get; set; }
public string Description { get; set; }
public Status Status { get; set; }
public void Reject()
{
if (!CanBeRejected())
{
throw new IllegalStateTransitionException("Cannot mark work order as rejected.");
}
Status = Status.Rejected;
}
private bool CanBeRejected()
{
return Status == Status.Draft || Status == Status.Open;
}
}
public class IllegalStateTransitionException(string message) : Exception(message);
public enum Status
{
Draft,
Open,
OfferPrepared,
OfferAccepted,
InProgress,
Done,
Invoiced,
Closed,
Rejected
}
It contains work order–related data together with the related offer. We also added some business logic for rejecting a work order.
Repositories & Unit of Work
Repositories encapsulate aggregate access:
namespace MaterialHandling.Domain.Devices;
public interface IDevicesRepository
{
void Add(Device device);
Task<Device> Load(Guid id);
}
namespace MaterialHandling.Domain.WorkOrders;
public interface IWorkOrdersRepository
{
void Add(WorkOrder workOrder);
Task<WorkOrder> Load(Guid id);
}
DbContext:
namespace MaterialHandling.Infrastructure;
public class MaterialHandlingEquipmentDbContext(DbContextOptions<MaterialHandlingEquipmentDbContext> options) : DbContext(options)
{
public DbSet<WorkOrder> WorkOrders { get; set; }
public DbSet<Device> Devices { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Mapping configurations
}
}
Repositories implementation:
namespace MaterialHandling.Infrastructure;
public class DevicesRepository(MaterialHandlingEquipmentDbContext dbContext) : IDevicesRepository
{
public void Add(Device device)
{
dbContext.Devices.Add(device);
}
public Task<Device> Load(Guid id)
{
var device = dbContext.Devices
.Include(x => x.ServiceTickets)
.SingleAsync(x => x.Id == id);
return device;
}
}
namespace MaterialHandling.Infrastructure;
public class WorkOrdersRepository(MaterialHandlingEquipmentDbContext dbContext) : IWorkOrdersRepository
{
public void Add(WorkOrder workOrder)
{
dbContext.WorkOrders.Add(workOrder);
}
public Task<WorkOrder> Load(Guid id)
{
var workOrder = dbContext.WorkOrders
.Include(x => x.Offer)
.SingleAsync(x => x.Id == id);
return workOrder;
}
}
Unit of Work:
namespace MaterialHandling.Infrastructure;
public interface IUnitOfWork
{
Task Commit();
}
public class UnitOfWork(MaterialHandlingEquipmentDbContext dbContext) : IUnitOfWork
{
public async Task Commit()
{
await dbContext.SaveChangesAsync();
}
}
Command Handlers
AddServiceTicketCommandHandler:
namespace MaterialHandling.Application.Devices;
public class AddServiceTicketCommandHandler(
IDevicesRepository repository,
IUnitOfWork unitOfWork) : ICommandHandler<AddServiceTicket>
{
public async Task Handle(AddServiceTicket command)
{
var device = await repository.Load(command.DeviceId);
device.AddServiceTicket(command.Description, command.AddedBy);
await unitOfWork.Commit();
}
}
AddWorkOrderCommandHandler
namespace MaterialHandling.Application.WorkOrders;
public class AddWorkOrderCommandHandler(
IWorkOrdersRepository repository,
IUnitOfWork unitOfWork,
WorkOrderNumberProvider workOrderNumberProvider,
IUserService userService) : ICommandHandler<AddWorkOrder>
{
public async Task Handle(AddWorkOrder command)
{
var workOrderNumber = await workOrderNumberProvider.Get();
var workOrder = new WorkOrder(workOrderNumber, command.ServiceTicketId, userService.UserId, command.Description);
repository.Add(workOrder);
await unitOfWork.Commit();
}
}
We load the aggregate, apply business logic, and save changes via the Unit of Work.
Dependency Injection Setup
builder.Services.AddDbContext<MaterialHandlingEquipmentDbContext>(options =>
options.UseSqlite("Data Source=device-management.db"));
builder.Services.AddScoped<IWorkOrdersRepository, WorkOrdersRepository>();
builder.Services.AddScoped<IDevicesRepository, DevicesRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<WorkOrderNumberProvider>();
builder.Services.AddScoped<ICommandHandler<AddWorkOrder>, AddWorkOrderCommandHandler>();
builder.Services.AddScoped<ICommandHandler<AddServiceTicket>, AddServiceTicketCommandHandler>();
With all of that in place, we now have a fully working write model. We’ve defined our domain objects with clear boundaries, managed them safely through repositories, and introduced handlers that capture business use cases in a straightforward way. The important part here is that we didn’t need to think about queries at all — we could focus entirely on modeling business rules and state transitions.
This is exactly the strength of CQRS: by separating reads and writes, the write model stays clean and focused on enforcing invariants, while the query side can later be designed purely for efficient data retrieval.
Read model
It’s time to focus on query model. As mentioned in domain description we want to return work order details, including the work order itself, the service ticket that caused it, the device, and customer data. We’ll use Dapper to return denormalized data:
WorkOrderDetailsDto:
namespace MaterialHandling.Application.WorkOrders;
public class WorkOrderDetailsDto
{
public Guid Id { get; set; }
public string Number { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public Guid AddedBy { get; set; }
public Guid ServiceTicketId { get; set; }
public string ServiceTicketAddedBy { get; set; } = string.Empty;
public DateTime ServiceTicketAddedAtUtc { get; set; }
public Guid DeviceId { get; set; }
public string DeviceType { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public Guid? CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
}
Query service:
namespace MaterialHandling.Application.WorkOrders;
public class WorkOrdersQueryService(IDbConnection connection)
{
public async Task<WorkOrderDetailsDto> GetWorkOrderDetails(Guid id)
{
const string sql = """
SELECT
wo.Id,
wo.Number,
wo.Description,
wo.Status,
wo.CreatedAtUtc,
wo.AddedBy,
st.Id as ServiceTicketId,
st.AddedBy as ServiceTicketAddedBy,
st.AddedAtUtc as ServiceTicketAddedAtUtc,
d.Id as DeviceId,
d.DeviceType,
d.SerialNumber,
c.Id as CustomerId,
c.Name as CustomerName,
c.Email as CustomerEmail
FROM WorkOrders wo
INNER JOIN ServiceTickets st ON wo.ServiceTicketId = st.Id
INNER JOIN Devices d ON st.DeviceId = d.Id
LEFT JOIN Customers c ON d.CustomerId = c.Id
WHERE wo.Id = @Id
""";
return await connection.QuerySingleAsync<WorkOrderDetailsDto>(sql, new { Id = id });
}
}
Thanks to Dapper, we can fetch data across multiple tables in a single optimized query. This keeps our query model independent and optimized for reads.
Final thoughts:
CQRS is a powerful pattern, especially in complex systems where business rules must be enforced, but queries need to stay efficient. Without CQRS, models often end up neither optimized for writing nor reading.
When you find yourself constantly trading off between read and write optimization, it may be a good time to introduce CQRS.
And remember: it doesn’t require two separate data stores. Often, a logical separation like the one presented in this article is enough. It also makes for a good starting point if you later need to move to full separation.