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
| Interface | Message Type | Return Type | Purpose |
|---|---|---|---|
ICommandHandler<TMessage> | Command | Task | Process commands |
IRequestHandler<TMessage, TResponse> | Request | Task<TResponse> | Handle requests |
INotificationHandler<TMessage> | Notification | Task | React to notifications |
IStreamHandler<TMessage, TResponse> | Stream | IAsyncEnumerable<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:
| Interface | Default Lifetime | ThreadIsolationPolicy |
|---|---|---|
ICommandHandler<TMessage> | Singleton | Transient |
INotificationHandler<TMessage> | Singleton | None |
IRequestHandler<TMessage, TResponse> | Scoped | Transient |
IStreamHandler<TMessage, TResponse> | Scoped | Scoped |
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 injectSingletonservices.Transient— a fresh DI scope is created per message dispatch. Use this when yourSingletonorScopedhandler needs to resolveScopedservices (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
- Pipeline Behaviors - Add cross-cutting concerns
- Testing - Learn testing strategies