# Extensibility Points Learn how to extend and customize Svrnty.CQRS for your specific needs. ## Overview Svrnty.CQRS provides multiple extension points for customization without modifying the framework code: - ✅ **Authorization services** - Custom command/query authorization - ✅ **Query alteration** - Modify queries before execution (security, tenant isolation) - ✅ **Dynamic query interceptors** - Customize PoweredSoft.DynamicQuery behavior - ✅ **Attributes** - Control endpoint generation and naming - ✅ **Middleware** - ASP.NET Core pipeline integration - ✅ **Custom validators** - Extend FluentValidation - ✅ **Event workflows** - Emit domain events from commands ## Authorization Services ### Command Authorization Control who can execute commands: ```csharp public interface ICommandAuthorizationService { Task CanExecuteAsync(TCommand command, ClaimsPrincipal user, CancellationToken cancellationToken = default); } ``` **Example:** ```csharp // Only authenticated users can create users public class CreateUserAuthorizationService : ICommandAuthorizationService { public Task CanExecuteAsync( CreateUserCommand command, ClaimsPrincipal user, CancellationToken cancellationToken) { return Task.FromResult(user.Identity?.IsAuthenticated == true); } } // Registration builder.Services.AddScoped, CreateUserAuthorizationService>(); ``` **Advanced example with roles:** ```csharp public class DeleteUserAuthorizationService : ICommandAuthorizationService { public Task CanExecuteAsync( DeleteUserCommand command, ClaimsPrincipal user, CancellationToken cancellationToken) { // Only admins can delete users return Task.FromResult(user.IsInRole("Admin")); } } ``` **Resource-based authorization:** ```csharp public class UpdateUserAuthorizationService : ICommandAuthorizationService { private readonly IUserRepository _userRepository; public UpdateUserAuthorizationService(IUserRepository userRepository) { _userRepository = userRepository; } public async Task CanExecuteAsync( UpdateUserCommand command, ClaimsPrincipal user, CancellationToken cancellationToken) { // Users can only update their own profile (or admins can update anyone) if (user.IsInRole("Admin")) return true; var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return command.UserId.ToString() == userId; } } ``` ### Query Authorization Control who can execute queries: ```csharp public interface IQueryAuthorizationService { Task CanExecuteAsync(TQuery query, ClaimsPrincipal user, CancellationToken cancellationToken = default); } ``` **Example:** ```csharp public class GetUserAuthorizationService : IQueryAuthorizationService { private readonly IUserRepository _userRepository; public GetUserAuthorizationService(IUserRepository userRepository) { _userRepository = userRepository; } public async Task CanExecuteAsync( GetUserQuery query, ClaimsPrincipal user, CancellationToken cancellationToken) { // Users can only view their own data (or admins can view anyone) if (user.IsInRole("Admin")) return true; var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return query.UserId.ToString() == userId; } } // Registration builder.Services.AddScoped, GetUserAuthorizationService>(); ``` ## Query Alteration Services Modify queryables before execution (security filters, tenant isolation): ```csharp public interface IAlterQueryableService { IQueryable AlterQueryable( IQueryable queryable, object query, ClaimsPrincipal user); } ``` **Example: Tenant Isolation** ```csharp public class TenantFilterService : IAlterQueryableService where TSource : ITenantEntity { public IQueryable AlterQueryable( IQueryable queryable, object query, ClaimsPrincipal user) { var tenantId = user.FindFirst("TenantId")?.Value; if (string.IsNullOrEmpty(tenantId)) return queryable.Where(e => false); // No data for users without tenant return queryable.Where(e => e.TenantId == tenantId); } } // Entity interface public interface ITenantEntity { string TenantId { get; } } // Entity implementation public class Order : ITenantEntity { public int Id { get; set; } public string TenantId { get; set; } = string.Empty; // ... other properties } // Registration builder.Services.AddScoped, TenantFilterService>(); ``` **Example: Soft Delete Filter** ```csharp public class SoftDeleteFilterService : IAlterQueryableService where TSource : ISoftDeletable { public IQueryable AlterQueryable( IQueryable queryable, object query, ClaimsPrincipal user) { // Exclude soft-deleted entities by default return queryable.Where(e => !e.IsDeleted); } } public interface ISoftDeletable { bool IsDeleted { get; } } ``` **Example: Row-Level Security** ```csharp public class OwnerFilterService : IAlterQueryableService { public IQueryable AlterQueryable( IQueryable queryable, object query, ClaimsPrincipal user) { // Non-admins can only see their own orders if (user.IsInRole("Admin")) return queryable; var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (userId == null) return queryable.Where(o => false); // No data for unauthenticated users return queryable.Where(o => o.UserId.ToString() == userId); } } ``` ## Dynamic Query Interceptors Customize PoweredSoft.DynamicQuery behavior: ```csharp public interface IDynamicQueryInterceptorProvider { IEnumerable GetInterceptors(object query); } ``` **Example: Custom Filter Behavior** ```csharp public class CustomFilterInterceptor : IQueryInterceptor { public IQueryable InterceptQuery(IQueryable query) { // Custom filtering logic return query; } } public class CustomInterceptorProvider : IDynamicQueryInterceptorProvider { public IEnumerable GetInterceptors(object query) { return new[] { new CustomFilterInterceptor() }; } } // Registration builder.Services.AddSingleton(); ``` ## Attributes Control endpoint generation and behavior: ### [CommandName] Override default command endpoint name: ```csharp [CommandName("register")] public record CreateUserCommand { public string Name { get; init; } = string.Empty; public string Email { get; init; } = string.Empty; } // Endpoint: POST /api/command/register (instead of createUser) ``` ### [QueryName] Override default query endpoint name: ```csharp [QueryName("user")] public record GetUserQuery { public int UserId { get; init; } } // Endpoint: GET /api/query/user (instead of getUser) ``` ### [IgnoreCommand] Prevent endpoint generation for internal commands: ```csharp [IgnoreCommand] public record InternalSyncCommand { // This command is only called from code, not exposed via API } ``` ### [IgnoreQuery] Prevent endpoint generation for internal queries: ```csharp [IgnoreQuery] public record InternalReportQuery { // This query is only called from code, not exposed via API } ``` ### [GrpcIgnore] Prevent gRPC service generation: ```csharp [GrpcIgnore] public record HttpOnlyCommand { // Only exposed via HTTP, not gRPC } ``` ### Custom Attributes Create your own attributes: ```csharp [AttributeUsage(AttributeTargets.Class)] public class RequiresAdminAttribute : Attribute { } [RequiresAdmin] public record DeleteUserCommand { public int UserId { get; init; } } // Check in custom middleware public class AdminAuthorizationMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // Check for [RequiresAdmin] attribute // Authorize accordingly await next(context); } } ``` ## ASP.NET Core Middleware Integration ### Custom Middleware Add custom logic before/after handlers: ```csharp public class CorrelationIdMiddleware { private readonly RequestDelegate _next; public CorrelationIdMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString(); context.Items["CorrelationId"] = correlationId; context.Response.Headers.Add("X-Correlation-ID", correlationId); await _next(context); } } // Registration app.UseMiddleware(); app.UseSvrntyCqrs(); // CQRS endpoints come after middleware ``` ### Endpoint Filters Apply filters to specific endpoints: ```csharp app.MapPost("/api/command/createUser", async (HttpContext context) => { // Handler logic }) .AddEndpointFilter(async (context, next) => { // Before handler var stopwatch = Stopwatch.StartNew(); var result = await next(context); // After handler var elapsed = stopwatch.ElapsedMilliseconds; context.HttpContext.Response.Headers.Add("X-Elapsed-Ms", elapsed.ToString()); return result; }); ``` ## Custom Validators Extend FluentValidation: ### Reusable Validator Rules ```csharp public static class CustomValidators { public static IRuleBuilderOptions MustBeValidUrl( this IRuleBuilder ruleBuilder) { return ruleBuilder .Must(url => Uri.TryCreate(url, UriKind.Absolute, out _)) .WithMessage("'{PropertyName}' must be a valid URL"); } public static IRuleBuilderOptions MustBeStrongPassword( this IRuleBuilder ruleBuilder) { return ruleBuilder .MinimumLength(8).WithMessage("Password must be at least 8 characters") .Matches(@"[A-Z]").WithMessage("Password must contain uppercase") .Matches(@"[a-z]").WithMessage("Password must contain lowercase") .Matches(@"\d").WithMessage("Password must contain digit") .Matches(@"[^\w]").WithMessage("Password must contain special character"); } } // Usage public class CreateUserCommandValidator : AbstractValidator { public CreateUserCommandValidator() { RuleFor(x => x.Website).MustBeValidUrl(); RuleFor(x => x.Password).MustBeStrongPassword(); } } ``` ### Async Validation with External Services ```csharp public class CreateUserCommandValidator : AbstractValidator { private readonly IUserRepository _userRepository; private readonly IEmailVerificationService _emailVerification; public CreateUserCommandValidator( IUserRepository userRepository, IEmailVerificationService emailVerification) { _userRepository = userRepository; _emailVerification = emailVerification; RuleFor(x => x.Email) .NotEmpty() .EmailAddress() .MustAsync(BeUniqueEmail).WithMessage("Email already exists") .MustAsync(BeValidEmail).WithMessage("Email does not exist or is invalid"); } private async Task BeUniqueEmail(string email, CancellationToken cancellationToken) { var exists = await _userRepository.GetByEmailAsync(email, cancellationToken); return exists == null; } private async Task BeValidEmail(string email, CancellationToken cancellationToken) { return await _emailVerification.VerifyAsync(email, cancellationToken); } } ``` ## Event Workflows Emit domain events from command handlers: ```csharp public class UserWorkflow : Workflow { public void EmitCreated(UserCreatedEvent @event) => Emit(@event); public void EmitUpdated(UserUpdatedEvent @event) => Emit(@event); public void EmitDeleted(UserDeletedEvent @event) => Emit(@event); } public class CreateUserCommandHandler : ICommandHandlerWithWorkflow { private readonly IUserRepository _userRepository; public async Task HandleAsync( CreateUserCommand command, UserWorkflow workflow, CancellationToken cancellationToken) { var user = new User { Name = command.Name, Email = command.Email }; var userId = await _userRepository.AddAsync(user, cancellationToken); // Emit domain event workflow.EmitCreated(new UserCreatedEvent { UserId = userId, Name = user.Name, Email = user.Email }); return userId; } } // Registration builder.Services.AddCommandWithWorkflow(); ``` ## Custom Endpoint Mapping Override default endpoint generation: ```csharp // Custom endpoint mapping app.MapPost("/users", async ( CreateUserCommand command, ICommandHandler handler, CancellationToken cancellationToken) => { var userId = await handler.HandleAsync(command, cancellationToken); return Results.Created($"/users/{userId}", new { id = userId }); }); // Or use UseSvrntyCqrs() for automatic mapping with custom prefix app.UseSvrntyCqrs(commandPrefix: "commands", queryPrefix: "queries"); // POST /commands/createUser // GET /queries/getUser ``` ## Response Transformation Transform handler results before returning to client: ```csharp public class ResultTransformationMiddleware { private readonly RequestDelegate _next; public ResultTransformationMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { var originalBodyStream = context.Response.Body; using var responseBody = new MemoryStream(); context.Response.Body = responseBody; await _next(context); context.Response.Body = originalBodyStream; responseBody.Seek(0, SeekOrigin.Begin); // Transform response var response = await new StreamReader(responseBody).ReadToEndAsync(); var transformed = TransformResponse(response); await context.Response.WriteAsync(transformed); } private string TransformResponse(string response) { // Add metadata wrapper return JsonSerializer.Serialize(new { data = JsonSerializer.Deserialize(response), metadata = new { timestamp = DateTime.UtcNow, version = "1.0" } }); } } ``` ## Best Practices ### ✅ DO - Use authorization services for access control - Use query alteration for tenant isolation - Create reusable validation rules - Document custom extension points - Test extensions thoroughly - Keep extensions simple and focused - Follow SOLID principles ### ❌ DON'T - Don't bypass framework abstractions - Don't create tight coupling - Don't ignore security implications - Don't over-engineer extensions - Don't modify framework code directly ## Common Extension Scenarios ### Scenario 1: Multi-Tenant Application ```csharp // Tenant filter for all queries public class TenantFilterService : IAlterQueryableService where TSource : ITenantEntity { public IQueryable AlterQueryable(IQueryable queryable, object query, ClaimsPrincipal user) { var tenantId = user.FindFirst("TenantId")?.Value; return queryable.Where(e => e.TenantId == tenantId); } } // Register for all entities builder.Services.AddScoped(typeof(IAlterQueryableService<,>), typeof(TenantFilterService<,>)); ``` ### Scenario 2: Audit Logging ```csharp public class AuditLoggingMiddleware { private readonly RequestDelegate _next; private readonly IAuditLogger _auditLogger; public AuditLoggingMiddleware(RequestDelegate next, IAuditLogger auditLogger) { _next = next; _auditLogger = auditLogger; } public async Task InvokeAsync(HttpContext context) { // Before command execution var command = await ReadCommandFromRequest(context); await _next(context); // After command execution await _auditLogger.LogAsync(new AuditEntry { User = context.User.Identity?.Name, Command = command.GetType().Name, Timestamp = DateTime.UtcNow }); } } ``` ### Scenario 3: Rate Limiting ```csharp public class RateLimitingMiddleware { private readonly RequestDelegate _next; private readonly IRateLimiter _rateLimiter; public async Task InvokeAsync(HttpContext context) { var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!await _rateLimiter.AllowRequestAsync(userId)) { context.Response.StatusCode = 429; // Too Many Requests await context.Response.WriteAsync("Rate limit exceeded"); return; } await _next(context); } } ``` ## What's Next? You've completed the Architecture section! Continue learning: - **[Core Features](../core-features/README.md)** - Commands, queries, validation - **[Best Practices](../best-practices/README.md)** - Production-ready patterns - **[Tutorials](../tutorials/README.md)** - Comprehensive examples ## See Also - [Getting Started](../getting-started/README.md) - Build your first application - [Best Practices: Security](../best-practices/security.md) - Security best practices - [Best Practices: Multi-Tenancy](../best-practices/multi-tenancy.md) - Multi-tenant patterns