What is CQRS (in one line)
- Separate the write side (commands that change state) from the read side (queries that return data), so each can optimize independently.
When to use CQRS
- Read/write needs are very different:
- Reads are hot and need tailored projections, denormalized views, caching, or a different store.
- Writes need strong validation, invariants, and transactions.
- Performance and scaling:
- Reads outnumber writes by a lot (10×–100×), and you want to scale/read-optimize without complicating write logic.
- UX wants different shapes than your domain:
- E.g., “Order with line items and fulfillment status by customer” needs a custom read model that doesn’t match your normalized write schema.
- Complex domains:
- Clearer boundaries and testing by separating write rules (invariants) from read concerns (reporting, search).
- Event-driven systems:
- You already publish events and can build read models/projections asynchronously.
When not to use CQRS
- Simple CRUD app, one DB, a few endpoints.
- Team is small, speed matters more than architecture; CQRS will slow you down.
- You can get 90% of the benefit with “read-optimized queries” (e.g., EF AsNoTracking + a couple SQL views).
- You can’t handle the operational overhead (two paths, possibly two stores, projections, eventual consistency).
Pros
- Performance and flexibility on reads without polluting domain logic.
- Cleaner write model with clear invariants and tests.
- Easier to evolve read models for new UX/reporting without risky schema churn.
Cons
- More moving parts (two paths, sometimes two stores).
- Eventual consistency if you use async projections.
- More code to maintain and operate.
A real-world shape
- Orders or bookings: writes enforce business rules (status transitions, inventory holds). Reads need fast lists, filters, totals, dashboards, and customer-friendly shapes.
- In prod I’ve seen:
- Write: Postgres/MySQL with a normalized schema.
- Read: denormalized tables or a search index (Elasticsearch/OpenSearch), or tailored SQL views, cached with Redis.
- Projections updated via outbox + background workers.
Below: a full working example that shows the pattern without too much boilerplate.
What we’ll build
- .NET 8 minimal API
- Writes (commands) via EF Core into SQLite (easy to run)
- Reads (queries) via Dapper with SQL projections
- Endpoints:
- POST /orders (create)
- POST /orders/{id}/approve (approve)
- GET /orders/{id} (query by id)
- GET /orders (list)
How to run locally
- Create a folder and init a web project:
- dotnet new web -n CqrsSample && cd CqrsSample
- Add packages:
- dotnet add package Microsoft.EntityFrameworkCore.Sqlite
- dotnet add package Dapper
- Replace Program.cs with the code below
- Run:
- dotnet run
- Try it:
- Create: curl -X POST http://localhost:8080/orders -H “Content-Type: application/json” -d ‘{“customerEmail”:“a@b.com”,“items”:[{“sku”:“SKU-1”,“quantity”:2,“price”:19.9},{“sku”:“SKU-2”,“quantity”:1,“price”:9.5}]}’
- List: curl http://localhost:8080/orders
- Approve: curl -X POST http://localhost:8080/orders/{id}/approve
- Get by id: curl http://localhost:8080/orders/{id}
Full working code (Program.cs)
using System.Data;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Config
var connString = builder.Configuration.GetConnectionString("App")
?? "Data Source=app.db"; // SQLite for a self-contained demo
// EF Core DbContext for writes
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite(connString));
// Dapper connection factory for reads
builder.Services.AddScoped<IDbConnection>(_ => new SqliteConnection(connString));
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Ensure DB exists (demo). In prod: use migrations + Migrate().
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
// Health
app.MapGet("/ping", () => "pong");
// Commands (write side)
// Create Order
app.MapPost("/orders", async (CreateOrderDto dto, AppDbContext db) =>
{
// Very basic validation; expand as needed
if (string.IsNullOrWhiteSpace(dto.CustomerEmail))
return Results.BadRequest("CustomerEmail is required");
if (dto.Items is null || dto.Items.Count == 0)
return Results.BadRequest("At least one item is required");
if (dto.Items.Any(i => i.Quantity <= 0 || i.Price < 0))
return Results.BadRequest("Invalid item quantity/price");
var order = Order.Create(dto.CustomerEmail);
foreach (var item in dto.Items)
order.AddItem(item.Sku, item.Quantity, item.Price);
await db.Orders.AddAsync(order);
await db.SaveChangesAsync();
return Results.Created($"/orders/{order.Id}", new { order.Id });
});
// Approve Order
app.MapPost("/orders/{id:guid}/approve", async (Guid id, AppDbContext db) =>
{
var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return Results.NotFound();
try
{
order.Approve();
await db.SaveChangesAsync();
return Results.Accepted($"/orders/{order.Id}");
}
catch (InvalidOperationException ex)
{
return Results.Conflict(new { message = ex.Message });
}
});
// Queries (read side) using Dapper
// Get by Id
app.MapGet("/orders/{id:guid}", async (Guid id, IDbConnection conn) =>
{
const string sql = @"
select
o.Id, o.CustomerEmail, o.Status, o.CreatedAt,
i.Id as ItemId, i.Sku, i.Quantity, i.Price
from Orders o
left join OrderItems i on i.OrderId = o.Id
where o.Id = @Id
order by i.Id";
var rows = await conn.QueryAsync<dynamic>(sql, new { Id = id });
var first = rows.FirstOrDefault();
if (first is null) return Results.NotFound();
var result = new OrderReadDto
{
Id = first.ID,
CustomerEmail = first.CUSTOMEREMAIL,
Status = first.STATUS,
CreatedAt = first.CREATEDAT,
Items = rows
.Where(r => r.ITEMID != null)
.Select(r => new OrderItemReadDto
{
Id = r.ITEMID,
Sku = r.SKU,
Quantity = (int)r.QUANTITY,
Price = (decimal)r.PRICE
})
.ToList()
};
return Results.Ok(result);
});
// List (latest N)
app.MapGet("/orders", async (int take, int skip, IDbConnection conn) =>
{
take = take <= 0 || take > 100 ? 20 : take;
skip = skip < 0 ? 0 : skip;
const string sql = @"
select o.Id, o.CustomerEmail, o.Status, o.CreatedAt
from Orders o
order by o.CreatedAt desc
limit @Take offset @Skip";
var orders = (await conn.QueryAsync<OrderListItemDto>(sql, new { Take = take, Skip = skip })).ToList();
return Results.Ok(orders);
});
// Run
app.Urls.Add("http://0.0.0.0:8080");
app.Run();
// -------------------- Domain + Data --------------------
public sealed class Order
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string CustomerEmail { get; private set; } = default!;
public string Status { get; private set; } = OrderStatus.Pending;
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public List<OrderItem> Items { get; private set; } = new();
private Order() { } // EF
public static Order Create(string customerEmail)
{
if (string.IsNullOrWhiteSpace(customerEmail))
throw new ArgumentException("CustomerEmail required", nameof(customerEmail));
return new Order { CustomerEmail = customerEmail.Trim() };
}
public void AddItem(string sku, int quantity, decimal price)
{
if (string.IsNullOrWhiteSpace(sku)) throw new ArgumentException("SKU required", nameof(sku));
if (quantity <= 0) throw new ArgumentException("Quantity must be > 0", nameof(quantity));
if (price < 0) throw new ArgumentException("Price must be >= 0", nameof(price));
Items.Add(new OrderItem { Sku = sku.Trim(), Quantity = quantity, Price = price });
}
public void Approve()
{
if (Status == OrderStatus.Approved) throw new InvalidOperationException("Order already approved");
if (Status != OrderStatus.Pending) throw new InvalidOperationException($"Cannot approve from status {Status}");
if (Items.Count == 0) throw new InvalidOperationException("Cannot approve order without items");
Status = OrderStatus.Approved;
}
}
public static class OrderStatus
{
public const string Pending = "Pending";
public const string Approved = "Approved";
}
public sealed class OrderItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Sku { get; set; } = default!;
public int Quantity { get; set; }
public decimal Price { get; set; }
public Guid OrderId { get; set; }
public Order Order { get; set; } = default!;
}
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.CustomerEmail).IsRequired();
e.Property(x => x.Status).IsRequired();
e.Property(x => x.CreatedAt).HasConversion(
v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); // keep UTC
e.HasMany(x => x.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
e.ToTable("Orders");
});
b.Entity<OrderItem>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Sku).IsRequired();
e.Property(x => x.Quantity).IsRequired();
e.Property(x => x.Price).IsRequired();
e.ToTable("OrderItems");
});
}
}
// -------------------- DTOs --------------------
public sealed record CreateOrderDto(string CustomerEmail, List<CreateOrderItemDto> Items);
public sealed record CreateOrderItemDto(string Sku, int Quantity, decimal Price);
public sealed class OrderReadDto
{
public Guid Id { get; set; }
public string CustomerEmail { get; set; } = default!;
public string Status { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public List<OrderItemReadDto> Items { get; set; } = new();
}
public sealed class OrderItemReadDto
{
public Guid Id { get; set; }
public string Sku { get; set; } = default!;
public int Quantity { get; set; }
public decimal Price { get; set; }
}
public sealed class OrderListItemDto
{
public Guid Id { get; set; }
public string CustomerEmail { get; set; } = default!;
public string Status { get; set; } = default!;
public DateTime CreatedAt { get; set; }
}
Notes and how this maps to CQRS
- Commands (write path): POST /orders and POST /orders/{id}/approve use EF Core to modify state and enforce invariants (domain rules).
- Queries (read path): GET endpoints use Dapper with SQL tailored to what the UI needs. This is where you’d denormalize, join, and shape data for performance.
- Same DB for demo simplicity. In production:
- Keep writes on your primary (e.g., Postgres on RDS).
- Build read models via views/materialized views, replicas, or a separate store (e.g., OpenSearch).
- If you go async projections, use the Outbox pattern to update the read model reliably.
Hardening suggestions (prod)
- Validation: FluentValidation or custom validators on commands.
- Migrations: add EF migrations and run db.Database.Migrate() on startup.
- Transactions: wrap multi‑aggregate writes in a transaction scope.
- Observability: add structured logging + OTEL traces on command and query handlers.
- Security: authN/authZ, row‑level access if multi‑tenant.
- Idempotency: for create/update commands if clients may retry.
If you want, I can split this into a clean multi‑project repo (Domain, Application, Infrastructure, Api) and wire MediatR + FluentValidation + test projects. We can also switch the read side to Postgres + Dapper or views to match your RDS setup.