Skip to content

CQRS

CQRS separates read (Query) and write (Command) operations into distinct interfaces. Each consumer injects only the interface matching its intent, making dependencies explicit and code easier to audit.

flowchart LR
    subgraph Read["Query side"]
        EP_R["Endpoint / Handler<br/>read-only"]
        Reader["IXxxReader"]
        DB_R[(Database<br/>AsNoTracking)]
    end

    subgraph Write["Command side"]
        EP_W["Endpoint / Handler<br/>write"]
        Writer["IXxxWriter"]
        DB_W[(Database<br/>SaveChangesAsync)]
    end

    EP_R --> Reader --> DB_R
    EP_W --> Writer --> DB_W

    style Read fill:#e8f4fd,stroke:#1a73e8
    style Write fill:#fef3e0,stroke:#e8a317
classDiagram
    class IBlobDescriptorReader {
        +FindAsync(blobId, ct) BlobDescriptor
        +FindOrphanedAsync(cutoff, batchSize, ct) IReadOnlyList
    }

    class IBlobDescriptorWriter {
        +SaveAsync(descriptor, ct) Task
        +UpdateAsync(descriptor, ct) Task
    }

    class IBlobDescriptorStore {
    }

    IBlobDescriptorStore --|> IBlobDescriptorReader
    IBlobDescriptorStore --|> IBlobDescriptorWriter

    class DefaultBlobStorage {
        -reader : IBlobDescriptorReader
        -writer : IBlobDescriptorWriter
    }
    DefaultBlobStorage ..> IBlobDescriptorReader : injects
    DefaultBlobStorage ..> IBlobDescriptorWriter : injects

Each data module exposes three interfaces:

InterfaceRoleExample
IXxxReaderRead-only operationsIBlobDescriptorReader, IFeatureStoreReader
IXxxWriterWrite-only operationsIBlobDescriptorWriter, IFeatureStoreWriter
IXxxStoreReader + Writer union (for DI registration)IBlobDescriptorStore : IBlobDescriptorReader, IBlobDescriptorWriter

Strict rule: the combined Store exists for DI registration only. Constructors of handlers, endpoints, and services must inject IXxxReader or IXxxWriter separately, never the combined Store.

ArchUnitNET tests (tests/Granit.ArchitectureTests/CqrsConventionTests.cs):

[Fact]
public void Reader_interfaces_should_end_with_Reader() =>
NamingConventionRules.ReaderInterfacesShouldEndWithReader(Architecture);
[Fact]
public void Writer_interfaces_should_end_with_Writer() =>
NamingConventionRules.WriterInterfacesShouldEndWithWriter(Architecture);
ModuleReaderWriter
BlobStorageIBlobDescriptorReaderIBlobDescriptorWriter
FeaturesIFeatureStoreReaderIFeatureStoreWriter
SettingsISettingStoreReaderISettingStoreWriter
BackgroundJobsIBackgroundJobReaderIBackgroundJobWriter
WebhooksIWebhookSubscriptionReaderIWebhookSubscriptionWriter
NotificationsIUserNotificationReaderIUserNotificationWriter
AuthorizationIPermissionManagerReaderIPermissionManagerWriter
LocalizationILocalizationOverrideStoreReaderILocalizationOverrideStoreWriter
TemplatingIDocumentTemplateStoreReaderIDocumentTemplateStoreWriter
TimelineITimelineReaderITimelineWriter
DataExchangeIImportJobReader / IExportJobReaderIImportJobWriter / IExportJobWriter
ReferenceDataIReferenceDataStoreReaderIReferenceDataStoreWriter
FileRole
src/Granit.BlobStorage/IBlobDescriptorReader.csCanonical Reader interface
src/Granit.BlobStorage/IBlobDescriptorWriter.csCanonical Writer interface
src/Granit.BlobStorage/IBlobDescriptorStore.csCombined Store interface (DI only)
src/Granit.BlobStorage/Internal/DefaultBlobStorage.csSeparate Reader + Writer injection
src/Granit.BackgroundJobs.Endpoints/Endpoints/BackgroundJobsReadEndpoints.csEndpoint injecting only IBackgroundJobReader
src/Granit.BackgroundJobs.Endpoints/Endpoints/BackgroundJobsWriteEndpoints.csEndpoint injecting only IBackgroundJobWriter
tests/Granit.ArchitectureTests/CqrsConventionTests.csCQRS convention tests
ProblemCQRS solution
A service injects a full store when it only readsInjecting the Reader alone makes the intent explicit
ISO 27001 audit: who can write what?The DI graph immediately shows components with write access
GDPR: no hard-delete on readersWriter interfaces do not expose a physical Delete method
Refactoring: merging Reader/Writer for convenienceArchUnitNET tests block the regression
Wolverine handlers: clear responsibilityCommand handlers inject the Writer, query handlers the Reader
// --- Read endpoint -- injects ONLY the Reader ---
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>(/* ... */));
}
// --- Write endpoint -- injects ONLY the Writer ---
private static async Task<Results<NoContent, NotFound>> PauseJobAsync(
string name,
IBackgroundJobWriter writer,
CancellationToken cancellationToken)
{
await writer.PauseAsync(name, cancellationToken).ConfigureAwait(false);
return TypedResults.NoContent();
}
// --- Wolverine command handler -- Writer only ---
public static async Task Handle(
SendWebhookCommand command,
IWebhookDeliveryWriter deliveryWriter,
CancellationToken cancellationToken)
{
await deliveryWriter.RecordSuccessAsync(command, /* ... */, cancellationToken)
.ConfigureAwait(false);
}