Suppose a repair shop has a small screen for technician handoff notes. Before a job changes hands, the technician records what was checked and what still needs attention.
The screen rejects blank notes. Then an import tool starts writing the same data, and a blank handoff slips through.
The next technician sees a record without the information the record was supposed to carry.
You could copy the screen’s validation into the importer. You could also introduce a message dispatcher and a full set of pipeline behaviors. Before choosing either, decide which responsibility actually needs a home.
The first screen wasn’t an unreasonable design
With one caller, validating beside the form was easy to understand. The assumption that stopped being true was that every write came through that form.
This independent .NET 10 console example represents the import path, not a deployed application:
var notes = new Dictionary<Guid, string>();Guid id = Guid.NewGuid();notes[id] = " ";Console.WriteLine($"Handoff: [{notes[id]}]");The dictionary stores exactly what it receives. Naming this operation a command wouldn’t improve it.
For the shop, a handoff note needs nonblank text with a sensible bound. Reading that note doesn’t need to repeat a write rule or acquire permission to change it.
That separation is enough reason to consider command and query handlers. It doesn’t imply separate databases, event sourcing, or a message broker.
Give the write a boundary every caller can use
The rest of this example is a console app using built-in DI. It targets .NET 10 with implicit usings enabled and a framework reference to Microsoft.AspNetCore.App. That reference supplies the DI and logging APIs without a mediator package.
These complete contracts belong together in Messages.cs:
public sealed record CreateNote(string Text);public sealed record FindNote(Guid Id);public sealed record Note(Guid Id, string Text);public interface ICommandHandler<TCommand, TResult>{ Task<TResult> HandleAsync(TCommand command, CancellationToken ct = default);}public interface IQueryHandler<TQuery, TResult>{ Task<TResult> HandleAsync(TQuery query, CancellationToken ct = default);}The messages name intent. The handlers own behavior. Neither contract conceals an automatic routing system.
Our complete NoteStore.cs keeps storage deliberately modest:
using System.Collections.Concurrent;
public sealed class NoteStore{ private readonly ConcurrentDictionary<Guid, Note> _notes = new(); public void Save(Note note) => _notes[note.Id] = note; public Note? Find(Guid id) => _notes.GetValueOrDefault(id);}Concurrent access is supported and the records are immutable, but restarting loses everything. This is enough to observe a handler boundary, not enough to operate the shop.
The write rule lives in CreateNoteHandler.cs:
public sealed class CreateNoteHandler(NoteStore store) : ICommandHandler<CreateNote, Guid>{ public Task<Guid> HandleAsync(CreateNote command, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(command.Text) || command.Text.Length > 200) throw new ArgumentException("Use 1 to 200 characters.", nameof(command)); var note = new Note(Guid.NewGuid(), command.Text.Trim()); store.Save(note); return Task.FromResult(note.Id); }}Now the screen and importer can call the same operation. Rejected input is their responsibility to report, not something the handler should silently turn into a successful handoff.
This in-memory write isn’t asynchronous I/O. The task-returning contract accommodates a later database implementation without pretending today’s dictionary needs an await.
The next technician only needs to read
The separate FindNoteHandler.cs asks the store a question:
public sealed class FindNoteHandler(NoteStore store) : IQueryHandler<FindNote, Note?>{ public Task<Note?> HandleAsync(FindNote query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); return Task.FromResult(store.Find(query.Id)); }}A missing note is null, not a new record. A future HTTP endpoint can turn that into 404 and turn expected invalid input into 400.
The distinction helps the next maintainer follow the operation. A mediator might make dispatch more convenient, but it isn’t what separates the write from the read.
Timing belongs around the rule
Once both callers use the handler, the team wants timing around writes. Copying timing code into every handler would recreate the duplication we just removed.
This complete LoggingHandler.cs is a decorator:
using System.Diagnostics;using Microsoft.Extensions.Logging;
public sealed class LoggingHandler<TCommand, TResult>( ICommandHandler<TCommand, TResult> inner, ILogger<LoggingHandler<TCommand, TResult>> logger) : ICommandHandler<TCommand, TResult>{ public async Task<TResult> HandleAsync(TCommand command, CancellationToken ct = default) { var timer = Stopwatch.StartNew(); try { return await inner.HandleAsync(command, ct); } finally { logger.LogInformation("{Command} finished after {Elapsed} ms", typeof(TCommand).Name, timer.Elapsed.TotalMilliseconds); } }}The log records elapsed time even if the command throws. It doesn’t label that failure as success or copy potentially sensitive note text into logs.
For the shop, this is a useful amount of infrastructure: one behavior in one place, with an explicit inner dependency.
Wiring is where an abstraction becomes a real promise
The final Program.cs uses the complete types above:
using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Logging;
var services = new ServiceCollection();services.AddLogging(o => o.AddSimpleConsole());services.AddSingleton<NoteStore>();services.AddScoped<CreateNoteHandler>();services.AddScoped<ICommandHandler<CreateNote, Guid>>(sp => new LoggingHandler<CreateNote, Guid>( sp.GetRequiredService<CreateNoteHandler>(), sp.GetRequiredService<ILogger<LoggingHandler<CreateNote, Guid>>>()));services.AddScoped<IQueryHandler<FindNote, Note?>, FindNoteHandler>();await using var provider = services.BuildServiceProvider();await using var scope = provider.CreateAsyncScope();var writer = scope.ServiceProvider.GetRequiredService<ICommandHandler<CreateNote, Guid>>();var reader = scope.ServiceProvider.GetRequiredService<IQueryHandler<FindNote, Note?>>();Guid id = await writer.HandleAsync(new(" Check the rear brake cable. "));Console.WriteLine((await reader.HandleAsync(new(id)))?.Text);try{ await writer.HandleAsync(new(" ")); throw new Exception("Expected rejection.");}catch (ArgumentException) { Console.WriteLine("Blank handoff rejected."); }It prints the trimmed instruction and Blank handoff rejected., with timing logs around both attempts. The local checks also cover missing IDs and canceled commands.
The concrete handler registration lets the decorator resolve its inner implementation without asking for the same interface from that interface’s own factory. That circular registration would not become cleverer by being shorter.
The store is singleton so scopes share the demonstration’s data. Handlers are scoped, leaving room for a scoped context later.
For reference, the project’s framework reference is:
<ItemGroup> <FrameworkReference Include="Microsoft.AspNetCore.App" /></ItemGroup>That belongs inside the generated project element. dotnet run executes the final console example. No external service or production database is involved.
The notification can still fail after the note is saved
The shop’s next request is predictable: notify the next technician.
Calling an in-process event handler after saving does not make those two operations atomic. If notification fails, the note may already exist. A sequential publisher may also stop before later subscribers run.
Adding a mediator doesn’t supply durable delivery. If saving and eventually publishing must be reliable together, investigate an outbox and the corresponding transaction boundaries.
Decorators can own validation, metrics, or transactions, but their order matters. They can’t invent a transaction across systems that don’t share one.
This is where keeping the example small pays off. You can see the responsibility that still needs a design instead of assuming an abstraction handled it.
Back at the workbench
The importer now has one write operation to call and one rejection to report. The next technician has a read operation whose job is simply to return the note.
That is useful CQRS without a dispatcher at the center of every interaction.
A typed dispatcher can be added if callers benefit, but avoid arbitrary type construction from incoming messages. Reflection, trimming, and Native AOT deserve deliberate review. Direct handler injection is often easier to navigate.
If a mediator’s established behaviors and conventions reduce maintenance for your team, keep it. Hand-writing every feature would still be maintaining a framework.
The sample also still needs durable storage, authorization, and real transport handling before deployment. Separating the note’s write and read made the rule easier to find. It didn’t finish the entire handoff system.