SimpleMediator is a lightweight mediator abstraction for .NET applications that lean on functional programming principles. It keeps request and response contracts explicit, integrates naturally with LanguageExt, and embraces pipeline behaviors so cross-cutting concerns stay composable.
ℹ️ Repository layout
src/SimpleMediator– library source code and packaging assets.tests/*– unit, property, and contract test suites.benchmarks/*– BenchmarkDotNet harness.docs/– architecture notes, RFCs, and policies.
- 📘 API Reference - Complete API documentation (auto-generated with DocFX)
- 🚀 Getting Started Guide - Quick start guide
- 📖 Introduction - Core concepts and architecture
- 🏛️ Architecture Patterns - Design patterns and best practices
- 📋 Architecture Decision Records - Key architectural decisions
- Why SimpleMediator
- Capabilities
- Quick Start
- Request Lifecycle
- Handlers and Contracts
- Pipeline Behaviors
- Functional Failure Detection
- Diagnostics and Metrics
- Error Metadata
- Configuration Reference
- Testing
- Quality Checklist
- Quality & Security Roadmap
- FAQ
- Future Work
- License
- Built for functional error handling with
EitherandOptionfrom LanguageExt, backed by the mediator'sMediatorErrorwrapper for rich metadata. - Lightweight dependency footprint:
LanguageExt.CoreandMicrosoft.Extensions.*abstractions. - Pipelines, pre-processors, and post-processors make cross-cutting concerns pluggable.
- Provides telemetry hooks (logging, metrics, activity tracing) without coupling to specific vendors.
- Ships with guardrails such as functional failure detection to keep domain invariants explicit.
- On track for a Zero Exceptions policy so operational failures travel through Railway Oriented Programming (ROP) patterns instead of bubbling up as exceptions.
SimpleMediator takes cues from MediatR, Kommand, and Wolverine, but positions itself as a functional, observable application pipeline for CQRS-style work.
- Messaging model: Commands, queries, and notifications with explicit contracts;
Send/PublishreturnValueTask<Either<MediatorError, TValue>>to keep async overhead low and failures explicit. - Pipeline composition: Ordered behaviors plus request pre/post processors to layer validation, retries, timeouts, audits, and tracing without touching handlers; works with open or closed generics.
- Discovery & DI integration: Assembly scanning for handlers, notifications, behaviors, and processors; configurable handler lifetimes; legacy aliases (
AddApplicationMessaging) for drop-in adoption; caches avoid repeated reflection. - Observability first: Built-in logging scopes and
ActivitySourcespans, metrics viaIMediatorMetricscounters/histograms, and OTEL-ready defaults for traces and metrics. - Functional failure handling:
IFunctionalFailureDetectorlets you translate domain envelopes intoMediatorErrorwith consistent codes/messages; ships with a null detector for opt-in mapping. - Notification fan-out: Publishes to zero or many handlers with per-handler error logging, cancellation awareness, and functional results instead of exceptions.
- Quality & reliability toolchain: Benchmarks, load harnesses, mutation testing, and coverage guardrails are baked into the repo to keep regressions visible.
SimpleMediator adopts a modular architecture where specialized functionality is distributed across focused satellite packages. This approach keeps the core library lightweight while providing rich integration options for common scenarios.
| Category | Packages | Status |
|---|---|---|
| Core & Validation | SimpleMediator, FluentValidation, DataAnnotations, MiniValidator, GuardClauses | ✅ Production |
| Web Integration | AspNetCore, SignalR | ✅ Production |
| Database Providers | EntityFrameworkCore, MongoDB, Dapper.{5 DBs}, ADO.{5 DBs} | ✅ Production |
| Messaging Transports | Wolverine, NServiceBus, RabbitMQ, AzureServiceBus, AmazonSQS, Kafka, Redis.PubSub, InMemory, NATS, MQTT, gRPC, GraphQL | ✅ Production |
| Caching | Caching, Caching.Memory, Caching.Hybrid, Caching.Redis, Caching.Valkey, Caching.KeyDB, Caching.Dragonfly, Caching.Garnet | ✅ Production |
| Job Scheduling | Hangfire, Quartz | ✅ Production |
| Resilience | Extensions.Resilience, Refit, Dapr | ✅ Production |
| Event Sourcing | Marten, EventStoreDB | ✅ Production |
Tests: 3,000+ tests passing across all packages.
Solution Filters (.slnf): For focused development, use solution filter files:
# Build/test specific areas
dotnet build SimpleMediator.Caching.slnf # Caching packages only
dotnet test SimpleMediator.Database.slnf # Database providers only
dotnet build SimpleMediator.Validation.slnf # Validation packages onlySee CHANGELOG.md for version history and docs/history/ for detailed implementation records.
| Área | Qué incluye | Dónde empezar |
|---|---|---|
| Contratos CQRS | ICommand/IQuery/INotification, handlers y callbacks explícitos; resultados funcionales (Either, Unit). |
Handlers and Contracts |
| Pipeline y cross-cutting | Behaviors ordenados, pre/post processors, soporta genéricos abiertos/cerrados y timeouts/reintentos vía behaviors personalizados. | Pipeline Behaviors |
| Observabilidad | MediatorDiagnostics (logging + ActivitySource), IMediatorMetrics (counters/histogram), behaviors de métricas y trazas listos para OTEL. |
Diagnostics and Metrics |
| Gestión de errores | MediatorError, iniciativa Zero Exceptions, IFunctionalFailureDetector para mapear envelopes de dominio a códigos consistentes. |
Zero Exceptions Initiative y Functional Failure Detection |
| Descubrimiento y DI | Escaneo de ensamblados, registro de handlers/behaviors/processors, control de lifetimes, alias AddApplicationMessaging. |
Configuration Reference |
| Notificaciones | Fan-out con múltiples handlers, registro de fallos por handler, cancelación y resultados funcionales en lugar de excepciones. | Handlers and Contracts |
| Calidad continua | Benchmarks, NBomber, Stryker, cobertura, umbrales en CI con scripts de apoyo y badges. | Quality Checklist |
- Mediador y contratos:
SimpleMediator.cs,IMediator.cs,IRequest.cs,INotification.cs. - Registro y escaneo:
ServiceCollectionExtensions.cs,MediatorAssemblyScanner.cs. - Pipelines y callbacks:
IPipelineBehavior.cs,IRequestPreProcessor.cs,IRequestPostProcessor.cs, behaviors enBehaviors/. - Observabilidad y métricas:
MediatorDiagnostics.cs,MediatorMetrics.cs. - Errores y ROP:
MediatorError.cs,MediatorResult.cs,IFunctionalFailureDetector.cs,NullFunctionalFailureDetector.cs.
var result = await mediator.Send(new RegisterUser("user@example.com", "Pass@123"), ct);
result.Match(
Left: err => logger.LogWarning("Registration failed {Code}", err.GetMediatorCode()),
Right: _ => logger.LogInformation("User registered"));await mediator.Publish(new SendWelcomeEmail("user@example.com"), ct);
// Handlers run independently; failures surface as MediatorError instead of exceptions.public sealed class TimeoutBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes>
where TReq : IRequest<TRes>
{
public async ValueTask<Either<MediatorError, TRes>> Handle(
TReq request,
RequestHandlerCallback<TRes> next,
CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
try { return await next().WaitAsync(cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
{ return MediatorErrors.FromException("mediator.timeout", ex, "Timed out"); }
}
}public sealed class AppFailureDetector : IFunctionalFailureDetector
{
public bool TryExtractFailure(object? response, out string reason, out object? error)
{
if (response is OperationResult r && !r.IsSuccess)
{ reason = r.Code ?? "operation.failed"; error = r; return true; }
reason = string.Empty; error = null; return false;
}
public string? TryGetErrorCode(object? error) => (error as OperationResult)?.Code;
public string? TryGetErrorMessage(object? error) => (error as OperationResult)?.Message;
}services.AddSimpleMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<ApplicationAssemblyMarker>()
.AddPipelineBehavior(typeof(TimeoutBehavior<,>))
.AddPipelineBehavior(typeof(CommandMetricsPipelineBehavior<,>))
.AddPipelineBehavior(typeof(QueryActivityPipelineBehavior<,>));
});
services.AddSingleton<IFunctionalFailureDetector, AppFailureDetector>();SimpleMediator is evolving toward a Zero Exceptions policy, where mediator operations stop throwing in expected operational scenarios. Handlers, behaviors, and the mediator itself report failures through functional results (Either<MediatorError, TValue>, Option<T>, etc.), keeping execution on the ROP rails. During the transition we track remaining throw sites and wrap them in functional results, reserving exceptions for truly exceptional failures. MediatorErrors exposes factory helpers to encapsulate exceptions inside MediatorError instances.
Add the GitHub Packages feed once per environment:
dotnet nuget add source "https://nuget.pkg.github.com/dlrivada/index.json" \
--name dlrivada-github \
--username <your-gh-username> \
--password <PAT-with-write-packages>Then reference the package from your project:
dotnet add <YourProject>.csproj package SimpleMediator --version 0.1.0using LanguageExt;
using Microsoft.Extensions.DependencyInjection;
using SimpleMediator;
var services = new ServiceCollection();
services.AddSimpleMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<ApplicationAssemblyMarker>()
.AddPipelineBehavior(typeof(CommandActivityPipelineBehavior<,>))
.AddPipelineBehavior(typeof(QueryActivityPipelineBehavior<,>))
.AddPipelineBehavior(typeof(CommandMetricsPipelineBehavior<,>))
.AddPipelineBehavior(typeof(QueryMetricsPipelineBehavior<,>));
});
services.AddSingleton<IFunctionalFailureDetector, AppFunctionalFailureDetector>();
await using var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();using LanguageExt;
using static LanguageExt.Prelude;
public sealed record RegisterUser(string Email, string Password) : ICommand<Unit>;
public sealed class RegisterUserHandler : ICommandHandler<RegisterUser, Unit>
{
public async Task<Unit> Handle(RegisterUser command, CancellationToken ct)
{
var hashed = await Hashing.HashPassword(command.Password, ct).ConfigureAwait(false);
await Users.StoreAsync(command.Email, hashed, ct).ConfigureAwait(false);
return Unit.Default;
}
}
var result = await mediator.Send(new RegisterUser("user@example.com", "Pass@123"), cancellationToken);
result.Match(
Left: error => Console.WriteLine($"Registration failed: {error.GetMediatorCode()}"),
Right: _ => Console.WriteLine("User registered"));public sealed record GetUserProfile(string Email) : IQuery<UserProfile>;
public sealed class GetUserProfileHandler : IQueryHandler<GetUserProfile, UserProfile>
{
public Task<UserProfile> Handle(GetUserProfile query, CancellationToken ct)
=> Users.FindAsync(query.Email, ct);
}
var profileResult = await mediator.Send(new GetUserProfile("user@example.com"), cancellationToken);
profileResult.Match(
Left: error => Console.WriteLine($"Lookup failed: {error.Message}"),
Right: profile => Console.WriteLine(profile.DisplayName));sequenceDiagram
autonumber
participant Caller
participant Mediator
participant Pipeline as Pipeline Behaviors
participant Handler
participant FailureDetector as Failure Detector
Caller->>Mediator: Send(request)
Mediator->>Pipeline: Execute(request)
Pipeline->>Handler: Handle(request, ct)
Handler-->>Pipeline: TValue
Pipeline->>FailureDetector: Inspect(result)
FailureDetector-->>Pipeline: Either<MediatorError, TValue>
Pipeline-->>Mediator: Either<MediatorError, TValue>
Mediator-->>Caller: Either<MediatorError, TValue>
SimpleMediator relies on explicit interfaces and result types so each operation documents its intent.
| Contract | Purpose | Default Expectations |
|---|---|---|
ICommand<TResult> |
Mutation or side effect returning TResult. |
Handler returns TResult; mediator lifts to Either<MediatorError, TResult>. |
IQuery<TResult> |
Read operation returning TResult. |
Handler returns TResult; mediator lifts to Either<MediatorError, TResult>. |
INotification |
Fire-and-forget signals. | Zero or more notification handlers. |
public sealed record SendWelcomeEmail(string Email) : INotification;
public sealed class SendWelcomeEmailHandler : INotificationHandler<SendWelcomeEmail>
{
public Task Handle(SendWelcomeEmail notification, CancellationToken ct)
=> EmailGateway.SendAsync(notification.Email, ct);
}
var publishResult = await mediator.Publish(new SendWelcomeEmail("user@example.com"), cancellationToken);
publishResult.Match(
Left: error => Console.WriteLine($"Notification failed: {error.Message}"),
Right: _ => Console.WriteLine("Welcome email dispatched"));Pipeline behaviors wrap handler execution so concerns such as logging, validation, and retries stay isolated. Behaviors are executed in the order they are registered.
services.AddSimpleMediator(cfg =>
{
cfg.AddPipelineBehavior(typeof(ActivityPipelineBehavior<,>))
.AddPipelineBehavior(typeof(ValidationPipelineBehavior<,>))
.AddRequestPreProcessor(typeof(NormalizeWhitespacePreProcessor<>))
.AddRequestPostProcessor(typeof(AuditTrailPostProcessor<,>));
}, typeof(ApplicationAssemblyMarker).Assembly);| Behavior | Responsibility |
|---|---|
CommandActivityPipelineBehavior<,> |
Creates OpenTelemetry Activity scopes for commands and annotates functional failures. |
QueryActivityPipelineBehavior<,> |
Emits tracing spans for queries and records failure metadata. |
CommandMetricsPipelineBehavior<,> |
Updates mediator counters/histograms after each command. |
QueryMetricsPipelineBehavior<,> |
Tracks success/failure metrics for queries, including functional errors. |
using LanguageExt;
public sealed class TimeoutPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
public async ValueTask<Either<MediatorError, TResponse>> Handle(
TRequest request,
RequestHandlerCallback<TResponse> nextStep,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(DefaultTimeout);
try
{
return await nextStep().WaitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
{
return MediatorErrors.FromException("mediator.timeout", ex, $"Timeout while running {typeof(TRequest).Name}.");
}
}
}
cfg.AddPipelineBehavior(typeof(TimeoutPipelineBehavior<,>));Functional failure detection inspects handler results to translate domain-specific error envelopes into consistent mediator failures.
public sealed class AppFunctionalFailureDetector : IFunctionalFailureDetector
{
public bool TryExtractFailure(object? response, out string reason, out object? error)
{
if (response is OperationResult result && !result.IsSuccess)
{
reason = result.Code ?? "operation.failed";
error = result;
return true;
}
reason = string.Empty;
error = null;
return false;
}
public string? TryGetErrorCode(object? error)
=> (error as OperationResult)?.Code;
public string? TryGetErrorMessage(object? error)
=> (error as OperationResult)?.Message;
}
services.AddSingleton<IFunctionalFailureDetector, AppFunctionalFailureDetector>();MediatorDiagnosticswires logging scopes and structured information for each request.MediatorMetricsexposes counters and histograms viaSystem.Diagnostics.Metrics.ActivityPipelineBehaviorintegrates withSystem.Diagnostics.ActivitySourceso OpenTelemetry exporters can pick up traces.
services.AddOpenTelemetry()
.WithTracing(b => b.AddSource("SimpleMediator"))
.WithMetrics(b => b.AddMeter("SimpleMediator"));MediatorError retains an internal MetadataException whose immutable metadata can be retrieved via GetMediatorMetadata(). Keys are consistent across the pipeline to aid diagnostics:
handler: fully qualified handler type when available.request/notification: type involved in the failure.expectedNotification: type expected whenHandleis missing.stage: pipeline stage (handler,behavior,preprocessor,postprocessor,invoke,execute).behavior/preProcessor/postProcessor: fully qualified type names for cross-cutting components.returnType: handler return type when theHandlesignature is invalid.
Usage example:
var outcome = await mediator.Send(new DoSomething(), ct);
outcome.Match(
Left: err =>
{
var metadata = err.GetMediatorMetadata();
var code = err.GetMediatorCode();
logger.LogWarning("Failure {Code} at {Stage} by {Handler}",
code,
metadata.TryGetValue("stage", out var stage) ? stage : "unknown",
metadata.TryGetValue("handler", out var handler) ? handler : "unknown");
},
Right: _ => { /* success */ });services.AddSimpleMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<ApplicationAssemblyMarker>()
.AddPipelineBehavior(typeof(CommandActivityPipelineBehavior<,>))
.AddPipelineBehavior(typeof(QueryActivityPipelineBehavior<,>))
.AddRequestPreProcessor(typeof(ValidationPreProcessor<>))
.AddRequestPostProcessor(typeof(AuditTrailPostProcessor<,>))
.WithHandlerLifetime(ServiceLifetime.Scoped);
});| API | Description |
|---|---|
RegisterServicesFromAssembly(assembly) |
Adds one assembly to scan for handlers, notifications, and processors. |
RegisterServicesFromAssemblies(params Assembly[]) |
Adds several assemblies at once, ignoring null entries. |
RegisterServicesFromAssemblyContaining<T>() |
Convenience helper that adds the assembly where T is defined. |
AddPipelineBehavior(Type) |
Registers a scoped pipeline behavior (open or closed generic). |
AddRequestPreProcessor(Type) |
Registers a scoped pre-processor executed before the handler. |
AddRequestPostProcessor(Type) |
Registers a scoped post-processor executed after the handler. |
WithHandlerLifetime(ServiceLifetime) |
Overrides the lifetime used for handlers discovered during scanning. |
dotnet test SimpleMediator.slnx --configuration ReleaseTo generate coverage (powered by coverlet.collector) and produce HTML/Text summaries locally:
dotnet test SimpleMediator.slnx --configuration Release --collect:"XPlat Code Coverage" --results-directory artifacts/test-results
dotnet tool restore
dotnet tool run reportgenerator -reports:"artifacts/test-results/**/coverage.cobertura.xml" -targetdir:"artifacts/coverage" -reporttypes:"Html;HtmlSummary;TextSummary"The HTML dashboard (artifacts/coverage/index.html) and condensed summary (artifacts/coverage/Summary.txt) highlight hot spots—SimpleMediator.SimpleMediator now sits just above 90% line coverage after the latest hardening pass, so incremental gains hinge on rare cancellation/error permutations. The CI workflow runs the same commands and publishes the output as a downloadable artifact.
Mutation testing is exercised via Stryker.NET. Run the full sweep using the single-file helper (the same command the CI workflow executes):
dotnet run --file scripts/run-stryker.csReports land in artifacts/mutation/<timestamp>/reports/ with HTML/JSON payloads. After a run, refresh the mutation badge and emit a concise summary by executing:
dotnet run --file scripts/update-mutation-summary.csThe helper mirrors Stryker's scoring (compile errors are excluded from the denominator) and colors the badge according to configured thresholds. It edits README.md in place when the standard badge pattern is present and will simply print a suggested badge if the section has been customized.
Use scripts/analyze-mutation-report.cs <filter> to inspect survivors by file fragment when triaging regressions.
The suite exercises:
- Mediator orchestration across happy-path and exceptional flows.
- Command/query telemetry behaviors (activities, metrics, cancellation, functional failures).
- Service registration helpers and configuration guards.
- Default implementations such as
MediatorMetricsand the null functional failure detector.
Two load harnesses validate sustained throughput and resource envelopes:
dotnet run --file scripts/run-load-harness.cs -- --duration 00:01:00 --send-workers 8 --publish-workers 4
dotnet run --file scripts/run-load-harness.cs -- --nbomber send-burst --duration 00:00:30- The console harness targets
load/SimpleMediator.LoadTestsand pairs withscripts/collect-load-metrics.csto capture CPU/memory samples (harness-<timestamp>.log,metrics-<timestamp>.csv). - NBomber scenarios live in
load/SimpleMediator.NBomberwith JSON profiles underload/profiles/. The summarizerscripts/summarize-nbomber-run.cs -- --thresholds ci/nbomber-thresholds.jsonprints throughput/latency stats and fails when send-burst throughput dips below 6.75M ops/sec or latency rises above 0.85 ms. - CI enforces guardrails via
scripts/check-load-metrics.cs -- --config ci/load-thresholds.jsonfor the console harness and the summarizer for NBomber; both pipelines publish artifacts inartifacts/load-metrics/and feedscripts/aggregate-performance-history.csto refreshdocs/data/load-history.md.
- Coverage ≥90% line:
dotnet test SimpleMediator.slnx --configuration Releasefollowed byreportgenerator(seedocs/en/guides/TESTING.md). - Mutation score ≥93.74%:
dotnet run --file scripts/run-stryker.csthendotnet run --file scripts/update-mutation-summary.csto refresh badges. - Benchmarks within guardrails:
dotnet run --file scripts/run-benchmarks.csanddotnet run --file scripts/check-benchmarks.cs. - Thresholds live in
ci/benchmark-thresholds.json; update them only after capturing a new baseline on CI. - Load guardrails: console (
dotnet run --file scripts/check-load-metrics.cs -- --config ci/load-thresholds.json), NBomber (dotnet run --file scripts/summarize-nbomber-run.cs -- --thresholds ci/nbomber-thresholds.json). - Requirements documentation current: update
docs/en/guides/REQUIREMENTS.mdanddocs/en/guides/REQUIREMENTS_MAPPING.mdwhen adding scenarios or gates.
The living roadmap in QUALITY_SECURITY_ROADMAP.md complements this README with quantified objectives, badge inventory, and an incremental plan covering immediate, short, medium, and long-term quality efforts. Highlights:
- Objectives: analyzer clean builds, ≥95 % branch coverage on critical packages, guarded benchmarks, and SBOM currency for each release.
- Automation: dedicated workflows (
dotnet-ci.yml,codeql.yml,sbom.yml,benchmarks.yml) keep formatting, analysis, dependency hygiene, and performance on autopilot. - Governance: Dependabot, Conventional Commits, and release documentation combine to enforce predictable delivery.
- Follow-up: the roadmap checklist tracks pending tasks such as expanding regression tests, instrumenting metrics, and pursuing SLSA provenance.
-
Does SimpleMediator replace MediatR? It takes inspiration from MediatR but focuses on functional result types and richer telemetry hooks.
-
Can I use it without LanguageExt? Handlers rely on
EitherandUnitfrom LanguageExt; alternative abstractions would require custom adapters. -
How do I handle retries? Wrap logic inside a custom pipeline behavior or delegate to Polly policies executed inside the handler.
-
Is streaming supported? Streaming notifications are supported via processors that work with
IAsyncEnumerablepayloads. -
How do I log error metadata? Use
GetMediatorCode()for the canonical code andGetMediatorMetadata()for context (handler, stage, request/notification). Example:var outcome = await mediator.Publish(notification, ct); outcome.Match( Left: err => { var meta = err.GetMediatorMetadata(); logger.LogError("{Code} at {Stage} by {Handler}", err.GetMediatorCode(), meta.TryGetValue("stage", out var stage) ? stage : "n/a", meta.TryGetValue("handler", out var handler) ? handler : "n/a"); }, Right: _ => { });
See ROADMAP.md for the complete roadmap. Key pending items:
- Developer Tooling (CLI, Testing helpers, OpenAPI generation)
- Renaming to "Encina" before 1.0
- SLSA Level 2 compliance and supply chain security
This repository is distributed under a private license. See LICENSE for details.