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

714 lines
18 KiB
Markdown

# 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<in TCommand>
{
Task<bool> CanExecuteAsync(TCommand command, ClaimsPrincipal user, CancellationToken cancellationToken = default);
}
```
**Example:**
```csharp
// 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:**
```csharp
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:**
```csharp
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:
```csharp
public interface IQueryAuthorizationService<in TQuery>
{
Task<bool> CanExecuteAsync(TQuery query, ClaimsPrincipal user, CancellationToken cancellationToken = default);
}
```
**Example:**
```csharp
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):
```csharp
public interface IAlterQueryableService<TSource, TDestination>
{
IQueryable<TSource> AlterQueryable(
IQueryable<TSource> queryable,
object query,
ClaimsPrincipal user);
}
```
**Example: Tenant Isolation**
```csharp
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**
```csharp
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**
```csharp
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:
```csharp
public interface IDynamicQueryInterceptorProvider
{
IEnumerable<IQueryInterceptor> GetInterceptors<TSource, TDestination>(object query);
}
```
**Example: Custom Filter Behavior**
```csharp
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:
```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<CorrelationIdMiddleware>();
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<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
```csharp
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:
```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<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:
```csharp
// 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:
```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<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
```csharp
// 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
```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