CQRS — Reader/Writer Separation
The problem
Section titled “The problem”A typical data access layer exposes a single interface per
entity — IProductStore — with both GetAsync and DeleteAsync
on the same contract. Any consumer that injects the store can
read and write, even if it only needs to display a list.
This causes three concrete issues:
- Principle of least privilege violated — a reporting endpoint has access to
DeleteAsyncit should never call. - ISO 27001 audit — when reviewing who can mutate data, every class injecting the store must be inspected manually. The DI graph gives no signal.
- GDPR risk — a Reader that accidentally calls a write method can corrupt or destroy personal data outside its intended scope.
The Granit approach
Section titled “The Granit approach”Every data module in Granit exposes three interfaces:
classDiagram
class IXxxReader {
<<interface>>
+GetAsync()
+FindAsync()
+ListAsync()
}
class IXxxWriter {
<<interface>>
+SaveAsync()
+UpdateAsync()
}
class IXxxStore {
<<interface>>
}
IXxxStore --|> IXxxReader
IXxxStore --|> IXxxWriter
| Interface | Injected by | Purpose |
|---|---|---|
IXxxReader | Read endpoints, queries | Read-only |
IXxxWriter | Write endpoints, commands | Mutations |
IXxxStore | DI registration only | Never inject |
The Store exists so a single EF Core class can implement both interfaces and be
registered once in the container. Application code always injects the narrower
Reader or Writer.
Compliance benefits
Section titled “Compliance benefits”ISO 27001 — instant write-access audit
Section titled “ISO 27001 — instant write-access audit”Because write access requires injecting IXxxWriter, the DI graph is a live audit
trail. A single query answers “which components can mutate blob metadata?”:
# Find every class that injects a Writergrep -r "IXxxWriter" src/ --include="*.cs" -lNo manual code review needed — the type system enforces the boundary.
GDPR — no accidental data destruction
Section titled “GDPR — no accidental data destruction”IXxxReader interfaces expose no Delete or Update methods. A read endpoint
cannot accidentally destroy personal data, even if a developer makes a mistake.
This is structural protection, not convention.
Where you will encounter it
Section titled “Where you will encounter it”CQRS shows up everywhere in Granit, not just in persistence:
- Endpoints — modules split routes into
*ReadEndpoints.csand*WriteEndpoints.cs, each injecting only the interface they need. - Wolverine handlers — command handlers inject Writers, query handlers inject Readers. The message type (command vs query) aligns with the interface.
- Interceptors and filters — EF Core interceptors (
AuditedEntityInterceptor,SoftDeleteInterceptor) operate on the write path. The read path usesAsNoTrackingfor performance. - Testing — InMemory implementations of Reader/Writer make unit tests fast and isolated. No database needed.
Quick example
Section titled “Quick example”// Injects ONLY the Reader — cannot mutate dataprivate static async Task<Ok<PagedResult<BackgroundJobStatus>>> GetAllJobsAsync( IBackgroundJobReader reader, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default){ IReadOnlyList<BackgroundJobStatus> all = await reader .GetAllAsync(cancellationToken).ConfigureAwait(false); return TypedResults.Ok(new PagedResult<BackgroundJobStatus>(/* ... */));}// Injects ONLY the Writer — cannot read dataprivate static async Task<Results<NoContent, NotFound>> PauseJobAsync( string name, IBackgroundJobWriter writer, CancellationToken cancellationToken){ await writer.PauseAsync(name, cancellationToken).ConfigureAwait(false); return TypedResults.NoContent();}// Command handler — Writer onlypublic static async Task Handle( SendWebhookCommand command, IWebhookDeliveryWriter deliveryWriter, CancellationToken cancellationToken){ await deliveryWriter.RecordSuccessAsync(command, /* ... */, cancellationToken) .ConfigureAwait(false);}Common mistakes
Section titled “Common mistakes”Further reading
Section titled “Further reading”- CQRS pattern reference — full Reader/Writer inventory across all 27+ modules, ArchUnitNET enforcement, and implementation details
- Repository (Store) pattern — how Store interfaces map to EF Core and InMemory implementations
- Anti-patterns — “Merging Reader/Writer interfaces” section with detailed problem description
- Persistence concept — isolated DbContexts and interceptors that power the write path