Cerise 1.0.0

dotnet add package Cerise --version 1.0.0
                    
NuGet\Install-Package Cerise -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Cerise" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cerise" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Cerise" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Cerise --version 1.0.0
                    
#r "nuget: Cerise, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Cerise@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Cerise&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Cerise&version=1.0.0
                    
Install as a Cake Tool

Cerise

NuGet License: MIT CI

A free, MIT-licensed mediator for .NET — shaped like MediatR 11, so moving off MediatR is a find-and-replace rather than a rewrite.

If you landed here because MediatR now requires a paid licence and you need somewhere to go: that is exactly why this exists, and migration is usually a single afternoon.

using Cerise;

services.AddCerise(typeof(Program).Assembly);

var user = await sender.Send(new GetUserQuery(id), cancellationToken);

At a glance

Licence MIT — free for commercial use, permanently, no seat count or revenue threshold
Dependencies One: Microsoft.Extensions.DependencyInjection.Abstractions
Size ~790 lines of code, still small enough to read end to end
Target frameworks .NET 8, 9, 10
Migration from MediatR 11 Change the using, change AddMediatR → AddCerise. Handlers and behaviors are untouched.
Performance Level with MediatR 12 on a plain send; 14–36% ahead once behaviors, notifications or streams are involved — numbers below
Maintained by CherryPeak, a software studio that uses it in its own products

Why this exists

MediatR became commercially licensed from v13. Plenty of teams are pinned to the last free version, which works but will never receive another fix.

The obvious move is to swap in another mediator package — but that just relocates the same bet to a different maintainer, who may make the same decision later. So instead we counted what a large production ASP.NET Core codebase actually used from MediatR — and then what everyone else uses too. It comes to requests, commands, notifications, streams, and behaviors around all of them.

MediatR exposes 47 public types to provide that. Seventeen of them are pre/post processors and exception handlers, which in MediatR are themselves IPipelineBehavior implementations — sugar over the primitive, not capability. Another eight are its own internals, made public.

So Cerise is the capability without the surface: everything above, owned outright, given away under MIT.

Is Cerise right for you?

We would rather you pick correctly than pick us.

Cerise fits if you used MediatR for:

  • Sending a request and getting a response back
  • Commands that return nothing
  • Domain events — Publish / INotification, with any number of handlers
  • Streamed sequences — IStreamRequest returning IAsyncEnumerable<T>
  • Cross-cutting pipeline behaviors around any of the above
  • Pre- and post-processors, exception handlers and exception actions
  • Synchronous handler base classes
  • Injecting IMediator, ISender or IPublisher

That is everything most codebases use MediatR for, and it is a same-day migration.

Look elsewhere if you need:

You need Cerise Where to look
Configurable publish strategies (parallel, aggregate errors) ✗ not implemented MediatR 12+
A message bus, queues, retries, sagas ✗ out of scope MassTransit, Rebus, Wolverine
Source-generated dispatch, zero reflection ✗ Wolverine, Mediator (by martinothamar)

If a real need appears, open an issue — we would rather add something because someone needs it than because a competitor has it.

Install

dotnet add package Cerise

Use

using Cerise;

// A request and its handler
public record GetUserQuery(Guid Id) : IRequest<UserResponse>;

public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserResponse>
{
    public Task<UserResponse> Handle(GetUserQuery request, CancellationToken cancellationToken)
        => /* ... */;
}
// Registration. AddCerise scans the given assemblies for IRequestHandler implementations
// (registered transient) and registers ISender. Behaviors are ordinary open generics you
// register yourself, in the order you want them to run.
services.AddCerise(typeof(GetUserQueryHandler).Assembly);
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
// Sending
var user = await sender.Send(new GetUserQuery(id), cancellationToken);

Cerise has no opinion about result types — wrap the response in your own Result<T> or ErrorOr<T> if you use one, and it passes through untouched.

Commands with no response

public record DeactivateUser(Guid Id) : IRequest;   // no type argument

public class DeactivateUserHandler : AsyncRequestHandler<DeactivateUser>
{
    protected override Task Handle(DeactivateUser request, CancellationToken cancellationToken)
        => /* ... */;   // a plain Task — nothing to return
}

IRequest is shorthand for IRequest<Unit>, so a void command travels the same path as any other and behaviors wrap it identically. Implement IRequestHandler<DeactivateUser> directly if you would rather return Unit.Value yourself.

Behaviors

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken
    )
    {
        // before
        var response = await next();
        // after
        return response;
    }
}

Behaviors run in registration order, first registered outermost — the first one registered sees the request first and the response last. That ordering is pinned by a test; if you change the fold in Mediator, SenderTests is what breaks.

Notifications

A notification is a fact that happened. Any number of handlers may react; the publisher learns nothing back and does not know who is listening.

public record InvoiceApproved(Guid InvoiceId) : INotification;

public class SendApprovalEmail : INotificationHandler<InvoiceApproved>
{
    public Task Handle(InvoiceApproved n, CancellationToken ct) => /* ... */;
}

public class WriteAuditEntry : INotificationHandler<InvoiceApproved>
{
    public Task Handle(InvoiceApproved n, CancellationToken ct) => /* ... */;
}
await publisher.Publish(new InvoiceApproved(invoice.Id), cancellationToken);

The value is that adding a third reaction later means adding a class, not editing the code that approved the invoice — which is how a command handler avoids slowly accumulating every side effect anyone ever wanted.

Handlers run one at a time, in registration order, and a throw stops the rest — matching MediatR, so code moving across behaves identically. Catch inside a handler if it should not be able to stop the others. Publishing something nobody handles does nothing; unlike a request, that is not an error.

Streams

A stream request answers with a sequence produced as it is consumed, rather than a single value.

public record StreamCompletion(string Prompt) : IStreamRequest<string>;

public class StreamCompletionHandler : IStreamRequestHandler<StreamCompletion, string>
{
    public async IAsyncEnumerable<string> Handle(
        StreamCompletion request,
        [EnumeratorCancellation] CancellationToken ct)
    {
        await foreach (var token in _model.StreamAsync(request.Prompt, ct))
            yield return token;
    }
}
await foreach (var token in sender.CreateStream(new StreamCompletion(prompt), ct))
    await response.WriteAsync(token, ct);

Nothing runs until you enumerate. The handler and its behaviors are resolved when CreateStream is called, though, so enumerate inside the scope you called it on. IStreamPipelineBehavior<,> wraps a stream the way IPipelineBehavior<,> wraps a request — anything it measures after enumerating covers the whole sequence, not the call that started it.

Not for sending files. The items are objects, not bytes. To return a file, write to the response stream — FileStreamResult, or copying into Response.Body. Putting a download through a stream request chops the file into objects and pays for a dispatch per chunk, for no benefit over a direct pipe. Use this for results: a query too large to materialise, or tokens arriving from a model.

Processors and exception handling

using Cerise.Pipeline;

public class LogBefore : IRequestPreProcessor<GetUserQuery>
{
    public Task Process(GetUserQuery request, CancellationToken ct) => /* ... */;
}

public class AuditAfter : IRequestPostProcessor<GetUserQuery, UserResponse>
{
    public Task Process(GetUserQuery request, UserResponse response, CancellationToken ct) => /* ... */;
}

// May turn a failure into a response
public class HandleTimeout : IRequestExceptionHandler<GetUserQuery, UserResponse, TimeoutException>
{
    public Task Handle(GetUserQuery request, TimeoutException ex,
        RequestExceptionHandlerState<UserResponse> state, CancellationToken ct)
    {
        state.SetHandled(UserResponse.Unavailable);   // exception stops here
        return Task.CompletedTask;
    }
}

// Only observes — the failure carries on regardless
public class RecordFailure : IRequestExceptionAction<GetUserQuery>
{
    public Task Execute(GetUserQuery request, Exception ex, CancellationToken ct) => /* ... */;
}

AddCerise registers the behaviors these need only when it finds an implementation, so an application using none of them pays nothing — no extra try/catch frames or enumerations in front of every request.

Ordering is fixed and deliberate: exception handlers outermost, then exception actions, then pre-processors, then post-processors, then whatever you register yourself. That means an action always observes a failure even when a handler afterwards turns it into a response, and an unhandled exception is rethrown with its original stack trace rather than one starting inside the pipeline.

A handler for a specific exception type is offered it before a handler for a base type, and the first to call SetHandled ends the search.

Migrating from MediatR

For the request/response subset, the types are the same shape:

MediatR 11 Cerise
using MediatR; using Cerise;
services.AddMediatR(assembly) services.AddCerise(assembly)
IRequest<TResponse> same
IRequestHandler<TRequest, TResponse> same
IPipelineBehavior<TRequest, TResponse> same
RequestHandlerDelegate<TResponse> same — next() takes no arguments, as in MediatR 11
Unit / Unit.Value same
ISender.Send same
IRequest (no response) same
IRequestHandler<TRequest> same
AsyncRequestHandler<TRequest> same
IMediator / IPublisher same
INotification / INotificationHandler<T> same
IPublisher.Publish same — sequential, stops on first exception
IStreamRequest<T> / IStreamRequestHandler<,> same
IStreamPipelineBehavior<,> / StreamHandlerDelegate<T> same
MediatR.Pipeline namespace Cerise.Pipeline
IRequestPreProcessor<T> / IRequestPostProcessor<,> same
IRequestExceptionHandler<,,> / <,> same
IRequestExceptionAction<,> / <> same
RequestExceptionHandler / AsyncRequestExceptionHandler same
RequestExceptionAction / AsyncRequestExceptionAction same
RequestExceptionHandlerState<T> same
RequestHandler<TRequest> / RequestHandler<TRequest,TResponse> (synchronous) same
AsyncRequestHandler<T> / NotificationHandler<T> same

In practice:

- using MediatR;
+ using Cerise;

- services.AddMediatR(typeof(Startup).Assembly);
+ services.AddCerise(typeof(Startup).Assembly);

Behavior registrations do not change — they were always ordinary open generics on IServiceCollection.

Coming from MediatR 12? Two differences. RequestHandlerDelegate gained a CancellationToken parameter in v12, so behaviors written against v12 need next() rather than next(cancellationToken). And v12 removed the exception-handling base classes; Cerise keeps them, so code written for either version compiles.

A deliberate difference from MediatR 11: its AsyncRequestExceptionAction<TRequest> is constrained to IRequest, so it cannot be used for a request that returns a value — which is inconsistent with its own interfaces and sync base classes, and which MediatR 12 dropped. Cerise has no such constraint. Anything valid in MediatR compiles here; the reverse is not guaranteed.

What you get

  • One handler per request. A missing handler throws an exception naming the request type, rather than failing somewhere less obvious later.
  • Your scope, not ours. Handlers resolve from the scope ISender was resolved from, so scoped dependencies — a DbContext above all — behave exactly as they do everywhere else.
  • Reflection once per request type, not once per send. The generic wrapper that bridges Send's compile-time-unknown concrete type is cached for the process lifetime.
  • Sources embedded in the package. Step straight into it in your debugger, no symbol server.

Performance

Run them yourself — the benchmark is in this repository:

dotnet run -c Release --project benchmarks/Cerise.Benchmarks

BenchmarkDotNet, Apple M3 Pro, .NET 10, every dispatch resolved through a DI scope because that is how a web request actually gets one. Baseline is MediatR 12, the last major before the licence changed and so the version most teams would be moving from.

Path MediatR 12 Cerise
Send 88 ns · 288 B 90 ns · 280 B level
Send through 3 behaviors 168 ns · 816 B 145 ns · 760 B 14% faster
Publish to 2 handlers 134 ns · 600 B 90 ns · 272 B 33% faster, 55% less allocated
Stream 10 items 298 ns · 736 B 191 ns · 464 B 36% faster, 37% less allocated

A plain send is a tie. Two runs put Cerise 3% either side of MediatR 12, which means the difference is noise and neither is faster. We are not going to dress that up.

The gap opens where there is more to do — behaviors to fold, handlers to enumerate, a sequence to carry — because there is less machinery in the way. Against MediatR 11 the same send costs 289 ns and 1208 B, so a team still pinned there gains roughly 3.5× and a quarter of the allocations; that figure comes from a separate run against 11.1.0, since the benchmark here targets 12.

And the caveat that outweighs all of it: this is dispatch overhead in isolation. A request that spends 20 ms in the database will not notice 200 ns. Do not choose a mediator on these numbers — choose on licence, surface area, and whether you can read the thing. The benchmark exists so nobody has to take a vague claim on trust, not because it is the reason to switch.

FAQ

Is it really free for commercial use? Yes. MIT, no exceptions, no revenue threshold, no seat count. The licence text ships inside the package. We were on the receiving end of a dependency going commercial, which is precisely why this one will not.

Is it production-ready? It was extracted from a production ASP.NET Core system where it dispatches every request, and it is covered by tests both here and in that codebase. It is also ~790 lines — you can read all of it before deciding, which is a stronger guarantee than most badges.

How do I know the behavior order is right? There is a test for it. Behaviors fold first-registered-outermost; if someone changes that, SenderTests fails.

Will it grow into a message bus? No. Cerise dispatches in-process and that is the whole scope. If you need queues, retries, sagas or transports, use MassTransit or Wolverine — they are excellent and this is not trying to be them.

Does it have notifications and streaming? Yes — Publish/INotification and IStreamRequest both work, with the same semantics as MediatR. What it does not have is the pre/post processor and exception-handler types, because in MediatR those are IPipelineBehavior implementations themselves: seventeen public types that a ten-line behavior replaces.

Why the name? Cerise is French for cherry, from CherryPeak.

Who maintains this

Cerise is built and maintained by CherryPeak — a digital innovation studio in Slovakia.

We design, develop and deploy digital products for enterprises and startups: web and mobile applications, AR/VR, and AI integration — taking AI-accelerated prototypes through to production-ready software, with the observability and scalable architecture that implies. Cerise came out of exactly that kind of work: we needed a mediator we could rely on indefinitely, so we wrote one and gave it away.

If you are building something and want a hand with it, we are at cherrypeak.eu or info@cherrypeak.eu.

Contributing

Suggestions, bug reports and pull requests are genuinely welcome — including "this is missing X and here is why I need it".

  • Something broken or missing? Open an issue.
  • Want to change something? Open a pull request. main is protected: every change goes through a PR with CI passing.
  • Security issue? Please report it privately — see SECURITY.md.

We would rather hear that Cerise does not fit your case than have you quietly work around it.

Releasing

For maintainers. Tag it:

git tag v1.0.1 && git push origin v1.0.1

The tag name is the version — the workflow packs with it, so a release cannot disagree with what is in the csproj. Only repository admins can create v* tags.

Authentication is trusted publishing: the workflow proves who it is with a short-lived GitHub OIDC token and nuget.org issues a key good for about an hour. There is no stored API key. The trust policy names this repository and .github/workflows/publish.yml by name, so renaming that file stops releases until the policy is updated to match.

Licence

MIT — see LICENSE. Free for commercial use, forever, which is rather the point.

Trademarks

MediatR is a trademark of its respective owner. Cerise is an independent implementation, written from the public API surface and not from MediatR's source. It is not affiliated with, endorsed by, or derived from MediatR, and references to it here are for compatibility and comparison only.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 604 8/26/2026