feat(backend): Implement Domain Events infrastructure and handlers

Add complete domain events dispatching infrastructure and critical event handlers for Identity module.

Changes:
- Added IMediator injection to IdentityDbContext
- Implemented SaveChangesAsync override to dispatch domain events before persisting
- Made DomainEvent base class implement INotification (added MediatR.Contracts dependency)
- Created 3 new domain events: UserRoleAssignedEvent, UserRemovedFromTenantEvent, UserLoggedInEvent
- Implemented 4 event handlers with structured logging:
  - UserRoleAssignedEventHandler (audit log, cache invalidation placeholder)
  - UserRemovedFromTenantEventHandler (notification placeholder)
  - UserLoggedInEventHandler (login tracking placeholder)
  - TenantCreatedEventHandler (welcome email placeholder)
- Updated unit tests to inject mock IMediator into IdentityDbContext

Technical Details:
- Domain events are now published via MediatR within the same transaction
- Events are dispatched BEFORE SaveChangesAsync to ensure atomicity
- Event handlers auto-registered by MediatR assembly scanning
- All handlers include structured logging for observability

Next Steps (Phase 3):
- Update command handlers to raise new events (UserLoggedInEvent, UserRoleAssignedEvent)
- Add event raising logic to User/Tenant aggregates
- Implement audit logging persistence (currently just logging)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 20:33:36 +01:00
parent 709068f68b
commit 0e503176c4
13 changed files with 1170 additions and 8 deletions

View File

@@ -0,0 +1,29 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class TenantCreatedEventHandler : INotificationHandler<TenantCreatedEvent>
{
private readonly ILogger<TenantCreatedEventHandler> _logger;
public TenantCreatedEventHandler(ILogger<TenantCreatedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Tenant {TenantId} created with slug '{Slug}'",
notification.TenantId,
notification.Slug);
// Future: Send welcome email
// Future: Initialize default settings
// Future: Create audit log entry
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,30 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserLoggedInEventHandler : INotificationHandler<UserLoggedInEvent>
{
private readonly ILogger<UserLoggedInEventHandler> _logger;
public UserLoggedInEventHandler(ILogger<UserLoggedInEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserLoggedInEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User {UserId} logged in to tenant {TenantId} from IP {IpAddress}",
notification.UserId,
notification.TenantId.Value,
notification.IpAddress);
// Future: Track login history
// Future: Detect suspicious login patterns
// Future: Update last login timestamp
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,30 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserRemovedFromTenantEventHandler : INotificationHandler<UserRemovedFromTenantEvent>
{
private readonly ILogger<UserRemovedFromTenantEventHandler> _logger;
public UserRemovedFromTenantEventHandler(ILogger<UserRemovedFromTenantEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserRemovedFromTenantEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User {UserId} removed from tenant {TenantId}. Removed by: {RemovedBy}. Reason: {Reason}",
notification.UserId,
notification.TenantId.Value,
notification.RemovedBy,
notification.Reason);
// Future: Send notification to user
// Future: Audit log
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,32 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserRoleAssignedEventHandler : INotificationHandler<UserRoleAssignedEvent>
{
private readonly ILogger<UserRoleAssignedEventHandler> _logger;
public UserRoleAssignedEventHandler(ILogger<UserRoleAssignedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User {UserId} assigned role {Role} in tenant {TenantId}. Previous role: {PreviousRole}. Assigned by: {AssignedBy}",
notification.UserId,
notification.Role,
notification.TenantId.Value,
notification.PreviousRole,
notification.AssignedBy);
// Future: Add audit log to database
// Future: Invalidate user permissions cache
// Future: Send notification to user
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,11 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserLoggedInEvent(
Guid UserId,
TenantId TenantId,
string IpAddress,
string UserAgent
) : DomainEvent;

View File

@@ -0,0 +1,11 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserRemovedFromTenantEvent(
Guid UserId,
TenantId TenantId,
Guid RemovedBy,
string Reason
) : DomainEvent;

View File

@@ -0,0 +1,12 @@
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
public sealed record UserRoleAssignedEvent(
Guid UserId,
TenantId TenantId,
TenantRole Role,
TenantRole? PreviousRole,
Guid AssignedBy
) : DomainEvent;

View File

@@ -1,6 +1,8 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using ColaFlow.Shared.Kernel.Common;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
@@ -8,13 +10,16 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
public class IdentityDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
private readonly IMediator _mediator;
public IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantContext tenantContext)
ITenantContext tenantContext,
IMediator mediator)
: base(options)
{
_tenantContext = tenantContext;
_mediator = mediator;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
@@ -50,4 +55,43 @@ public class IdentityDbContext : DbContext
{
return Set<T>().IgnoreQueryFilters();
}
/// <summary>
/// Override SaveChangesAsync to dispatch domain events before saving
/// </summary>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events BEFORE saving changes
await DispatchDomainEventsAsync(cancellationToken);
// Save changes to database
return await base.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Dispatch domain events to handlers via MediatR
/// </summary>
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
// Get all aggregate roots with pending domain events
var domainEntities = ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
// Collect all domain events
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
// Clear events from aggregates (prevent double-publishing)
domainEntities.ForEach(entity => entity.ClearDomainEvents());
// Publish each event via MediatR
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
}