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,950 @@
# Domain Events Implementation Analysis & Plan
**Date:** 2025-11-03
**Module:** Identity Module (ColaFlow.Modules.Identity)
**Status:** Gap Analysis Complete - Implementation Required
---
## Executive Summary
### Current State
The Identity module has **partial domain events implementation**:
- ✅ Domain event infrastructure exists (base classes, AggregateRoot pattern)
-**11 domain events defined** in the domain layer
- ✅ Domain events are being **raised** in aggregates (Tenant, User)
-**Domain events are NOT being dispatched** (events are raised but never published)
-**No domain event handlers** implemented
- ❌ Repository pattern calls `SaveChangesAsync` directly, bypassing event dispatching
### Critical Finding
**Domain events are being collected but never published!** This means:
- Events like `TenantCreated`, `UserCreated`, `UserRoleAssigned` are raised but silently discarded
- No audit logging, no side effects, no cross-module notifications
- The infrastructure is 80% complete but missing the final critical piece
### Recommended Action
**Immediate implementation required** - Domain events are foundational for:
- Audit logging (required for compliance)
- Cross-module communication (required for modularity)
- Side effects (email notifications, cache invalidation, etc.)
- Event sourcing (future requirement)
---
## 1. Current State Assessment
### 1.1 Domain Event Infrastructure (✅ Complete)
#### Base Classes
**`ColaFlow.Shared.Kernel.Events.DomainEvent`**
```csharp
public abstract record DomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}
```
- ✅ Properly designed as record (immutable)
- ✅ Auto-generates EventId and timestamp
- ✅ Follows best practices
**`ColaFlow.Shared.Kernel.Common.AggregateRoot`**
```csharp
public abstract class AggregateRoot : Entity
{
private readonly List<DomainEvent> _domainEvents = new();
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(DomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
```
- ✅ Encapsulates domain events collection
- ✅ Provides AddDomainEvent method for aggregates
- ✅ Provides ClearDomainEvents for cleanup after dispatching
- ✅ Follows DDD best practices (encapsulation)
### 1.2 Domain Events Defined (✅ Complete)
#### Tenant Events (7 events)
| Event | File | Raised In | Purpose |
|-------|------|-----------|---------|
| `TenantCreatedEvent` | `Tenants/Events/` | `Tenant.Create()` | New tenant registration |
| `TenantActivatedEvent` | `Tenants/Events/` | `Tenant.Activate()` | Tenant reactivation |
| `TenantSuspendedEvent` | `Tenants/Events/` | `Tenant.Suspend()` | Tenant suspension |
| `TenantCancelledEvent` | `Tenants/Events/` | `Tenant.Cancel()` | Tenant cancellation |
| `TenantPlanUpgradedEvent` | `Tenants/Events/` | `Tenant.UpgradePlan()` | Plan upgrade |
| `SsoConfiguredEvent` | `Tenants/Events/` | `Tenant.ConfigureSso()` | SSO setup |
| `SsoDisabledEvent` | `Tenants/Events/` | `Tenant.DisableSso()` | SSO removal |
**Example:**
```csharp
public sealed record TenantCreatedEvent(Guid TenantId, string Slug) : DomainEvent;
```
#### User Events (4 events)
| Event | File | Raised In | Purpose |
|-------|------|-----------|---------|
| `UserCreatedEvent` | `Users/Events/` | `User.CreateLocal()` | Local user registration |
| `UserCreatedFromSsoEvent` | `Users/Events/` | `User.CreateFromSso()` | SSO user registration |
| `UserPasswordChangedEvent` | `Users/Events/` | `User.UpdatePassword()` | Password change |
| `UserSuspendedEvent` | `Users/Events/` | `User.Suspend()` | User suspension |
**Example:**
```csharp
public sealed record UserCreatedEvent(
Guid UserId,
string Email,
TenantId TenantId
) : DomainEvent;
```
### 1.3 Event Dispatching Infrastructure (❌ Missing in Identity Module)
#### ProjectManagement Module (Reference Implementation)
**`ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.UnitOfWork`**
```csharp
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events before saving
await DispatchDomainEventsAsync(cancellationToken);
// Save changes to database
return await _context.SaveChangesAsync(cancellationToken);
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
// Get all entities with domain events
var domainEntities = _context.ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
// Get all domain events
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
// Clear domain events from entities
domainEntities.ForEach(entity => entity.ClearDomainEvents());
// TODO: Dispatch domain events to handlers
// This will be implemented when we add MediatR
await Task.CompletedTask;
}
```
**Status:** ✅ Infrastructure exists in ProjectManagement module, ❌ Not implemented in Identity module
#### Identity Module (Current Implementation)
**`IdentityDbContext`**
- ❌ No `SaveChangesAsync` override
- ❌ No domain event dispatching
- ❌ No UnitOfWork pattern
**Repositories (TenantRepository, UserRepository, etc.)**
```csharp
public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
await _context.Tenants.AddAsync(tenant, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); // ❌ Direct call, bypasses events
}
```
**Problem:** Repositories call `DbContext.SaveChangesAsync()` directly, so domain events are never dispatched.
### 1.4 Domain Event Handlers (❌ Missing)
**Current State:**
- ❌ No `INotificationHandler<TEvent>` implementations
- ❌ No event handler folder structure
- ❌ MediatR registered in Application layer but not configured for domain events
**Expected Structure (Not Present):**
```
ColaFlow.Modules.Identity.Application/
├── EventHandlers/
│ ├── Tenants/
│ │ ├── TenantCreatedEventHandler.cs ❌ Missing
│ │ └── TenantPlanUpgradedEventHandler.cs ❌ Missing
│ └── Users/
│ ├── UserCreatedEventHandler.cs ❌ Missing
│ └── UserSuspendedEventHandler.cs ❌ Missing
```
---
## 2. Gap Analysis
### 2.1 What's Working
| Component | Status | Notes |
|-----------|--------|-------|
| Domain Event Base Class | ✅ Complete | Well-designed record with EventId and timestamp |
| AggregateRoot Pattern | ✅ Complete | Proper encapsulation of domain events collection |
| Domain Events Defined | ✅ Complete | 11 events defined and raised in aggregates |
| MediatR Registration | ✅ Complete | MediatR registered in Application layer |
### 2.2 What's Missing
| Component | Status | Impact | Priority |
|-----------|--------|--------|----------|
| **Event Dispatching in DbContext** | ❌ Missing | HIGH - Events never published | **CRITICAL** |
| **UnitOfWork Pattern** | ❌ Missing | HIGH - No transaction boundary for events | **CRITICAL** |
| **Domain Event Handlers** | ❌ Missing | HIGH - No side effects, no audit logging | **HIGH** |
| **MediatR Integration for Events** | ❌ Missing | HIGH - Events not routed to handlers | **CRITICAL** |
| **Repository Pattern Refactoring** | ❌ Missing | MEDIUM - Repositories bypass UnitOfWork | **HIGH** |
### 2.3 Missing Events (Day 6+ Features)
Based on Day 4-6 implementation, these events should exist but don't:
| Event | Scenario | Raised In | Priority |
|-------|----------|-----------|----------|
| `UserLoggedInEvent` | Login success | LoginCommandHandler | HIGH |
| `UserLoginFailedEvent` | Login failure | LoginCommandHandler | MEDIUM |
| `RefreshTokenGeneratedEvent` | Token refresh | RefreshTokenService | MEDIUM |
| `RefreshTokenRevokedEvent` | Token revocation | RefreshTokenService | MEDIUM |
| `UserRoleAssignedEvent` | Role assignment | AssignUserRoleCommand | **HIGH** |
| `UserRoleUpdatedEvent` | Role change | AssignUserRoleCommand | **HIGH** |
| `UserRemovedFromTenantEvent` | User removal | RemoveUserFromTenantCommand | **HIGH** |
| `UserTokensRevokedEvent` | Token revocation | RemoveUserFromTenantCommand | MEDIUM |
---
## 3. Recommended Architecture
### 3.1 Domain Event Dispatching Pattern
**Option A: Dispatch in DbContext.SaveChangesAsync (Recommended)**
**Pros:**
- ✅ Centralized event dispatching
- ✅ Consistent across all operations
- ✅ Events dispatched within transaction boundary
- ✅ Follows EF Core best practices
**Cons:**
- ⚠️ Requires overriding `SaveChangesAsync` in each module's DbContext
- ⚠️ Tight coupling to EF Core
**Implementation:**
```csharp
// IdentityDbContext.cs
public class IdentityDbContext : DbContext
{
private readonly IMediator _mediator;
public IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantContext tenantContext,
IMediator mediator) // ✅ Inject MediatR
: base(options)
{
_tenantContext = tenantContext;
_mediator = mediator;
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events BEFORE saving
await DispatchDomainEventsAsync(cancellationToken);
// Save changes to database
return await base.SaveChangesAsync(cancellationToken);
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
// Get all aggregate roots with domain events
var domainEntities = ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
// Get all domain events
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
// Clear domain events from entities
domainEntities.ForEach(entity => entity.ClearDomainEvents());
// Dispatch events to handlers via MediatR
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
}
```
**Option B: Dispatch in UnitOfWork (Alternative)**
**Pros:**
- ✅ Decouples from DbContext
- ✅ Testable without EF Core
- ✅ Follows Clean Architecture more strictly
**Cons:**
- ⚠️ Requires UnitOfWork pattern implementation
- ⚠️ More boilerplate code
- ⚠️ Repositories must use UnitOfWork instead of direct SaveChangesAsync
**Not recommended for now** - Option A is simpler and sufficient for current needs.
### 3.2 MediatR Configuration
**Current Configuration:**
```csharp
// Application/DependencyInjection.cs
public static IServiceCollection AddIdentityApplication(this IServiceCollection services)
{
// MediatR
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
// FluentValidation
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
return services;
}
```
**Status:** ✅ Already configured for commands/queries, will automatically handle domain events
**How MediatR Works:**
1. Domain events inherit from `DomainEvent` (which is a record)
2. Event handlers implement `INotificationHandler<TEvent>`
3. `_mediator.Publish(event)` dispatches to ALL handlers
**Key Point:** MediatR treats domain events as notifications (pub-sub pattern), so multiple handlers can react to the same event.
### 3.3 Domain Event Handler Pattern
**Handler Structure:**
```csharp
// Application/EventHandlers/Users/UserCreatedEventHandler.cs
public class UserCreatedEventHandler : INotificationHandler<UserCreatedEvent>
{
private readonly IAuditLogRepository _auditLogRepository;
private readonly ILogger<UserCreatedEventHandler> _logger;
public UserCreatedEventHandler(
IAuditLogRepository auditLogRepository,
ILogger<UserCreatedEventHandler> logger)
{
_auditLogRepository = auditLogRepository;
_logger = logger;
}
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User {UserId} created in tenant {TenantId}",
notification.UserId,
notification.TenantId);
// Example: Log to audit trail
var auditLog = AuditLog.Create(
entityType: "User",
entityId: notification.UserId,
action: "Created",
performedBy: notification.UserId, // Self-registration
timestamp: notification.OccurredOn);
await _auditLogRepository.AddAsync(auditLog, cancellationToken);
}
}
```
**Multiple Handlers for Same Event:**
```csharp
// Application/EventHandlers/Users/UserCreatedEmailNotificationHandler.cs
public class UserCreatedEmailNotificationHandler : INotificationHandler<UserCreatedEvent>
{
private readonly IEmailService _emailService;
public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{
// Send welcome email
await _emailService.SendWelcomeEmailAsync(
notification.Email,
notification.UserId,
cancellationToken);
}
}
```
**Key Benefits:**
- ✅ Single Responsibility Principle (each handler does one thing)
- ✅ Decoupled side effects (audit, email, cache, etc.)
- ✅ Easy to add new handlers without modifying existing code
---
## 4. Implementation Plan
### Option A: Implement Now (Recommended)
**Reasoning:**
- Domain events are fundamental to the architecture
- Required for Day 6 features (role management audit)
- Critical for audit logging and compliance
- Relatively small implementation effort (2-4 hours)
**Timeline:** Day 6 (Today) - Implement alongside role management features
---
### Option B: Implement in Day 7
**Reasoning:**
- Can defer if Day 6 deadline is tight
- Focus on completing role management first
- Implement events as cleanup/refactoring task
**Timeline:** Day 7 (Tomorrow) - Dedicated domain events implementation day
---
### Option C: Incremental Implementation
**Reasoning:**
- Implement infrastructure first (dispatching in DbContext)
- Add event handlers incrementally as needed
- Start with critical events (UserCreated, TenantCreated, UserRoleAssigned)
**Timeline:** Days 6-8 - Spread across multiple days
---
### ✅ RECOMMENDED: Option C (Incremental Implementation)
**Phase 1: Infrastructure (Day 6, ~1 hour)**
1. Override `SaveChangesAsync` in `IdentityDbContext`
2. Implement `DispatchDomainEventsAsync` method
3. Inject `IMediator` into DbContext
4. Test that events are being published (add logging)
**Phase 2: Critical Event Handlers (Day 6-7, ~2 hours)**
1. `UserCreatedEventHandler` - Audit logging
2. `TenantCreatedEventHandler` - Audit logging
3. `UserRoleAssignedEventHandler` - Audit logging + cache invalidation
**Phase 3: Additional Event Handlers (Day 7-8, ~2 hours)**
1. `UserLoggedInEvent` + handler - Login audit trail
2. `RefreshTokenRevokedEvent` + handler - Security audit
3. `TenantSuspendedEvent` + handler - Notify users, revoke tokens
**Phase 4: Future Events (Day 9+)**
1. Email verification events
2. Password reset events
3. SSO events
4. Cross-module integration events
---
## 5. Step-by-Step Implementation Guide
### Step 1: Add Domain Event Dispatching to DbContext
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs`
**Changes:**
```csharp
using ColaFlow.Shared.Kernel.Common;
using MediatR;
public class IdentityDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
private readonly IMediator _mediator; // ✅ Add
public IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantContext tenantContext,
IMediator mediator) // ✅ Add
: base(options)
{
_tenantContext = tenantContext;
_mediator = mediator; // ✅ Add
}
// ✅ Add SaveChangesAsync override
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await DispatchDomainEventsAsync(cancellationToken);
return await base.SaveChangesAsync(cancellationToken);
}
// ✅ Add DispatchDomainEventsAsync method
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
var domainEntities = ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
}
}
```
**Estimated Time:** 15 minutes
---
### Step 2: Create Missing Domain Events
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserRoleAssignedEvent.cs`
```csharp
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,
Guid AssignedBy
) : DomainEvent;
```
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserRemovedFromTenantEvent.cs`
```csharp
public sealed record UserRemovedFromTenantEvent(
Guid UserId,
TenantId TenantId,
Guid RemovedBy
) : DomainEvent;
```
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/UserLoggedInEvent.cs`
```csharp
public sealed record UserLoggedInEvent(
Guid UserId,
TenantId TenantId,
string IpAddress,
string UserAgent
) : DomainEvent;
```
**Estimated Time:** 30 minutes
---
### Step 3: Raise Events in Aggregates
**Update:** `AssignUserRoleCommandHandler` to raise `UserRoleAssignedEvent`
```csharp
// AssignUserRoleCommandHandler.cs
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
{
// ... existing validation logic ...
// Create or update role assignment
var userTenantRole = UserTenantRole.Create(userId, tenantId, request.Role);
await _userTenantRoleRepository.AddOrUpdateAsync(userTenantRole, cancellationToken);
// ✅ Raise domain event (if we make UserTenantRole an AggregateRoot)
// OR raise event from User aggregate
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
if (user != null)
{
user.AddDomainEvent(new UserRoleAssignedEvent(
userId.Value,
tenantId,
request.Role,
currentUserId)); // From JWT claims
}
return Unit.Value;
}
```
**Estimated Time:** 1 hour (refactor command handlers)
---
### Step 4: Create Event Handlers
**File:** `src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/Users/UserRoleAssignedEventHandler.cs`
```csharp
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers.Users;
public 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} by user {AssignedBy}",
notification.UserId,
notification.Role,
notification.TenantId,
notification.AssignedBy);
// TODO: Add to audit log
// TODO: Invalidate user's cached permissions
// TODO: Send notification to user
return Task.CompletedTask;
}
}
```
**Estimated Time:** 30 minutes per handler (create 3-5 handlers)
---
### Step 5: Test Domain Events
**Test Script:**
```csharp
// Integration test
[Fact]
public async Task AssignUserRole_Should_Raise_UserRoleAssignedEvent()
{
// Arrange
var command = new AssignUserRoleCommand(userId, tenantId, TenantRole.Admin);
// Act
await _mediator.Send(command);
// Assert
// Verify event was raised and handled
_mockLogger.Verify(
x => x.LogInformation(
It.Is<string>(s => s.Contains("User") && s.Contains("assigned role")),
It.IsAny<object[]>()),
Times.Once);
}
```
**Manual Test:**
1. Assign a role to a user via API
2. Check logs for "User {UserId} assigned role {Role}"
3. Verify event was published and handler executed
**Estimated Time:** 30 minutes
---
## 6. Priority Assessment
### Critical Events (Implement in Day 6)
| Event | Scenario | Handler Actions | Priority |
|-------|----------|----------------|----------|
| `UserRoleAssignedEvent` | Role assignment | Audit log, cache invalidation, notification | **CRITICAL** |
| `UserRemovedFromTenantEvent` | User removal | Audit log, revoke tokens, cleanup | **CRITICAL** |
| `TenantCreatedEvent` | Tenant registration | Audit log, send welcome email | **HIGH** |
| `UserCreatedEvent` | User registration | Audit log, send welcome email | **HIGH** |
### High Priority Events (Implement in Day 7)
| Event | Scenario | Handler Actions | Priority |
|-------|----------|----------------|----------|
| `UserLoggedInEvent` | Login success | Audit log, update LastLoginAt | **HIGH** |
| `RefreshTokenRevokedEvent` | Token revocation | Audit log, security notification | **HIGH** |
| `TenantSuspendedEvent` | Tenant suspension | Notify users, revoke all tokens | **HIGH** |
| `UserSuspendedEvent` | User suspension | Revoke tokens, audit log | **HIGH** |
### Medium Priority Events (Implement in Day 8+)
| Event | Scenario | Handler Actions | Priority |
|-------|----------|----------------|----------|
| `UserPasswordChangedEvent` | Password change | Audit log, revoke old tokens, email notification | MEDIUM |
| `TenantPlanUpgradedEvent` | Plan upgrade | Update limits, audit log, send invoice | MEDIUM |
| `SsoConfiguredEvent` | SSO setup | Audit log, notify admins | MEDIUM |
---
## 7. Risks & Mitigation
### Risk 1: Performance Impact
**Risk:** Dispatching many events could slow down SaveChangesAsync
**Mitigation:**
- Domain events are published in-process (fast)
- Consider async background processing for non-critical events (future)
- Monitor performance with logging
### Risk 2: Event Handler Failures
**Risk:** Event handler throws exception, entire transaction rolls back
**Mitigation:**
- Wrap event dispatching in try-catch
- Log exceptions but don't fail transaction
- Consider eventual consistency for non-critical handlers
### Risk 3: Event Ordering
**Risk:** Events might be processed out of order
**Mitigation:**
- Events are dispatched in the order they were raised (in single transaction)
- Use OccurredOn timestamp for ordering if needed
- Consider event sequence numbers (future)
### Risk 4: Missing Events
**Risk:** Forgetting to raise events in new features
**Mitigation:**
- Document event-raising conventions
- Code review checklist
- Integration tests to verify events are raised
---
## 8. Success Metrics
### Implementation Success Criteria
**Phase 1: Infrastructure (Day 6)**
-`SaveChangesAsync` override implemented in IdentityDbContext
- ✅ Domain events are being published (verified via logging)
- ✅ No breaking changes to existing functionality
- ✅ Unit tests pass
**Phase 2: Critical Handlers (Day 6-7)**
- ✅ 3-5 event handlers implemented and tested
- ✅ Audit logs are being created for critical operations
- ✅ Events are visible in application logs
- ✅ Integration tests verify event handling
**Phase 3: Full Coverage (Day 8+)**
- ✅ All 15+ events have at least one handler
- ✅ Audit logging complete for all major operations
- ✅ Cross-module events work correctly
- ✅ Performance impact is acceptable (<10ms per event)
---
## 9. Example: Complete Event Flow
### Scenario: User Role Assignment
**1. Domain Event Definition**
```csharp
// Domain/Aggregates/Users/Events/UserRoleAssignedEvent.cs
public sealed record UserRoleAssignedEvent(
Guid UserId,
TenantId TenantId,
TenantRole Role,
Guid AssignedBy
) : DomainEvent;
```
**2. Raise Event in Aggregate**
```csharp
// Domain/Aggregates/Users/User.cs
public class User : AggregateRoot
{
public void AssignRole(TenantRole role, Guid assignedBy)
{
// Business logic validation
if (Status == UserStatus.Deleted)
throw new InvalidOperationException("Cannot assign role to deleted user");
// Raise domain event
AddDomainEvent(new UserRoleAssignedEvent(
Id,
TenantId,
role,
assignedBy));
}
}
```
**3. Event Handler (Audit Logging)**
```csharp
// Application/EventHandlers/Users/UserRoleAssignedAuditHandler.cs
public class UserRoleAssignedAuditHandler : INotificationHandler<UserRoleAssignedEvent>
{
private readonly IAuditLogRepository _auditLogRepository;
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
{
var auditLog = AuditLog.Create(
entityType: "User",
entityId: notification.UserId,
action: $"RoleAssigned:{notification.Role}",
performedBy: notification.AssignedBy,
timestamp: notification.OccurredOn,
tenantId: notification.TenantId);
await _auditLogRepository.AddAsync(auditLog, cancellationToken);
}
}
```
**4. Event Handler (Cache Invalidation)**
```csharp
// Application/EventHandlers/Users/UserRoleAssignedCacheHandler.cs
public class UserRoleAssignedCacheHandler : INotificationHandler<UserRoleAssignedEvent>
{
private readonly IDistributedCache _cache;
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
{
// Invalidate user's permissions cache
var cacheKey = $"user:permissions:{notification.UserId}";
await _cache.RemoveAsync(cacheKey, cancellationToken);
}
}
```
**5. Event Handler (Notification)**
```csharp
// Application/EventHandlers/Users/UserRoleAssignedNotificationHandler.cs
public class UserRoleAssignedNotificationHandler : INotificationHandler<UserRoleAssignedEvent>
{
private readonly INotificationService _notificationService;
public async Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
{
// Send notification to user
await _notificationService.SendAsync(
userId: notification.UserId,
title: "Role Updated",
message: $"Your role has been changed to {notification.Role}",
cancellationToken);
}
}
```
**6. Dispatching Flow**
```
User calls: POST /api/tenants/{tenantId}/users/{userId}/role
→ AssignUserRoleCommandHandler
→ user.AssignRole(role, currentUserId)
→ user.AddDomainEvent(new UserRoleAssignedEvent(...))
→ _userRepository.UpdateAsync(user)
→ _context.SaveChangesAsync()
→ DispatchDomainEventsAsync()
→ _mediator.Publish(UserRoleAssignedEvent)
→ UserRoleAssignedAuditHandler.Handle()
→ UserRoleAssignedCacheHandler.Handle()
→ UserRoleAssignedNotificationHandler.Handle()
→ base.SaveChangesAsync() // Commit transaction
```
---
## 10. Next Steps
### Immediate Actions (Day 6)
1. **Implement Domain Event Dispatching**
- Override `SaveChangesAsync` in `IdentityDbContext`
- Inject `IMediator` into DbContext
- Test event dispatching with logging
2. **Create Missing Events**
- `UserRoleAssignedEvent`
- `UserRemovedFromTenantEvent`
- `UserLoggedInEvent`
3. **Implement Critical Handlers**
- `UserRoleAssignedEventHandler` (audit logging)
- `TenantCreatedEventHandler` (audit logging)
- `UserCreatedEventHandler` (audit logging)
### Follow-up Actions (Day 7-8)
4. **Expand Event Coverage**
- Add handlers for all existing 11 domain events
- Implement audit logging for all major operations
- Add cache invalidation handlers where needed
5. **Testing & Validation**
- Integration tests for event handling
- Performance testing (event dispatching overhead)
- Audit log verification
6. **Documentation**
- Update architecture documentation
- Document event-raising conventions
- Create event handler development guide
---
## 11. Conclusion
### Summary
**Current State:**
- Domain event infrastructure: 80% complete
- Domain events defined: 11 events (sufficient for Day 1-6)
- Critical gap: Event dispatching not implemented
**Recommended Action:**
- Implement domain event dispatching in Day 6 (1 hour)
- Add critical event handlers alongside Day 6 features (2 hours)
- Complete event coverage in Day 7-8 (2-4 hours)
**Total Effort:** 5-7 hours spread across Days 6-8
**Value:**
- Complete audit trail for compliance
- Foundation for cross-module communication
- Side effects (notifications, cache invalidation)
- Event sourcing ready (future)
### Decision
**Proceed with Option C (Incremental Implementation)**
- Phase 1 (Day 6): Infrastructure + critical handlers
- Phase 2 (Day 7-8): Complete event coverage
- Phase 3 (Day 9+): Advanced features (background processing, event sourcing)
---
**Document Status:** Analysis Complete
**Recommended Decision:** Implement domain events incrementally starting Day 6
**Next Review:** After Phase 1 implementation
**Owner:** Backend Team
**Last Updated:** 2025-11-03

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);
}
}
}

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />

View File

@@ -1,9 +1,11 @@
using MediatR;
namespace ColaFlow.Shared.Kernel.Events;
/// <summary>
/// Base class for all domain events
/// </summary>
public abstract record DomainEvent
public abstract record DomainEvent : INotification
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;

View File

@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using FluentAssertions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Moq;
@@ -11,6 +12,7 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Tests.Persistence;
public class GlobalQueryFilterTests : IDisposable
{
private readonly Mock<ITenantContext> _mockTenantContext;
private readonly Mock<IMediator> _mockMediator;
private readonly IdentityDbContext _context;
public GlobalQueryFilterTests()
@@ -20,7 +22,8 @@ public class GlobalQueryFilterTests : IDisposable
.Options;
_mockTenantContext = new Mock<ITenantContext>();
_context = new IdentityDbContext(options, _mockTenantContext.Object);
_mockMediator = new Mock<IMediator>();
_context = new IdentityDbContext(options, _mockTenantContext.Object, _mockMediator.Object);
}
[Fact]
@@ -39,7 +42,8 @@ public class GlobalQueryFilterTests : IDisposable
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var mockMediator = new Mock<IMediator>();
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
var user1 = User.CreateLocal(
tenant1Id,
@@ -81,7 +85,8 @@ public class GlobalQueryFilterTests : IDisposable
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var mockMediator = new Mock<IMediator>();
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("admin@tenant1.com"), "pass", FullName.Create("Admin One"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("admin@tenant2.com"), "pass", FullName.Create("Admin Two"));
@@ -111,7 +116,8 @@ public class GlobalQueryFilterTests : IDisposable
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var mockMediator = new Mock<IMediator>();
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("user1@test.com"), "pass", FullName.Create("User One"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("user2@test.com"), "pass", FullName.Create("User Two"));
@@ -142,7 +148,8 @@ public class GlobalQueryFilterTests : IDisposable
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new IdentityDbContext(options, mockTenantContext.Object);
var mockMediator = new Mock<IMediator>();
using var context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
var user1 = User.CreateLocal(tenant1Id, Email.Create("john@tenant1.com"), "pass", FullName.Create("John Doe"));
var user2 = User.CreateLocal(tenant2Id, Email.Create("jane@tenant2.com"), "pass", FullName.Create("Jane Doe"));

View File

@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using FluentAssertions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Moq;
@@ -22,7 +23,9 @@ public class TenantRepositoryTests : IDisposable
var mockTenantContext = new Mock<ITenantContext>();
mockTenantContext.Setup(x => x.IsSet).Returns(false);
_context = new IdentityDbContext(options, mockTenantContext.Object);
var mockMediator = new Mock<IMediator>();
_context = new IdentityDbContext(options, mockTenantContext.Object, mockMediator.Object);
_repository = new TenantRepository(_context);
}