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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user