CQRS with MediatR has a great pitch: every use case is a small message, and validation, logging, caching and transactions live once in a pipeline instead of being copy-pasted into every controller. I like that pitch. I also learned that a pipeline can be wrong in ways that never throw: a transaction that opens for a read, an error that shows up twice, a rule that silently switched itself off.
This post is the list of those quiet failures. It comes from a small order API I built on .NET 10 with MediatR 14.2, FluentValidation 12.2, EF Core 10 on SQLite and HybridCache, with 42 xUnit tests (unit plus WebApplicationFactory integration). Three of the five problems below were caught by those tests or by a quick curl, not by reading the code. The snippets are trimmed to the shape of each fix, and the omitted parts are marked.
Tech Skill Builder is $5/month for a very limited time (normally $10) β grab it now before it goes back to $10. You get 20+ complete, tested .NET + AI projects with full source code and PDF guides, and a new one added every day. π Join Tech Skill Builder
If you'd rather start from a complete solution where CQRS + MediatR with pipeline behaviors already sits inside an enforced Clean Architecture, the .NET 9 Clean Architecture Project + Architecture Tests is $5. Details at the end.
Before trap 1: check the license, not just the API
This isn't a bug, but it surprises people. MediatR 12.5.0 was the last Apache-2.0 release. From 13.0 on it's published by Lucky Penny Software under a dual license (RPL 1.5 or commercial), with a free Community tier for smaller organizations. Without a key it still runs and logs a warning that unlicensed use is for development and testing.
So decide on purpose: get a key, pin 12.5.0, or write your own small dispatcher. Everything below is about the pattern, and the pattern works with any of them. My sample used 14.2.0 and read the key from configuration (every test run used the unlicensed warning mode).
Trap 1: Your queries open transactions too
If commands and queries are both just IRequest<T>, the pipeline can't tell them apart. The "save changes in a transaction" behavior then runs for every read as well. Nothing fails. You just pay for a transaction on every GET, and your intent is invisible.
The fix is a few marker interfaces, then a generic constraint on the behavior:
public interface ICommandBase; // non-generic, so a behavior can say "commands only"
public interface ICommand<out T> : IRequest<T>, ICommandBase;
public interface IQuery<out T> : IRequest<T>;
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext db)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommandBase
{
// begin transaction -> next() -> SaveChanges -> commit
// (join an existing transaction if one is open; omitted here)
}
When MediatR resolves behaviors for a query, the DI container sees the query doesn't satisfy where TRequest : ICommandBase and skips this behavior. Handlers stop calling SaveChanges themselves; the behavior commits once per command.
The test: don't just test handlers, test the pipeline's shape. Resolve the real behaviors for one command and one query and assert the exact list. In my sample the command gets logging, validation, cache invalidation and unit of work; the query gets logging, validation and caching. A second test sends a command whose handler adds an order and then throws, and checks the database has zero orders. That's the rollback, proven instead of assumed.
Trap 2: Every validation error appears twice
The validation behavior you'll find in a lot of posts builds one ValidationContext and runs all validators in parallel with Task.WhenAll. With a single validator that looks fine. My test with two validators failed: one error came back twice.
Two problems hide in that pattern:
- A
ValidationContextcollects failures in a shared list, so each result also carries the other validators' errors. - Running validators in parallel means two of them can hit the same scoped
DbContextat once, which EF Core doesn't allow.
The fix is boring on purpose:
var failures = new List<ValidationFailure>();
foreach (var validator in validators)
{
// a NEW ValidationContext<TRequest> per validator, awaited one at a time
// (accumulate result.Errors into failures; omitted here)
}
if (failures.Count != 0) throw new ValidationException(failures);
The test: register two validators for the same request, both failing, and assert the exact number of errors.
Trap 3: One When() switched off a rule
This one wasn't the pipeline, it was a validator, and the result was the same: an invalid request reached the handler. My first PlaceOrder validator ended the Lines rule chain like this (shape only):
RuleFor(c => c.Lines)
.NotEmpty()
.Must(noDuplicateSkus)
.When(c => c.Lines is { Count: > 0 }); // looks like it guards only Must()
In FluentValidation, a When at the end of a chain applies to every rule in that chain. So NotEmpty() only ran when the list was non-empty, which is never when it matters. An order with zero lines passed validation. I found it with curl, not a test.
The fix: give the duplicate-SKU check its own RuleFor(...) with its own When, then add a regression test: a validator test asserting that an order with no lines fails with a clear message. (Separately, an API-level test checks that a bad order comes back as a 400 with field errors.)
Trap 4: The cache gets cleared before the commit
Reads use a cacheable query (ICacheableQuery with a key and tags) and HybridCache. Writes declare which tags they make stale (IInvalidatesCache). The question is where the invalidation runs.
If it runs inside the transaction, or before next(), a command that later fails has already thrown away good cached data. So the invalidation behavior is registered outside the unit of work, and it only acts after next() returned:
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); // everything
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); // everything
cfg.AddOpenBehavior(typeof(CacheInvalidationBehavior<,>)); // IInvalidatesCache
cfg.AddOpenBehavior(typeof(CachingBehavior<,>)); // ICacheableQuery
cfg.AddOpenBehavior(typeof(UnitOfWorkBehavior<,>)); // commands only
// CacheInvalidationBehavior.Handle (shape):
// var response = await next(ct); // the commit happens in here
// RemoveByTagAsync(request.TagsToInvalidate) only after that; omitted here
Registration order is execution order, outermost first. Read that list as an onion and the rule is visible: invalidation wraps the commit, so it only runs if the commit succeeded.
The test: list orders (now cached), place an order through the pipeline, list again, and expect to see the new order.
Trap 5: A frozen clock makes the cache test flaky
That exact test failed for me with 2 instead of 1. The cause was in the test setup, not the app. HybridCache picked up my fake TimeProvider from DI, and tag invalidation is based on timestamps. With a frozen clock, an entry created in the same tick as an invalidation counts as stale.
The fix in tests is one line between steps: advance the fake clock by a second. In production the clock moves on its own, and the worst case is one extra cache miss. Still, it's worth knowing before you spend an hour blaming your behavior order.
The checklist
- [ ] Did you choose a MediatR version or license on purpose?
- [ ] Are commands and queries different types, with a non-generic command marker?
- [ ] Do "commands only" behaviors use a generic constraint instead of
ifchecks? - [ ] Do handlers leave
SaveChangesto the pipeline? - [ ] Does each validator get its own
ValidationContext, run one at a time? - [ ] Have you checked for a trailing
When()that switches off a whole chain? - [ ] Does cache invalidation run outside the transaction, after the commit?
- [ ] Is there a test that asserts the pipeline's shape for a command and a query?
- [ ] If your tests use a fake clock with
HybridCache, do they advance it?
When not to bother
CQRS with MediatR adds a type per use case. For a CRUD app with five endpoints, that's ceremony. It pays off when the same concerns repeat across dozens of use cases and you want each one in a small file you can find by name.
What I didn't verify
Only SQLite, only the in-memory side of HybridCache (no distributed L2 cache), no real MediatR license key, and no run on MediatR 12.5.0.
Want CQRS inside a complete, enforced architecture?
Make architecture rules fail the build β so your codebase never drifts.
Production-ready solution with CQRS/MediatR, EF Core, DI, Docker, and CIβcomplete architecture test suite to enforce rules and prevent drift.
.NET 9 Clean Architecture Project + Architecture Tests (NetArchTest & ArchUnitNET) is $5 on Gumroad, one-time payment, instant download, from Elite Solutions.
π Get it on Gumroad
Keep building with Tech Skill Builder
Tech Skill Builder is $5/month for a very limited time (normally $10) β grab it now before it goes back to $10. You get 20+ complete, tested .NET + AI projects with full source code and step-by-step PDF guides, and a new one is added every day. π Join Tech Skill Builder
Which of these five has bitten you, or is there a sixth I should add?
Top comments (0)