Cyrus Mediator
(martinothamar/Mediator)
How It Works & Gotchas
Overview
RoundTrip uses martinothamar/Mediator (referred to as "Cyrus Mediator") as its mediator implementation. It is a source generator-based mediator, which makes it fundamentally different from MediatR. Understanding how the source generator works is essential to avoid subtle bugs.
How the Source Generator Works
Cyrus Mediator uses a Roslyn source generator that runs at compile time. It scans the project being compiled and generates a Mediator.g.cs file containing:
- The
IMediatorimplementation - DI registrations for all discovered handlers
- The pipeline behavior execution chain
Publish<T>()dispatch for notification handlers
Key Rule: It only generates for the project it runs in
The source generator only runs in the project that has Mediator.SourceGenerator referenced as an analyzer and that directly contains (or references) the AddMediator() call. It does NOT run in projects that only reference Mediator.Abstractions.
How to find the generated file
In Rider/Visual Studio: Project → Dependencies → Analyzers → Mediator.SourceGenerator → Mediator.SourceGenerator.MediatorGenerator → Mediator.g.cs
Or via F12 navigation on the IMediator type.
If Mediator.g.cs does not exist in the project's obj/ folder, the generator did not run.
Where to find the generated file
In Rider: Project → Dependencies → Analyzers → Mediator.SourceGenerator → Mediator.SourceGenerator.IncrementalMediatorGenerator → Mediator.g.cs
The red squiggles in this file are normal — it's a generated file Rider can't fully resolve in the analyzer view. Ignore them. Check the content directly.
The key section to inspect is:
// Register notification handlers
// Register pipeline behaviors configured through options
If // Register notification handlers is followed immediately by // Register pipeline behaviors with nothing in between, the generator found zero notification handlers. This is a critical diagnostic signal.
Pipeline Behaviors — Critical Rules
Rule 1: Options are parsed at compile time
"Since parsing of these options is done during compilation/source generation,
all values must be compile time constants."
— Cyrus Mediator documentation
The source generator reads options.PipelineBehaviors statically during compilation. The generated Mediator.g.cs registers each behavior as a closed generic for every request/command/query type — not as an open generic. This is why the generated file is so large.
This means options.PipelineBehaviors is NOT a runtime configuration. The source generator reads it statically during compilation. If the generator cannot see the array at compile time, behaviors are silently omitted from the generated pipeline.
Rule 2: AddMediator() must be directly visible to the generator
The generator needs to find the AddMediator(options => { ... }) call site. If AddMediator is called inside a helper/extension method (e.g. services.AddMediatorSourceGen()), the generator may not walk into that method and the options.PipelineBehaviors array will be invisible to it.
❌ This does NOT work reliably:
// MediatorConfig.cs — extension method
public static IServiceCollection AddMediatorSourceGen(this IServiceCollection services)
{
services.AddMediator(options =>
{
options.PipelineBehaviors = [ typeof(TransactionBehavior<,>) ]; // IGNORED by generator
});
}
// Program.cs / ServiceConfigs.cs
services.AddMediatorSourceGen(); // Generator doesn't see inside this
✅ This works:
// Directly in ServiceConfigs.cs or Program.cs
services.AddMediator(options =>
{
options.PipelineBehaviors =
[
typeof(LoggingBehavior<,>),
typeof(ValidationBehavior<,>),
typeof(TenantScopingBehavior<,>),
typeof(SubscriptionEnforcementBehavior<,>),
typeof(TransactionBehavior<,>),
typeof(CachingBehavior<,>),
];
});
Rule 3: Pipeline behaviors are NOT auto-discovered
Unlike message handlers (which are auto-discovered from the scanned assemblies), pipeline behaviors must be explicitly listed in options.PipelineBehaviors. They are never picked up by scanning alone.
Rule 4: The symptom of a missing behavior is silent pass-through
If TransactionBehavior is missing from the generated pipeline, commands execute without a transaction and domain events are silently discarded. There is no exception — the handler simply runs without the wrapping behavior. Always verify via logs that [INF] Beginning transaction for XxxCommand appears after deploying pipeline changes.
Rule 5: All TransactionBehavior logs are LogDebug
Every log line in TransactionBehavior uses logger.LogDebug. The default Azure App Service log level is Information. This means you will never see transaction logs in the Azure log stream unless you temporarily change lines to LogInformation for debugging. Do not assume TransactionBehavior is not running just because nothing appears in the logs.
Domain Event Dispatch — CRITICAL
Do NOT use mediator.Publish() for domain events
The Cyrus source generator generates dispatch code for INotificationHandler<T> at compile time by scanning assemblies. If your domain event handlers implement IDomainEventHandler<T> (which extends INotificationHandler<T>) but the generator doesn't walk the interface chain, it generates an empty // Register notification handlers section and mediator.Publish() silently dispatches to nobody.
Confirmed symptom: Domain events are collected (Count > 0 in logs), mediator.Publish() is called, but notification handlers never fire and no log lines from those handlers appear.
Use IDomainEventPublisher instead
RoundTrip uses a custom IDomainEventPublisher / DomainEventPublisher that bypasses the source generator entirely and resolves handlers directly from DI:
public sealed class DomainEventPublisher(IServiceProvider serviceProvider)
: IDomainEventPublisher
{
public async Task PublishAsync(IDomainEvent domainEvent, CancellationToken cancellationToken)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType)
.Where(h => h is not null)
.ToList();
foreach (var handler in handlers)
{
await ((dynamic)handler!).Handle((dynamic)domainEvent, cancellationToken);
}
}
}
TransactionBehavior injects IDomainEventPublisher (not IMediator) for domain event dispatch.
Domain Event Handlers — Registration Rules
Domain event handlers (implementations of IDomainEventHandler<T> or INotificationHandler<T>) are not auto-discovered by the source generator. They must be registered manually in DI.
// Must be registered explicitly — NOT auto-discovered
services.AddScoped<IDomainEventHandler<ServiceTicketCreatedEvent>, ServiceTicketCreatedEventHandler>();
services.AddScoped<IDomainEventHandler<ServiceTicketCreatedEvent>, TicketNotificationHandlers>();
Multiple handlers for the same event are supported — register each one. When mediator.Publish() fires, Cyrus resolves all INotificationHandler<T> registrations for that event type from DI.
The (dynamic) cast requirement
Domain events stored on aggregates are typed as IDomainEvent. When dispatching them from TransactionBehavior, you must cast to the concrete type using (dynamic):
// ❌ Wrong — silently finds no handlers (resolves INotificationHandler<IDomainEvent>)
await mediator.Publish(domainEvent, cancellationToken);
// ✅ Correct — resolves concrete type at runtime, finds correct handlers
await mediator.Publish((dynamic)domainEvent, cancellationToken);
SubscriptionEnforcementBehavior — Internal Commands Must Be Exempt
SubscriptionEnforcementBehavior gates ALL commands (anything whose type name ends with "Command") unless:
- The tenant has an active subscription (
SubscriptionStatus == "Active") - The tenant has an active trial (
TrialEndsAt > DateTime.UtcNow) - The command is in the
ExemptCommandsset
Any internal system command dispatched from a domain event handler must be added to ExemptCommands. These are not user-initiated writes and must never be subscription-gated.
Current exempt internal commands:
CreateNotificationCommand
If you add new commands that are dispatched internally (e.g. from background jobs or domain event handlers), add them to ExemptCommands immediately or they will be silently blocked on dev/staging environments where tenants may not have active subscriptions.
The failure mode is invisible: SubscriptionEnforcementBehavior returns a PaymentRequired result, TicketNotificationHandlers logs it at LogWarning (invisible at Information log level), and no notification is created.
Notification Handler (INotificationHandler<T>) Rules
INotificationHandler<T>implementations ARE auto-discovered by the source generator when found in the scanned assemblies- BUT they must also be registered in DI — the generator generates the dispatch code, DI provides the instances
IDomainEventHandler<T>extendsINotificationHandler<T>— registering as either works for DI resolution- Multiple handlers per event are supported and all fire on
Publish()
Integration Test Project Rules
The source generator does not run in test projects that only reference Mediator.Abstractions transitively. For integration tests:
- Add
Mediator.SourceGeneratoras an analyzer to the test project.csproj - Add
Mediator(runtime, not just abstractions) — but as of v3.x this package may not exist separately; useMediator.Abstractions+Mediator.SourceGenerator - The
AddMediator()call in test setup (IntegrationTestBase) must be directly in the test project source — not wrapped
Workaround if generator won't run in tests
If the source generator cannot be made to run in the test project, call handlers directly rather than going through mediator.Send() / mediator.Publish():
// ✅ Pragmatic test approach — bypasses generated pipeline entirely
var handler = scope.ServiceProvider.GetRequiredService<TicketNotificationHandlers>();
await handler.Handle(new ServiceTicketCreatedEvent(...), CancellationToken.None);
This tests the handler logic directly and avoids the source generator problem. The pipeline itself (TransactionBehavior, domain event dispatch) is tested via integration tests that go through mediator.Send() — those work as long as the generator runs in the Web project.
Deployment Verification Checklist
After any pipeline or domain event handler change, temporarily add this to TransactionBehavior and verify in the dev log stream:
var domainEvents = unitOfWork.CollectDomainEvents();
logger.LogInformation("Collected {Count} domain events after committing {RequestName}",
domainEvents.Count, typeof(TRequest).Name);
Create a ticket and watch for:
- Count > 0 — domain events are being collected
NotifyAllAsync calledlog — handlers are being invokedNotification created for userlog —CreateNotificationHandlersucceeded- Bell badge increments in browser — SignalR push working
Remove the temporary LogInformation line before merging to main.
Summary Table
| Concern | Auto-discovered? | Manual registration needed? |
|---|---|---|
| Request/Command/Query handlers | ✅ Yes | No |
Notification handlers (INotificationHandler<T>) | ✅ Yes (dispatch code) | ✅ Yes (DI instance) |
| Pipeline behaviors | ❌ No | ✅ Yes, in options.PipelineBehaviors |
Domain event handlers (IDomainEventHandler<T>) | ❌ No | ✅ Yes, in DI |
Summary of Root Causes Debugged (TRA-254)
The full chain investigated during TRA-254 debugging:
TransactionBehaviorlogs atLogDebug— appeared not to be running but was running finemediator.Publish((dynamic)domainEvent)— appeared to work but source generator had empty// Register notification handlerssection, so handlers were never invokedSubscriptionEnforcementBehavior— was blockingCreateNotificationCommandon dev tenant with expired/no subscriptionIDomainEventHandler<T>vsINotificationHandler<T>registration — Cyrus resolves by the exact registered interface;DomainEventPublisherusesIDomainEventHandler<T>directly
The fix: Replace mediator.Publish() for domain events with IDomainEventPublisher which resolves handlers directly from DI, bypassing the source generator entirely.