Skip to content

CQRS — Reader/Writer Separation

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:

  1. Principle of least privilege violated — a reporting endpoint has access to DeleteAsync it should never call.
  2. ISO 27001 audit — when reviewing who can mutate data, every class injecting the store must be inspected manually. The DI graph gives no signal.
  3. GDPR risk — a Reader that accidentally calls a write method can corrupt or destroy personal data outside its intended scope.

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
InterfaceInjected byPurpose
IXxxReaderRead endpoints, queriesRead-only
IXxxWriterWrite endpoints, commandsMutations
IXxxStoreDI registration onlyNever 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.

Because write access requires injecting IXxxWriter, the DI graph is a live audit trail. A single query answers “which components can mutate blob metadata?”:

Terminal window
# Find every class that injects a Writer
grep -r "IXxxWriter" src/ --include="*.cs" -l

No manual code review needed — the type system enforces the boundary.

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.

CQRS shows up everywhere in Granit, not just in persistence:

  • Endpoints — modules split routes into *ReadEndpoints.cs and *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 uses AsNoTracking for performance.
  • Testing — InMemory implementations of Reader/Writer make unit tests fast and isolated. No database needed.
// Injects ONLY the Reader — cannot mutate data
private 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>(/* ... */));
}
  • 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