CQRS Pipeline
SCRIPE mediator pipeline behaviors: LoggingBehavior, ValidationBehavior, FeatureCheckBehavior, WebhookDispatchBehavior, CachingBehavior, Result pattern, and full command/query map.
Every command and query in SCRIPE flows through a configurable SCRIPE mediator pipeline with 5 built-in behaviors: LoggingBehavior, ValidationBehavior, FeatureCheckBehavior, WebhookDispatchBehavior, and CachingBehavior. The order is managed from appsettings or environment variables and validated at startup.
Pipeline Overview
The default SCRIPE mediator order is Logging -> Validation -> FeatureCheck -> WebhookDispatch -> Caching -> Handler. Validation and feature checks intentionally run before cache lookup, while cache invalidation unwinds before webhook dispatch after successful mutations.
Command vs Query Separation
CQRS separates the application into two distinct paths: Commands (writes) mutate state and go through full validation + audit, while Queries (reads) are optimized for performance with caching and AsNoTracking.
Commands (Write)
- Modify state (Create, Update, Delete)
- Return Result<T> with success value or error
- Validated by FluentValidation
- Trigger domain events
- Invalidate caches
- Logged with full request body
- Example: CreateAdminCommand, DeleteUserCommand
Queries (Read)
- Read-only — never modify state
- Return Result<T> with data or error
- May use caching (CachingBehavior)
- No side effects
- May use IDataScopeService for tenant filtering
- Optimized with AsNoTracking()
- Example: GetAdminsQuery, GetTenantByIdQuery
Result Pattern
All handlers return Result<T> instead of throwing exceptions for expected failures. Result<T> is a discriminated union with IsSuccess/IsFailure, Value (on success), and AppError (on failure). This eliminates try-catch blocks in controllers and enables pattern matching.
/// <summary>
/// Discriminated union for operation results.
/// Eliminates exceptions for expected failure cases.
/// </summary>
public class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T Value { get; } // Only valid when IsSuccess
public AppError Error { get; } // Only valid when IsFailure
// Factory methods
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(AppError error) => new(error);
// Implicit conversions
public static implicit operator Result<T>(T value) => Success(value);
public static implicit operator Result<T>(AppError error) => Failure(error);
}
/// <summary>
/// Structured error with code, message, and optional details.
/// </summary>
public record AppError(
string Code, // e.g., "Admin.NotFound"
string Message, // e.g., "Admin with ID {id} was not found"
object? Details = null // Optional additional context
);[HttpGet("{id}")]
public async Task<IActionResult> GetAdmin(string id)
{
var result = await _sender.Send(new GetAdminByIdQuery(id));
return result.IsSuccess
? Ok(result.Value) // 200 with data
: NotFound(new { // 404 with error
error = result.Error.Message
});
}ValidationBehavior
The ValidationBehavior runs immediately after logging. It collects all IValidator<TRequest> validators, returns structured Result failures for invalid requests, and prevents invalid requests from reaching handlers or caches.
/// <summary>
/// AstraFlow mediator pipeline behavior that runs FluentValidation validators
/// BEFORE the request handler executes.
/// If validation fails, returns Result.Failure without hitting the handler.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : AstraFlow.Mediator.IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!_validators.Any())
return await next(); // No validators registered
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(result => result.Errors)
.Where(failure => failure != null)
.ToList();
if (failures.Any())
{
// Return validation error without executing handler
var errors = failures.Select(f => new {
Field = f.PropertyName,
Message = f.ErrorMessage
});
throw new ValidationException(failures);
}
return await next(); // All valid — proceed to handler
}
}Validator Examples
public class CreateAdminCommandValidator
: AbstractValidator<CreateAdminCommand>
{
public CreateAdminCommandValidator(IAdminRepository repo)
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MustAsync(async (email, ct) =>
!await repo.EmailExistsAsync(email, ct))
.WithMessage("Email already in use");
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First name is required")
.MaximumLength(100);
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches("[A-Z]").WithMessage("Must contain uppercase")
.Matches("[a-z]").WithMessage("Must contain lowercase")
.Matches("[0-9]").WithMessage("Must contain digit")
.Matches("[^a-zA-Z0-9]").WithMessage("Must contain special char");
}
}LoggingBehavior
The LoggingBehavior logs every SCRIPE mediator request with the user ID, tenant ID, request type, and execution time. Requests exceeding 500ms are logged as warnings for performance monitoring.
/// <summary>
/// Pipeline behavior that logs every AstraFlow mediator request with timing.
/// Logs: request type, user ID, tenant ID, execution time, and outcome.
/// Warns if execution exceeds 500ms threshold.
/// </summary>
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : AstraFlow.Mediator.IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
var userId = _currentUser.GetUserId();
var tenantId = _currentUser.GetTenantId();
_logger.LogInformation(
"Handling {RequestName} | User: {UserId} | Tenant: {TenantId}",
requestName, userId, tenantId);
var stopwatch = Stopwatch.StartNew();
var response = await next();
stopwatch.Stop();
var elapsed = stopwatch.ElapsedMilliseconds;
if (elapsed > 500) // Slow request warning
{
_logger.LogWarning(
"SLOW: {RequestName} took {ElapsedMs}ms | User: {UserId}",
requestName, elapsed, userId);
}
else
{
_logger.LogInformation(
"Completed {RequestName} in {ElapsedMs}ms",
requestName, elapsed);
}
return response;
}
}CachingBehavior
The CachingBehavior intercepts queries that implement the ICacheable interface, performing tenant-scoped cache lookups. To prevent concurrent cache stampedes under high load, it relies on key-specific SemaphoreSlim locks to serialize database reads on miss. It also handles mutation invalidation via IInvalidatesCache, clearing exact keys or prefix-based namespaces. Furthermore, it incorporates key eviction to limit growth and links feature configurations to a global eviction token source for instant, thread-safe cache invalidation.
/// <summary>
/// Pipeline behavior that handles query caching (ICacheable) and mutation invalidation (IInvalidatesCache).
/// Integrates with the cache stampede prevention, eviction controls, and tenant feature token caching.
/// </summary>
public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : AstraFlow.Mediator.IRequest<TResponse>
{
private readonly ICacheService _cache;
private readonly ICurrentUser _currentUser;
private readonly ILogger<CachingBehavior<TRequest, TResponse>> _logger;
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
// 1. Invalidation Check (IInvalidatesCache) for Mutations
if (request is IInvalidatesCache invalidator)
{
var response = await next();
// Evict exact keys and prefix-based namespaces (e.g., Module regions)
foreach (var key in invalidator.CacheKeysToInvalidate)
await _cache.RemoveAsync(key, ct);
foreach (var prefix in invalidator.CachePrefixesToInvalidate)
await _cache.RemoveByPrefixAsync(prefix, ct);
return response;
}
// 2. Cache Read Check (ICacheable) for Queries
if (request is not ICacheable cacheable)
return await next();
// Build scoped cache key to prevent cross-tenant/cross-user data leaks
var scopedKey = BuildScopedCacheKey(cacheable.CacheKey);
// Retrieve or generate with stampede prevention locks and double-checked locking
return await _cache.GetOrCreateAsync(
scopedKey,
async () => await next(),
cacheable.Expiration ?? TimeSpan.FromMinutes(30),
ct);
}
private string BuildScopedCacheKey(string baseKey)
{
var tenantId = _currentUser.EffectiveTenantId ?? "platform";
var userId = _currentUser.Id ?? "anonymous";
var role = _currentUser.ActorRole ?? "user";
var hierarchy = _currentUser.IsHierarchyScope ? "hierarchy" : "own";
return $"{baseKey}::scope:t={tenantId};u={userId};a={role};h={hierarchy}";
}
}
/// <summary>
/// Implement this interface on queries to enable caching.
/// </summary>
public interface ICacheable
{
string CacheKey { get; }
TimeSpan? Expiration { get; }
}
/// <summary>
/// Implement this interface on commands to invalidate caching regions.
/// </summary>
public interface IInvalidatesCache
{
string[] CacheKeysToInvalidate { get; }
string[] CachePrefixesToInvalidate { get; }
}
// MemoryCacheService implementation details for Stampede Prevention and Key Eviction
public class MemoryCacheService : ICacheService
{
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
private readonly ConcurrentDictionary<string, byte> _keys = new();
private const int MaxTrackedKeys = 10000;
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan expiration, CancellationToken ct)
{
// Check cache first (fast path)
if (_memoryCache.TryGetValue(key, out T? value))
return value!;
// Acquire key-specific lock to prevent Cache Stampede
var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
try
{
// Double-checked lock: re-query cache
if (_memoryCache.TryGetValue(key, out value))
return value!;
// Execute factory (DB/API call)
value = await factory();
// Unbounded Growth Prevention: evict oldest keys if over limit
if (_keys.Count >= MaxTrackedKeys)
EvictOldestKeys();
// Set in cache with PostEvictionCallback to clean up semaphores
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(expiration)
.RegisterPostEvictionCallback((k, v, reason, state) =>
{
_keys.TryRemove((string)k, out _);
if (_locks.TryRemove((string)k, out var sem))
sem.Dispose();
});
_memoryCache.Set(key, value, cacheEntryOptions);
_keys.TryAdd(key, 0);
return value;
}
finally
{
semaphore.Release();
}
}
}
// Entitlements Module Feature Cache Global Eviction
public class FeatureCache : IFeatureCache
{
private readonly IMemoryCache _memoryCache;
private CancellationTokenSource _globalEvictionSource = new();
public void InvalidateAll()
{
// Cancel the global token source to immediately invalidate all cached feature sets
var oldSource = Interlocked.Exchange(ref _globalEvictionSource, new CancellationTokenSource());
oldSource.Cancel();
oldSource.Dispose();
}
public void CacheTenantFeatures(Guid tenantId, Dictionary<string, string> features)
{
var cacheKey = $"tenant:{tenantId}:features";
var options = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(10))
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30))
// Bind to the global eviction token
.AddExpirationToken(new CancellationChangeToken(_globalEvictionSource.Token));
_memoryCache.Set(cacheKey, features, options);
}
}Cache Concurrency & Stampede Prevention
To prevent performance degradation during heavy load, the caching system implements Cache Stampede mitigation. Key-specific semaphores ensure that if multiple concurrent requests ask for a missing or expired key, only the first thread executes the database/API query, while subsequent requests await the semaphore and retrieve the newly cached value. Additionally, unbounded growth is prevented by tracking cache keys and randomly evicting 50% of them when exceeding 10,000 tracked keys. Feature cache entries are also bound to a global eviction token for instant cache clearing.
Command & Query Catalog
The following table lists every command, query, and validator registered in the system, organized by domain area. This is the complete CQRS surface area of SCRIPE.
| Category | Commands | Queries | Validators |
|---|---|---|---|
| Admin Management | Create, Update, Delete, SetActive, BulkActivate, BulkDeactivate, BulkDelete, ChangePassword, ResetPassword | GetAdmins, GetAdminById, GetCurrentAdmin, GetAdminRoles, MySecurityLog, MySessions | CreateAdminValidator, AssignRoleValidator |
| Admin Auth | Login, RefreshToken, Logout, UpdateProfile, ChangePassword, UploadAvatar, RemoveAvatar | GetCurrentAdmin | AdminLoginValidator |
| User Management | Update, Delete, SetActive, Unlock, BulkActivate, BulkDeactivate, BulkDelete, BulkAll variants | GetUsers, GetUserById, GetCurrentUser | RegisterUserValidator, UserLoginValidator |
| User Auth | Login, Register, ExternalLogin, RefreshToken, Logout, VerifyEmail, VerifyPhone, SendVerification, RequestPasswordReset, ResetPassword | GetCurrentUser | — |
| 2FA (Admin & User) | Enable2FA, Confirm2FA, Disable2FA, RegenerateBackupCodes | — (part of auth flow) | — |
| Roles | Create, Update, Delete, AssignPermissions, Clone, SyncScopes | GetRoles, GetRoleById, GetRolePermissions, MyTenantRoles, MyTenantAvailablePermissions | CreateRoleValidator |
| Tenants | Create, Update, Delete, UpdateSettings, UpdateMySettings | GetTenants, GetById, Hierarchy, Children, MyChildren, Settings, Stats, Admins, Roles, Permissions | CreateTenantValidator |
| Permissions | — (seeded at startup) | GetAll, GetMyPermissions, GetById, GetCategories, AvailableForTenantCreation | — |
| Menus | Create, Update, Delete, Reorder, SetRoleVisibility, SetTenantOverride | GetAllMenuItems, GetMyMenu, GetMyOverrides | — |
| Impersonation | Impersonate, StopImpersonation, TransferAdmin, TransferProtection | — | — |
| Emails | SendManual, SendBulk, Cancel, Resend | EmailQueries (list, stats), SearchRecipients | SendManualEmailValidator |
| Webhooks | Subscribe, Update, Delete, Test | GetSubscriptions | — |
| Notifications | Send (via service), MarkAsRead, MarkAllAsRead, Delete | GetNotifications, GetUnreadCount, SearchTargets | — |
| Message Templates | Create, Update, Delete | GetAll, GetById, Preview, Render | CreateValidator, UpdateValidator |
| Dashboard | — (read-only) | Summary, LoginActivity, RecentChanges, EventDistribution, SecurityEvents, TopBlockedIPs | — |
| Dashboard Export | ExportOverview, ExportAnalytics, ExportSecurity | — | — |
| RecycleBin | Restore, Purge | GetDeletedItems | — |
| Audit | — (auto-captured) | Search, Export | — |
Pipeline Registration
Pipeline behaviors are registered by AddCoreApplication() from Mediator options. Handler scanning, request coverage validation, notification failure policy, and pipeline order are controlled from configuration.
public static IServiceCollection AddCoreApplication(
this IServiceCollection services,
IConfiguration? configuration = null,
params Type[] handlerAssemblyMarkerTypes)
{
var mediatorOptions = configuration?
.GetSection(MediatorOptions.SectionName)
.Get<MediatorOptions>() ?? new MediatorOptions();
services.AddScripeMediator(
validateRequestCoverage: mediatorOptions.ValidateRequestHandlerCoverage,
assemblyMarkerTypes: handlerAssemblyMarkerTypes);
RegisterConfiguredPipelineBehaviors(services, mediatorOptions);
foreach (var markerType in handlerAssemblyMarkerTypes)
services.AddValidatorsFromAssemblyContaining(markerType);
return services;
}Domain Events & Outbox System Pipeline
To guarantee transactional consistency and prevent the dual-write problem, SCRIPE uses an Outbox pattern. Domain events are raised inside Aggregate Roots, intercepted by EF Core SaveChangesInterceptor, serialized to JSON, and persisted as OutboxMessage entities in the same database transaction. A background job (OutboxProcessorJob) runs every minute to poll unprocessed messages and publish them locally (via AstraFlow Mediator) or externally (via EventBus). Finally, a daily OutboxCleanupJob runs at 5:00 AM to purge processed messages older than 7 days.
// OutboxInterceptor intercepts SavingChangesAsync
public class OutboxInterceptor : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context!;
// 1. Capture events from aggregate roots implementing IHasDomainEvents
var entities = context.ChangeTracker.Entries<IHasDomainEvents>()
.Where(e => e.Entity.DomainEvents.Any())
.ToList();
var domainEvents = entities.SelectMany(e => e.Entity.DomainEvents).ToList();
// 2. Clear events from entities to avoid double processing
entities.ForEach(e => e.Entity.ClearDomainEvents());
// 3. Serialize events to OutboxMessage entities
var outboxMessages = domainEvents.Select(evt => new OutboxMessage
{
Id = Guid.NewGuid(),
Type = evt.GetType().AssemblyQualifiedName!,
Content = JsonSerializer.Serialize(evt, evt.GetType()),
OccurredOnUtc = DateTime.UtcNow
}).ToList();
// 4. Save messages in the same database transaction
await context.Set<OutboxMessage>().AddRangeAsync(outboxMessages, ct);
return await base.SavingChangesAsync(eventData, result, ct);
}
}
// OutboxProcessorJob polls and processes messages every minute
public class OutboxProcessorJob : IAutoRegisteredJob
{
public string JobId => "core-outbox-processor";
public string CronExpression => "* * * * *"; // Every minute
public async Task ExecuteAsync(CancellationToken ct = default)
{
foreach (var dbContext in _registry.GetContexts())
{
var messages = await dbContext.OutboxMessages
.Where(m => m.ProcessedOnUtc == null && m.RetryCount < 5)
.OrderBy(m => m.OccurredOnUtc)
.Take(50)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
var type = Type.GetType(message.Type)!;
var domainEvent = JsonSerializer.Deserialize(message.Content, type);
// Dispatch locally via Mediator and externally via EventBus
await _publisher.PublishAsync(domainEvent, ct);
message.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
message.Error = ex.Message;
message.RetryCount++;
}
}
await dbContext.SaveChangesAsync(ct);
}
}
}
// OutboxCleanupJob runs daily to purge old processed messages
public class OutboxCleanupJob : RecurringJobBase
{
public override string JobId => "core-outbox-cleanup";
public override string CronExpression => "0 5 * * *"; // Daily at 5 AM UTC
protected override async Task ExecuteJobAsync(CancellationToken ct)
{
var cutoff = DateTime.UtcNow.AddDays(-7);
foreach (var dbContext in _registry.GetContexts())
{
// Retrieve IDs to delete and use EF Core Attach/RemoveRange to avoid full load
var oldMessageIds = await dbContext.OutboxMessages
.Where(m => m.ProcessedOnUtc != null && m.ProcessedOnUtc < cutoff)
.Select(m => m.Id)
.Take(1000)
.ToListAsync(ct);
var stubs = oldMessageIds.Select(id => new OutboxMessage { Id = id });
dbContext.OutboxMessages.RemoveRange(stubs);
await dbContext.SaveChangesAsync(ct);
}
}
}