Events - PogovorovDaniil/Requestum GitHub Wiki

Events

Events are notifications about important things that have happened in your system. This guide covers everything you need to know about working with events in Requestum.

📋 What is an Event?

An event is a notification about something that has already happened. Events:

  • Represent facts that occurred in the past
  • Do not modify state directly (but receivers might)
  • Do not return values
  • Can have zero or more receivers
  • Use past tense naming

🎯 Events vs Commands

Aspect Command Event
Intent Tell system to do something Notify that something happened
Naming Imperative (CreateUser) Past tense (UserCreated)
Handlers Exactly one Zero or more receivers
Timing Before action After action
Certainty May fail Already happened

📝 Creating Events

Basic Event

public record UserCreatedEvent : IEventMessage
{
    public required int UserId { get; init; }
    public required string Name { get; init; }
    public required string Email { get; init; }
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}

Event with Constructor

public record UserCreatedEvent(
    int UserId, 
    string Name, 
    string Email, 
    DateTime CreatedAt) : IEventMessage;

Simple Event

public record CacheRefreshedEvent : IEventMessage;
public record OrderCancelledEvent(int OrderId) : IEventMessage;

Complex Event

public record OrderPlacedEvent : IEventMessage
{
    public required int OrderId { get; init; }
    public required int CustomerId { get; init; }
    public required decimal TotalAmount { get; init; }
    public required List<OrderItemDto> Items { get; init; }
    public required DateTime PlacedAt { get; init; }
    public string? DiscountCode { get; init; }
}

public record OrderItemDto(int ProductId, string ProductName, int Quantity, decimal Price);

🔧 Creating Event Receivers

Synchronous Receiver

Use for simple, non-I/O operations:

public class LogUserCreationReceiver : IEventMessageReceiver<UserCreatedEvent>
{
    private readonly ILogger<LogUserCreationReceiver> _logger;
    
    public LogUserCreationReceiver(ILogger<LogUserCreationReceiver> logger)
    {
        _logger = logger;
    }
    
    public void Receive(UserCreatedEvent message)
    {
        _logger.LogInformation(
            "User created: {UserId} - {Name} ({Email})", 
            message.UserId, 
            message.Name, 
            message.Email);
    }
}

Asynchronous Receiver

Use for I/O operations:

public class SendWelcomeEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<SendWelcomeEmailReceiver> _logger;
    
    public SendWelcomeEmailReceiver(
        IEmailService emailService,
        ILogger<SendWelcomeEmailReceiver> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }
    
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        _logger.LogInformation("Sending welcome email to {Email}", message.Email);
      
        await _emailService.SendWelcomeEmailAsync(
            message.Email, 
            message.Name, 
            ct);
         
        _logger.LogInformation("Welcome email sent to {Email}", message.Email);
    }
}

Multiple Receivers for Same Event

One event can have multiple receivers:

// Receiver 1: Send email
public class SendWelcomeEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        await _emailService.SendWelcomeEmailAsync(message.Email, message.Name, ct);
    }
}

// Receiver 2: Update analytics
public class TrackUserRegistrationReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        await _analyticsService.TrackUserRegistrationAsync(message.UserId, ct);
    }
}

// Receiver 3: Log to file
public class LogUserCreationToFileReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        await _fileLogger.LogAsync($"User {message.UserId} created at {message.CreatedAt}", ct);
    }
}

// Receiver 4: Notify admin
public class NotifyAdminOfNewUserReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        await _notificationService.NotifyAdminAsync($"New user: {message.Name}", ct);
    }
}

🚀 Publishing Events

Publish with Instance

var eventMessage = new UserCreatedEvent(
    UserId: 123,
    Name: "Alice",
    Email: "[email protected]",
    CreatedAt: DateTime.UtcNow
);

// Synchronous
requestum.Publish(eventMessage);

// Asynchronous
await requestum.PublishAsync(eventMessage);

// With cancellation token
await requestum.PublishAsync(eventMessage, cancellationToken);

Publish without Instance

For events with parameterless constructors:

// Synchronous
requestum.Publish<CacheRefreshedEvent>();

// Asynchronous
await requestum.PublishAsync<CacheRefreshedEvent>();

Publishing from Command Handler

public class CreateUserCommandHandler : IAsyncCommandHandler<CreateUserCommand>
{
    private readonly IUserRepository _repository;
    private readonly IRequestum _requestum;
    
    public CreateUserCommandHandler(IUserRepository repository, IRequestum requestum)
    {
        _repository = repository;
        _requestum = requestum;
    }
    
    public async Task ExecuteAsync(CreateUserCommand command, CancellationToken ct = default)
    {
        // Execute command
        var user = new User(command.Name, command.Email);
        await _repository.AddAsync(user, ct);
        
        // Publish event
        await _requestum.PublishAsync(
            new UserCreatedEvent(user.Id, user.Name, user.Email, DateTime.UtcNow),
            ct);
    }
}

🎨 Event Patterns

Domain Events

Events that represent domain-specific business events:

public record OrderPlacedEvent(
    int OrderId,
    int CustomerId,
    decimal TotalAmount,
    DateTime PlacedAt) : IEventMessage;

public record OrderShippedEvent(
    int OrderId,
    string TrackingNumber,
    DateTime ShippedAt) : IEventMessage;

public record OrderDeliveredEvent(
    int OrderId,
    DateTime DeliveredAt) : IEventMessage;

Integration Events

Events for cross-system communication:

public record PaymentProcessedEvent : IEventMessage
{
    public required string ExternalPaymentId { get; init; }
    public required int OrderId { get; init; }
    public required decimal Amount { get; init; }
    public required string Currency { get; init; }
    public required DateTime ProcessedAt { get; init; }
}

// Receiver that publishes to message queue
public class PublishPaymentToQueueReceiver : IAsyncEventMessageReceiver<PaymentProcessedEvent>
{
    private readonly IMessageQueue _queue;
    
    public async Task ReceiveAsync(PaymentProcessedEvent message, CancellationToken ct = default)
    {
        await _queue.PublishAsync("payments", message, ct);
    }
}

Event Sourcing

Using events to build system state:

public record AccountCreatedEvent(string AccountId, decimal InitialBalance) : IEventMessage;
public record MoneyDepositedEvent(string AccountId, decimal Amount) : IEventMessage;
public record MoneyWithdrawnEvent(string AccountId, decimal Amount) : IEventMessage;

public class AccountBalanceProjection : 
    IAsyncEventMessageReceiver<AccountCreatedEvent>,
    IAsyncEventMessageReceiver<MoneyDepositedEvent>,
    IAsyncEventMessageReceiver<MoneyWithdrawnEvent>
{
    private readonly IAccountRepository _repository;
  
    public async Task ReceiveAsync(AccountCreatedEvent message, CancellationToken ct = default)
    {
        await _repository.CreateAccountAsync(message.AccountId, message.InitialBalance, ct);
    }
    
    public async Task ReceiveAsync(MoneyDepositedEvent message, CancellationToken ct = default)
    {
        await _repository.UpdateBalanceAsync(message.AccountId, balance => balance + message.Amount, ct);
    }
    
    public async Task ReceiveAsync(MoneyWithdrawnEvent message, CancellationToken ct = default)
    {
        await _repository.UpdateBalanceAsync(message.AccountId, balance => balance - message.Amount, ct);
    }
}

Cascading Events

One event triggering another:

// First event
public record OrderPlacedEvent(int OrderId, int CustomerId, decimal Total) : IEventMessage;

// Receiver that publishes another event
public class ProcessOrderPaymentReceiver : IAsyncEventMessageReceiver<OrderPlacedEvent>
{
    private readonly IPaymentService _paymentService;
    private readonly IRequestum _requestum;
    
    public async Task ReceiveAsync(OrderPlacedEvent message, CancellationToken ct = default)
    {
        var paymentId = await _paymentService.ProcessPaymentAsync(
            message.CustomerId, 
            message.Total, 
            ct);
            
        // Publish another event
        await _requestum.PublishAsync(
            new PaymentProcessedEvent(paymentId, message.OrderId, message.Total),
            ct);
    }
}

⚙️ Configuration

Requiring Event Handlers

By default, Requestum requires at least one receiver for each event:

services.AddRequestum(cfg =>
{
    cfg.RequireEventHandlers = true; // Default
    cfg.RegisterHandlers(typeof(Program).Assembly);
});

// Publishing event without receivers will throw exception
await requestum.PublishAsync(new UserCreatedEvent(...)); // ❌ Throws if no receivers

Allowing Events without Handlers

services.AddRequestum(cfg =>
{
    cfg.RequireEventHandlers = false; // Allow events without receivers
    cfg.RegisterHandlers(typeof(Program).Assembly);
});

// Publishing event without receivers is allowed (no-op)
await requestum.PublishAsync(new UserCreatedEvent(...)); // ✅ No error, silently ignored

🛡️ Error Handling

Exception in One Receiver

If one receiver throws an exception, it doesn't prevent other receivers from executing:

// Receiver 1: Throws exception
public class SendEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        throw new Exception("Email service is down"); // This throws
    }
}

// Receiver 2: Will still execute
public class LogUserReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        _logger.LogInformation("User created: {UserId}", message.UserId); // Still runs
    }
}

Handling Exceptions in Receivers

public class SendWelcomeEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<SendWelcomeEmailReceiver> _logger;
    
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        try
        {
            await _emailService.SendWelcomeEmailAsync(message.Email, message.Name, ct);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send welcome email to {Email}", message.Email);
            // Don't rethrow - allow other receivers to execute
        }
    }
}

✅ Best Practices

1. Use Past Tense Naming

// ✅ Good - past tense
public record UserCreatedEvent(...) : IEventMessage;
public record OrderPlacedEvent(...) : IEventMessage;
public record PaymentProcessedEvent(...) : IEventMessage;

// ❌ Bad - present or future tense
public record CreateUserEvent(...) : IEventMessage;
public record PlaceOrderEvent(...) : IEventMessage;
public record ProcessPaymentEvent(...) : IEventMessage;

2. Include Relevant Data

// ✅ Good - includes all relevant data
public record UserCreatedEvent(
    int UserId,
    string Name,
    string Email,
    DateTime CreatedAt) : IEventMessage;

// ❌ Bad - missing important data
public record UserCreatedEvent(int UserId) : IEventMessage;

3. Make Events Immutable

// ✅ Good - immutable
public record UserCreatedEvent(int UserId, string Name, string Email) : IEventMessage;

// ❌ Bad - mutable
public class UserCreatedEvent : IEventMessage
{
    public int UserId { get; set; }
    public string Name { get; set; }
}

4. Keep Receivers Independent

// ✅ Good - independent receivers
public class SendEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        // Only sends email, doesn't care about other receivers
        await _emailService.SendWelcomeEmailAsync(message.Email, message.Name, ct);
    }
}

// ❌ Bad - coupled receivers
public class SendEmailReceiver : IAsyncEventMessageReceiver<UserCreatedEvent>
{
    public async Task ReceiveAsync(UserCreatedEvent message, CancellationToken ct = default)
    {
        await _emailService.SendWelcomeEmailAsync(message.Email, message.Name, ct);
        
        // Don't call other receivers directly
        await new LogUserReceiver().ReceiveAsync(message, ct); // ❌ Bad
    }
}

5. Don't Perform Heavy Operations

// ✅ Good - queue heavy work
public class ProcessOrderReceiver : IAsyncEventMessageReceiver<OrderPlacedEvent>
{
    public async Task ReceiveAsync(OrderPlacedEvent message, CancellationToken ct = default)
  {
        // Queue for background processing
        await _backgroundJobQueue.EnqueueAsync(
            new ProcessOrderJob(message.OrderId), 
            ct);
    }
}

// ❌ Bad - heavy synchronous work
public class ProcessOrderReceiver : IAsyncEventMessageReceiver<OrderPlacedEvent>
{
    public async Task ReceiveAsync(OrderPlacedEvent message, CancellationToken ct = default)
    {
        // This blocks for a long time
        await Task.Delay(TimeSpan.FromMinutes(10), ct); // ❌ Bad
    }
}

6. Include Timestamps

// ✅ Good - includes when event occurred
public record UserCreatedEvent(
    int UserId,
    string Name,
    string Email,
    DateTime CreatedAt) : IEventMessage;

// Also good - use DateTimeOffset for timezone awareness
public record UserCreatedEvent(
    int UserId,
    string Name,
    string Email,
    DateTimeOffset OccurredAt) : IEventMessage;

🎓 Next Steps


← Queries | Home | Middleware Pipeline →