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

public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
private readonly IUserRepository _repository;
private readonly ILogger<CreateUserCommandHandler> _logger;

public CreateUserCommandHandler(
IUserRepository repository,
ILogger<CreateUserCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}

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

public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserDto>
{
private readonly IUserRepository _repository;

public GetUserQueryHandler(IUserRepository repository)
{
_repository = repository;
}

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

public class SendWelcomeEmailHandler : INotificationHandler<UserCreatedNotification>
{
private readonly IEmailService _emailService;

public SendWelcomeEmailHandler(IEmailService emailService)
{
_emailService = emailService;
}

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

Stream Handler

public class GetUserActivityHandler : IStreamHandler<GetUserActivityQuery, ActivityDto>
{
private readonly IActivityRepository _repository;

public GetUserActivityHandler(IActivityRepository repository)
{
_repository = repository;
}

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.

Dependency Injection

Handlers support constructor injection just like any other service:

public class MyHandler : ICommandHandler<MyCommand>
{
private readonly IRepository _repository;
private readonly ILogger _logger;
private readonly IEmailService _emailService;

public MyHandler(
IRepository repository,
ILogger<MyHandler> logger,
IEmailService emailService)
{
_repository = repository;
_logger = logger;
_emailService = emailService;
}

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

Handler Lifetime

All handlers are registered as Singleton services by default. The same instance is reused across mediator operations.

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 in parallel (Task.WhenAll), fire-and-forget — handler results and exceptions are discarded by the executor.

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.Send(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.RequestStream<MyQuery, MyResult>(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