cqrs
Pragmatic CQRS levels (separate methods, separate models, separate stores) and how to implement each in .NET with EF Core writes and Dapper/views/stored-procedure reads on SQL Server, including projec
SKILL.md.claude/skills/cqrs/SKILL.md
GitHubCQRS — pragmatic levels
| Level | Write side | Read side | Consistency | Use when |
|---|---|---|---|---|
| separate-methods (CQS) | handler mutates, returns id/Result | handler returns data, never mutates | immediate | default for every module |
| separate-models | aggregates via EF Core | projections straight from the DB to response DTOs (EF Select/AsNoTracking, Dapper, views, SPs) |
immediate (same DB) | domain-model modules; any list/search screen |
| separate-stores | aggregates + outbox | dedicated read tables/other store updated by projectors | eventual | measured read load, reporting isolation, heavy denormalization |
Rules (all levels)
- A query never loads an aggregate and never calls
SaveChanges. - A command never returns a read model larger than an id + minimal status.
- Read models are owned by the slice that needs them; do not build a shared "InvoiceDto".
- Authorization filters apply to reads too (row-level: tenant, company, user scope) — inside the query.
separate-models on SQL Server
- Simple lists/details: EF Core
AsNoTracking().Where().Select(x => new Response(...)). - Complex reads (many joins, aggregation, window functions): Dapper + SQL in the slice, or a module-owned
view
{schema}.{Name}View; SPs when execution-plan stability or security (EXECUTE-only) matter. - Paging: keyset (
WHERE (Date, Id) < (@LastDate, @LastId)) for large/infinite lists; OFFSET only for small bounded sets with a total count. - Indexes are designed for the read queries; list them in the plan.
separate-stores
- Write side emits domain events → outbox (same transaction).
- Projector (background service) reads outbox/integration events in order → upserts read tables
(
reporting.InvoiceFacts). Idempotent by event id; track position per projector. - Read handlers query only read tables.
- Consistency contract is explicit in the UI/API: after a command, either return the new version number and poll, or read-your-writes from the write model for that one entity.
- Rebuild procedure exists: truncate + replay from source tables or event history. Test it.
When not to
- CRUD module with forms mirroring tables → separate-methods only.
- "We might need to scale reads" without numbers → no separate stores.
- Team cannot operate eventual consistency (support, UI patterns) → stay at separate-models.
Review checklist
- Query handlers free of tracking, aggregates, SaveChanges
- Every read query has index support or a stated reason
- Read-side authorization applied in SQL, not after materialization
- Projectors idempotent, ordered, rebuildable (separate-stores only)