Skip to main content

Handlers

Handlers contain the logic that processes messages in NetMediate. Each handler implements one of the four handler interfaces corresponding to the message type.

Handler Interfaces​

InterfaceMessage TypeReturn TypePurpose
ICommandHandler<TMessage>CommandTaskProcess commands
IRequestHandler<TMessage, TResponse>RequestTask<TResponse>Handle requests
INotificationHandler<TMessage>NotificationTaskReact to notifications
IStreamHandler<TMessage, TResponse>StreamIAsyncEnumerable<TResponse>Stream responses

Creating Handlers​

Command Handler​

using GenDI;
using Microsoft.Extensions.DependencyInjection;

[ServiceInjection]
public interface IUserRepository
{
Task AddAsync(User user, CancellationToken cancellationToken);
Task<User?> GetByIdAsync(string id, CancellationToken cancellationToken);
}

[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
[Inject] public required IUserRepository Repository { get; init; }
[Inject] public required ILogger<CreateUserCommandHandler> Logger { get; init; }

public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken)
{
Logger.LogInformation("Creating user {Email}", command.Email);

var user = new User
{
Email = command.Email,
Name = command.Name
};

await Repository.AddAsync(user, cancellationToken);

Logger.LogInformation("User created with ID {UserId}", user.Id);
}
}

Request Handler​

[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserDto>
{
[Inject] public required IUserRepository Repository { get; init; }

public async Task<UserDto> Handle(GetUserQuery query, CancellationToken cancellationToken)
{
var user = await Repository.GetByIdAsync(query.UserId, cancellationToken);

if (user == null)
throw new UserNotFoundException(query.UserId);

return new UserDto(user.Id, user.Email, user.Name);
}
}

Notification Handler​

[ServiceInjection]
public interface IEmailService
{
Task SendWelcomeEmailAsync(string email, string name, CancellationToken cancellationToken);
}

[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class SendWelcomeEmailHandler : INotificationHandler<UserCreatedNotification>
{
[Inject] public required IEmailService EmailService { get; init; }

public async Task Handle(UserCreatedNotification notification, CancellationToken cancellationToken)
{
await EmailService.SendWelcomeEmailAsync(
notification.Email,
notification.Name,
cancellationToken);
}
}

Stream Handler​

[ServiceInjection]
public interface IActivityRepository
{
IAsyncEnumerable<Activity> GetUserActivitiesAsync(string userId, CancellationToken cancellationToken);
}

[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class GetUserActivityHandler : IStreamHandler<GetUserActivityQuery, ActivityDto>
{
[Inject] public required IActivityRepository Repository { get; init; }

public async IAsyncEnumerable<ActivityDto> Handle(
GetUserActivityQuery query,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var activity in Repository.GetUserActivitiesAsync(
query.UserId,
cancellationToken))
{
yield return new ActivityDto(
activity.Id,
activity.Type,
activity.Timestamp);
}
}
}

Handler Registration​

Handlers are automatically discovered and registered by the source generator when you call AddNetMediate():

builder.Services.AddNetMediate();

The generator scans your assembly for all concrete (non-abstract, non-generic) classes implementing handler interfaces and registers them automatically. AddNetMediate() also runs AddGenDIServices(), so GenDI metadata is active in the same setup.

Dependency Injection​

Prefer GenDI property injection for handlers and supporting services:

[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1, Key = "primary")]
public class MyHandler : ICommandHandler<MyCommand>
{
[Inject] public required IRepository Repository { get; init; }
[Inject] public required ILogger<MyHandler> Logger { get; init; }
[Inject(Key = "primary")] public required IEmailService EmailService { get; init; }

public async Task Handle(MyCommand command, CancellationToken cancellationToken)
{
// Implementation
}
}

Handler Lifetime​

Each handler and behavior interface declares a default ServiceLifetime via [ServiceInjection]. When you annotate your implementing class with [Injectable] without specifying a lifetime, these defaults apply:

InterfaceDefault LifetimeThreadIsolationPolicy
ICommandHandler<TMessage>SingletonTransient
INotificationHandler<TMessage>SingletonNone
IRequestHandler<TMessage, TResponse>ScopedTransient
IStreamHandler<TMessage, TResponse>ScopedScoped

ThreadIsolationPolicy controls how GenDI resolves scoped dependencies for non-transient registrations:

  • None — no scope isolation; all dependencies come from the ambient (root or request) scope. Suitable for handlers that only inject Singleton services.
  • Transient — a fresh DI scope is created per message dispatch. Use this when your Singleton or Scoped handler needs to resolve Scoped services (e.g. DbContext, HttpClient) without lifetime violations.
  • Scoped — the existing ambient scope is reused per operation. Appropriate for stream handlers where all items are consumed within a single request scope.

Override the lifetime on your implementation whenever the defaults don't fit:

// Use Scoped so a DbContext can be injected directly without thread-isolation overhead
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class MyCommandHandler : ICommandHandler<MyCommand>
{
[Inject] public required AppDbContext Db { get; init; }

public async Task Handle(MyCommand command, CancellationToken ct)
{
Db.Orders.Add(new Order(command.OrderId));
await Db.SaveChangesAsync(ct);
}
}

With GenDI you can also control Group, Order, and Key. Use [Injectable<TService>] only when you need to force a specific non-generic contract and contract discovery does not already find [ServiceInjection]. Concrete non-generic classes that implement closed generic contracts can still use [Injectable]. Only generic/open service implementations (for example AuditBehavior<TMessage, TResponse>) should be registered manually in builder.Services for the AOT-oriented path.

Multiple Handlers​

Commands and Notifications​

Both commands and notifications support multiple handlers:

// First handler
public class Handler1 : ICommandHandler<MyCommand>
{
public Task Handle(MyCommand command, CancellationToken ct)
{
Console.WriteLine("Handler 1");
return Task.CompletedTask;
}
}

// Second handler
public class Handler2 : ICommandHandler<MyCommand>
{
public Task Handle(MyCommand command, CancellationToken ct)
{
Console.WriteLine("Handler 2");
return Task.CompletedTask;
}
}

Commands: Handlers execute sequentially in registration order. Notifications: All handlers started concurrently (fire-and-forget) — handler exceptions are logged by the executor but do not propagate to the caller.

Requests and Streams​

Only one handler can be registered for requests. Registering multiple handlers will result in only the first registered handler being invoked.

For streams, multiple IStreamHandler<TMessage, TResponse> implementations can be registered. Their results are merged sequentially — each handler's async-enumerable output is yielded in registration order.

Error Handling​

Commands and Requests​

Exceptions thrown in handlers propagate to the caller wrapped in MediatorException:

try
{
await mediator.SendMyCommandAsync(new MyCommand());
}
catch (MediatorException ex)
{
// ex.InnerException contains the original exception
// ex.MessageType contains the message type
// ex.HandlerType contains the handler type
// ex.TraceId contains the correlation ID
}

Notifications​

Exceptions in notification handlers are caught and logged but do not propagate to the caller. This ensures one failing handler doesn't prevent other handlers from executing.

Streams​

Exceptions in stream handlers propagate immediately to the consumer:

try
{
await foreach (var item in mediator.StreamMyQueryAsync(new MyQuery()))
{
Console.WriteLine(item);
}
}
catch (MediatorException ex)
{
// Handle stream error
}

Best Practices​

Keep Handlers Focused​

Each handler should have a single responsibility:

// ✅ Good - focused handler
public class CreateUserHandler : ICommandHandler<CreateUserCommand>
{
public async Task Handle(CreateUserCommand command, CancellationToken ct)
{
// Only creates user
}
}

// ❌ Avoid - doing too much
public class CreateUserHandler : ICommandHandler<CreateUserCommand>
{
public async Task Handle(CreateUserCommand command, CancellationToken ct)
{
// Creates user
// Sends email
// Updates analytics
// Notifies external service
// ... too many responsibilities
}
}

Use Cancellation Tokens​

Always respect the cancellation token:

public async Task Handle(MyCommand command, CancellationToken cancellationToken)
{
await _httpClient.GetAsync("https://api.example.com", cancellationToken);
await _repository.SaveAsync(data, cancellationToken);
}

Log Appropriately​

Use structured logging:

_logger.LogInformation(
"Processing command {CommandType} for user {UserId}",
typeof(TCommand).Name,
command.UserId);

Don't Block​

Avoid blocking calls in async handlers:

// ❌ Avoid
public async Task Handle(MyCommand command, CancellationToken ct)
{
Thread.Sleep(1000); // Blocking!
var result = _service.GetData().Result; // Blocking!
}

// ✅ Good
public async Task Handle(MyCommand command, CancellationToken ct)
{
await Task.Delay(1000, ct);
var result = await _service.GetDataAsync(ct);
}

Testing Handlers​

Handlers are easy to test in isolation:

[Fact]
public async Task Handle_ValidCommand_CreatesUser()
{
// Arrange
var repository = new InMemoryUserRepository();
var handler = new CreateUserHandler(repository);
var command = new CreateUserCommand("john@example.com", "John Doe");

// Act
await handler.Handle(command, CancellationToken.None);

// Assert
var users = await repository.GetAllAsync();
Assert.Single(users);
Assert.Equal("john@example.com", users[0].Email);
}

For more testing examples, see the Testing Guide.

Next Steps​