dotnet-cqrs/docs/architecture/extensibility-points.md

18 KiB

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:

public interface ICommandAuthorizationService<in TCommand>
{
    Task<bool> CanExecuteAsync(TCommand command, ClaimsPrincipal user, CancellationToken cancellationToken = default);
}

Example:

// Only authenticated users can create users
public class CreateUserAuthorizationService : ICommandAuthorizationService<CreateUserCommand>
{
    public Task<bool> CanExecuteAsync(
        CreateUserCommand command,
        ClaimsPrincipal user,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(user.Identity?.IsAuthenticated == true);
    }
}

// Registration
builder.Services.AddScoped<ICommandAuthorizationService<CreateUserCommand>, CreateUserAuthorizationService>();

Advanced example with roles:

public class DeleteUserAuthorizationService : ICommandAuthorizationService<DeleteUserCommand>
{
    public Task<bool> CanExecuteAsync(
        DeleteUserCommand command,
        ClaimsPrincipal user,
        CancellationToken cancellationToken)
    {
        // Only admins can delete users
        return Task.FromResult(user.IsInRole("Admin"));
    }
}

Resource-based authorization:

public class UpdateUserAuthorizationService : ICommandAuthorizationService<UpdateUserCommand>
{
    private readonly IUserRepository _userRepository;

    public UpdateUserAuthorizationService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public async Task<bool> 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:

public interface IQueryAuthorizationService<in TQuery>
{
    Task<bool> CanExecuteAsync(TQuery query, ClaimsPrincipal user, CancellationToken cancellationToken = default);
}

Example:

public class GetUserAuthorizationService : IQueryAuthorizationService<GetUserQuery>
{
    private readonly IUserRepository _userRepository;

    public GetUserAuthorizationService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public async Task<bool> 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<IQueryAuthorizationService<GetUserQuery>, GetUserAuthorizationService>();

Query Alteration Services

Modify queryables before execution (security filters, tenant isolation):

public interface IAlterQueryableService<TSource, TDestination>
{
    IQueryable<TSource> AlterQueryable(
        IQueryable<TSource> queryable,
        object query,
        ClaimsPrincipal user);
}

Example: Tenant Isolation

public class TenantFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ITenantEntity
{
    public IQueryable<TSource> AlterQueryable(
        IQueryable<TSource> 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<IAlterQueryableService<Order, OrderDto>, TenantFilterService<Order, OrderDto>>();

Example: Soft Delete Filter

public class SoftDeleteFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ISoftDeletable
{
    public IQueryable<TSource> AlterQueryable(
        IQueryable<TSource> 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

public class OwnerFilterService : IAlterQueryableService<Order, OrderDto>
{
    public IQueryable<Order> AlterQueryable(
        IQueryable<Order> 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:

public interface IDynamicQueryInterceptorProvider
{
    IEnumerable<IQueryInterceptor> GetInterceptors<TSource, TDestination>(object query);
}

Example: Custom Filter Behavior

public class CustomFilterInterceptor : IQueryInterceptor
{
    public IQueryable<T> InterceptQuery<T>(IQueryable<T> query)
    {
        // Custom filtering logic
        return query;
    }
}

public class CustomInterceptorProvider : IDynamicQueryInterceptorProvider
{
    public IEnumerable<IQueryInterceptor> GetInterceptors<TSource, TDestination>(object query)
    {
        return new[] { new CustomFilterInterceptor() };
    }
}

// Registration
builder.Services.AddSingleton<IDynamicQueryInterceptorProvider, CustomInterceptorProvider>();

Attributes

Control endpoint generation and behavior:

[CommandName]

Override default command endpoint name:

[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:

[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:

[IgnoreCommand]
public record InternalSyncCommand
{
    // This command is only called from code, not exposed via API
}

[IgnoreQuery]

Prevent endpoint generation for internal queries:

[IgnoreQuery]
public record InternalReportQuery
{
    // This query is only called from code, not exposed via API
}

[GrpcIgnore]

Prevent gRPC service generation:

[GrpcIgnore]
public record HttpOnlyCommand
{
    // Only exposed via HTTP, not gRPC
}

Custom Attributes

Create your own attributes:

[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:

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<CorrelationIdMiddleware>();
app.UseSvrntyCqrs(); // CQRS endpoints come after middleware

Endpoint Filters

Apply filters to specific endpoints:

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

public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> MustBeValidUrl<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .Must(url => Uri.TryCreate(url, UriKind.Absolute, out _))
            .WithMessage("'{PropertyName}' must be a valid URL");
    }

    public static IRuleBuilderOptions<T, string> MustBeStrongPassword<T>(
        this IRuleBuilder<T, string> 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<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Website).MustBeValidUrl();
        RuleFor(x => x.Password).MustBeStrongPassword();
    }
}

Async Validation with External Services

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    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<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        var exists = await _userRepository.GetByEmailAsync(email, cancellationToken);
        return exists == null;
    }

    private async Task<bool> BeValidEmail(string email, CancellationToken cancellationToken)
    {
        return await _emailVerification.VerifyAsync(email, cancellationToken);
    }
}

Event Workflows

Emit domain events from command handlers:

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<CreateUserCommand, int, UserWorkflow>
{
    private readonly IUserRepository _userRepository;

    public async Task<int> 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<CreateUserCommand, int, UserWorkflow, CreateUserCommandHandler>();

Custom Endpoint Mapping

Override default endpoint generation:

// Custom endpoint mapping
app.MapPost("/users", async (
    CreateUserCommand command,
    ICommandHandler<CreateUserCommand, int> 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:

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<object>(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

// Tenant filter for all queries
public class TenantFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ITenantEntity
{
    public IQueryable<TSource> AlterQueryable(IQueryable<TSource> 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

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

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:

See Also