Skip to content

Mediator

5 posts with the tag “Mediator”

Shiny Mediator & AOT - Zero Reflection, Full Speed

Native AOT and trimming are no longer “nice to have” — they’re table stakes for modern .NET apps. iOS has never allowed JIT. Blazor WebAssembly ships every byte to the browser. ASP.NET minimal APIs are racing toward sub-10ms cold starts. If your library leans on reflection, you’re the bottleneck.

Shiny Mediator took this personally.

Starting in v5 and fully realized in v6, Shiny Mediator has waged a war on reflection. Every piece of runtime introspection has been replaced with compile-time source generation. The result? A mediator pipeline that is 100% AOT-safe, fully trimmable, and frankly… faster than it has any right to be.

Let’s walk through every source generator and design decision that makes this possible.


1. Handler & Middleware Registration — [MediatorSingleton] / [MediatorScoped]

Section titled “1. Handler & Middleware Registration — [MediatorSingleton] / [MediatorScoped]”

The old way of registering handlers meant scanning assemblies, resolving open generics, and hoping the DI container could figure it all out at runtime. That’s… not AOT-friendly.

Shiny Mediator replaces all of that with two attributes:

[MediatorSingleton]
public class GetUserHandler : IRequestHandler<GetUserRequest, UserResponse>
{
public async Task<UserResponse> Handle(
GetUserRequest request,
IMediatorContext context,
CancellationToken ct)
{
// your handler logic
}
}

At compile time, the source generator discovers every class decorated with [MediatorSingleton] or [MediatorScoped] and emits all the DI registration code for you. No assembly scanning. No typeof() gymnastics. No reflection.

The generated code feeds into a module initializer registry — a static registry that collects every handler and middleware registration across your entire solution. To wire it all up:

services.AddShinyMediator(x => x.AddMediatorRegistry());

One line. Every handler. Every middleware. All generated at compile time.

These attributes also handle middleware registration — so if you have a custom middleware class, slap [MediatorSingleton] on it and it joins the party automatically.


Here’s a fun .NET limitation: you can’t easily resolve a generic type at runtime without knowing the type parameters at compile time. Previous versions of Shiny Mediator used reflection to bridge that gap. It worked, but it was slow and it was the single biggest AOT blocker.

Starting in v5, source generators create typed executors for every request and stream request in your project. These executors know the exact types at compile time, so the mediator can dispatch directly without any MakeGenericType or Activator.CreateInstance shenanigans.

You don’t need to do anything extra — if you’re using [MediatorSingleton] / [MediatorScoped], the executor generation comes along for the ride.


AOT’s nemesis is System.Text.Json with reflection-based serialization. The standard fix is JsonSerializerContext and [JsonSerializable] — but here’s the problem: you can’t chain source generators. Shiny Mediator’s source generator runs first, and System.Text.Json’s source generator can’t see the types that were just generated.

So Shiny Mediator built its own JSON serialization source generator.

When you generate HTTP clients from OpenAPI specs, just flip one switch:

<ItemGroup>
<MediatorHttp Include="MyApi"
Uri="https://api.example.com/openapi.json"
Namespace="MyApp.Api"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>

Setting GenerateJsonConverters="true" tells the source generator to emit high-performance, AOT-safe JSON converters for every contract and response type it produces. No reflection. No JsonSerializerContext registration. The [JsonConverter] attribute is placed directly on each type.

Got your own classes that need serialization — maybe for offline storage, caching, or custom contracts? Use the [SourceGenerateJsonConverter] attribute:

[SourceGenerateJsonConverter]
public partial class WeatherForecast
{
public string? City { get; set; }
public double Temperature { get; set; }
public DateTime Date { get; set; }
}

The class must be partial (the generator needs to attach code to it). That’s it — you get a compile-time JSON converter without ever touching System.Text.Json source generation configuration.


Middleware like caching, offline storage, and stream replay all need a key to identify unique requests. The “old school” way was implementing IContractKey on your contract:

// Before — manual, tedious, error-prone
public class SearchRequest : IRequest<SearchResult>, IContractKey
{
public string? Query { get; set; }
public int? Page { get; set; }
public DateTime? Since { get; set; }
public string GetKey()
{
var key = "SearchRequest";
if (Query != null) key += $"_{Query}";
if (Page != null) key += $"_{Page}";
if (Since != null) key += $"_{Since:yyyyMMdd}";
return key;
}
}

Writing null checks and format strings for every property on every contract gets old fast. And the default IContractKeyProvider used reflection to build keys when you didn’t implement the interface.

The source-generated version:

// After — one attribute, zero reflection
[ContractKey("SearchRequest_{Query}_{Page}_{Since:yyyyMMdd}")]
public partial class SearchRequest : IRequest<SearchResult>
{
public string? Query { get; set; }
public int? Page { get; set; }
public DateTime? Since { get; set; }
}

The source generator handles null checks, ToString() calls, format strings — everything. If a property is null, that portion of the key is replaced with an empty string. The class must be partial, and you can use the same format specifiers you’d use in string interpolation.

Leave the format string blank and it uses all public instance properties automatically.


This one is subtle but critical. Many of Shiny Mediator’s middleware components are driven by attributes on handler methods:

public partial class GetProductHandler : IRequestHandler<GetProductRequest, Product>
{
[Cache(MaxAgeSeconds = 300)]
[OfflineAvailable]
public async Task<Product> Handle(
GetProductRequest request,
IMediatorContext context,
CancellationToken ct)
{
// fetch product from API
}
}

Reading attributes from methods at runtime requires deep reflection — MethodInfo.GetCustomAttributes() and friends. In an AOT world, that can fail silently or crash spectacularly.

Shiny Mediator’s source generator scans handler methods at compile time, extracts every attribute that inherits from MediatorMiddlewareAttribute, and emits code that makes them available via context.GetHandlerAttribute<T>() — no reflection needed at runtime.

Your handler class must be partial for this to work. That’s the one rule.

Best of all, this works with your own attributes too. Just inherit from MediatorMiddlewareAttribute:

public class AuditLogAttribute : MediatorMiddlewareAttribute
{
public string Category { get; set; } = "General";
}
public partial class CreateOrderHandler : IRequestHandler<CreateOrderRequest, Order>
{
[AuditLog(Category = "Orders")]
public async Task<Order> Handle(
CreateOrderRequest request,
IMediatorContext context,
CancellationToken ct)
{
// ...
}
}
// In your middleware — zero reflection
public class AuditLogMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> where TRequest : IRequest<TResult>
{
public async Task<TResult> Process(
IMediatorContext context,
RequestHandlerDelegate<TResult> next,
CancellationToken ct)
{
var attr = context.GetHandlerAttribute<AuditLogAttribute>();
if (attr != null)
{
// log with attr.Category
}
return await next();
}
}

The HTTP extension’s OpenAPI source generator is arguably the crown jewel of Shiny Mediator’s AOT story. From a single OpenAPI spec, it generates:

  • Request contracts with proper HTTP verb, route, query, header, and body annotations
  • Response types matching the API schema
  • A typed request handler that uses HttpClient under the hood
  • JSON converters (when GenerateJsonConverters="true") for every generated type
  • DI registration via .AddGeneratedOpenApiClient()

All from a csproj item:

<ItemGroup>
<MediatorHttp Include="PetStore"
Uri="https://petstore.swagger.io/v2/swagger.json"
Namespace="MyApp.PetStore"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>
services.AddShinyMediator(x =>
{
x.AddMediatorRegistry();
x.AddGeneratedOpenApiClient();
});

Now every HTTP call flows through the mediator pipeline — which means caching, offline, validation, performance logging, and every other middleware you’ve configured automatically applies to your API calls. And it’s all AOT-safe because every type is known at compile time.


On the server side, Shiny Mediator can source-generate minimal API endpoints directly from your handlers:

[MediatorScoped]
public partial class CreateUserHandler : IRequestHandler<CreateUserRequest, UserResponse>
{
[Post("/api/users")]
public async Task<UserResponse> Handle(
CreateUserRequest request,
IMediatorContext context,
CancellationToken ct)
{
// create user
}
}

The source generator emits the app.MapPost("/api/users", ...) call and the DI wiring. No controller classes. No manual endpoint mapping. Just your handler with an HTTP attribute and the generator does the rest.


Here’s what Shiny Mediator source-generates at compile time — and what it doesn’t do at runtime:

FeatureCompile Time (Source Gen)Runtime (Reflection)
Handler DI Registration[MediatorSingleton] / [MediatorScoped]❌ No scanning
Request/Stream Executors✅ Typed dispatch❌ No MakeGenericType
JSON Serialization[SourceGenerateJsonConverter]❌ No reflection-based serializers
Contract Keys[ContractKey]❌ No property reflection
Middleware Attributes[Cache], [OfflineAvailable], custom❌ No GetCustomAttributes
HTTP ClientsMediatorHttp MSBuild item❌ No runtime proxy generation
ASP.NET Endpoints✅ Handler method attributes❌ No controller discovery

The result is a mediator library that:

  • Ships on iOS without fighting the linker
  • Runs in Blazor WASM without bloating the download
  • Cold-starts fast on ASP.NET because there’s nothing to scan or JIT
  • Trims clean because every code path is statically reachable

If you’re already using Shiny Mediator, the path to full AOT is straightforward:

  1. Make handler and contract classes partial — the source generators need this
  2. Add [MediatorSingleton] or [MediatorScoped] to your handlers and middleware
  3. Use [ContractKey] instead of implementing IContractKey manually
  4. Set GenerateJsonConverters="true" on your MediatorHttp items
  5. Use [SourceGenerateJsonConverter] on custom types that need serialization
  6. Call x.AddMediatorRegistry() in your startup instead of manual registration

That’s it. No reflection. No runtime surprises. Just compile-time confidence.


AOT and trimming aren’t just performance checkboxes — they’re the foundation of where .NET is heading. Shiny Mediator has gone all-in on source generation to make sure you can take your mediator pipeline anywhere .NET runs: mobile, browser, server, and beyond.

If reflection was the training wheels, source generation is the carbon fiber frame. Time to ride.

Check out the full source generation docs and the HTTP extension docs for all the details.

What's New in Shiny Mediator 6

Shiny Mediator v6 has landed and it’s packed with some exciting new features and improvements. This release is a big step forward for the library, focusing on better source generation, AOT readiness, ASP.NET interop, and some slick new middleware. Let’s dive in.

The biggest (and breaking) change is that Shiny Mediator v6 now targets .NET 8 and .NET 10 as minimum versions. This allows us to take advantage of the latest runtime features for performance, trimming, and AOT compilation. If you’re still on older versions, you’ll need to stay on v5.

The HTTP client source generation has been completely overhauled. In v5, the OpenAPI generation would produce contracts, but you’d still need to wire up handlers and serialization yourself. In v6, the source generator now produces everything — handlers, contracts, JSON converters, and dependency injection registration.

This is a breaking change because you’ll need to call a new registration method, but the setup through MediatorHttpItem in your csproj remains the same:

<ItemGroup>
<MediatorHttp Include="OpenApiRemote"
Uri="https://yourapi.com/openapi.json"
Namespace="My.Namespace"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>

And then in your startup:

builder.Services.AddShinyMediator(x => x.AddGeneratedOpenApiClient());

That’s it. Full HTTP client with AOT-safe serialization, middleware support, and zero boilerplate.

Another nice improvement: if your OpenAPI spec doesn’t have an OperationId, the source generator now infers a name using the HTTP verb and path. For example, GET /user/list becomes GetUserList. No more missing contracts because someone forgot to set an operation ID.

The DirectHttpRequest type has been removed. You can still create your own HTTP objects with the [Http] attribute which will source generate a handler for you. This was cleaned up to reduce confusion between the direct approach and the more powerful generated approach.

One of the more subtle but impactful additions is PublishToBackground on IMediator. If you’ve ever tried to fire-and-forget an event in ASP.NET, you’ve probably run into scoping issues where the HttpContext or scoped services get disposed before your event handlers finish.

PublishToBackground solves this by spawning a new child scope, so you can fire events without worrying about disposal:

public class MyController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> DoSomething()
{
// this returns immediately and the event handlers
// run in a separate scope
await mediator.PublishToBackground(new SomethingHappenedEvent());
return Ok();
}
}

You can still use the standard Publish to await all event handlers in the same scope.

Async Enumerable Responses & Server-Sent Events

Section titled “Async Enumerable Responses & Server-Sent Events”

Stream requests now support IAsyncEnumerable<T> responses that can be wired directly to Server-Sent Events endpoints. Just mark your contract with IServerSentEventsStream:

[Get("/api/sse")]
public class ServerSentEventsRequest : IStreamRequest<EventItem>, IServerSentEventsStream
{
}

If your endpoint does not support server-sent events, you can still use IStreamRequest<T> — just drop the IServerSentEventsStream interface and you get a standard async enumerable response.

The new [Throttle] attribute implements a debounce pattern for event handlers. This is huge for scenarios like search-as-you-type, sensor data processing, or any rapid-fire event where you only care about the latest value:

services.AddShinyMediator(cfg => cfg.AddMediatorRegistry().AddThrottleEventMiddleware());
[MediatorSingleton]
public partial class SearchHandler : IEventHandler<SearchChangedEvent>
{
[Throttle(300)] // wait 300ms after last event
public async Task Handle(
SearchChangedEvent @event,
IMediatorContext context,
CancellationToken ct)
{
// only fires once the user stops typing for 300ms
var results = await searchService.Search(@event.Query, ct);
}
}

How it works:

  1. When an event is published, a timer starts
  2. If the same event fires again before the timer expires, the timer resets and the previous event is discarded
  3. Only after the full delay with no new events does the handler execute with the latest data

Your handler class must be partial for source generation to work.

Previously, middleware executed in DI registration order. That’s fine for simple scenarios, but as your pipeline grows you need more control. The new [MiddlewareOrder] attribute gives you explicit ordering:

[MiddlewareOrder(-100)] // runs first (outermost)
public class ValidationMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }
[MiddlewareOrder(0)] // default
public class LoggingMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }
[MiddlewareOrder(100)] // runs last (closest to handler)
public class CachingMiddleware<TRequest, TResult>
: IRequestMiddleware<TRequest, TResult> { ... }

This produces: Validation → Logging → Caching → Handler → Caching → Logging → Validation

Lower values run first. If you don’t use the attribute, everything works exactly as before — it’s fully opt-in.

A new built-in middleware that caches HTTP responses based on CacheControl MaxAge headers. If your API sends proper cache headers, Shiny Mediator will respect them automatically without any extra configuration on your part.

Beyond the headline features, v6 includes a steady stream of refinements:

  • EventStream redesignedIMediator.EventStream was dropping events under load. It’s been completely redesigned for speed and reliability.
  • RuntimeEventRegister is now thread-safe — dynamic event handler registration no longer has race conditions.
  • BaseHttpRequestHandler now handles commands — not just requests.
  • OpenAPI source generation fixes — proper URI path generation for PUT/POST, fixed nullable support, TimeSpan handling, and duplicate type issues.
  • Prism RegionNavigationCommand — contributed by codelisk for region-based navigation in Prism.

If you’re coming from v5, here’s the key things to watch for:

  1. Update your target framework to .NET 8 or .NET 10
  2. HTTP client registration — call the new AddGeneratedOpenApiClient() method
  3. Remove DirectHttpRequest usages — switch to [Http] attribute-based contracts
  4. OpenAPI contracts may have slightly different names due to the automatic HttpRequest postfix

Shiny Mediator v6 continues the push toward zero-boilerplate, AOT-ready mediation for .NET. The revamped HTTP source generation alone is worth the upgrade — and features like event throttling and middleware ordering bring capabilities that would take significant effort to build yourself.

Check out the full documentation and the complete release notes for all the details.

Shiny Mediator - Getting Started

Mediator patterns have been getting a lot of attention lately, and for good reason. They help to decouple components in an application, making it easier to manage complexity and improve maintainability. Mediator patterns also go by the names “Vertical slice architecture” and CQRS (Command Query Responsibility Segregation). The big guy in .NET mediation is obviously MediatR by Jimmy Bogard. It’s an amazing library that has been used by an absolute ton of applications. It recently went to a paid model, which is understandable given the amount of work that goes into maintaining a library of that size.

The reason for building Shiny Mediator was to create a mediation library, but make sure it works with apps built on platforms like .NET MAUI and Blazor WebAssembly while also including some more “batteries included” features. This doesn’t mean I’ve neglected things like ASPNET support, but we’ll get to that in a future article.

Many engineers will call mediators “over engineering” or “an anti-pattern”. I disagree. In fact, I think mediators are one of the best patterns for building complex applications without going full bat stuff crazy with microservices out of the gate. You can run in a monolith while still keeping things decoupled and manageable thereby making it easy to “SLICE” a piece out and move it to a microservice as your application traffic grows. Another advantage of a mediation pattern is can remove the “dependency injection hell” of adding a services for things like logging, caching, and other cross cutting concerns. Instead of having to add these services to every handler, you can just add them to the mediator pipeline, configure them or stick an attribute on a handler.

public class MyViewModel(IDataService data, ICacheManager cache, IConfiguration configuration, ILogger<MyViewModel> logger) {
public async Task LoadData() {
this.Data = cache.TryGet("MyKey", ct => await data.GetData());
}
}

The above code doesn’t look too bad, but

  • Imagine you have 20 view models that all need these services.
  • How do I make sure I’m grabbing from the same cache everytime or clearing it for that matter?
  • Building cache keys can be a pain
[ContractKey("{UserId}-{IsActive}")]
public record GetDataRequest(bool IsActive, string UserId) : IRequest<GetDataResponse>;
public class MyViewModel(IMediator mediator) {
[Cache(300)]
public async Task LoadData() {
var response = await mediator.Send(new GetDataRequest());
this.Data = response.Result;
}
}

Homegrown/built in mediation patterns are often added, but require you do the growing. Middleware management is also not a simple process. Creating keys for caching requests, another whamo of complexity. These are all things solved by Shiny Mediator. AI generated code solutions is another one I hear. “Just use AI to generate the code for you”. AI is great at boilerplate or when you give it a very specific input & output, but architecture… it will throw “slop” at you.

As with all patterns, there are some trade offs. Mediators map objects to handlers (methods or controllers). It is currently not tool friendly to find a corresponding handler for a contract, so you want to make sure you structure your solution and projects well to find handlers without too much effort. Another problem that can occur is middleware executes in a pipeline, so if you have a lot of middleware, you can end up with performance issues if you’re not careful about managing the middleware to ensure quick execution.

I’ve seen some large .NET MAUI applications that had so many engineers working on them, there was constant “fire drills” (the running joke that came to be). Team A would change something that would break Team B’s work. Team C would have to make changes to Team A’s code to update navigation or data retrieval.
Quite often, these breaking changes wouldn’t even be picked up until a regression test.

In this post, we’ll explore how to get started with Shiny Mediator, a library that implements the Mediator pattern with a focus on apps written with .NET.

What are some of the “batteries included”?

Section titled “What are some of the “batteries included”?”

Most mediator libraries hand you a pipe and say “good luck”. Shiny Mediator hands you a pipe, a toolbox, a hard hat, and a coffee. Here’s what’s in the box:

Your users don’t always have internet. Shocking, I know. But building offline support from scratch is the kind of soul-crushing work that makes devs question their career choices. With Shiny Mediator, slap an attribute on your handler and you’re done:

public partial class GetOrdersHandler : IRequestHandler<GetOrdersRequest, IReadOnlyList<Order>>
{
[OfflineAvailable]
public async Task<IReadOnlyList<Order>> Handle(
GetOrdersRequest request,
IMediatorContext context,
CancellationToken ct)
{
// When online: calls your API, stores the result
// When offline: returns the last stored result
// You did nothing. You're welcome.
return await api.GetOrders(ct);
}
}

You can also configure it via appsettings.json if attributes aren’t your thing:

{
"Mediator": {
"Offline": {
"MyNamespace.GetOrdersRequest": true
}
}
}

Want to know if the data came from the offline store? The context tells you:

var response = await mediator.Request(new GetOrdersRequest());
var offline = response.Context.Offline();
if (offline != null)
{
// data is from offline store
// offline.Timestamp tells you WHEN it was stored
// maybe show a "stale data" indicator to the user
}

Caching is one of the two hardest problems in computer science (the other being naming things and off-by-one errors). Shiny Mediator makes it embarrassingly easy:

public partial class GetProductsHandler : IRequestHandler<GetProductsRequest, List<Product>>
{
[Cache(AbsoluteExpirationSeconds = 300, SlidingExpirationSeconds = 60)]
public async Task<List<Product>> Handle(
GetProductsRequest request,
IMediatorContext context,
CancellationToken ct)
{
// This only runs when cache misses.
// No cache key management. No IMemoryCache injection. No tears.
return await db.GetProducts(ct);
}
}

Setup is one line:

services.AddShinyMediator(x => x.AddMemoryCaching());

Need a cache that survives app restarts? The MAUI and Uno extensions have a persistent cache that writes to disk. Same attribute, same config — it just doesn’t evaporate when the user kills your app:

services.AddShinyMediator(x => x.AddMauiPersistentCache());

You can also force a cache refresh when you need fresh data (pull-to-refresh, anyone?):

var response = await mediator.Request(
new GetProductsRequest(),
CancellationToken.None,
ctx => ctx.ForceCacheRefresh()
);
// response.Result is guaranteed fresh

Validation: where developers go to argue about whether to throw exceptions or return error objects. We support both. Pick your poison.

Data Annotations (built-in, no extra package):

services.AddShinyMediator(cfg => cfg.AddDataAnnotations());
[Validate]
public class CreateUserCommand : ICommand
{
[Required]
public string Name { get; set; }
[Range(1, 150)] // optimistic about human lifespans
public int Age { get; set; }
}

FluentValidation (for the overachievers):

services.AddShinyMediator(cfg => cfg.AddFluentValidation());
[Validate]
public class CreateUserCommand : ICommand
{
public string? Name { get; set; }
}
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("A user needs a name. Even 'Bob' will do.");
}
}

Validation runs before your handler ever sees the request. Invalid data never touches your business logic. It’s like a bouncer for your code.

Want to know which handler is being a lazy bum? Performance logging middleware tracks execution time through built-in diagnostics via Microsoft.Extensions.Diagnostics. No extra setup, no third-party APM required. Your handlers are already emitting telemetry — you just need to listen.

This one deserves its own blog post (coming soon), but the short version: point Shiny Mediator at your OpenAPI spec and it generates contracts, handlers, JSON converters, and DI registration. No HttpClientFactory plumbing. No System.Text.Json serialization contexts. No Polly setup. Just:

<ItemGroup>
<MediatorHttp Include="MyApi"
Uri="https://myapi.com/openapi.json"
Namespace="MyApp.Api"
ContractPostfix="HttpRequest"
GenerateJsonConverters="true"
Visible="false" />
</ItemGroup>
services.AddShinyMediator(x => x.AddGeneratedOpenApiClient());
// Now just use it like any other mediator request
var users = await mediator.Request(new GetUsersHttpRequest());

All your middleware (caching, offline, validation, resilience) works with HTTP requests too. One attribute to cache API calls. One attribute for offline fallback. Your API client just became the most resilient thing in your entire codebase — and you wrote zero infrastructure code.

Enough talk. Let’s build something. I’ll walk you through a .NET MAUI setup since that’s where Shiny Mediator really flexes, but this works just as well with Blazor, ASP.NET, or plain old console apps (we don’t judge).

Terminal window
dotnet add package Shiny.Mediator
dotnet add package Shiny.Mediator.Maui

For Blazor, swap Shiny.Mediator.Maui for Shiny.Mediator.Blazor. For ASP.NET or console apps, just Shiny.Mediator on its own is fine.

Contracts are just plain C# records or classes that implement one of the mediator interfaces. Think of them as the “what” — what data goes in, what data comes out.

using Shiny.Mediator;
// A command - fire and forget, no return value
public record CreateTodoCommand(string Title, string Description) : ICommand;
// A request - send something in, get something back
public record GetTodosRequest(bool IncludeCompleted) : IRequest<List<TodoItem>>;
// An event - broadcast to anyone who's listening
public record TodoCreatedEvent(TodoItem Item) : IEvent;

Handlers are where the actual work happens. One handler per command/request, but you can have multiple event handlers.

using Shiny.Mediator;
[MediatorSingleton] // source generator handles DI registration for you
public class CreateTodoHandler : ICommandHandler<CreateTodoCommand>
{
readonly IMyDatabase db;
readonly IMediator mediator;
public CreateTodoHandler(IMyDatabase db, IMediator mediator)
{
this.db = db;
this.mediator = mediator;
}
public async Task Handle(
CreateTodoCommand command,
IMediatorContext context,
CancellationToken ct)
{
var item = new TodoItem(command.Title, command.Description);
await db.Insert(item, ct);
// broadcast that a todo was created
await mediator.Publish(new TodoCreatedEvent(item));
}
}
[MediatorSingleton]
public partial class GetTodosHandler : IRequestHandler<GetTodosRequest, List<TodoItem>>
{
readonly IMyDatabase db;
public GetTodosHandler(IMyDatabase db) => this.db = db;
[Cache(AbsoluteExpirationSeconds = 120)]
[OfflineAvailable]
public async Task<List<TodoItem>> Handle(
GetTodosRequest request,
IMediatorContext context,
CancellationToken ct)
{
// cached for 2 minutes AND available offline
// two attributes, zero infrastructure code, infinite smugness
return await db.GetTodos(request.IncludeCompleted, ct);
}
}
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp
.CreateBuilder()
.UseMauiApp<App>();
builder.Services.AddShinyMediator(x =>
{
x.UseMaui();
x.AddMemoryCaching();
x.AddDataAnnotations();
});
// let the source generator register everything
builder.Services.AddMediatorRegistry();
return builder.Build();
}
}
public class TodoListViewModel : BaseViewModel, IEventHandler<TodoCreatedEvent>
{
readonly IMediator mediator;
public TodoListViewModel(IMediator mediator)
{
this.mediator = mediator;
}
public async Task LoadTodos()
{
// one line. cached. offline-available. validated.
var response = await mediator.Request(new GetTodosRequest(IncludeCompleted: false));
this.Todos = response.Result;
}
public async Task CreateTodo(string title, string description)
{
await mediator.Send(new CreateTodoCommand(title, description));
// don't reload manually - the event handler below will fire
}
// this fires automatically when TodoCreatedEvent is published
// no subscription. no unsubscription. no memory leaks. no drama.
public async Task Handle(
TodoCreatedEvent @event,
IMediatorContext context,
CancellationToken ct)
{
await LoadTodos();
}
}

Notice that the ViewModel implements IEventHandler<TodoCreatedEvent> directly. With the MAUI extension, your ViewModels and Pages automatically participate in event broadcasting without being registered in DI. When the page is popped from navigation, it stops receiving events. No MessagingCenter.Unsubscribe nightmares. No WeakReferenceMessenger gymnastics. It just works.

Shiny Mediator has come a long way from its v1 days. We’re now on v6 with full AOT and trimming support baked into the source generators. So what’s next?

  • Deeper ASP.NET integration — we’ve already got minimal API endpoint generation from handlers, server-sent events from stream requests, and HTTP response caching middleware. Expect more first-class server scenarios.
  • Event throttling & middleware ordering — both landed in v6. [Throttle(300)] on your event handlers gives you debounce for free (goodbye, search-as-you-type headaches). [MiddlewareOrder] gives you explicit pipeline control.
  • Better tooling — finding the handler for a contract is still a navigation exercise. We’re exploring IDE integrations to make this seamless.
  • More source generation — the goal is zero reflection, zero runtime surprises. If a handler is missing or misconfigured, you should know at compile time, not when your app blows up in production at 2 AM on a Saturday.
  • Community contributions — shout out to codelisk for Prism region navigation and JeremyBP for MAUI modal stack iteration. Keep ‘em coming.

Shiny Mediator isn’t trying to replace MediatR. It’s trying to be the mediator that gives app developers — especially those on MAUI, Blazor, and Uno — a batteries-included experience that “just works” out of the box. Caching, offline, validation, HTTP clients, event broadcasting, source generated registration… all of it designed so you can focus on your actual business logic instead of plumbing.

Give it a spin. Check out the full docs, play with the sample apps, and come yell at me on BlueSky if something doesn’t work.

Happy coding. May your caches always be warm and your pipelines always be clean.

Source generation and full blown AOT. AOT is becoming such an important part of .NET, especially with .NET MAUI and Blazor. We’re working hard on a version 5 release that will be fully AOT compliant out of the box!

Mediator v2.0

Only a little over 3 months, I released v1.0 of Mediator. Since then, I’ve been adding features as fast as they come to mind. The focus on making the Mediator pattern upfront for apps (in my opinion) has been a huge success. I’ve been using it in my MAUI apps for offline, caching, & resiliency middleware. The amount of time it saves me is pretty crazy.

Since version the v1 release, I’ve been adding a ton of features including an ASP.NET extension for HTTP endpoints straight to Mediator request handlers. Quite recently - I found a lot of mediated calls would end up just wrapping HTTP calls done with Refit or Kiota. I decided to add my own source generator to deal with this. Check out the HTTP Extension for more on this.

So what does 2.0 bring that requires a major version bump? The first major feature is that I’ve brought the offline, stream replay, and user error notification middleware to Blazor webassembly. This middleware was already present in MAUI, but Blazor needed a “connectivity” & “storage” service to match MAUI.

The second major feature is that I’ve been moving a lot of the middleware to be configured via Microsoft.Extensions.Configuration. This allows you to configure middleware and handlers in a global way and without polluting your code with attributes everywhere. Below is an example of all configuration we offer now:

{
"Mediator": {
"Http": {
"My.Namespace.Contract": "https://shinylib.net/newbase",
"My.Namespace.*" : "https://shinylib.net/therestofhtenamespace",
"*": "https://shinylib.net/everythingelse"
},
"Performance": {
"*": {
"ErrorThresholdMilliseconds": 5000
}
},
"Offline": {
"Sample.Handlers.OfflineRequestHandler": {
"AvailableAcrossSessions": true
}
},
"ReplayStream": {
"Sample.Handlers.MyRequest": {
"AvailableAcrossSessions": true
}
},
"TimerRefresh": {
"My.Contacts.HttpData": {
"IntervalSeconds": 10
}
},
"Resilience": {
"My.Namespace.ResilientContract": {
"RetryCount": 3,
"RetryDelay": 500
}
},
"Cache": {
"My.Contacts.*": {
"Priority": "High",
"AbsoluteExpirationSeconds": 300,
"SlidingExpirationSeconds": 60
}
},
"UserErrorNotifications": {
"*": {
"*": {
"Title": "ERROR",
"Message" : "Failed to do something"
},
"fr-CA": {
"Title": "ERREUR",
"Message" : "Échec de faire quelque chose"
}
}
}
}
}

So let’s unpack this. The first thing to notice is that we have ”*” to “glob” a namespace or ALL calls. If you want to attack a specific contract, just fully label it. We will find the nearest namespace to your contract before giving up as “not enabled” for a feature to be disabled on a contract.

I’m pretty happy with this release, but I still have a ton of ideas. All the stuff you can do around the handlers with middleware is really exciting. Have an idea for the mediator, head on over to GitHub and add a feature request. I’m always looking for new ideas.

Mediator v1.0

Shiny Mediator is something new I’ve been working on. I love Jimmy Bogard’s MediatR library on the server, but I just couldn’t get it to fit the way I wanted for Apps… especially Blazor & .NET MAUI apps.

What is a mediator? It’s a small in-process version of a message bus. MAUI has MessagingCenter, the Community toolkit has the weak message center, and Prism offers an event aggregator. They’re all great, but they lack in areas that I want “more”.

  • If an event errors, the whole chain dies in the publish
  • Events are fired in a foreach (mostly)
  • You have to tie into them and unsubscribe from them or you can leak memory
  • They don’t provide the concept of command or request/response models (a command can only be responded to by a single handler)
  • They don’t provide any sort of middleware (pre & post handling)

Sure - they aren’t geared for these things, but the question is “what is”?

Some might say “this is overengineering” or “too complex”. I would counter that comment by saying that this can actually simplify your architecture overall by removing a lot of complex plumbing around services, references, and navigation.

Let’s go over some of the problems that we use mediator to solve within our apps

We believe that Shiny Mediator is the answer to these problems. It’s a simple, yet powerful library that allows you to create a mediator in your app

Does this look familiar to you?

public class MyViewModel(
IConnectivity conn,
IDataService data,
IAuthService auth,
IDialogsService dialogs,
ILogger<MyViewModel> logger
) {
// ...
try {
if (conn.IsConnected)
{
var myData = await data.GetDataRequest();
}
else {
dialogs.Show("No Connection");
// cache?
}
}
catch (Exception ex) {
dialogs.Show(ex.Message);
logger.LogError(ex);
}
}

With a bit of our middleware and some events, you can get here:

public class MyViewModel(IMediator mediator) : IEventHandler<ConnectivityChangedEvent>, IEventHandler<AuthChangedEvent> {
// ...
var myData = await mediator.Request(new GetDataRequest());
// logging, exception handling, offline caching can all be bundle into one nice clean call without the need for coupling
}

Problem #2 - Messages EVERYWHERE (+ Leaks)

Section titled “Problem #2 - Messages EVERYWHERE (+ Leaks)”

Do you use the MessagingCenter in Xamarin.Forms? It’s a great tool, but it can lead to some memory leaks if you’re not careful. It also doesn’t have a pipeline, so any errors in any of the responders will crash the entire chain. It doesn’t have a request/response style setup (not that it was meant for it), but this means you still require other services.

public class MyViewModel
{
public MyViewModel()
{
MessagingCenter.Subscribe<SomeEvent1>(this, @event => {
// do something
});
MessagingCenter.Subscribe<SomeEvent2>(this, @event => {
// do something
});
MessagingCenter.Send(new SomeEvent1());
MessagingCenter.Send(new SomeEvent2());
// and don't forget to unsubscribe
MessagingCenter.Unsubscribe<SomeEvent1>(this);
MessagingCenter.Unsubscribe<SomeEvent2>(this);
}
}

Let’s take a look at our mediator in action for this scenarios

public class MyViewModel : IEventHandler<SomeEvent1>, IEventHandler<SomeEvent2>
{
public MyViewModel(IMediator mediator)
{
// no need to unsubscribe
mediator.Publish(new SomeEvent1());
mediator.Publish(new SomeEvent2());
}
}

Problem #3 - Strongly Typed Navigation with Strongly Typed Arguments

Section titled “Problem #3 - Strongly Typed Navigation with Strongly Typed Arguments”

Our amazing friends over in Prism offer the “best in class” MVVM framework. We’ll them upsell you beyond that, but one of their amazing features is ‘Modules’. Modules help break up your navigation registration, services, etc.

What they don’t solve is providing a strongly typed nature for this stuff (not their job though). We think we can help addon to their beautiful solution.

A normal call to a navigation service might look like this:

_navigationService.NavigateAsync("MyPage", new NavigationParameters { { "MyArg", "MyValue" } });

This is great. It works, but I don’t know the type OR argument requirements of “MyPage” without going to look it up. In a small project with a small dev team, this is fine. In a large project with a large dev team, this can be difficult.

Through our Shiny.Framework library we offer a GlobalNavigationService that can be used to navigate to any page in your app from anywhere, however, for the nature of this example, we’ll pass our navigation service FROM our viewmodel through the mediator request to ensure proper scope.

public record MyPageNavigatonRequest(INavigationService navigator, string MyArg) : IRequest;
public class MyPageNavigationHandler : IRequestHandler<MyPageNavigatonRequest>
{
public async Task Handle(MyPageNavigatonRequest request, CancellationToken cancellationToken)
{
await request.navigator.NavigateAsync("MyPage", new NavigationParameters { { "MyArg", request.MyArg } });
}
}

Now, in your viewmodel, you can do this:

public class MyViewModel
{
public MyViewModel(IMediator mediator)
{
mediator.Request(new MyPageNavigatonRequest(_navigationService, "MyValue"));
}
}

Strongly typed. No page required page knowledge from the module upfront. The other dev team of the module can define HOW things work.

Lastly, we offer some absolutely epic middleware that you can use to do things like logging, caching, error handling, etc.
Check out our middleware documentation for more information.

Check out our overall documentation for more information on how to use the mediator in your app. We think you’ll love it!