A support dashboard needs fast searches across thousands of cases. Updating a case requires permissions, transition rules and a record of why a decision changed. It is tempting to introduce CQRS and event sourcing together, particularly when both appear in the same architecture diagram.

They answer different questions. CQRS concerns how the application models reads and writes. Event sourcing concerns what the application treats as its authoritative history. Separating those decisions makes it much easier to judge the benefits and the operational cost.

Introduction

Consider a case management application used by advisers and supervisors. Advisers open cases, assign owners, escalate difficult issues and record resolutions. Supervisors need filters, workload summaries and reports showing how long cases spend waiting for specialist attention.

The write side has business rules. A closed case cannot simply be escalated. Some changes require a supervisor. Two advisers must not overwrite each other's decisions without knowing that another update happened.

The read side has different needs. A list page needs a small, sortable result; a timeline needs chronological detail; a report needs totals across many cases. Loading the same large object graph for all three is unlikely to be ideal.

We will use this application to examine CQRS alone, event sourcing alone and the combination. The aim is to choose the least complicated design that meets a concrete requirement, while understanding what changes when history becomes the primary record.

Establish the Requirements Before Choosing Patterns

Start by writing down the questions the application must answer. “Show the cases assigned to me” is a current-state query. “Explain why this case was escalated last Thursday” needs historical information. “Recalculate waiting times using a new definition of specialist review” needs enough historical detail to apply a new interpretation.

Those requirements are related, but they do not all imply event sourcing. A conventional table plus carefully designed audit records might answer the first two perfectly well. The third becomes more demanding if the old audit records only contain a generic “case updated” message.

Record the consistency expectations too. After an adviser changes priority, must every dashboard display the new priority immediately? Can a report be a minute behind? Can a supervisor approve a transition using a slightly stale search result?

These are product decisions with architectural consequences. A separate reporting database can make searches cheaper, but someone must define acceptable delay and recovery. Without that agreement, the team may optimise query speed while creating confusing user behaviour.

Also establish the boundary of the proposed change. Case transitions might justify specialised modelling while reference lists, notification preferences and profile settings remain ordinary CRUD features. Applying one pattern everywhere increases the amount of infrastructure and conceptual machinery every developer must understand.

What CQRS Actually Separates

CQRS stands for Command Query Responsibility Segregation. A command expresses an intended change, such as EscalateCase. A query returns information, such as cases awaiting specialist review.

The useful separation is between models and responsibilities. The write model decides whether a transition is valid. The query model returns a shape designed for its consumer. Microsoft's CQRS pattern guidance describes implementations that share a database as well as implementations with separate stores.

Sharing one relational database is a sensible starting point. The command handler loads the case, checks permissions and state, changes it, then commits. A query selects a few columns directly into a response object. Both operations can live in the same application and deployment.

A mediator library may route messages to handlers, but it does not create this separation by itself. An application can have hundreds of command classes while still making every query load and mutate the same domain objects. Conversely, clearly separated methods can implement useful CQRS without any mediator.

Model Commands Around Business Intent

A command called SetStatus exposes a storage detail. A command called EscalateCase communicates intent and gives the application somewhere to enforce escalation-specific rules.

For example, escalation may require a reason, an eligible target team and an active case. Closure may require a resolution code. These actions should not quietly share a generic update path that accepts arbitrary field changes from the browser.

The following records sketch that distinction:

public sealed record EscalateCase(
Guid CaseId,
Guid TargetTeamId,
string Reason,
long ExpectedVersion,
Guid CommandId);

public sealed record CaseSummary(
Guid Id,
string Reference,
string Status,
string AssignedTeam);

The command is not itself proof that anything happened. It is an input to validation and persistence. The summary is not a safe authority for deciding whether an escalation is currently valid.

Authentication and authorisation still apply to both paths. A query must not leak cases from another customer, and a command must not trust an adviser identifier supplied by an untrusted client.

Keep Queries Focused

Suppose the list page needs a reference, status and assigned team name. The query can project exactly those values, apply an explicit order and paginate. It does not need to invoke every domain behaviour or load every case note.

A separate query model also makes changes visible during review. Adding an expensive total to every list row becomes a deliberate query change rather than an accidental property access that performs additional database work.

This is not a licence to bypass data protection or duplicate every rule. Tenant boundaries, visibility constraints and data classification remain part of querying. Business transitions belong on the write side; access rules belong wherever protected data is used.

What Event Sourcing Stores

In conventional persistence, the case row is the primary representation of current state. Historical records may accompany it. In event sourcing, an ordered sequence of accepted business facts is authoritative:

CaseOpened       case-42, customer-8
CaseAssigned case-42, adviser-3
CaseEscalated case-42, specialist-team, reason
CaseClosed case-42, replacement-agreed

Applying those events reconstructs the case. If a derived current-state table disappears, the system can rebuild it from the event history. If losing that history would instead leave the main case table authoritative, the application is using a different persistence model.

An audit table is therefore not automatically an event store. Audit records may omit details needed for reconstruction, be written after the main transaction, or describe technical field changes rather than stable business facts.

Microsoft's event sourcing guidance highlights both historical reconstruction and the substantial changes it brings to persistence and schema evolution. Adoption should be deliberate because those contracts can remain relevant long after the original application code is replaced.

Distinguish Requests From Accepted Facts

CloseCaseRequested and CaseClosed communicate different things. The former might be rejected because the case needs another approval. The latter states that the system accepted closure.

Store enough information to explain the accepted decision. If an escalation records only the new status, a later report cannot discover its target team or reason. Equally, recording the entire request object indiscriminately can preserve unnecessary personal information.

An event envelope commonly carries a stable event identifier, stream identifier, stream version, event type, schema version, recorded timestamp and correlation information. The payload carries the business fact. Define which fields are contractual and which are diagnostic.

Do not depend on timestamps alone for ordering. Two events can share a timestamp, and clocks can disagree. An explicit sequence within a stream gives reconstruction a clear order.

Keep Reconstruction Deterministic

Rebuilding a case should apply already accepted facts. It should not rerun today's validation against every historical command.

Suppose escalation originally required a reason of at least ten characters, but the rule later changes to twenty. Replaying an old valid event must not suddenly reject the case history. Validation decides whether a new command is accepted; event application reconstructs what was accepted at the time.

The same principle applies to time and external lookups. A replay that asks the current team directory for an old team name may produce a different result. Record the historical value when it matters, or explicitly define that the view uses current reference data.

Random identifiers and current timestamps should be supplied when creating events, not generated again while applying them. A replay should not silently manufacture a different history.

When CQRS Alone Is Enough

Return to the initial dashboard problem. Advisers need faster filtering, clearer transition rules and an ordinary explanation of who changed a case. None of those requirements necessarily demands replayable authoritative events.

Separate command and query code over the existing database. Give commands explicit validation and concurrency handling. Shape list results in the database and add indexes that match the filtering and ordering.

An audit record can be committed in the same transaction as the state change. That establishes a dependable history of the information you choose to record without making reconstruction the basis of every write.

For example, a relational command handler can load the case, compare its concurrency token, apply an escalation and insert an audit entry before saving. If another writer changed the row, it can return a conflict and let the adviser review the new state. EF Core's optimistic concurrency documentation explains how configured concurrency tokens detect conflicting updates.

This approach leaves fewer moving parts. There is no projection subscription to monitor, no need to replay every historical schema and no requirement to reconcile separate read storage. It also gives the team a useful boundary if the application later grows.

CQRS alone can also support separate read storage. An ordinary transactional database might publish changes to a search index using an outbox. That does not make the source database event sourced; its current rows can remain authoritative.

When Event Sourcing Earns Its Cost

Event sourcing becomes more attractive when the sequence of decisions is a core part of the product. The support business may need to reconstruct eligibility at a previous moment, explain reversals or calculate new measures from retained transitions.

Imagine a report showing how long each case spent at every escalation level. A current Priority column cannot answer that. A suitable history can, provided the events contain the transition details and relevant times.

Even then, ask whether existing history meets the need. Event sourcing is a larger commitment than adding a reliable transition history to a conventional application. Historical reporting alone should trigger investigation, not an automatic rewrite.

The stronger case appears when many important behaviours depend on replay, correction and multiple interpretations of history. The team must also be prepared to operate streams, projections, schema compatibility and recovery as normal product capabilities.

A small team maintaining a simple administration site may reasonably reject that cost. A complex workflow product may reasonably accept it. Neither choice is more sophisticated by default; the useful measure is whether the operational work buys a requirement the product actually values.

Combining CQRS and Event Sourcing

The combined design uses event streams to support commands and projections to support queries. Each part has an explicit responsibility:

  • The command handler authenticates the caller and validates intent against authoritative state.
  • The event store durably appends accepted facts with a concurrency condition.
  • Projection processors transform committed facts into useful read models.
  • Query handlers return those read models with appropriate access controls.
  • Separate integration workers perform external effects that follow accepted changes.

A message broker is one way to distribute committed events, but it is not automatically the authoritative event store. Retention, ordering, replay and concurrency guarantees must match the design.

Follow a Command Through the System

An adviser requests escalation of case 42. The application checks that the adviser can access and escalate this case. It loads the stream and reconstructs its current state, which is at version 12.

The business method decides whether escalation is allowed. If so, it produces CaseEscalated with the target team and reason. The store appends it only if the stream is still at version 12.

If the append succeeds, the new version becomes 13. The application can return a committed result containing the case identifier and new version. A projection later updates the dashboard row and reporting totals.

This illustrative handler assumes an event store with an atomic expected-version append:

public async Task<long> HandleAsync(
EscalateCase command,
CancellationToken cancellationToken)
{
var stream = await store.LoadAsync(
command.CaseId, cancellationToken);

var supportCase = SupportCase.FromHistory(stream.Events);
var events = supportCase.Escalate(
command.TargetTeamId, command.Reason);

return await store.AppendAsync(
command.CaseId,
expectedVersion: stream.Version,
commandId: command.CommandId,
events: events,
cancellationToken: cancellationToken);
}

The sample omits application-specific authorisation and validation plumbing. More importantly, AppendAsync is a required storage contract, not a guarantee provided by this method name. Its implementation must atomically enforce the expected version and the intended duplicate-command behaviour.

The command's ExpectedVersion can also express the version the user saw. Decide whether a mismatch should reject the action immediately or whether the server may reevaluate it against newer state. These are different product policies.

Handle Concurrent Decisions Explicitly

Suppose two advisers load version 12. One closes the case while the other escalates it. Without a concurrency condition, both could append decisions based on a state that no longer exists.

With expected-version checks, one append succeeds and the other receives a conflict. The losing handler reloads the stream and reevaluates the intended action. Escalation may now be invalid because the case is closed.

Blindly retrying the same events would defeat the validation boundary. A retry should reconsider the command against current authoritative state, unless the operation has a specifically designed commutative meaning.

There are two distinct duplicate problems. Concurrent commands can conflict because they share an old version. The same command can also be sent twice because a browser retries after a timeout.

A stable command identifier can make the second situation recognisable. Persist the identifier and outcome within the append contract or another suitable atomic boundary. If the client cannot tell whether the first attempt committed, a retry should return the known result rather than duplicate the decision.

Do not promise exactly-once execution across arbitrary networks. Describe the concrete mechanisms: durable command identity, atomic writes, duplicate detection and idempotent consumers.

Make Projection Delay Understandable

An asynchronous projection can be behind a successful command. The adviser presses “Escalate”, receives success, then refreshes the dashboard and sees the old team. Without an explicit experience, the adviser may repeat the action.

One option is to return the accepted state needed by the current screen and show that the wider dashboard is updating. Another is to include the committed stream version and let the client wait briefly until the relevant read model has caught up.

A version on one case is not the same as a global projection checkpoint. A report containing thousands of cases may need a different freshness indicator. Define the token's meaning before exposing it in an API.

Waiting should have a timeout. A broken projection must not make every command response wait indefinitely. Return an understandable status and preserve the accepted outcome so the user does not have to guess whether the change was lost.

Enforce business rules against the event stream or equivalent authoritative write state. The fact that a dashboard still displays “Open” cannot justify closing a case that another adviser already closed.

Make Projection Processing Recoverable

A projection often receives events more than once. The processor might update the database successfully and crash before acknowledging the message. On restart, the same message is delivered again.

Consider a report that increments an escalation count. Repeating that update without duplicate protection overstates the total. A stable event identifier and durable processing record can prevent the second application.

Where possible, update the projection and its processing checkpoint in one transaction within the projection store. The transaction should either commit both or neither. A checkpoint saved first can skip work; a checkpoint saved later without deduplication can repeat work.

Ordering matters as well. Applying CaseClosed before CaseEscalated may leave the wrong final status. Preserve order within each case stream or use explicit version checks that detect gaps and defer out-of-order processing.

Global ordering is a stronger requirement and can reduce scalability. Most case transitions require per-case order; a cross-case report may tolerate independently ordered streams if its aggregation handles them correctly.

A poison event needs a visible recovery path. Record its identifier, schema type, failure and projection version. Decide whether processing must stop at that point or whether unrelated partitions can continue. Quietly skipping a failed event creates a view that looks healthy while losing information.

Rebuild Views Without Repeating External Effects

A new reporting requirement may need a projection built from the beginning. Create a new projection version alongside the current one, replay retained events, compare results, then switch readers when it catches up.

Keep the existing view available during the rebuild if the product requires continuity. Replaying into the live table without a clear strategy can expose partial totals or mixed calculation rules.

External effects need a separate boundary. Rebuilding a report must not resend historical customer emails, issue refunds again or reopen completed tasks in another system.

A useful design keeps replayable calculations pure with respect to external systems. Integration workers consume durable delivery work with their own deduplication and delivery records. They do not run merely because a historical event is being applied to a new read model.

For events published from a conventional database, a transactional outbox can tie the state change to durable publication intent. Microsoft's transactional outbox example illustrates the principle in a specific storage environment; the transaction boundaries must be adapted to the store you actually use.

Recovery also needs capacity planning. A replay competes for storage throughput and database connections. Throttle it, monitor lag and make it resumable rather than assuming it can run at unlimited speed alongside normal traffic.

Evolve Events and Use Snapshots Carefully

Historical events outlive individual deployments. Renaming a C# property or moving a CLR type should not make five years of stored payloads unreadable.

Use explicit event type names and schema versions. Keep readers for older forms or transform old payloads into a current in-memory representation during reading. Test those transformations using representative historical fixtures.

Adding an optional field can be straightforward if a missing value has a valid meaning. Changing the meaning of a field is more dangerous. A historical “priority” might have represented urgency before it later represented a contractual service tier.

Corrections should preserve an understandable history. An incorrect escalation may be followed by a correction or reversal event, with the reporting rules explaining its effect. Editing old records casually can make existing projections, backups and downstream consumers disagree.

Snapshots reduce the cost of reconstructing long streams. A snapshot records reconstructed state at a known stream version; loading applies only later events. It is an optimisation whose format can evolve separately from event contracts.

Validate snapshots and retain a way to rebuild them. If a snapshot contains a bug, the underlying history should still support recovery. Monitor reconstruction cost before adding snapshots everywhere; short streams may not need the extra mechanism.

Address Privacy, Access and Retention

An append-only design makes it especially important to decide what belongs in an event. Customer addresses, free-text notes and authentication tokens should not be copied into every payload simply because they appeared in a command.

Prefer stable references where the historical value is unnecessary. Where historical personal information is genuinely required, document its purpose, access controls and retention strategy with the people responsible for those policies.

Projection security can differ from stream security. A supervisor may be allowed to see aggregate workload totals without reading sensitive case notes. Replaying events into a new reporting store should preserve those boundaries.

Encryption and backups also form part of the design. A claim that events are immutable does not answer how authorised corrections, retention or deletion requirements will be handled across projections and backups.

Avoid treating event sourcing as automatic compliance. It provides a persistence model. The organisation still needs an explicit policy and an implementation that can carry it out.

Observe and Test the Whole Workflow

Monitor command acceptance and rejection rates, append conflicts, stream load times, projection lag, failed events and replay progress. A healthy command API can coexist with a broken dashboard projection.

Correlate a command identifier with its committed events and projection updates. This lets support distinguish “the command failed” from “the command succeeded but this view is behind”.

A worked investigation might start with a dashboard missing yesterday's escalations. Check the authoritative streams first. If the events exist, inspect the projection checkpoint and the first failed event. A newly introduced schema version may be blocking the processor.

Repair the reader, replay from the last safe checkpoint and verify totals against known cases. Do not manually edit the dashboard row and leave the failed processor untouched; that removes the symptom while preserving the next incident.

Useful tests include conflicting commands, duplicate submissions, a crash between projection update and acknowledgement, old event schemas, out-of-order delivery, replay with integrations disabled and a rebuild that produces the same result as normal processing.

Introduce the Design Incrementally

Start with one workflow whose pain is visible. Separate its commands from queries, measure query cost and make concurrency handling explicit. These changes can provide value before a second store exists.

If history becomes essential, identify exactly which transitions and historical fields must be authoritative. A migration from current rows cannot magically reconstruct decisions that were never recorded.

An initial migration event can establish a known starting state, but label that limitation clearly. “Imported as open on this date” is honest; invented historic transitions are not.

Before extending the pattern to other workflows, demonstrate recovery. Rebuild a projection, restore from backup, process duplicate deliveries and deploy a compatible event reader. Operational proof is a better expansion criterion than a clean diagram.

Compare the Alternatives in a Decision Review

A useful decision review compares concrete options against the same workflow. For the case application, consider conventional persistence with audit records, CQRS over one store, CQRS with a separate search view, and event sourcing with projections.

Estimate what each option requires to answer today's questions. Include implementation effort, recovery procedures, storage growth, operational support and the cost of explaining failures to advisers.

Then consider a representative change: adding a report for time spent awaiting specialist review. If the current audit trail already records every relevant transition reliably, the conventional design may support it without changing the system of record.

Consider a representative incident too: the search database is unavailable for an hour. Which actions can continue, what does the user see, and how does the view catch up afterwards?

Finally, identify assumptions that would change the decision. A requirement to reconstruct historical decisions from durable facts is different from an unconfirmed possibility that a future report might be useful.

Record the chosen scope and revisit trigger. “Use CQRS over the existing case database; reconsider authoritative event streams if historical reconstruction becomes a contractual product capability” is much more useful than “we prefer simple architecture”. It gives later teams a reasoned starting point while leaving room for new evidence.

Summary

Choose CQRS when reads and writes benefit from different models, rules or performance decisions. Choose event sourcing when the accepted sequence of business facts must support reconstruction and further interpretation.

For the support dashboard, separate command and query code over one database is a reasonable first step. Combining the patterns becomes more compelling when historical decisions are central to the product and multiple read models need that history.

Make the decision alongside concrete plans for concurrency, duplicate commands, delayed projections, schema evolution, privacy and replay. The architecture succeeds when both ordinary use and recovery remain understandable to the people building and operating it.