-
Notifications
You must be signed in to change notification settings - Fork 0
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.
- Overview
- Installation
- Basic Usage
- Event Types
- Event Handlers
- Pre and Post Handlers
- Event Pipeline Behaviors
- Registration and Configuration
- Best Practices
- Testing
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
- 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
Install the Event module package:
dotnet add package Arcanic.Mediator.EventFor abstractions only (useful in domain/application layers):
dotnet add package Arcanic.Mediator.Event.AbstractionsEvents 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;
}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);
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Event;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddEvents(Assembly.GetExecutingAssembly());
var app = builder.Build();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;
}
}[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 });
}
}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;
}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;
}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);
}
}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);
}
}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;
}
}// 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);
}
}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;
}
}
}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;
}
}using Arcanic.Mediator;
using Arcanic.Mediator.Event;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArcanicMediator()
.AddEvents(Assembly.GetExecutingAssembly());
var app = builder.Build();builder.Services.AddArcanicMediator()
// Add pipeline behaviors in order of execution
.AddEventPipelineBehavior(typeof(EventLoggingPipelineBehavior<>))
.AddEventPipelineBehavior(typeof(EventRetryPipelineBehavior<>))
// Register events
.AddEvents(Assembly.GetExecutingAssembly());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();// ✅ 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();
}// ✅ 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;
}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
}
}
}// ✅ 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 tensepublic 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);
}
}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
}
}- Command Module - Learn about write operations and command handling
- Query Module - Learn about read operations and query handling
- Pipeline Behaviors - Advanced cross-cutting concerns
- Getting Started - Basic setup and first steps
- Sample Projects - Complete examples