714 lines
18 KiB
Markdown
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
|