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:
950
colaflow-api/DOMAIN-EVENTS-ANALYSIS.md
Normal file
950
colaflow-api/DOMAIN-EVENTS-ANALYSIS.md
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user