Skip to content

Event Module

Sébastien Bernier edited this page Feb 23, 2026 · 5 revisions

The Event module in Arcanic Mediator provides a powerful way to handle domain events in your application. Events represent notifications that something has happened in the system and follow the publish-subscribe pattern.

Table of Contents

Overview

Events in Arcanic Mediator are messages that represent domain events - notifications that something significant has happened in your application. They provide several benefits:

  • Decoupling - Components can react to events without direct dependencies
  • Scalability - Multiple handlers can process the same event independently
  • Auditability - Events provide a natural audit trail
  • Testability - Events and handlers can be easily unit tested
  • Extensibility - New event handlers can be added without modifying existing code

Key Concepts

  • Event - A message representing something that happened in the domain
  • Event Handler - A class that reacts to a specific event type
  • Event Publisher - The service responsible for publishing events to all registered handlers
  • Pre-Handler - Executes before the main handlers (validation, enrichment)
  • Post-Handler - Executes after all main handlers (cleanup, notifications)
  • Pipeline Behavior - Cross-cutting concerns like logging, performance monitoring

Installation

Install the Event module package:

dotnet add package Arcanic.Mediator.Event

For abstractions only (useful in domain/application layers):

dotnet add package Arcanic.Mediator.Event.Abstractions

Basic Usage

1. Define Events

Events represent something that has happened, so they should be named in past tense:

using Arcanic.Mediator.Event.Abstractions;

// Simple domain event
public class UserCreatedEvent : IEvent
{
    public int UserId { get; set; }
    public string Email { get; set; } = string.Empty;
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

// Order-related events
public class OrderPlacedEvent : IEvent
{
    public int OrderId { get; set; }
    public int CustomerId { get; set; }
    public decimal TotalAmount { get; set; }
    public DateTime OrderDate { get; set; } = DateTime.UtcNow;
}

// Product events
public class ProductStockUpdatedEvent : IEvent
{
    public int ProductId { get; set; }
    public int PreviousStock { get; set; }
    public int NewStock { get; set; }
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

2. Create Event Handlers

Multiple handlers can respond to the same event:

using Arcanic.Mediator.Event.Abstractions.Handler;

// Welcome email handler
public class UserCreatedWelcomeEmailHandler : IEventHandler<UserCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<UserCreatedWelcomeEmailHandler> _logger;

    public UserCreatedWelcomeEmailHandler(IEmailService emailService, ILogger<UserCreatedWelcomeEmailHandler> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }

    public async Task HandleAsync(UserCreatedEvent request, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Sending welcome email to user {UserId}", request.UserId);

        await _emailService.SendWelcomeEmailAsync(new WelcomeEmailRequest
        {
            ToEmail = request.Email,
            FirstName = request.FirstName,
            LastName = request.LastName
        }, cancellationToken);
    }
}

// Analytics tracking handler
public class UserCreatedAnalyticsHandler : IEventHandler<UserCreatedEvent>
{
    private readonly IAnalyticsService _analyticsService;

    public UserCreatedAnalyticsHandler(IAnalyticsService analyticsService)
    {
        _analyticsService = analyticsService;
    }

    public async Task HandleAsync(UserCreatedEvent request, CancellationToken cancellationToken = default)
    {
        await _analyticsService.TrackUserRegistrationAsync(new UserRegistrationEvent
        {
            UserId = request.UserId,
            Email = request.Email,
            RegistrationDate = request.CreatedAt
        }, cancellationToken);
    }
}

// Order confirmation email handler
public class OrderPlacedConfirmationEmailHandler : IEventHandler<OrderPlacedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepository;

    public OrderPlacedConfirmationEmailHandler(IEmailService emailService, ICustomerRepository customerRepository)
    {
        _emailService = emailService;
        _customerRepository = customerRepository;
    }

    public async Task HandleAsync(OrderPlacedEvent request, CancellationToken cancellationToken = default)
    {
        var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken);
        if (customer == null) return;

        await _emailService.SendOrderConfirmationAsync(new OrderConfirmationEmail
        {
            ToEmail = customer.Email,
            OrderId = request.OrderId,
            TotalAmount = request.TotalAmount,
            OrderDate = request.OrderDate
        }, cancellationToken);
    }
}

3. Configure Services

using Arcanic.Mediator;
using Arcanic.Mediator.Event;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddEvents(Assembly.GetExecutingAssembly());

var app = builder.Build();

4. Publish Events in Domain Services

using Arcanic.Mediator.Event.Abstractions;

public class UserService
{
    private readonly IUserRepository _userRepository;
    private readonly IPublisher _publisher;

    public UserService(IUserRepository userRepository, IPublisher publisher)
    {
        _userRepository = userRepository;
        _publisher = publisher;
    }

    public async Task<int> CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken = default)
    {
        var user = new User
        {
            Email = request.Email,
            FirstName = request.FirstName,
            LastName = request.LastName,
            CreatedAt = DateTime.UtcNow
        };

        await _userRepository.AddAsync(user, cancellationToken);
        await _userRepository.SaveChangesAsync(cancellationToken);

        // Publish user created event
        await _publisher.PublishAsync(new UserCreatedEvent
        {
            UserId = user.Id,
            Email = user.Email,
            FirstName = user.FirstName,
            LastName = user.LastName,
            CreatedAt = user.CreatedAt
        }, cancellationToken);

        return user.Id;
    }
}

public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPublisher _publisher;

    public OrderService(IOrderRepository orderRepository, IPublisher publisher)
    {
        _orderRepository = orderRepository;
        _publisher = publisher;
    }

    public async Task<int> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken cancellationToken = default)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            TotalAmount = request.TotalAmount,
            OrderDate = DateTime.UtcNow,
            Status = OrderStatus.Placed
        };

        await _orderRepository.AddAsync(order, cancellationToken);
        await _orderRepository.SaveChangesAsync(cancellationToken);

        // Publish order placed event
        await _publisher.PublishAsync(new OrderPlacedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            TotalAmount = order.TotalAmount,
            OrderDate = order.OrderDate
        }, cancellationToken);

        return order.Id;
    }
}

5. Use in Controllers

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpPost]
    public async Task<ActionResult<CreateUserResponse>> CreateUser(CreateUserRequest request)
    {
        var userId = await _userService.CreateUserAsync(request);
        return Ok(new CreateUserResponse { UserId = userId });
    }
}

Event Types

Domain Events

Events that represent core business occurrences:

public class CustomerRegisteredEvent : IEvent
{
    public int CustomerId { get; set; }
    public string Email { get; set; } = string.Empty;
    public DateTime RegisteredAt { get; set; } = DateTime.UtcNow;
}

public class ProductPriceChangedEvent : IEvent
{
    public int ProductId { get; set; }
    public decimal OldPrice { get; set; }
    public decimal NewPrice { get; set; }
    public DateTime ChangedAt { get; set; } = DateTime.UtcNow;
}

Integration Events

Events for external system integration:

public class PaymentProcessedEvent : IEvent
{
    public string PaymentId { get; set; } = string.Empty;
    public int OrderId { get; set; }
    public decimal Amount { get; set; }
    public PaymentStatus Status { get; set; }
    public DateTime ProcessedAt { get; set; } = DateTime.UtcNow;
}

Event Handlers

Email Notification Handlers

public class OrderShippedEmailHandler : IEventHandler<OrderShippedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepository;

    public OrderShippedEmailHandler(IEmailService emailService, ICustomerRepository customerRepository)
    {
        _emailService = emailService;
        _customerRepository = customerRepository;
    }

    public async Task HandleAsync(OrderShippedEvent request, CancellationToken cancellationToken = default)
    {
        var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken);
        if (customer == null) return;

        await _emailService.SendShippingNotificationAsync(new ShippingNotificationEmail
        {
            ToEmail = customer.Email,
            OrderId = request.OrderId,
            TrackingNumber = request.TrackingNumber,
            ShippingCarrier = request.ShippingCarrier
        }, cancellationToken);
    }
}

Integration Handlers

public class OrderPlacedInventoryUpdateHandler : IEventHandler<OrderPlacedEvent>
{
    private readonly IInventoryService _inventoryService;
    private readonly IPublisher _publisher;

    public OrderPlacedInventoryUpdateHandler(IInventoryService inventoryService, IPublisher publisher)
    {
        _inventoryService = inventoryService;
        _publisher = publisher;
    }

    public async Task HandleAsync(OrderPlacedEvent request, CancellationToken cancellationToken = default)
    {
        var previousStock = await _inventoryService.GetStockAsync(request.ProductId, cancellationToken);
        await _inventoryService.ReduceStockAsync(request.ProductId, request.Quantity, cancellationToken);
        var newStock = await _inventoryService.GetStockAsync(request.ProductId, cancellationToken);

        // Publish stock updated event
        await _publisher.PublishAsync(new ProductStockUpdatedEvent
        {
            ProductId = request.ProductId,
            PreviousStock = previousStock,
            NewStock = newStock,
            UpdatedAt = DateTime.UtcNow
        }, cancellationToken);
    }
}

Pre and Post Handlers

Pre-Handlers (Validation, Enrichment)

using Arcanic.Mediator.Event.Abstractions.Handler;

// Event validation pre-handler
public class OrderPlacedEventValidationPreHandler : IEventPreHandler<OrderPlacedEvent>
{
    public async Task HandleAsync(OrderPlacedEvent request, CancellationToken cancellationToken = default)
    {
        if (request.OrderId <= 0)
            throw new ValidationException("OrderId must be greater than 0");

        if (request.TotalAmount <= 0)
            throw new ValidationException("TotalAmount must be greater than 0");

        await Task.CompletedTask;
    }
}

// Event enrichment pre-handler
public class UserCreatedEventEnrichmentPreHandler : IEventPreHandler<UserCreatedEvent>
{
    private readonly IGeoLocationService _geoLocationService;

    public UserCreatedEventEnrichmentPreHandler(IGeoLocationService geoLocationService)
    {
        _geoLocationService = geoLocationService;
    }

    public async Task HandleAsync(UserCreatedEvent request, CancellationToken cancellationToken = default)
    {
        // Enrich event with additional context if needed
        await Task.CompletedTask;
    }
}

Post-Handlers (Logging, Cleanup)

// Audit logging post-handler
public class OrderEventsAuditPostHandler : IEventPostHandler<OrderPlacedEvent>
{
    private readonly IAuditService _auditService;

    public OrderEventsAuditPostHandler(IAuditService auditService)
    {
        _auditService = auditService;
    }

    public async Task HandleAsync(OrderPlacedEvent request, CancellationToken cancellationToken = default)
    {
        await _auditService.LogEventAsync(new AuditLogEntry
        {
            EventType = nameof(OrderPlacedEvent),
            EntityId = request.OrderId.ToString(),
            Timestamp = DateTime.UtcNow,
            Details = new { request.OrderId, request.CustomerId, request.TotalAmount }
        }, cancellationToken);
    }
}

Event Pipeline Behaviors

Event Logging

using Arcanic.Mediator.Event.Abstractions.Pipeline;

public class EventLoggingPipelineBehavior<TEvent> : IEventPipelineBehavior<TEvent>
    where TEvent : IEvent
{
    private readonly ILogger<EventLoggingPipelineBehavior<TEvent>> _logger;

    public EventLoggingPipelineBehavior(ILogger<EventLoggingPipelineBehavior<TEvent>> logger)
    {
        _logger = logger;
    }

    public async Task HandleAsync(TEvent @event, PipelineDelegate next, CancellationToken cancellationToken = default)
    {
        var eventName = typeof(TEvent).Name;
        
        _logger.LogInformation("Publishing event {EventName}: {@Event}", eventName, @event);

        try
        {
            await next(cancellationToken);
            _logger.LogInformation("Event {EventName} processed successfully", eventName);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing event {EventName}", eventName);
            throw;
        }
    }
}

Event Retry

public class EventRetryPipelineBehavior<TEvent> : IEventPipelineBehavior<TEvent>
    where TEvent : IEvent
{
    private readonly ILogger<EventRetryPipelineBehavior<TEvent>> _logger;

    public EventRetryPipelineBehavior(ILogger<EventRetryPipelineBehavior<TEvent>> logger)
    {
        _logger = logger;
    }

    public async Task HandleAsync(TEvent @event, PipelineDelegate next, CancellationToken cancellationToken = default)
    {
        const int maxRetries = 3;
        var retryCount = 0;

        while (retryCount <= maxRetries)
        {
            try
            {
                await next(cancellationToken);
                return; // Success
            }
            catch (Exception ex) when (retryCount < maxRetries && IsRetryableException(ex))
            {
                retryCount++;
                var delay = TimeSpan.FromSeconds(Math.Pow(2, retryCount - 1));
                
                _logger.LogWarning(ex, "Event {EventType} failed on attempt {Attempt}/{MaxRetries}. Retrying in {DelaySeconds}s", 
                    typeof(TEvent).Name, retryCount, maxRetries, delay.TotalSeconds);

                await Task.Delay(delay, cancellationToken);
            }
        }
    }

    private static bool IsRetryableException(Exception ex)
    {
        return ex is HttpRequestException or TimeoutException or TaskCanceledException;
    }
}

Registration and Configuration

Basic Registration

using Arcanic.Mediator;
using Arcanic.Mediator.Event;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddArcanicMediator()
    .AddEvents(Assembly.GetExecutingAssembly());

var app = builder.Build();

Registration with Pipeline Behaviors

builder.Services.AddArcanicMediator()
    // Add pipeline behaviors in order of execution
    .AddEventPipelineBehavior(typeof(EventLoggingPipelineBehavior<>))
    .AddEventPipelineBehavior(typeof(EventRetryPipelineBehavior<>))
    // Register events
    .AddEvents(Assembly.GetExecutingAssembly());

Advanced Configuration

var builder = WebApplication.CreateBuilder(args);

// Add dependencies
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Email services
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddScoped<IEmailService, EmailService>();

// Repositories and services
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IOrderService, OrderService>();

// Add Mediator
builder.Services.AddArcanicMediator()
    .AddEventPipelineBehavior(typeof(EventLoggingPipelineBehavior<>))
    .AddEventPipelineBehavior(typeof(EventRetryPipelineBehavior<>))
    .AddEvents(Assembly.GetExecutingAssembly());

var app = builder.Build();

Best Practices

1. Design Events for the Domain

// ✅ Good - Business-focused event
public class CustomerPromotedToVipEvent : IEvent
{
    public int CustomerId { get; set; }
    public decimal LifetimeValue { get; set; }
    public VipTier NewTier { get; set; }
    public DateTime PromotedAt { get; set; } = DateTime.UtcNow;
}

// ❌ Bad - Implementation-focused event
public class DatabaseTableUpdatedEvent : IEvent
{
    public string TableName { get; set; } = string.Empty;
    public Dictionary<string, object> UpdatedFields { get; set; } = new();
}

2. Keep Events Immutable and Self-Contained

// ✅ Good - Immutable with all necessary data
public class OrderCancelledEvent : IEvent
{
    public int OrderId { get; init; }
    public int CustomerId { get; init; }
    public decimal RefundAmount { get; init; }
    public string CancellationReason { get; init; } = string.Empty;
    public DateTime CancelledAt { get; init; } = DateTime.UtcNow;
}

3. Handle Failures Gracefully

public class UserCreatedWelcomeEmailHandler : IEventHandler<UserCreatedEvent>
{
    public async Task HandleAsync(UserCreatedEvent request, CancellationToken cancellationToken = default)
    {
        try
        {
            await _emailService.SendWelcomeEmailAsync(request.Email, cancellationToken);
        }
        catch (EmailServiceException ex)
        {
            _logger.LogError(ex, "Failed to send welcome email to user {UserId}", request.UserId);
            // Don't throw - email failure shouldn't break user creation
        }
    }
}

4. Use Meaningful Event Names

// ✅ Good - Clear, past-tense names
public class ProductReviewApprovedEvent : IEvent { }
public class ShippingAddressChangedEvent : IEvent { }
public class SubscriptionRenewedEvent : IEvent { }

// ❌ Bad - Unclear or present-tense names
public class ProductReviewEvent : IEvent { }  // What happened?
public class ChangeAddressEvent : IEvent { }  // Present tense

Testing

Unit Testing Event Handlers

public class UserCreatedWelcomeEmailHandlerTests
{
    private readonly Mock<IEmailService> _mockEmailService;
    private readonly UserCreatedWelcomeEmailHandler _handler;

    public UserCreatedWelcomeEmailHandlerTests()
    {
        _mockEmailService = new Mock<IEmailService>();
        _handler = new UserCreatedWelcomeEmailHandler(_mockEmailService.Object, Mock.Of<ILogger<UserCreatedWelcomeEmailHandler>>());
    }

    [Fact]
    public async Task HandleAsync_ValidEvent_SendsWelcomeEmail()
    {
        // Arrange
        var userCreatedEvent = new UserCreatedEvent
        {
            UserId = 123,
            Email = "john@example.com",
            FirstName = "John",
            LastName = "Doe"
        };

        // Act
        await _handler.HandleAsync(userCreatedEvent, CancellationToken.None);

        // Assert
        _mockEmailService.Verify(x => x.SendWelcomeEmailAsync(
            It.Is<WelcomeEmailRequest>(req => 
                req.ToEmail == "john@example.com" && 
                req.FirstName == "John"),
            It.IsAny<CancellationToken>()), 
            Times.Once);
    }
}

Integration Testing Event Publishing

public class EventPublishingIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public EventPublishingIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task CreateUser_PublishesUserCreatedEvent_TriggersHandlers()
    {
        // Arrange
        using var scope = _factory.Services.CreateScope();
        var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
        
        var request = new CreateUserRequest
        {
            Email = "test@example.com",
            FirstName = "Test",
            LastName = "User"
        };

        // Act
        var userId = await userService.CreateUserAsync(request);

        // Assert
        Assert.True(userId > 0);
        // Additional assertions for event handling effects
    }
}

Next Steps

Related Documentation

Clone this wiki locally