Skip to content
Shiny.Maui.Shell v6 support for AI routing tools Learn More

Mediator

6 posts with the tag “Mediator”

One Contract, Three Transports — Mediator AI Tooling

What if you could write a single C# record and have it automatically become a fully typed AI tool — with zero adapter code? That’s what Shiny Mediator 6.3 delivers.

Building AI tool calling today means writing repetitive adapter code. You define a JSON schema by hand, parse arguments from the LLM response, validate them, call your business logic, and serialize the result back. If you already have a mediator contract for the same operation, you’re duplicating intent across two representations. Multiply that by every tool your agent needs — ten, twenty, fifty tools — and it becomes a real maintenance problem.

Worse, the schema and the code drift apart. You rename a property in your contract but forget to update the JSON schema. You add a new required parameter but the tool adapter still treats it as optional. The LLM hallucinates a parameter name that used to exist, and your hand-written parser silently swallows the error. These bugs are subtle, hard to test, and only surface at runtime.

In Shiny Mediator, a contract is a plain record that describes an operation:

[Description("Get the current weather forecast for a given city")]
public record GetWeather(
[property: Description("The city name to get weather for")]
string City,
[property: Description("Temperature unit: 'celsius' or 'fahrenheit'")]
string Unit = "celsius"
) : IRequest<WeatherResult>;
public record WeatherResult(string City, double Temperature, string Unit, string Condition);

And a handler implements the logic:

[MediatorSingleton]
public partial class GetWeatherHandler : IRequestHandler<GetWeather, WeatherResult>
{
public async Task<WeatherResult> Handle(
GetWeather request, IMediatorContext context, CancellationToken ct)
{
// your logic here
}
}

That’s the only code you write. From here, source generators take over.

Add a [Description] attribute to your contract and set ShinyMediatorGenerateAITools=true in your project:

<PropertyGroup>
<ShinyMediatorGenerateAITools>true</ShinyMediatorGenerateAITools>
</PropertyGroup>

The source generator produces a fully typed AIFunction subclass compatible with Microsoft.Extensions.AI:

// auto-generated
internal sealed class GetWeatherAIFunction : AIFunction
{
private readonly IMediator _mediator;
private static readonly JsonElement _jsonSchema =
JsonDocument.Parse("""
{
"type": "object",
"properties": {
"city": { "description": "The city name to get weather for", "type": "string" },
"unit": { "description": "Temperature unit", "type": "string", "default": "celsius" }
},
"required": ["city"]
}
""").RootElement.Clone();
public override string Name => "GetWeather";
public override string Description => "Get the current weather forecast for a given city";
public override JsonElement JsonSchema => _jsonSchema;
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments, CancellationToken cancellationToken)
{
var json = JsonSerializer.SerializeToElement(arguments);
var contract = new GetWeather(
City: json.GetProperty("city").GetString()!,
Unit: json.TryGetProperty("unit", out var u) && u.ValueKind != JsonValueKind.Null
? u.GetString()! : "celsius"
);
var (_, result) = await _mediator.Request<WeatherResult>(contract, cancellationToken);
return result;
}
}

A registration extension is also generated:

builder.Services.AddShinyMediator(x => x
.AddMediatorRegistry()
.AddGeneratedAITools() // registers every [Description] contract as an AITool
);

Then pass the tools to any IChatClient:

var tools = services.GetServices<AITool>().ToList();
var options = new ChatOptions { Tools = tools };
var response = await chatClient.GetResponseAsync(history, options);

Because the generated AI tools dispatch through the mediator pipeline, every middleware you’ve already configured applies to AI tool calls automatically. Logging, validation, authorization, exception handling, caching — all of it fires without any extra wiring.

This is a significant advantage over hand-rolled AIFunction implementations. When you write a tool adapter manually, it typically calls your service layer directly, bypassing cross-cutting concerns. With the mediator approach, an AI tool call follows the same pipeline as a UI-triggered action or an API call. Your audit log captures it. Your validation middleware rejects bad input before the handler runs. Your error handling middleware catches exceptions and returns structured errors the LLM can interpret.

You can even write middleware that targets AI calls specifically — for example, injecting a MediatorContext value that tells the handler the call originated from an LLM, so you can apply tighter authorization or rate limiting for AI-initiated operations.

The real power shows when your agent needs many tools. Instead of maintaining dozens of AIFunction subclasses with hand-written schemas, you just add [Description] to your existing contracts. Every contract with a description attribute becomes a tool at the next build.

Adding a new tool to your agent is the same workflow as adding any new mediator operation:

  1. Define the contract record with [Description]
  2. Implement the handler
  3. Done — the tool is registered automatically

No schema files to maintain. No adapter classes to write. No registration code to update. The source generator handles the JSON schema, argument parsing, DI wiring, and AIFunction implementation.

This also means removing a tool is just deleting the [Description] attribute (or the contract itself). There are no orphaned adapters or stale schema definitions to clean up.

Beyond AI: The Same Contract Powers HTTP Too

Section titled “Beyond AI: The Same Contract Powers HTTP Too”

The same contract-first approach extends beyond AI tooling. Shiny Mediator also generates HTTP clients and ASP.NET endpoints from your contracts — meaning a single record and handler can serve as an AI tool, a typed HTTP client, and a REST endpoint simultaneously. The transports are generated; you write the logic once.

Traditional tool-calling setups require you to maintain parallel definitions:

LayerWithout MediatorWith Mediator
Business logicHandler classHandler class
AI tool schemaManual JSON schemaGenerated from contract
AI tool adapterManual AIFunction subclassGenerated
Argument parsingManual deserializationGenerated
DI registrationManual for each toolGenerated
Middleware/validationManual per toolAutomatic via pipeline

With the contract-first approach, adding a new capability to your application — whether it’s exposed as an AI tool, an HTTP endpoint, or both — is one record and one handler.

The generated AIFunction classes are fully Native AOT compatible. Here’s what makes that possible:

No reflection. The generator reads [Description] attributes, property types, nullability, and default values at compile time. It emits direct property access code — json.GetProperty("city").GetString()! — instead of relying on JsonSerializer.Deserialize<T>() or reflection-based binding.

Static JSON schema. The schema is a compile-time constant string parsed once into a JsonElement on first use. There’s no runtime schema construction, no JsonSerializerOptions configuration, and no dynamic type inspection.

Constructor-based hydration. The generated code constructs the contract using its primary constructor with named arguments. No Activator.CreateInstance, no FormatterServices, no property setters via reflection.

Concrete types throughout. Each generated class is a sealed, non-generic concrete type. The DI registrations are explicit AddSingleton<AITool>(sp => new GetWeatherAIFunction(...)) calls — no open generics or service descriptor scanning at runtime.

This means your AI tools work in trimmed, ahead-of-time compiled applications — including .NET MAUI apps targeting iOS and Android — without linker warnings or runtime failures. The same tools that power your cloud API also run on-device in a fully native binary.

The generator handles the full range of C# types in your contracts:

C# TypeJSON SchemaNotes
string, Guid, Uri, DateTime"string"
bool"boolean"
int, long, short, byte"integer"
float, double, decimal"number"
enum"string" with "enum" arrayAll values listed for the LLM
T[], IEnumerable<T>"array"
Nullable types (T?)Omitted from "required"
Default valuesIncluded as "default" in schemaFallback used when LLM omits the parameter

ICommand contracts are also supported — the generated tool returns a success message string instead of a typed result.

  1. Add the [Description] attribute to your contracts and their properties
  2. Set <ShinyMediatorGenerateAITools>true</ShinyMediatorGenerateAITools> in your project file
  3. Reference Microsoft.Extensions.AI
  4. Call .AddGeneratedAITools() during mediator setup
  5. Resolve IEnumerable<AITool> from DI and pass to your chat client

Every contract with a [Description] attribute automatically becomes a tool. Add a new contract, and the next build picks it up — no registration changes, no schema files, no adapter classes.

Check out the Sample.CopilotConsole for a working example that wires up AI tools with a chat loop, or browse the Mediator documentation for the full setup guide.

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!