Skip to main content

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 IMediator implementation
  • 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 ExemptCommands set

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> extends INotificationHandler<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:

  1. Add Mediator.SourceGenerator as an analyzer to the test project .csproj
  2. Add Mediator (runtime, not just abstractions) — but as of v3.x this package may not exist separately; use Mediator.Abstractions + Mediator.SourceGenerator
  3. 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 called log — handlers are being invoked
  • Notification created for user log — CreateNotificationHandler succeeded
  • Bell badge increments in browser — SignalR push working

Remove the temporary LogInformation line before merging to main.


Summary Table

ConcernAuto-discovered?Manual registration needed?
Request/Command/Query handlers✅ YesNo
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:

  1. TransactionBehavior logs at LogDebug — appeared not to be running but was running fine
  2. mediator.Publish((dynamic)domainEvent) — appeared to work but source generator had empty // Register notification handlers section, so handlers were never invoked
  3. SubscriptionEnforcementBehavior — was blocking CreateNotificationCommand on dev tenant with expired/no subscription
  4. IDomainEventHandler<T> vs INotificationHandler<T> registration — Cyrus resolves by the exact registered interface; DomainEventPublisher uses IDomainEventHandler<T> directly

The fix: Replace mediator.Publish() for domain events with IDomainEventPublisher which resolves handlers directly from DI, bypassing the source generator entirely.


  • TRA-254 — BUG: TransactionBehavior not executing in pipeline — domain events never dispatched
  • TRA-162 — TransactionBehavior missing IMediator injection (domain events never dispatched — fixed)
  • TRA-250 — Wire domain event handlers for notifications