DEV Community

Cover image for Why I Built Yet Another Mediator (And Why You Might Care)
1 Pouria Dev
1 Pouria Dev

Posted on

Why I Built Yet Another Mediator (And Why You Might Care)

Let me start with a confession: I built AnAspect.Mediator to scratch my own itch. And like most "scratching your own itch" projects, it started with frustration, evolved through experimentation, and ended up solving problems I didn't even know I had.

The Journey: From Performance Hunt to Flexibility Need

About a year and a half ago, I was architecting a modular monolith and obsessing over performance. I'd been using MediatR for years, but I wanted something faster. I discovered Mediator by Martin Othamar - a SourceGenerator-based implementation that promised better performance through compile-time code generation.

Perfect, right? Almost.

I was heavily using internal access modifiers to enforce proper encapsulation between modules. Clean boundaries, proper separation of concerns, the whole nine yards. But the SourceGenerator-based mediator couldn't discover my internal handlers. It silently skipped them.

I could have fought with it, maybe contributed a fix, or changed my architecture. Instead, I did what any developer does when they're already deep in the rabbit hole. At that point, I wasn't trying to build a library - I just wanted my architecture to stop fighting me

The Accidental Year-Long Break

I got it working for my needs and... forgot about it. Nearly a year passed. The code sat there, doing its job in my internal projects, stable but unremarkable.

Then, a few weeks ago, I revisited it with fresh eyes (and Claude Opus as a rubber duck). What started as "let me clean this up" turned into "wait, this actually solves a real problem."

The Real Problem: Pipeline Rigidity

Here's what I realized: MediatR's pipeline is elegant but rigid. Once you configure your behaviors at startup, that's it. Every request goes through the same pipeline, whether it needs to or not.

Real-world scenario: You have logging and validation behaviors. In production, most requests need both. But during a performance-critical batch operation? You might want to skip logging. During development? Maybe you want extra verbose logging for specific requests.

With MediatR, your options are:

  1. Redeploy with different configuration (not practical)
  2. Add conditional logic inside behaviors (messy and defeats the purpose)
  3. Live with the overhead (expensive)

Enter Conditional Behaviors

AnAspect.Mediator introduces runtime pipeline control:

// Skip all pipeline behaviors for performance-critical operations
await _mediator.WithoutPipeline().SendAsync(new ProcessBatchCommand());

// Use specific pipeline group
await _mediator.WithPipelineGroup("admin").SendAsync(command);

// Exclude specific behavior types
await _mediator
    .ExcludeBehavior<ILoggingBehavior>()
    .ExcludeBehavior<ICachingBehavior>()
    .SendAsync(command);

// Skip only global behaviors
await _mediator.SkipGlobalBehaviors().SendAsync(command);
Enter fullscreen mode Exit fullscreen mode

documents...

The behaviors are still there, configured at startup. But now you control which ones run, when they run, for each individual request.

Why Order Matters (And Why It's Explicit)

I made a deliberate choice: behavior order is set via explicit Order property and can't be changed at runtime.

Why? Two reasons:

  1. Runtime order changes aren't that useful. In practice, you rarely need to change execution order dynamically. You either need a behavior or you don't.

  2. Code traceability matters. When debugging, I want to look at my registration code and know exactly what order things execute in. Runtime order manipulation makes the pipeline a black box.

Instead, I focused on making order work seamlessly with groups:

services.AddMediator(cfg =>
{
    // Global behaviors with explicit ordering
    cfg.AddBehavior<ValidationBehavior>(order: 1);
    cfg.AddBehavior<LoggingBehavior>(order: 10, groups: ["monitoring"]);
    cfg.AddBehavior<CachingBehavior>(order: 20, groups: ["monitoring"]);

    // Typed behaviors for specific requests
    cfg.AddBehavior<CreateUserValidation, CreateUserCommand, UserDto>(order: 5);
});
Enter fullscreen mode Exit fullscreen mode

Now you can reason about your pipeline AND control it dynamically without sacrificing clarity.

Performance: The Real Story

Here's where it gets interesting. I didn't build this to beat everyone on benchmarks, but the design choices led to some unexpected wins.

The Scalability Sweet Spot

When you have 100+ handlers in a large application (which is typical in production systems), AnAspect.Mediator shines:

Scenario AnAspect MediatR SourceGenerator
100 handlers, with pipeline 88 ns / 96 B 117 ns / 344 B 109 ns / 160 B
Performance gain Baseline 25% slower 19% slower

The key insight: AnAspect maintains consistent performance as your application grows. More handlers? Same speed.

The Serverless Advantage

For serverless/cloud scenarios where cold starts matter, the story is even better:

Scenario AnAspect SourceGenerator
Cold start (no pipeline) 39,525 ns 56,695 ns
Performance gain 35% faster Baseline

This isn't just about raw speed - it's about predictable, consistent performance that doesn't degrade as your codebase grows.

But Let's Be Honest

In simple scenarios without pipelines, SourceGenerator-based mediators are roughly equivalent in performance. The difference is negligible for basic request/response.

Where AnAspect wins:

  • Complex pipelines with multiple behaviors
  • Large-scale applications (50+ handlers)
  • Serverless/cold start scenarios
  • When you need runtime pipeline control

When SourceGenerator is fine:

  • Simple CQRS without complex pipelines
  • Small to medium applications
  • You don't need conditional behaviors

What's NOT Here (And Why)

No Notifications/Events. I know, MediatR has INotification. I'm leaving it out deliberately. If you need pub/sub, there are better tools for that (MassTransit, Brighter, even plain .NET events). AnAspect.Mediator focuses on request/response with flexible pipelines. One thing, done well.

Current State: Alpha, But Battle-Tested

I'm calling this Alpha because:

  • Documentation needs work
  • I want better test coverage
  • The API might still evolve based on feedback

But it's already running in production-adjacent environments (my internal projects), and it's stable. I'm using it, which means I care about keeping it that way.

What's Next

Here's what I'm working on:

1. Build-time handler validation
Report at registration time when:

  • A request has no handler (probably a bug)
  • A request has multiple handlers (definitely a bug, unless you want notifications)

This will be configurable: Warn, Exception, or None.

services.AddMediator(cfg =>
{
    cfg.HandlerValidation = ValidationMode.Exception; // Fail fast
    cfg.RegisterServicesFromAssembly(typeof(MyHandler).Assembly);
});
Enter fullscreen mode Exit fullscreen mode

2. Telemetry integration
Making it play nice with OpenTelemetry and other observability tools. The conditional behaviors make this interesting - you can see which behaviors ran for each request in your traces.

3. Better documentation
Real examples, migration guides from MediatR, common patterns and anti-patterns.

4. Public roadmap
What I'm building, what I'm considering, what I'm explicitly not doing.

Why Share This Now?

Because I think the conditional behavior pattern is genuinely useful, and I haven't seen it elsewhere. ASP.NET Core's Minimal API recently added the ability to skip middleware (like validation) for specific endpoints. That's the same instinct - sometimes you need fine-grained control.

If you've ever wanted to:

  • Skip expensive behaviors for specific requests
  • Toggle diagnostics without redeploying
  • A/B test different pipeline configurations
  • Optimize performance-critical paths without compromising safety elsewhere

...then this might be worth a look.

Try It, Break It, Tell Me What You Think

I'd genuinely love feedback:

  • What's confusing in the API?
  • What features are missing?
  • What would make this useful for your projects?
  • Where did I make the wrong design choice?

The API is still soft enough to change if there's a better way. Drop a comment, open an issue, or just tell me I'm wrong about something. All feedback is valuable.

And if you're thinking "I wish I could just..." with your current mediator - tell me. Maybe it's something worth adding, or maybe it's deliberately not in scope. Either way, I want to know what real developers actually need.


P.S. Should you migrate from MediatR? Probably not yet, unless you specifically need conditional behaviors or have scalability concerns with large handler counts. Let this mature a bit. But if you're starting fresh or evaluating options? Give it a shot. Worst case, you learn something. Best case, you save yourself some future pain.

Top comments (1)

Collapse
 
goldsteinnick profile image
Nick Goldstein

Great stuff!