
CQRS: A Practical Guide to Command and Query Responsibility Segregation
đź§ What is CQRS?
CQRS stands for:
Command and Query Responsibility Segregation
That sounds complicated, but it just means splitting reading and writing into separate parts of your code.
Think of it like this:
Commands are actions that change data (like "Add a new user" or "Update an order"), while queries are actions that read data (like "Get all users" or "Show order history").
🍕 Real-World Analogy
Imagine you're ordering pizza.
- When you call the pizza place and place an order, you're doing a command. You're changing their system—they now have to make you a pizza!
- When you ask how many pizzas you've ordered this month, you're doing a query. You're just asking for information.
You don't want those two actions to use the same system. Why?
- Placing an order might go through a bunch of rules.
- Asking for order history is just reading from a report.
âś… Benefits of CQRS
- Better Performance — You can optimize reads and writes separately. Example: Use caching for queries, but not for commands.
- Cleaner Code — Easier to understand. One class for writing, one for reading.
- Scales Well — Reads usually happen more often than writes. You can scale them differently (like having a read-only database).
- Good for Complex Domains — Helps when your rules for writing data are much more complex than your rules for reading.
⚠️ Downsides of CQRS
- More Code to Write — Instead of one method like
UserService.Save(user), you now might have:CreateUserCommandHandler,GetUsersQueryHandler. - Harder to Learn — Junior developers might get confused at first.
- Eventual Consistency — If you separate databases for reading and writing, they might not update at the exact same time. This can be tricky.
đź’ˇ When Should You Use CQRS?
Use CQRS when:
In many systems, especially dashboards, analytics platforms, or content-heavy apps like blogs and e-commerce sites, users are constantly reading data—browsing products, viewing articles, or checking reports—while the number of writes (like adding a product or submitting an order) is much smaller. In these scenarios, the system spends most of its time answering queries. CQRS allows you to optimize your read model separately from your write model, making reads faster, more efficient, and potentially even served from a cache or read-only replica. This separation reduces load on the core database and improves responsiveness for the end user, especially as traffic increases.
While read-heavy systems benefit from performance tuning, some applications struggle more with the complexity of writes than with volume. When writing data involves many business rules, validations, or side effects, it becomes hard to manage inside a single service or method. For example, creating an invoice might require checking stock, applying taxes, validating discounts, updating account balances, and logging actions—all before saving anything. CQRS lets you isolate these complex write operations into dedicated command handlers, keeping them clean and focused. Meanwhile, the read model stays simple and doesn't have to worry about the intricacies of the domain logic. This clear separation not only enhances maintainability but also prepares the system to handle more load in the future.
As your system grows—whether due to more users, more data, or just more features—the need to scale becomes critical. Often, reads increase much faster than writes, especially when many users are interacting with the same data. CQRS naturally supports this scaling model by allowing the read side to be scaled independently using techniques like read replicas, caching, or even separate databases tuned specifically for querying. Meanwhile, the write side can remain consistent and secure without being burdened by excessive read traffic. This separation also opens the door to eventual consistency patterns, where updates to the read model are processed asynchronously—giving you the flexibility to design highly scalable, resilient systems without overengineering your early stages.
Don't use CQRS for:
While CQRS offers powerful benefits in complex or high-scale systems, it introduces extra architectural layers that may be unnecessary for simple CRUD (Create, Read, Update, Delete) apps. If your application only needs basic operations on a single table or two—like a task manager, address book, or internal admin dashboard—then splitting reads and writes into separate models adds overhead without a clear return on investment. The codebase becomes more complex, with more classes, interfaces, and handlers to manage, which can slow down development and confuse new developers. In these cases, a straightforward, unified service layer is usually easier to understand and maintain.
That simplicity becomes even more important when you're building apps for a small user base. If you're not facing performance pressure or high concurrency, the benefits of separating reads and writes won't materialize. Instead, you'll spend more time wiring up infrastructure—like mediators, dependency injection, and event handling—that may never be needed. Worse, if you're using CQRS without any real performance or complexity problems to solve, you might end up making your system harder to debug and test, not easier. The added mental overhead can become a liability when the app just needs to deliver simple, reliable functionality.
In both cases, the key issue is unnecessary complexity. CQRS shines when your system is struggling with scale, performance, or domain complexity—but if those aren't your pain points, it's perfectly okay to use a simpler architecture. The best systems are the ones that are just complex enough to solve the real problem, and no more.
Walk through example
🏗️ Domain Overview
Let's define the domain:
Client
- Has many ScheduledJobs
ScheduledJob
- Has many Services
- Is assigned to one or more Employees
Service
- Represents work done (like "Window Cleaning", "Lawn Mowing")
Employee
- Can be paid:
- Per Service Completed
- By Time Worked
🗂️ Entity Models (EF Core)
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<ScheduledJob> ScheduledJobs { get; set; }
}
public class ScheduledJob
{
public int Id { get; set; }
public DateTime ScheduledDate { get; set; }
public int ClientId { get; set; }
public Client Client { get; set; }
public ICollection<Service> Services { get; set; }
public ICollection<EmployeeScheduledJob> EmployeeAssignments { get; set; }
}
public class Service
{
public int Id { get; set; }
public string Type { get; set; }
public decimal CostToClient { get; set; }
public int ScheduledJobId { get; set; }
public ScheduledJob ScheduledJob { get; set; }
}
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public PayType PayType { get; set; }
public decimal Rate { get; set; } // Could be per hour or per service
public ICollection<EmployeeScheduledJob> JobAssignments { get; set; }
}
public class EmployeeScheduledJob
{
public int EmployeeId { get; set; }
public Employee Employee { get; set; }
public int ScheduledJobId { get; set; }
public ScheduledJob ScheduledJob { get; set; }
public TimeSpan? HoursWorked { get; set; } // Optional if paid by time
public int? ServicesCompleted { get; set; } // Optional if paid by service
}
public enum PayType
{
PerHour,
PerService
}
đź§ CQRS Commands (Writes)
1. CreateScheduledJobCommand
public class CreateScheduledJobCommand
{
public int ClientId { get; set; }
public DateTime Date { get; set; }
public List<string> Services { get; set; }
public List<int> AssignedEmployeeIds { get; set; }
}
Handler:
public class CreateScheduledJobHandler
{
private readonly IClientRepository _clientRepo;
private readonly IScheduledJobRepository _jobRepo;
private readonly IEmployeeRepository _employeeRepo;
public async Task<int> Handle(CreateScheduledJobCommand cmd)
{
var job = new ScheduledJob
{
ClientId = cmd.ClientId,
ScheduledDate = cmd.Date,
Services = cmd.Services.Select(s => new Service { Type = s }).ToList(),
EmployeeAssignments = new List<EmployeeScheduledJob>()
};
foreach (var empId in cmd.AssignedEmployeeIds)
{
job.EmployeeAssignments.Add(new EmployeeScheduledJob
{
EmployeeId = empId
});
}
await _jobRepo.AddAsync(job);
return job.Id;
}
}
2. LogWorkCommand
public class LogWorkCommand
{
public int JobId { get; set; }
public int EmployeeId { get; set; }
public TimeSpan? HoursWorked { get; set; }
public int? ServicesCompleted { get; set; }
}
Handler:
public class LogWorkHandler
{
private readonly IScheduledJobRepository _jobRepo;
public async Task Handle(LogWorkCommand cmd)
{
var job = await _jobRepo.GetByIdAsync(cmd.JobId);
var assignment = job.EmployeeAssignments.First(e => e.EmployeeId == cmd.EmployeeId);
assignment.HoursWorked = cmd.HoursWorked;
assignment.ServicesCompleted = cmd.ServicesCompleted;
await _jobRepo.UpdateAsync(job);
}
}
🔍 CQRS Queries (Reads)
1. GetClientJobHistoryQuery
public class GetClientJobHistoryQuery
{
public int ClientId { get; set; }
}
Handler:
public class GetClientJobHistoryHandler
{
private readonly IClientRepository _repo;
public async Task<List<JobSummaryDto>> Handle(GetClientJobHistoryQuery query)
{
var client = await _repo.GetWithJobsAsync(query.ClientId);
return client.ScheduledJobs.Select(job => new JobSummaryDto
{
JobId = job.Id,
Date = job.ScheduledDate,
Services = job.Services.Select(s => s.Type).ToList(),
Employees = job.EmployeeAssignments.Select(e => e.Employee.Name).ToList()
}).ToList();
}
}
2. GetEmployeePayoutSummaryQuery
public class GetEmployeePayoutSummaryQuery
{
public int EmployeeId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
Handler:
public class GetEmployeePayoutSummaryHandler
{
private readonly IEmployeeRepository _repo;
public async Task<decimal> Handle(GetEmployeePayoutSummaryQuery query)
{
var employee = await _repo.GetWithJobAssignmentsAsync(query.EmployeeId);
var jobs = employee.JobAssignments
.Where(j => j.ScheduledJob.ScheduledDate >= query.StartDate &&
j.ScheduledJob.ScheduledDate <= query.EndDate)
.ToList();
decimal total = 0;
foreach (var assignment in jobs)
{
if (employee.PayType == PayType.PerHour && assignment.HoursWorked.HasValue)
total += (decimal)assignment.HoursWorked.Value.TotalHours * employee.Rate;
else if (employee.PayType == PayType.PerService && assignment.ServicesCompleted.HasValue)
total += assignment.ServicesCompleted.Value * employee.Rate;
}
return total;
}
}
đź§ľ Summary
âś… CQRS Benefits in This Case:
- Read queries are optimized (payouts, job history, service reporting)
- Commands enforce rules like employee assignment and work logging
- Easier to test and scale independently
⚠️ CQRS Costs:
- More classes, more plumbing
- Requires solid understanding of business rules to avoid confusion
đź§µ Wrap-Up
- CQRS splits reading and writing code.
- It makes complex systems easier to manage and scale.
- It's not for every project—use it when your system has different needs for reading and writing.
- You can combine it with the Repository Pattern and EF Core to keep things clean.