diff --git a/csla-examples/BlazorConfiguration.md b/csla-examples/BlazorConfiguration.md new file mode 100644 index 0000000..fa31a8e --- /dev/null +++ b/csla-examples/BlazorConfiguration.md @@ -0,0 +1,493 @@ +# Blazor Configuration + +CSLA supports various Blazor application architectures, including modern Blazor with multiple render modes, legacy Blazor Server, and legacy Blazor WebAssembly. The configuration differs based on your Blazor architecture and requirements. + +## Overview + +Blazor applications can use different render modes: +- **Server-static**: Rendered on the server without interactivity +- **Server-interactive**: Interactive components running on the server with SignalR +- **WebAssembly-interactive**: Interactive components running in the browser +- **Auto**: Automatically chooses between Server and WebAssembly based on availability + +CSLA configuration must account for these different modes and whether state needs to be synchronized between client and server. + +## Modern Blazor App (Solution Template) + +The modern Blazor solution template creates two projects: a server-side project and a client-side project. This configuration supports all render modes, including InteractiveAuto which can switch between server and WebAssembly rendering. + +> **Security Note:** For Blazor Web Apps using InteractiveAuto or multiple render modes, it is **strongly recommended** to authenticate users on the server using SSR (Server-Side Rendering) login processes, and have the user identity flow from server to client via the CSLA Blazor state management subsystem. **Avoid** setting `FlowSecurityPrincipalFromClient = true` as this creates potential security vulnerabilities by allowing client-side code to specify the user identity. + +### Server-Side Project Configuration (Recommended) + +Configure CSLA in the server project's `Program.cs`: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Configure authentication (e.g., Cookie, Identity, etc.) +builder.Services.AddAuthentication() + .AddCookie(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = false)); + +var app = builder.Build(); + +// Configure middleware +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorComponents(); + +app.Run(); +``` + +**Key Configuration Points:** + +- `AddAspNetCore()` - Enables ASP.NET Core integration +- `UseInMemoryApplicationContextManager = false` - Uses state management subsystem for multi-mode support +- Authentication configured on the server - User identity is established server-side +- **No** `FlowSecurityPrincipalFromClient` - Security principal flows from server to client automatically via state management + +### Client-Side Project Configuration (Recommended) + +Configure CSLA in the client project's `Program.cs`: + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly(blazor => blazor + .SyncContextWithServer = true) + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .DataPortalUrl = "/api/DataPortal")))); + +await builder.Build().RunAsync(); +``` + +**Key Configuration Points:** + +- `AddBlazorWebAssembly()` - Enables Blazor WebAssembly integration +- `SyncContextWithServer = true` - Synchronizes application context (including user identity) from server to client +- **No** `FlowSecurityPrincipalFromClient` - Client receives user identity from server +- `UseHttpProxy()` - Configures HTTP communication with the server data portal + +### Alternative Configuration (Not Recommended) + +While it is technically possible to configure `FlowSecurityPrincipalFromClient = true` to allow the client to send the user identity to the server, this approach is **not recommended** due to security concerns: + +```csharp +// NOT RECOMMENDED - Security risk +.Security(security => security + .FlowSecurityPrincipalFromClient = true) +``` + +This configuration should only be used in specific scenarios where: +- You have a legacy application that requires this behavior +- You have implemented additional security measures to validate client-provided identities +- You fully understand the security implications + +### Server Data Portal Controller + +Add a data portal controller in the server project: + +```csharp +[Route("api/[controller]")] +[ApiController] +public class DataPortalController : Csla.Server.Hosts.HttpPortalController +{ + public DataPortalController(ApplicationContext applicationContext) + : base(applicationContext) + { + } +} +``` + +## Legacy Blazor Server + +For traditional Blazor Server applications (single project, server-side only), there is no client/server boundary and therefore no need for identity flow configuration: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Configure authentication +builder.Services.AddAuthentication() + .AddCookie(); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = true)); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); +``` + +**Key Configuration Points:** + +- `UseInMemoryApplicationContextManager = true` - Uses in-memory context for server-only scenarios +- No data portal proxy needed since everything runs on the server +- **No identity flow concerns** - Everything executes in the same server-side context +- Authentication is handled through standard ASP.NET Core mechanisms + +## Legacy Blazor WebAssembly + +For traditional Blazor WebAssembly applications (client-only), there is no server-side CSLA execution: + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.RootComponents.Add("#app"); + +builder.Services.AddCsla(options => options + .AddClientSideBlazor()); + +await builder.Build().RunAsync(); +``` + +**Key Configuration Points:** + +- `AddClientSideBlazor()` - Minimal configuration for client-side only +- No server communication configured (suitable for standalone WebAssembly) +- **No identity flow concerns** - Everything executes in the browser context +- Authentication would be handled separately if the app calls backend APIs + +## Blazor WebAssembly with Server API + +When your Blazor WebAssembly app needs to communicate with a server API: + +### Client Configuration + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly() + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(60)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); + +await builder.Build().RunAsync(); +``` + +### Server Configuration + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .DataPortal(dp => dp + .AddServerSideDataPortal())); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins("https://localhost:5001") + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +var app = builder.Build(); + +app.UseCors(); +app.MapControllers(); + +app.Run(); +``` + +## Common Scenarios + +### Scenario 1: Modern Blazor with Auto Render Mode (Recommended) + +Complete configuration for a modern Blazor app supporting all render modes with server-side authentication: + +**Server Project (Program.cs):** + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Configure authentication on the server +builder.Services.AddAuthentication() + .AddCookie(); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = false) + .DataPortal(dp => dp + .AddServerSideDataPortal())); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(Client._Imports).Assembly); + +app.MapControllers(); + +app.Run(); +``` + +**Client Project (Program.cs):** + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly(blazor => blazor + .SyncContextWithServer = true) + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .DataPortalUrl = "/api/DataPortal")))); + +await builder.Build().RunAsync(); +``` + +**Security Notes:** +- User authenticates on the server using standard ASP.NET Core authentication +- User identity flows from server to client via CSLA state management (`SyncContextWithServer`) +- No `FlowSecurityPrincipalFromClient` configuration needed or recommended + +### Scenario 2: Blazor Server with Authentication + +Blazor Server app with authentication: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = true)); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); +``` + +### Scenario 3: Blazor WebAssembly with Custom Headers + +Client configuration with authentication headers: + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddHttpClient("CslaDataPortal", client => +{ + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); +}); + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly() + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .DataPortalUrl = "/api/DataPortal")))); + +await builder.Build().RunAsync(); +``` + +### Scenario 4: Environment-Based Configuration + +Different configuration for development and production: + +```csharp +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +var dataPortalUrl = builder.HostEnvironment.IsDevelopment() + ? "https://localhost:5001/api/DataPortal" + : "https://api.production.com/api/DataPortal"; + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly() + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds( + builder.HostEnvironment.IsDevelopment() ? 300 : 60)) + .DataPortalUrl = dataPortalUrl)))); + +await builder.Build().RunAsync(); +``` + +## State Management + +### In-Memory Context Manager + +Use when state only needs to persist within a single execution context: + +```csharp +.AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = true) +``` + +**Use for:** +- Legacy Blazor Server apps +- Single render mode scenarios + +### Persistent Context Manager + +Use when state needs to persist across multiple execution contexts: + +```csharp +.AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = false) +``` + +**Use for:** +- Modern Blazor apps with multiple render modes +- Apps that switch between server and WebAssembly rendering + +## Security Configuration + +### Recommended Approach: Server-Side Authentication + +For Blazor Web Apps with multiple render modes (InteractiveAuto), **always authenticate users on the server** using standard ASP.NET Core authentication mechanisms: + +```csharp +// Server-side configuration +builder.Services.AddAuthentication() + .AddCookie(); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .AddServerSideBlazor(blazor => blazor + .UseInMemoryApplicationContextManager = false)); +``` + +```csharp +// Client-side configuration +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly(blazor => blazor + .SyncContextWithServer = true)); +``` + +The user identity automatically flows from server to client through the CSLA state management subsystem when `SyncContextWithServer = true` is set. + +### Flow Security Principal (Not Recommended) + +While technically supported, flowing the security principal from client to server is **not recommended** due to security concerns: + +```csharp +// NOT RECOMMENDED - potential security vulnerability +.Security(security => security + .FlowSecurityPrincipalFromClient = true) +``` + +This configuration allows client-side code to specify the user identity, which creates a security risk. Only use this if: +- You have a legacy application requiring this behavior +- You have implemented additional security measures to validate client-provided identities +- You fully understand the security implications + +### Custom Authorization + +Implement custom authorization rules on the server: + +```csharp +builder.Services.AddCsla(options => options + .AddAspNetCore() + .Security(security => + { + security.AuthorizationRuleType = typeof(MyCustomAuthorizationRules); + })); +``` + +## Best Practices + +1. **Authenticate on the server** - For multi-mode Blazor apps, use server-side SSR authentication +2. **Choose the right context manager** - Use in-memory for server-only, persistent for multi-mode +3. **Sync context for multi-mode apps** - Set `SyncContextWithServer = true` to flow state from server to client +4. **Avoid client-to-server identity flow** - Do not set `FlowSecurityPrincipalFromClient = true` in production +5. **Configure timeouts appropriately** - WebAssembly apps may need longer timeouts than server apps +6. **Use HTTPS in production** - Always use HTTPS for data portal URLs in production +7. **Configure CORS properly** - Ensure CORS is configured correctly for cross-origin scenarios +8. **Test all render modes** - If using Auto mode, test with both server and WebAssembly rendering +9. **Handle connection failures** - Implement proper error handling for network issues +10. **Use compression** - Consider `HttpCompressionProxy` for large data transfers + +## Troubleshooting + +### State Not Persisting + +If state doesn't persist across render mode changes: +- Ensure `UseInMemoryApplicationContextManager = false` on the server +- Verify `SyncContextWithServer = true` on the client + +### Authentication Not Working + +If authentication isn't working in a multi-mode Blazor app: +- Verify authentication is configured on the server (not the client) +- Ensure `UseAuthentication()` and `UseAuthorization()` middleware are added to the server pipeline +- Check that `SyncContextWithServer = true` is set on the client +- Verify `UseInMemoryApplicationContextManager = false` is set on the server +- Ensure the data portal controller is using the correct ApplicationContext +- Check that you are **not** using `FlowSecurityPrincipalFromClient = true` (this is not recommended) + +### Data Portal Calls Failing + +If data portal calls fail: +- Verify the data portal URL is correct +- Check that the server has the data portal controller +- Ensure CORS is configured if client and server are on different origins +- Check browser console and server logs for errors + +## Notes + +- Modern Blazor apps require careful configuration to support all render modes +- The `UseInMemoryApplicationContextManager` setting is critical for state management +- **Security Recommendation**: Authenticate users on the server and let identity flow from server to client via state management +- **Avoid** `FlowSecurityPrincipalFromClient = true` in production - this creates security vulnerabilities +- For pure server-side or pure WebAssembly apps, identity flow is not a concern as there is no client/server boundary +- Always test with the actual render modes you'll use in production diff --git a/csla-examples/BusinessRules.md b/csla-examples/BusinessRules.md new file mode 100644 index 0000000..0fcb5df --- /dev/null +++ b/csla-examples/BusinessRules.md @@ -0,0 +1,271 @@ +# Business Rules Overview + +CSLA business rules provide a powerful, flexible system for implementing validation, calculation, and authorization logic in business objects. Rules execute automatically when properties change and can produce error, warning, or information messages. + +## Rule Types + +CSLA supports several types of rules: + +1. **Validation Rules** - Validate property values and add error, warning, or information messages +2. **Calculation Rules** - Calculate and set property values based on other properties +3. **Authorization Rules** - Control read/write access to properties +4. **Async Rules** - Rules that execute asynchronously (e.g., server lookups, API calls) + +## Rule Severity Levels + +Rules can produce different severity levels of messages: + +- **Error** - Prevents the object from being saved (sets `IsValid` to false) +- **Warning** - Indicates potential issues but doesn't prevent saving +- **Information** - Provides informational feedback to users + +## Basic Rule Structure + +All business rules inherit from `BusinessRule` or `BusinessRuleAsync` and implement the `Execute` method: + +```csharp +public class MyRule : BusinessRule +{ + public MyRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + // Configure input properties if needed + InputProperties.Add(primaryProperty); + } + + protected override void Execute(IRuleContext context) + { + // Rule logic here + // Add results using context.AddErrorResult(), AddWarningResult(), or AddInformationResult() + } +} +``` + +## Registering Rules + +Rules are registered in the `AddBusinessRules()` method: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); // Processes data annotations + + // Add validation rules + BusinessRules.AddRule(new Required(NameProperty)); + BusinessRules.AddRule(new MaxLength(NameProperty, 50)); + + // Add custom rules + BusinessRules.AddRule(new MyCustomRule(EmailProperty)); +} +``` + +## Key Concepts + +### Input Properties + +Input properties specify which property values the rule needs to execute: + +```csharp +public MyRule(IPropertyInfo primaryProperty, IPropertyInfo secondProperty) + : base(primaryProperty) +{ + InputProperties.Add(primaryProperty); + InputProperties.Add(secondProperty); +} +``` + +### Output Values + +Rules can set property values using `context.AddOutValue()`: + +```csharp +protected override void Execute(IRuleContext context) +{ + int value1 = (int)context.InputPropertyValues[Property1]; + int value2 = (int)context.InputPropertyValues[Property2]; + int sum = value1 + value2; + + context.AddOutValue(SumProperty, sum); +} +``` + +### Affected Properties + +Affected properties specify which properties should be re-validated when this rule runs: + +```csharp +public MyRule(IPropertyInfo primaryProperty) + : base(primaryProperty) +{ + AffectedProperties.Add(RelatedProperty); +} +``` + +### Dependencies + +Dependencies ensure that when one property changes, rules on dependent properties are re-evaluated: + +```csharp +// When Num1 changes, re-check rules on Sum +BusinessRules.AddRule(new Dependency(Num1Property, SumProperty)); +``` + +### Severity Levels + +Rules can add results with different severity levels: + +```csharp +context.AddErrorResult("This must be fixed"); // Error - prevents save +context.AddWarningResult("This should be checked"); // Warning - allows save +context.AddInformationResult("Just FYI"); // Information - no action needed +``` + +**Important:** Only **Error** severity stops rule execution (short-circuiting). Warnings and Information do not stop subsequent rules. + +## Rule Execution Flow + +1. Property value changes +2. Rules attached to that property execute (by priority, lowest first) +3. **If any rule adds an Error, execution stops (short-circuits)** +4. Rules calculate output values +5. Dependent property rules execute +6. Affected property rules execute +7. Results are collected and applied + +**Short-Circuiting Example:** +```csharp +// Priority -1: Check if required (runs first) +BusinessRules.AddRule(new Required(EmailProperty) { Priority = -1 }); + +// Priority 0: Check format (only if Required passed) +BusinessRules.AddRule(new Email(EmailProperty)); + +// Priority 1: Check uniqueness (only if Email format passed) +BusinessRules.AddRule(new UniqueEmailAsync(EmailProperty) { Priority = 1 }); +``` + +If `EmailProperty` is empty, only the `Required` rule runs. The `Email` and `UniqueEmailAsync` rules don't execute because `Required` returned an Error. + +See [BusinessRulesPriority.md](BusinessRulesPriority.md) for detailed information on priorities and short-circuiting. + +## Common Rule Patterns + +### Simple Validation + +```csharp +BusinessRules.AddRule(new Required(NameProperty)); +BusinessRules.AddRule(new MaxLength(NameProperty, 50)); +BusinessRules.AddRule(new RegEx(EmailProperty, @"^[^@]+@[^@]+\.[^@]+$")); +``` + +### Multi-Property Validation + +```csharp +BusinessRules.AddRule(new LessThan(StartDateProperty, EndDateProperty)); +``` + +### Calculation + +```csharp +BusinessRules.AddRule(new CalcSum(TotalProperty, Subtotal Property, TaxProperty)); +``` + +### Conditional Validation + +```csharp +BusinessRules.AddRule(new RequiredIf(StateProperty, CountryProperty, "US")); +``` + +## Related Documentation + +For detailed information on specific rule types and patterns, see: + +- [BusinessRulesValidation.md](BusinessRulesValidation.md) - Simple and complex validation rules +- [BusinessRulesCalculation.md](BusinessRulesCalculation.md) - Property calculation rules +- [BusinessRulesPriority.md](BusinessRulesPriority.md) - Rule priorities and execution order +- [BusinessRulesAsync.md](BusinessRulesAsync.md) - Asynchronous rules +- [BusinessRulesAuthorization.md](BusinessRulesAuthorization.md) - Authorization rules + +## Best Practices + +1. **Call base.AddBusinessRules()** - Always call the base method to process data annotations +2. **Use InputProperties** - Declare all properties the rule needs to read +3. **Use Dependencies** - Set up dependencies for calculated properties +4. **Consider priorities** - Use priorities when rule execution order matters (see [BusinessRulesPriority.md](BusinessRulesPriority.md)) +5. **Remember short-circuiting** - Error results stop subsequent rules from executing +6. **Keep rules focused** - Each rule should have a single responsibility +7. **Test rules independently** - Rules should be testable in isolation +8. **Use appropriate severity** - Use Error for critical validation, Warning for suggestions, Information for helpful messages +9. **Avoid side effects** - Rules should not modify external state + +## Common Built-In Rules + +CSLA provides many built-in rules in the `Csla.Rules.CommonRules` namespace: + +- `Required` - Property must have a value +- `MaxLength` - String length maximum +- `MinLength` - String length minimum +- `MaxValue` - Maximum numeric value +- `MinValue` - Minimum numeric value +- `RegEx` - Regular expression pattern match +- `Range` - Value must be within a range +- `Dependency` - Establishes property dependencies +- `IsInRole` - Authorization based on user roles +- `IsNotInRole` - Authorization preventing certain roles + +## Example Business Object + +```csharp +[Serializable] +public class Order : BusinessBase +{ + public static readonly PropertyInfo SubtotalProperty = + RegisterProperty(nameof(Subtotal)); + public decimal Subtotal + { + get => GetProperty(SubtotalProperty); + set => SetProperty(SubtotalProperty, value); + } + + public static readonly PropertyInfo TaxProperty = + RegisterProperty(nameof(Tax)); + public decimal Tax + { + get => GetProperty(TaxProperty); + private set => LoadProperty(TaxProperty, value); + } + + public static readonly PropertyInfo TotalProperty = + RegisterProperty(nameof(Total)); + public decimal Total + { + get => GetProperty(TotalProperty); + private set => LoadProperty(TotalProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Validation + BusinessRules.AddRule(new MinValue(SubtotalProperty, 0)); + + // Dependencies for calculations + BusinessRules.AddRule(new Dependency(SubtotalProperty, TaxProperty)); + BusinessRules.AddRule(new Dependency(SubtotalProperty, TotalProperty)); + BusinessRules.AddRule(new Dependency(TaxProperty, TotalProperty)); + + // Calculation rules with priorities + BusinessRules.AddRule(new CalcTax(TaxProperty, SubtotalProperty) { Priority = -1 }); + BusinessRules.AddRule(new CalcTotal(TotalProperty, SubtotalProperty, TaxProperty) { Priority = 0 }); + } +} +``` + +## Notes + +- Rules execute automatically when properties change via `SetProperty()` +- Rules do not execute when using `LoadProperty()` (typically used in data portal methods) +- Use `BusinessRules.CheckRules()` (CSLA 9) or `await CheckRulesAsync()` (CSLA 10) to manually trigger rule execution +- Async rules run in the background and complete asynchronously +- Rule results are cached - rules only re-execute when input properties change diff --git a/csla-examples/BusinessRulesAsync.md b/csla-examples/BusinessRulesAsync.md new file mode 100644 index 0000000..7d4a244 --- /dev/null +++ b/csla-examples/BusinessRulesAsync.md @@ -0,0 +1,556 @@ +# Business Rules: Asynchronous Rules + +Asynchronous business rules allow you to perform long-running operations like database queries, web service calls, or complex computations without blocking the UI thread. Async rules inherit from `BusinessRuleAsync` instead of `BusinessRule`. + +## Overview + +Async rules are useful when you need to: + +- Call external web services or APIs +- Query databases for validation data +- Perform expensive calculations +- Execute data portal operations +- Make network requests + +## Basic Async Rule Structure + +```csharp +using Csla.Rules; +using System.Threading.Tasks; + +public class MyAsyncRule : BusinessRuleAsync +{ + public MyAsyncRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + // Async operation here + await Task.Delay(1000); + + // Access input property values + var value = (string)context.InputPropertyValues[PrimaryProperty]; + + // Add results + if (value == "Error") + context.AddErrorResult("Invalid value"); + } +} +``` + +## Key Differences from Synchronous Rules + +| Feature | Synchronous Rule | Asynchronous Rule | +|---------|-----------------|-------------------| +| Base class | `BusinessRule` | `BusinessRuleAsync` | +| Execute method | `Execute(IRuleContext)` | `async Task ExecuteAsync(IRuleContext)` | +| IsAsync property | `false` (default) | `true` (always) | +| Execution | Runs on calling thread | Runs asynchronously | +| UI blocking | Blocks UI | Doesn't block UI | + +## Simple Async Validation Rule + +A rule that simulates a delay (like a web service call): + +```csharp +public class AsyncRule : BusinessRuleAsync +{ + public AsyncRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + // Simulate async operation (e.g., web service call) + await Task.Delay(3000); + + string value = (string)context.InputPropertyValues[PrimaryProperty]; + + if (value == "Error") + context.AddErrorResult("Invalid data!"); + else if (value == "Warning") + context.AddWarningResult("This might not be a great idea!"); + else if (value == "Information") + context.AddInformationResult("Just an FYI!"); + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + BusinessRules.AddRule(new AsyncRule(NameProperty)); +} +``` + +## Async Rule with Data Portal + +Validate a value by fetching data from the database: + +```csharp +public class ValidRole : BusinessRuleAsync +{ + public ValidRole(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + // Get the role ID to validate + int roleId = (int)context.InputPropertyValues[PrimaryProperty]; + + // Fetch the valid roles list from the database + var portal = context.ApplicationContext.GetRequiredService>(); + var roles = await portal.FetchAsync(); + + // Check if the role is valid + if (!roles.ContainsKey(roleId)) + context.AddErrorResult("Role must be in RoleList"); + } +} +``` + +**Usage:** + +```csharp +[CslaImplementProperties] +public partial class Assignment : BusinessBase +{ + public partial int RoleId { get; set; } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + BusinessRules.AddRule(new ValidRole(RoleIdProperty)); + } +} +``` + +## Async Rule with Command Object + +Execute a command object to perform validation: + +```csharp +public class MyExpensiveRule : BusinessRuleAsync +{ + protected override async Task ExecuteAsync(IRuleContext context) + { + // Get the data portal for the command + var portal = context.ApplicationContext.GetRequiredService>(); + + // Create and execute the command + var command = portal.Create(); + var result = await portal.ExecuteAsync(command); + + // Process the result + if (result == null) + context.AddErrorResult("Command failed to run"); + else if (result.Result) + context.AddInformationResult(result.ResultText); + else + context.AddErrorResult(result.ResultText); + } +} +``` + +## Async Rule with External Web Service + +Validate data by calling an external API: + +```csharp +public class ValidateEmailWithService : BusinessRuleAsync +{ + private readonly HttpClient _httpClient; + + public ValidateEmailWithService(IPropertyInfo primaryProperty, HttpClient httpClient) + : base(primaryProperty) + { + _httpClient = httpClient; + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + string email = (string)context.InputPropertyValues[PrimaryProperty]; + + if (string.IsNullOrWhiteSpace(email)) + return; // Let Required rule handle this + + try + { + // Call external validation service + var response = await _httpClient.GetAsync($"https://api.example.com/validate-email?email={email}"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + + if (!result.IsValid) + context.AddErrorResult($"Email validation failed: {result.Reason}"); + } + else + { + context.AddWarningResult("Unable to verify email address with validation service"); + } + } + catch (Exception ex) + { + // Log the error but don't fail validation + context.AddWarningResult("Email validation service is currently unavailable"); + } + } +} +``` + +## Async Rule with Database Query + +Query the database directly to validate uniqueness: + +```csharp +public class UniqueUsername : BusinessRuleAsync +{ + private IPropertyInfo _idProperty; + + public UniqueUsername(IPropertyInfo usernameProperty, IPropertyInfo idProperty) + : base(usernameProperty) + { + _idProperty = idProperty; + InputProperties.Add(usernameProperty); + InputProperties.Add(idProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + string username = (string)context.InputPropertyValues[PrimaryProperty]; + int id = (int)context.InputPropertyValues[_idProperty]; + + if (string.IsNullOrWhiteSpace(username)) + return; + + // Get database connection from DI + var dal = context.ApplicationContext.GetRequiredService(); + + // Check if username exists (excluding current user) + bool exists = await dal.UsernameExistsAsync(username, id); + + if (exists) + context.AddErrorResult("Username is already taken"); + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + BusinessRules.AddRule(new UniqueUsername(UsernameProperty, IdProperty)); +} +``` + +## Controlling Async Rule Execution + +### RunMode for Async Rules + +Async rules often need to control when they run. Use `RunMode` to prevent execution in certain contexts: + +```csharp +public class AsyncLookupRule : BusinessRuleAsync +{ + public AsyncLookupRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + + // Don't run on server-side data portal + RunMode = RunModes.DenyOnServerSidePortal; + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + // This only runs on the client + await CallExternalServiceAsync(); + } +} +``` + +### Preventing Rule Execution + +Create a base class for async rules that should only run on the client: + +```csharp +public abstract class AsyncLookupRule : BusinessRule +{ + protected AsyncLookupRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + IsAsync = true; + + // Control when the rule runs + RunMode = RunModes.DenyOnServerSidePortal | RunModes.DenyAsAffectedProperty; + } +} +``` + +**Why use these settings:** +- `DenyOnServerSidePortal` - Prevents the rule from running on the server (useful for client-only validation) +- `DenyAsAffectedProperty` - Prevents the rule from running when it's triggered by another rule +- `DenyCheckRules` - Prevents the rule from running during explicit CheckRules calls + +## Async Rules and Priority + +Async rules support priorities just like synchronous rules, but they have special execution characteristics: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Priority -1: Sync calculation runs first + BusinessRules.AddRule(new CalcTotal(TotalProperty, SubtotalProperty, TaxProperty) { Priority = -1 }); + + // Priority 0: Async validation runs after sync rules at same priority + BusinessRules.AddRule(new ValidateWithWebService(TotalProperty)); + + // Priority 1: Sync validation runs after async completes + BusinessRules.AddRule(new MinValue(TotalProperty, 0) { Priority = 1 }); +} +``` + +**Execution order:** +1. All synchronous rules at priority -1 +2. All asynchronous rules at priority -1 (run concurrently) +3. Wait for all async rules to complete +4. All synchronous rules at priority 0 +5. All asynchronous rules at priority 0 +6. And so on... + +## Async Rules and Dependencies + +Async rules work with dependencies just like synchronous rules: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // When Email changes, run async validation + BusinessRules.AddRule(new Dependency(EmailProperty, EmailProperty)); + BusinessRules.AddRule(new ValidateEmailWithService(EmailProperty, _httpClient)); +} +``` + +## Exception Handling in Async Rules + +### Basic Exception Handling + +Handle exceptions within the rule: + +```csharp +public class SafeAsyncRule : BusinessRuleAsync +{ + public SafeAsyncRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + try + { + await CallExternalServiceAsync(); + } + catch (HttpRequestException ex) + { + // Network error - add warning instead of error + context.AddWarningResult("Unable to validate with external service"); + } + catch (Exception ex) + { + // Unexpected error - add error + context.AddErrorResult($"Validation failed: {ex.Message}"); + } + } + + private async Task CallExternalServiceAsync() + { + // External service call + await Task.Delay(1000); + } +} +``` + +### Using IUnhandledAsyncRuleExceptionHandler (CSLA 10) + +In CSLA 10, you can register a global exception handler for async rules: + +```csharp +public class LoggingAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + + public LoggingAsyncRuleExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + // Handle all exceptions + return true; + } + + public ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + _logger.LogError(exception, $"Async rule {rule.GetType().Name} failed"); + context.AddErrorResult("A validation error occurred. Please try again."); + return ValueTask.CompletedTask; + } +} + +// Register in Startup +services.AddCsla(options => +{ + options.UseUnhandledAsyncRuleExceptionHandler(); +}); +``` + +See [AsyncRuleExceptionHandler.md](v10/AsyncRuleExceptionHandler.md) for more details on CSLA 10's async rule exception handling. + +## Best Practices + +1. **Always use async/await** - Don't block async operations with `.Result` or `.Wait()` +2. **Handle exceptions gracefully** - Catch and handle expected exceptions within the rule +3. **Use RunMode appropriately** - Prevent async rules from running on the server if they call external services +4. **Keep rules focused** - Each async rule should do one thing well +5. **Consider timeouts** - Add timeout logic for external service calls +6. **Test async rules thoroughly** - Async code is harder to test, so write comprehensive tests +7. **Use priorities carefully** - Remember that async rules at the same priority run concurrently +8. **Log failures** - Always log unexpected failures for debugging +9. **Provide user feedback** - Add appropriate error/warning/information messages +10. **Optimize for performance** - Async rules can be expensive; only use them when necessary + +## Common Patterns + +### Pattern 1: Lookup Validation + +```csharp +public class ValidateAgainstLookup : BusinessRuleAsync +{ + public ValidateAgainstLookup(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + var value = context.InputPropertyValues[PrimaryProperty]; + var portal = context.ApplicationContext.GetRequiredService>(); + var list = await portal.FetchAsync(); + + if (!list.Contains(value)) + context.AddErrorResult("Value not found in valid list"); + } +} +``` + +### Pattern 2: External Service with Timeout + +```csharp +public class ValidateWithTimeout : BusinessRuleAsync +{ + private readonly HttpClient _httpClient; + private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + public ValidateWithTimeout(IPropertyInfo primaryProperty, HttpClient httpClient) + : base(primaryProperty) + { + _httpClient = httpClient; + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + var value = (string)context.InputPropertyValues[PrimaryProperty]; + + using var cts = new CancellationTokenSource(_timeout); + + try + { + var response = await _httpClient.GetAsync($"https://api.example.com/validate?value={value}", cts.Token); + + if (!response.IsSuccessStatusCode) + context.AddErrorResult("Validation service rejected the value"); + } + catch (TaskCanceledException) + { + context.AddWarningResult("Validation service timed out"); + } + catch (Exception ex) + { + context.AddWarningResult($"Unable to validate: {ex.Message}"); + } + } +} +``` + +### Pattern 3: Async Calculation + +```csharp +public class CalculateFromService : BusinessRuleAsync +{ + private IPropertyInfo _resultProperty; + + public CalculateFromService(IPropertyInfo inputProperty, IPropertyInfo resultProperty) + : base(inputProperty) + { + _resultProperty = resultProperty; + InputProperties.Add(inputProperty); + AffectedProperties.Add(resultProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + var input = (decimal)context.InputPropertyValues[PrimaryProperty]; + + // Call external service for calculation + var portal = context.ApplicationContext.GetRequiredService>(); + var command = await portal.CreateAsync(input); + var result = await portal.ExecuteAsync(command); + + // Set the calculated value + context.AddOutValue(_resultProperty, result.CalculatedValue); + } +} +``` + +## Notes + +- Async rules always have `IsAsync = true` +- Multiple async rules at the same priority level execute concurrently +- Async rules execute after all synchronous rules at the same priority level +- Use `context.ApplicationContext.GetRequiredService()` to get dependencies within rules +- Async rules work seamlessly with the Data Portal across application boundaries +- The UI remains responsive while async rules execute + +## See Also + +- [BusinessRules.md](BusinessRules.md) - Overview of the business rules system +- [BusinessRulesPriority.md](BusinessRulesPriority.md) - Rule priorities and execution order +- [BusinessRulesValidation.md](BusinessRulesValidation.md) - Synchronous validation rules +- [v10/AsyncRuleExceptionHandler.md](v10/AsyncRuleExceptionHandler.md) - CSLA 10 async rule exception handling +- [DataPortalOperation*.md](DataPortalOperationFetch.md) - Data Portal operations used in async rules diff --git a/csla-examples/BusinessRulesAuthorization.md b/csla-examples/BusinessRulesAuthorization.md new file mode 100644 index 0000000..ddd4829 --- /dev/null +++ b/csla-examples/BusinessRulesAuthorization.md @@ -0,0 +1,919 @@ +# Business Rules: Authorization + +Authorization rules control what users can do with business objects and their properties. These rules determine whether a user has permission to read or write properties, execute methods, or perform data portal operations (create, fetch, update, delete). + +## Overview + +Authorization rules differ from validation and calculation rules: + +- **Validation rules** check if data is valid +- **Calculation rules** compute property values +- **Authorization rules** check if the current user has permission to perform an action + +Authorization rules inherit from `AuthorizationRule` instead of `BusinessRule`. + +## Important: One Authorization Rule Per Action + +**CRITICAL:** You can only have **ONE** authorization rule per property/action combination: + +- Only one rule for `WriteProperty` on `SalaryProperty` +- Only one rule for `ReadProperty` on `SalaryProperty` +- Only one rule for `CreateObject` on the type +- Only one rule for `EditObject` on the type + +If you add multiple authorization rules for the same action, only the last one added will be used. + +**Correct:** +```csharp +// One rule per action - CORRECT +BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin", "HR")); +BusinessRules.AddRule( + new IsInRole(AuthorizationActions.ReadProperty, SalaryProperty, "Admin", "HR", "Manager")); +``` + +**Incorrect:** +```csharp +// Multiple rules for same action - WRONG! Only the last rule will be used +BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin")); +BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "HR")); // This replaces the previous rule! +``` + +If you need complex authorization logic, create a single custom rule that handles all the logic. + +## Authorization Actions + +Authorization rules enforce one of these actions: + +```csharp +public enum AuthorizationActions +{ + WriteProperty, // Can the user set a property value? + ReadProperty, // Can the user read a property value? + ExecuteMethod, // Can the user execute a method? + CreateObject, // Can the user create a new object? + GetObject, // Can the user fetch an existing object? + EditObject, // Can the user update/save an object? + DeleteObject // Can the user delete an object? +} +``` + +## Basic Authorization Rule Structure + +```csharp +using Csla.Rules; + +public class MyAuthorizationRule : AuthorizationRule +{ + public MyAuthorizationRule(AuthorizationActions action, IMemberInfo element) + : base(action, element) + { + } + + protected override void Execute(IAuthorizationContext context) + { + // Check if the user has permission + if (/* user has permission */) + { + context.HasPermission = true; + } + else + { + context.HasPermission = false; // Default is false + } + } +} +``` + +## Property Authorization + +### IsInRole Rule (Built-in) + +The most common authorization rule checks if the user is in a specific role: + +```csharp +using Csla.Rules.CommonRules; + +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Only Admin users can write the Salary property + BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin")); + + // Only HR and Manager users can read the Salary property + BusinessRules.AddRule( + new IsInRole(AuthorizationActions.ReadProperty, SalaryProperty, "HR", "Manager")); +} +``` + +### IsNotInRole Rule (Built-in) + +Deny access to users in specific roles: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Everyone EXCEPT Guest users can write the Name property + BusinessRules.AddRule( + new IsNotInRole(AuthorizationActions.WriteProperty, NameProperty, "Guest")); +} +``` + +## Claims-Based Authorization (Most Common Custom Pattern) + +**Claims-based authorization is the most common pattern for custom authorization rules.** Modern authentication systems use Claims to represent user attributes like ID, department, permissions, etc. + +**IMPORTANT:** When working with Claims, use `ApplicationContext.Principal` (returns `ClaimsPrincipal`) instead of `ApplicationContext.User` (returns `IPrincipal`). The `Principal` property provides access to the `Claims` collection. + +```csharp +// CORRECT - Use Principal for Claims +var principal = context.ApplicationContext.Principal; +var userIdClaim = principal.Claims.FirstOrDefault(c => c.Type == "UserId"); + +// For role checks, you can use either User or Principal +var user = context.ApplicationContext.User; +bool isAdmin = user.IsInRole("Admin"); +``` + +### Simple Claims Authorization + +Check if a user has a specific claim: + +```csharp +public class HasClaim : AuthorizationRule +{ + private readonly string _claimType; + private readonly string _claimValue; + + public HasClaim( + AuthorizationActions action, + IMemberInfo element, + string claimType, + string claimValue = null) + : base(action, element) + { + _claimType = claimType; + _claimValue = claimValue; + } + + protected override void Execute(IAuthorizationContext context) + { + var principal = context.ApplicationContext.Principal; + + if (_claimValue == null) + { + // Just check if the claim exists + context.HasPermission = principal.Claims.Any(c => c.Type == _claimType); + } + else + { + // Check for specific claim value + context.HasPermission = principal.Claims.Any(c => c.Type == _claimType && c.Value == _claimValue); + } + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Only users with "CanApprovePurchases" claim can write ApprovalDate + BusinessRules.AddRule( + new HasClaim(AuthorizationActions.WriteProperty, ApprovalDateProperty, "CanApprovePurchases")); + + // Only users in Finance department can read Budget + BusinessRules.AddRule( + new HasClaim(AuthorizationActions.ReadProperty, BudgetProperty, "Department", "Finance")); +} +``` + +### Claims Combined with Property Values + +The most common custom pattern combines claims with object state: + +```csharp +public class CanEditIfOwnerOrManager : AuthorizationRule +{ + private readonly IPropertyInfo _ownerIdProperty; + + public CanEditIfOwnerOrManager( + AuthorizationActions action, + IMemberInfo element, + IPropertyInfo ownerIdProperty) + : base(action, element) + { + _ownerIdProperty = ownerIdProperty; + CacheResult = false; // Re-evaluate because it depends on property values + } + + protected override void Execute(IAuthorizationContext context) + { + var principal = context.ApplicationContext.Principal; + + // Check if user is a manager (via claim) + var isManager = principal.Claims.Any(c => c.Type == "Role" && c.Value == "Manager"); + if (isManager) + { + context.HasPermission = true; + return; + } + + // Check if user is the owner (via claim + property value) + var userIdClaim = principal.Claims.FirstOrDefault(c => c.Type == "UserId"); + if (userIdClaim != null && int.TryParse(userIdClaim.Value, out int userId)) + { + int ownerId = (int)ReadProperty(context.Target, _ownerIdProperty); + context.HasPermission = (userId == ownerId); + } + else + { + context.HasPermission = false; + } + } +} +``` + +**Usage:** + +```csharp +[CslaImplementProperties] +public partial class Document : BusinessBase +{ + public partial int DocumentId { get; private set; } + public partial int OwnerId { get; private set; } + public partial string Title { get; set; } + public partial string Content { get; set; } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Users can edit if they're the owner OR a manager + BusinessRules.AddRule( + new CanEditIfOwnerOrManager( + AuthorizationActions.WriteProperty, + ContentProperty, + OwnerIdProperty)); + } +} +``` + +### Department-Based Authorization with Claims + +Control access based on user's department: + +```csharp +public class RequiresDepartment : AuthorizationRule +{ + private readonly string[] _allowedDepartments; + + public RequiresDepartment( + AuthorizationActions action, + IMemberInfo element, + params string[] allowedDepartments) + : base(action, element) + { + _allowedDepartments = allowedDepartments; + } + + protected override void Execute(IAuthorizationContext context) + { + var principal = context.ApplicationContext.Principal; + + // Get user's department from claims + var departmentClaim = principal.Claims.FirstOrDefault(c => c.Type == "Department"); + + if (departmentClaim == null) + { + context.HasPermission = false; + return; + } + + // Check if user's department is in the allowed list + context.HasPermission = _allowedDepartments.Contains(departmentClaim.Value); + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Only Finance and Accounting departments can write Budget + BusinessRules.AddRule( + new RequiresDepartment( + AuthorizationActions.WriteProperty, + BudgetProperty, + "Finance", "Accounting")); + + // Only HR department can write Salary + BusinessRules.AddRule( + new RequiresDepartment( + AuthorizationActions.WriteProperty, + SalaryProperty, + "HR")); +} +``` + +### Permission Level with Claims + +Check permission levels from claims: + +```csharp +public class RequiresPermissionLevel : AuthorizationRule +{ + private readonly int _requiredLevel; + private readonly IPropertyInfo _contextProperty; + + public RequiresPermissionLevel( + AuthorizationActions action, + IMemberInfo element, + int requiredLevel, + IPropertyInfo contextProperty = null) + : base(action, element) + { + _requiredLevel = requiredLevel; + _contextProperty = contextProperty; + CacheResult = (contextProperty == null); // Cache if not context-dependent + } + + protected override void Execute(IAuthorizationContext context) + { + var principal = context.ApplicationContext.Principal; + + // Get user's permission level from claims + var levelClaim = principal.Claims.FirstOrDefault(c => c.Type == "PermissionLevel"); + + if (levelClaim == null || !int.TryParse(levelClaim.Value, out int userLevel)) + { + context.HasPermission = false; + return; + } + + // Basic permission check + if (userLevel < _requiredLevel) + { + context.HasPermission = false; + return; + } + + // If there's a context property, check additional conditions + if (_contextProperty != null) + { + decimal amount = (decimal)ReadProperty(context.Target, _contextProperty); + + // Higher amounts need higher permission levels + if (amount > 10000 && userLevel < 3) + context.HasPermission = false; + else if (amount > 1000 && userLevel < 2) + context.HasPermission = false; + else + context.HasPermission = true; + } + else + { + context.HasPermission = true; + } + } +} +``` + +**Usage:** + +```csharp +[CslaImplementProperties] +public partial class PurchaseOrder : BusinessBase +{ + public partial decimal Amount { get; set; } + public partial string Status { get; set; } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Users need permission level 2+ to approve, but amount affects this + BusinessRules.AddRule( + new RequiresPermissionLevel( + AuthorizationActions.WriteProperty, + StatusProperty, + 2, + AmountProperty)); + } +} +``` + +## Custom Property Authorization Rule (Context-Based) + +Create a custom rule with conditional logic: + +```csharp +public class OnlyForUS : AuthorizationRule +{ + private IMemberInfo _countryProperty; + + public OnlyForUS( + AuthorizationActions action, + IMemberInfo element, + IMemberInfo countryProperty) + : base(action, element) + { + _countryProperty = countryProperty; + + // Don't cache the result - re-evaluate each time + CacheResult = false; + } + + protected override void Execute(IAuthorizationContext context) + { + // Get the country value from the target object + string country = (string)ReadProperty(context.Target, _countryProperty); + + // Only allow the action if country is "US" + context.HasPermission = country == "US"; + } +} +``` + +**Usage:** + +```csharp +[CslaImplementProperties] +public partial class Customer : BusinessBase +{ + public partial string Country { get; set; } + public partial string State { get; set; } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // State property can only be written if Country is "US" + BusinessRules.AddRule( + new OnlyForUS(AuthorizationActions.WriteProperty, StateProperty, CountryProperty)); + } +} +``` + +## Object-Level Authorization + +Control who can create, fetch, update, or delete entire objects: + +```csharp +public class CanEditOrder : AuthorizationRule +{ + public CanEditOrder(AuthorizationActions action) + : base(action) + { + } + + protected override void Execute(IAuthorizationContext context) + { + var user = context.ApplicationContext.User; + + // Check if user is in Admin role OR is the order owner + if (user.IsInRole("Admin")) + { + context.HasPermission = true; + } + else if (context.Target is Order order) + { + // Get current user's ID from claims + var userIdClaim = user.Claims.FirstOrDefault(c => c.Type == "UserId"); + if (userIdClaim != null && int.TryParse(userIdClaim.Value, out int userId)) + { + context.HasPermission = (order.OwnerId == userId); + } + } + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Control who can edit orders + BusinessRules.AddRule(new CanEditOrder(AuthorizationActions.EditObject)); + + // Control who can delete orders + BusinessRules.AddRule(new CanEditOrder(AuthorizationActions.DeleteObject)); +} +``` + +## Static Object Authorization + +Use attributes for type-level authorization that doesn't depend on object state: + +```csharp +[ObjectAuthorizationRules] +public static void AddObjectAuthorizationRules(IAddObjectAuthorizationRulesContext context) +{ + // Only Admin users can create new Customer objects + context.Rules.AddRule(typeof(Customer), + new IsInRole(AuthorizationActions.CreateObject, "Admin")); + + // Only Admin and Manager users can delete Customer objects + context.Rules.AddRule(typeof(Customer), + new IsInRole(AuthorizationActions.DeleteObject, "Admin", "Manager")); + + // Anyone can fetch Customer objects + // (No rule = allowed by default) +} +``` + +**Alternative syntax using AddBusinessRules:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Object-level authorization + BusinessRules.AddRule(typeof(Customer), + new IsInRole(AuthorizationActions.CreateObject, "Admin")); + + // Property-level authorization + BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin")); +} +``` + +## Method Authorization + +Authorize specific method execution: + +```csharp +public class CanApproveOrder : AuthorizationRule +{ + public CanApproveOrder(AuthorizationActions action, IMemberInfo method) + : base(action, method) + { + } + + protected override void Execute(IAuthorizationContext context) + { + var user = context.ApplicationContext.User; + + // Only users in Manager or Admin roles can approve orders + context.HasPermission = user.IsInRole("Manager") || user.IsInRole("Admin"); + } +} +``` + +**Usage:** + +```csharp +[CslaImplementProperties] +public partial class Order : BusinessBase +{ + // ... properties ... + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Authorize the Approve method + BusinessRules.AddRule( + new CanApproveOrder(AuthorizationActions.ExecuteMethod, + typeof(Order).GetMethod(nameof(Approve)))); + } + + public void Approve() + { + // Check authorization before executing + if (!CanExecuteMethod(nameof(Approve))) + throw new System.Security.SecurityException("Not authorized to approve orders"); + + // Approve the order + Status = "Approved"; + ApprovedDate = DateTime.Now; + } +} +``` + +## CacheResult Property + +By default, authorization results are cached. Set `CacheResult = false` if the result depends on object state: + +```csharp +public class OnlyForUS : AuthorizationRule +{ + public OnlyForUS(AuthorizationActions action, IMemberInfo element, IMemberInfo countryProperty) + : base(action, element) + { + // Re-evaluate every time because it depends on the Country property value + CacheResult = false; + } + + protected override void Execute(IAuthorizationContext context) + { + // ... + } +} +``` + +**When to use CacheResult = false:** +- Authorization depends on property values in the object +- Authorization depends on database data that might change +- Authorization is based on time-sensitive data + +**When to use CacheResult = true (default):** +- Authorization only depends on user roles +- Authorization only depends on user claims +- Authorization logic is expensive and the result won't change + +## Checking Authorization in Code + +### Check Property Authorization + +```csharp +public void DoSomething() +{ + if (CanWriteProperty(SalaryProperty)) + { + Salary = 50000; + } + else + { + throw new System.Security.SecurityException("Not authorized to write Salary"); + } +} +``` + +### Check Method Authorization + +```csharp +public void Approve() +{ + if (!CanExecuteMethod(nameof(Approve))) + throw new System.Security.SecurityException("Not authorized to approve"); + + // Proceed with approval + Status = "Approved"; +} +``` + +### Check Object Authorization + +```csharp +public static bool CanUserCreateOrder(IDataPortal portal) +{ + return Csla.Rules.BusinessRules.HasPermission( + AuthorizationActions.CreateObject, + typeof(Order)); +} +``` + +## UI Integration + +UI frameworks can query authorization rules to show/hide controls: + +```csharp +// In a view model or controller +public bool CanEditSalary +{ + get + { + if (_customer != null) + return _customer.CanWriteProperty(Customer.SalaryProperty); + return false; + } +} +``` + +**In XAML (WPF/MAUI):** + +```xml + +``` + +**In Razor (Blazor):** + +```razor +@if (Customer.CanWriteProperty(Customer.SalaryProperty)) +{ + +} +else +{ + @Customer.Salary +} +``` + +## Combining Authorization and Validation + +Authorization rules run before validation rules. A property that fails authorization won't trigger validation: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Authorization - runs first + BusinessRules.AddRule( + new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin")); + + // Validation - only runs if authorized + BusinessRules.AddRule( + new MinValue(SalaryProperty, 0)); +} +``` + +## Asynchronous Authorization Rules + +For authorization that requires async operations (like database queries), use `AuthorizationRuleAsync`: + +```csharp +public class CanEditDocument : AuthorizationRuleAsync +{ + public CanEditDocument(AuthorizationActions action, IMemberInfo element) + : base(action, element) + { + CacheResult = false; // Re-check each time + } + + protected override async Task ExecuteAsync(IAuthorizationContext context) + { + if (context.Target is Document doc) + { + // Query database to check permissions + var dal = context.ApplicationContext.GetRequiredService(); + var hasPermission = await dal.UserCanEditAsync(doc.Id, context.ApplicationContext.User); + + context.HasPermission = hasPermission; + } + } +} +``` + +**Usage:** + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Async authorization rule + BusinessRules.AddRule( + new CanEditDocument(AuthorizationActions.WriteProperty, ContentProperty)); +} +``` + +## Common Patterns + +### Pattern 1: Role-Based Property Access + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Admin can write everything + BusinessRules.AddRule(new IsInRole(AuthorizationActions.WriteProperty, SalaryProperty, "Admin")); + BusinessRules.AddRule(new IsInRole(AuthorizationActions.WriteProperty, TitleProperty, "Admin")); + + // Manager can write some fields + BusinessRules.AddRule(new IsInRole(AuthorizationActions.WriteProperty, TitleProperty, "Manager")); +} +``` + +### Pattern 2: Ownership-Based Authorization + +```csharp +public class IsOwner : AuthorizationRule +{ + private IPropertyInfo _ownerIdProperty; + + public IsOwner(AuthorizationActions action, IMemberInfo element, IPropertyInfo ownerIdProperty) + : base(action, element) + { + _ownerIdProperty = ownerIdProperty; + CacheResult = false; // Depends on object state + } + + protected override void Execute(IAuthorizationContext context) + { + var ownerId = (int)ReadProperty(context.Target, _ownerIdProperty); + var currentUserId = GetCurrentUserId(context); + + context.HasPermission = (ownerId == currentUserId) || context.ApplicationContext.User.IsInRole("Admin"); + } + + private int GetCurrentUserId(IAuthorizationContext context) + { + var claim = context.ApplicationContext.Principal.Claims.FirstOrDefault(c => c.Type == "UserId"); + return claim != null && int.TryParse(claim.Value, out int id) ? id : 0; + } +} +``` + +### Pattern 3: Time-Based Authorization + +```csharp +public class OnlyDuringBusinessHours : AuthorizationRule +{ + public OnlyDuringBusinessHours(AuthorizationActions action, IMemberInfo element) + : base(action, element) + { + CacheResult = false; // Time changes + } + + protected override void Execute(IAuthorizationContext context) + { + var now = DateTime.Now; + var isBusinessHours = now.Hour >= 9 && now.Hour < 17 && now.DayOfWeek != DayOfWeek.Saturday && now.DayOfWeek != DayOfWeek.Sunday; + + context.HasPermission = isBusinessHours || context.ApplicationContext.User.IsInRole("Admin"); + } +} +``` + +### Pattern 4: Combined Role and State + +```csharp +public class CanApproveBasedOnAmount : AuthorizationRule +{ + private IPropertyInfo _amountProperty; + + public CanApproveBasedOnAmount(AuthorizationActions action, IMemberInfo element, IPropertyInfo amountProperty) + : base(action, element) + { + _amountProperty = amountProperty; + CacheResult = false; + } + + protected override void Execute(IAuthorizationContext context) + { + decimal amount = (decimal)ReadProperty(context.Target, _amountProperty); + var user = context.ApplicationContext.User; + + if (amount <= 1000 && user.IsInRole("Manager")) + context.HasPermission = true; + else if (amount <= 10000 && user.IsInRole("Director")) + context.HasPermission = true; + else if (user.IsInRole("CEO")) + context.HasPermission = true; + else + context.HasPermission = false; + } +} +``` + +## Best Practices + +1. **Remember: One rule per action** - Only one authorization rule per property/action combination +2. **Prefer Claims over Roles** - Claims provide more granular control and are more flexible +3. **Combine Claims with property values** - Most custom rules check both user claims and object state +4. **Use built-in rules for simple cases** - `IsInRole` and `IsNotInRole` work for basic scenarios +5. **Set CacheResult appropriately** - Use `false` when authorization depends on object state or property values +6. **Check authorization in methods** - Always verify authorization before sensitive operations +7. **Provide user feedback** - UI should reflect what users can/cannot do +8. **Fail securely** - Default to `HasPermission = false` +9. **Keep rules simple** - Complex authorization logic should be in a service, not a rule +10. **Test authorization thoroughly** - Security bugs are critical +11. **Don't put business logic in auth rules** - Keep authorization separate from validation +12. **Document authorization requirements** - Make security requirements clear to developers + +## Authorization vs. Validation + +| Authorization Rules | Validation Rules | +|---------------------|------------------| +| Answer: Can the user do this? | Answer: Is the data valid? | +| Based on user identity | Based on data values | +| Security concern | Data quality concern | +| Failure = SecurityException | Failure = BrokenRules | +| Run first | Run after authorization passes | +| Usually role-based | Usually property-based | + +## Notes + +- Authorization rules run before validation rules +- If authorization fails, validation rules don't run +- Authorization results can be cached for performance (default) +- Object-level authorization can be checked before creating/fetching objects +- The `ReadProperty` method in authorization rules bypasses authorization checks +- Authorization rules work seamlessly across application tiers with CSLA's Data Portal + +## See Also + +- [BusinessRules.md](BusinessRules.md) - Overview of the business rules system +- [BusinessRulesPriority.md](BusinessRulesPriority.md) - Rule priorities and execution order +- [BusinessRulesValidation.md](BusinessRulesValidation.md) - Validation rules +- [BusinessRulesAsync.md](BusinessRulesAsync.md) - Asynchronous rules (including async authorization) diff --git a/csla-examples/BusinessRulesCalculation.md b/csla-examples/BusinessRulesCalculation.md new file mode 100644 index 0000000..0007b28 --- /dev/null +++ b/csla-examples/BusinessRulesCalculation.md @@ -0,0 +1,479 @@ +# Business Rules: Calculation + +Calculation rules compute and set property values based on other properties. These rules use `context.AddOutValue()` to modify property values. + +## Simple Property Calculation + +Calculate a single property from one input: + +```csharp +public class CalculateTax : BusinessRule +{ + private decimal _taxRate; + + public CalculateTax(IPropertyInfo taxProperty, IPropertyInfo subtotalProperty, decimal taxRate) + : base(taxProperty) + { + _taxRate = taxRate; + + InputProperties.Add(subtotalProperty); + AffectedProperties.Add(taxProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal subtotal = (decimal)context.InputPropertyValues[InputProperties[0]]; + decimal tax = subtotal * _taxRate; + + context.AddOutValue(PrimaryProperty, tax); + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // When Subtotal changes, recalculate Tax + BusinessRules.AddRule(new Dependency(SubtotalProperty, TaxProperty)); + BusinessRules.AddRule(new CalculateTax(TaxProperty, SubtotalProperty, 0.08m)); +} +``` + +## Multi-Property Calculation + +Calculate a property from multiple inputs: + +```csharp +public class CalcSum : BusinessRule +{ + public CalcSum(IPropertyInfo sumProperty, params IPropertyInfo[] inputProperties) + : base(sumProperty) + { + InputProperties.AddRange(inputProperties); + AffectedProperties.Add(sumProperty); + } + + protected override void Execute(IRuleContext context) + { + // Sum all input property values + decimal sum = context.InputPropertyValues.Sum(kvp => Convert.ToDecimal(kvp.Value)); + + context.AddOutValue(PrimaryProperty, sum); + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Set up dependencies so Sum recalculates when Num1 or Num2 changes + BusinessRules.AddRule(new Dependency(Num1Property, SumProperty)); + BusinessRules.AddRule(new Dependency(Num2Property, SumProperty)); + + // Add calculation rule + BusinessRules.AddRule(new CalcSum(SumProperty, Num1Property, Num2Property)); +} +``` + +## Complex Calculation Rules + +### Order Total Calculation + +Calculate total from subtotal, tax, and shipping: + +```csharp +public class CalculateOrderTotal : BusinessRule +{ + private IPropertyInfo _subtotalProperty; + private IPropertyInfo _taxProperty; + private IPropertyInfo _shippingProperty; + + public CalculateOrderTotal( + IPropertyInfo totalProperty, + IPropertyInfo subtotalProperty, + IPropertyInfo taxProperty, + IPropertyInfo shippingProperty) + : base(totalProperty) + { + _subtotalProperty = subtotalProperty; + _taxProperty = taxProperty; + _shippingProperty = shippingProperty; + + InputProperties.Add(subtotalProperty); + InputProperties.Add(taxProperty); + InputProperties.Add(shippingProperty); + AffectedProperties.Add(totalProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal subtotal = (decimal)context.InputPropertyValues[_subtotalProperty]; + decimal tax = (decimal)context.InputPropertyValues[_taxProperty]; + decimal shipping = (decimal)context.InputPropertyValues[_shippingProperty]; + + decimal total = subtotal + tax + shipping; + + context.AddOutValue(PrimaryProperty, total); + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Dependencies + BusinessRules.AddRule(new Dependency(SubtotalProperty, TotalProperty)); + BusinessRules.AddRule(new Dependency(TaxProperty, TotalProperty)); + BusinessRules.AddRule(new Dependency(ShippingProperty, TotalProperty)); + + // Calculation + BusinessRules.AddRule(new CalculateOrderTotal( + TotalProperty, SubtotalProperty, TaxProperty, ShippingProperty)); +} +``` + +## Modifying Multiple Properties + +A single rule can modify multiple properties: + +```csharp +public class CalculateTaxAndTotal : BusinessRule +{ + private IPropertyInfo _taxProperty; + private IPropertyInfo _totalProperty; + private decimal _taxRate; + + public CalculateTaxAndTotal( + IPropertyInfo subtotalProperty, + IPropertyInfo taxProperty, + IPropertyInfo totalProperty, + decimal taxRate) + : base(subtotalProperty) + { + _taxProperty = taxProperty; + _totalProperty = totalProperty; + _taxRate = taxRate; + + InputProperties.Add(subtotalProperty); + AffectedProperties.Add(taxProperty); + AffectedProperties.Add(totalProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal subtotal = (decimal)context.InputPropertyValues[PrimaryProperty]; + + // Calculate tax + decimal tax = subtotal * _taxRate; + + // Calculate total + decimal total = subtotal + tax; + + // Set both output values + context.AddOutValue(_taxProperty, tax); + context.AddOutValue(_totalProperty, total); + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + BusinessRules.AddRule(new Dependency(SubtotalProperty, TaxProperty)); + BusinessRules.AddRule(new Dependency(SubtotalProperty, TotalProperty)); + + BusinessRules.AddRule(new CalculateTaxAndTotal( + SubtotalProperty, TaxProperty, TotalProperty, 0.08m)); +} +``` + +## Lookup-Based Calculation + +Set a property based on looking up another property: + +```csharp +public class SetStateName : BusinessRule +{ + private IPropertyInfo _stateNameProperty; + private IDataPortal _statesPortal; + + public SetStateName( + IPropertyInfo stateCodeProperty, + IPropertyInfo stateNameProperty, + IDataPortal statesPortal) + : base(stateCodeProperty) + { + _stateNameProperty = stateNameProperty; + _statesPortal = statesPortal; + + InputProperties.Add(stateCodeProperty); + AffectedProperties.Add(stateNameProperty); + } + + protected override void Execute(IRuleContext context) + { + string stateCode = (string)context.InputPropertyValues[PrimaryProperty]; + + if (string.IsNullOrWhiteSpace(stateCode)) + { + context.AddOutValue(_stateNameProperty, string.Empty); + return; + } + + // Look up state name + var lookup = _statesPortal.Fetch(); + var state = lookup.FirstOrDefault(s => s.Code == stateCode); + + string stateName = state?.Name ?? "Unknown"; + + context.AddOutValue(_stateNameProperty, stateName); + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + BusinessRules.AddRule(new Dependency(StateCodeProperty, StateNameProperty)); + + var statesPortal = ApplicationContext.GetRequiredService>(); + BusinessRules.AddRule(new SetStateName(StateCodeProperty, StateNameProperty, statesPortal)); +} +``` + +## Cascading Calculations + +Multiple rules that depend on each other need proper priority ordering: + +```csharp +public class Order : BusinessBase +{ + public static readonly PropertyInfo SubtotalProperty = + RegisterProperty(nameof(Subtotal)); + public decimal Subtotal + { + get => GetProperty(SubtotalProperty); + set => SetProperty(SubtotalProperty, value); + } + + public static readonly PropertyInfo DiscountProperty = + RegisterProperty(nameof(Discount)); + public decimal Discount + { + get => GetProperty(DiscountProperty); + set => SetProperty(DiscountProperty, value); + } + + public static readonly PropertyInfo DiscountAmountProperty = + RegisterProperty(nameof(DiscountAmount)); + public decimal DiscountAmount + { + get => GetProperty(DiscountAmountProperty); + private set => LoadProperty(DiscountAmountProperty, value); + } + + public static readonly PropertyInfo TaxableAmountProperty = + RegisterProperty(nameof(TaxableAmount)); + public decimal TaxableAmount + { + get => GetProperty(TaxableAmountProperty); + private set => LoadProperty(TaxableAmountProperty, value); + } + + public static readonly PropertyInfo TaxProperty = + RegisterProperty(nameof(Tax)); + public decimal Tax + { + get => GetProperty(TaxProperty); + private set => LoadProperty(TaxProperty, value); + } + + public static readonly PropertyInfo TotalProperty = + RegisterProperty(nameof(Total)); + public decimal Total + { + get => GetProperty(TotalProperty); + private set => LoadProperty(TotalProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Dependencies + BusinessRules.AddRule(new Dependency(SubtotalProperty, DiscountAmountProperty)); + BusinessRules.AddRule(new Dependency(DiscountProperty, DiscountAmountProperty)); + BusinessRules.AddRule(new Dependency(DiscountAmountProperty, TaxableAmountProperty)); + BusinessRules.AddRule(new Dependency(TaxableAmountProperty, TaxProperty)); + BusinessRules.AddRule(new Dependency(TaxProperty, TotalProperty)); + + // Calculations with priorities to ensure correct order + // Priority -3: Calculate discount amount first + BusinessRules.AddRule(new CalcDiscountAmount( + DiscountAmountProperty, SubtotalProperty, DiscountProperty) { Priority = -3 }); + + // Priority -2: Calculate taxable amount second + BusinessRules.AddRule(new CalcTaxableAmount( + TaxableAmountProperty, SubtotalProperty, DiscountAmountProperty) { Priority = -2 }); + + // Priority -1: Calculate tax third + BusinessRules.AddRule(new CalcTax( + TaxProperty, TaxableAmountProperty, 0.08m) { Priority = -1 }); + + // Priority 0: Calculate total last (default priority) + BusinessRules.AddRule(new CalcTotal( + TotalProperty, TaxableAmountProperty, TaxProperty) { Priority = 0 }); + } +} +``` + +## Common Patterns + +### Pattern 1: Simple Derived Value + +```csharp +public class FullNameRule : BusinessRule +{ + private IPropertyInfo _firstNameProperty; + private IPropertyInfo _lastNameProperty; + + public FullNameRule( + IPropertyInfo fullNameProperty, + IPropertyInfo firstNameProperty, + IPropertyInfo lastNameProperty) + : base(fullNameProperty) + { + _firstNameProperty = firstNameProperty; + _lastNameProperty = lastNameProperty; + + InputProperties.Add(firstNameProperty); + InputProperties.Add(lastNameProperty); + AffectedProperties.Add(fullNameProperty); + } + + protected override void Execute(IRuleContext context) + { + string firstName = (string)context.InputPropertyValues[_firstNameProperty]; + string lastName = (string)context.InputPropertyValues[_lastNameProperty]; + + string fullName = $"{firstName} {lastName}".Trim(); + + context.AddOutValue(PrimaryProperty, fullName); + } +} +``` + +### Pattern 2: Percentage Calculation + +```csharp +public class CalcPercentage : BusinessRule +{ + private IPropertyInfo _totalProperty; + + public CalcPercentage( + IPropertyInfo percentageProperty, + IPropertyInfo partProperty, + IPropertyInfo totalProperty) + : base(percentageProperty) + { + _totalProperty = totalProperty; + + InputProperties.Add(partProperty); + InputProperties.Add(totalProperty); + AffectedProperties.Add(percentageProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal part = (decimal)context.InputPropertyValues[InputProperties[0]]; + decimal total = (decimal)context.InputPropertyValues[_totalProperty]; + + decimal percentage = total > 0 ? (part / total) * 100 : 0; + + context.AddOutValue(PrimaryProperty, percentage); + } +} +``` + +### Pattern 3: Conditional Calculation + +```csharp +public class CalcShipping : BusinessRule +{ + private IPropertyInfo _subtotalProperty; + private IPropertyInfo _isPrimeProperty; + + public CalcShipping( + IPropertyInfo shippingProperty, + IPropertyInfo subtotalProperty, + IPropertyInfo isPrimeProperty) + : base(shippingProperty) + { + _subtotalProperty = subtotalProperty; + _isPrimeProperty = isPrimeProperty; + + InputProperties.Add(subtotalProperty); + InputProperties.Add(isPrimeProperty); + AffectedProperties.Add(shippingProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal subtotal = (decimal)context.InputPropertyValues[_subtotalProperty]; + bool isPrime = (bool)context.InputPropertyValues[_isPrimeProperty]; + + decimal shipping; + + if (isPrime) + { + shipping = 0; // Free shipping for Prime members + } + else if (subtotal >= 50) + { + shipping = 0; // Free shipping over $50 + } + else + { + shipping = 7.99m; // Standard shipping + } + + context.AddOutValue(PrimaryProperty, shipping); + } +} +``` + +## Best Practices + +1. **Use AddOutValue** - Always use `context.AddOutValue()` to modify properties, never call `SetProperty()` in a rule +2. **Declare affected properties** - Add calculated properties to `AffectedProperties` +3. **Set up dependencies** - Use `Dependency` rules to trigger recalculation when inputs change +4. **Use priorities** - When calculations depend on other calculations, use priorities to control execution order +5. **Handle division by zero** - Check for zero before dividing +6. **Keep calculations pure** - Don't access external state; only use input property values +7. **Make properties read-only** - Calculated properties should typically have private setters +8. **Test independently** - Calculation logic should be testable without a full business object + +## Notes + +- Calculation rules execute automatically when input properties change +- Use negative priorities for calculations that must run before validation rules +- Multiple properties can be calculated in a single rule using multiple `AddOutValue()` calls +- The calculated property should use `LoadProperty()` in its setter, not `SetProperty()` +- Calculation rules should not produce error/warning/information messages (use validation rules for that) + +See [BusinessRulesPriority.md](BusinessRulesPriority.md) for more information on controlling rule execution order with priorities. diff --git a/csla-examples/BusinessRulesPriority.md b/csla-examples/BusinessRulesPriority.md new file mode 100644 index 0000000..c5dd37f --- /dev/null +++ b/csla-examples/BusinessRulesPriority.md @@ -0,0 +1,537 @@ +# Business Rules: Priority and Execution Order + +Business rules execute in a specific order determined by their **priority** values and **dependency chains**. Understanding this execution order is critical when rules depend on the results of other rules. + +## Priority Values + +Each business rule has a `Priority` property that controls when it executes relative to other rules. Lower numbers run first. + +### Default Priority + +- Default priority is **0** +- Most validation rules should use the default priority +- Calculation rules often need negative priorities to run before validation + +### Setting Priority + +Set the priority when adding the rule: + +```csharp +BusinessRules.AddRule(new CalcSum(SumProperty, Num1Property, Num2Property) { Priority = -1 }); +``` + +## Priority Guidelines + +| Priority Range | Typical Use | +|----------------|-------------| +| -10 to -1 | Calculation rules that must run before validation | +| 0 (default) | Most validation rules | +| 1 to 10 | Rules that should run after validation | +| 100+ | Low-priority informational rules | + +## Execution Order + +Rules execute in this order: + +1. **By Priority** - Lower priority values run first +2. **By Registration Order** - Rules with the same priority run in the order they were added +3. **By Dependencies** - When a rule completes, rules for its `AffectedProperties` run + +## Short-Circuiting on Error + +**CRITICAL:** If a synchronous validation rule returns an **Error** severity result, rule execution stops immediately: + +- No subsequent synchronous rules at the same or higher priority execute +- No asynchronous rules are started +- This is called "short-circuiting" + +**Warnings and Information messages do NOT stop rule execution** - only Errors cause short-circuiting. + +### Short-Circuiting Example + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Priority -1: Runs first + BusinessRules.AddRule(new Required(NameProperty) { Priority = -1 }); + + // Priority 0: Only runs if Required passes + BusinessRules.AddRule(new MaxLength(NameProperty, 50)); + + // Priority 1: Only runs if MaxLength passes + BusinessRules.AddRule(new UniqueNameAsync(NameProperty) { Priority = 1 }); +} +``` + +**Execution scenarios:** + +1. **Name is empty**: + - `Required` fails with Error → short-circuit + - `MaxLength` does NOT run + - `UniqueNameAsync` does NOT run + +2. **Name is 100 characters**: + - `Required` passes + - `MaxLength` fails with Error → short-circuit + - `UniqueNameAsync` does NOT run + +3. **Name is valid**: + - `Required` passes + - `MaxLength` passes + - `UniqueNameAsync` runs (async) + +### Why Short-Circuiting Matters + +Short-circuiting prevents: +- Wasting resources on unnecessary validation (e.g., async database checks) +- Confusing error messages (e.g., "Name is required" AND "Name is too long") +- Exceptions from rules that expect valid data + +### Using Priority to Control Short-Circuiting + +Place critical validation rules at lower priorities to ensure they run first: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Priority -10: Most critical - check for required value + BusinessRules.AddRule(new Required(EmailProperty) { Priority = -10 }); + + // Priority -5: Check format if value exists + BusinessRules.AddRule(new Email(EmailProperty) { Priority = -5 }); + + // Priority 0: Check uniqueness if format is valid (async) + BusinessRules.AddRule(new UniqueEmailAsync(EmailProperty)); +} +``` + +This ensures: +1. Don't check email format if email is missing +2. Don't check uniqueness in database if email format is invalid + +## Priority Example: Calculation Before Validation + +A calculation rule must run before a validation rule that checks the calculated value: + +```csharp +public class Order : BusinessBase +{ + public static readonly PropertyInfo Num1Property = + RegisterProperty(nameof(Num1)); + public int Num1 + { + get => GetProperty(Num1Property); + set => SetProperty(Num1Property, value); + } + + public static readonly PropertyInfo Num2Property = + RegisterProperty(nameof(Num2)); + public int Num2 + { + get => GetProperty(Num2Property); + set => SetProperty(Num2Property, value); + } + + public static readonly PropertyInfo SumProperty = + RegisterProperty(nameof(Sum)); + public int Sum + { + get => GetProperty(SumProperty); + private set => LoadProperty(SumProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Dependencies to trigger Sum recalculation + BusinessRules.AddRule(new Dependency(Num1Property, SumProperty)); + BusinessRules.AddRule(new Dependency(Num2Property, SumProperty)); + + // Priority -1: Calculate sum FIRST + BusinessRules.AddRule(new CalcSum(SumProperty, Num1Property, Num2Property) { Priority = -1 }); + + // Priority 0 (default): Validate sum SECOND + BusinessRules.AddRule(new MinValue(SumProperty, 1)); + } +} +``` + +**Why this matters:** +- When `Num1` or `Num2` changes, `CalcSum` runs first (priority -1) to update `Sum` +- Then `MinValue` rule runs (priority 0) to validate the new `Sum` value +- Without the priority, validation might run on the old `Sum` value + +## Cascading Calculations + +When multiple calculations depend on each other, use priorities to control the cascade order: + +```csharp +public class Order : BusinessBase +{ + public static readonly PropertyInfo SubtotalProperty = + RegisterProperty(nameof(Subtotal)); + public decimal Subtotal + { + get => GetProperty(SubtotalProperty); + set => SetProperty(SubtotalProperty, value); + } + + public static readonly PropertyInfo DiscountProperty = + RegisterProperty(nameof(Discount)); + public decimal Discount + { + get => GetProperty(DiscountProperty); + set => SetProperty(DiscountProperty, value); + } + + public static readonly PropertyInfo DiscountAmountProperty = + RegisterProperty(nameof(DiscountAmount)); + public decimal DiscountAmount + { + get => GetProperty(DiscountAmountProperty); + private set => LoadProperty(DiscountAmountProperty, value); + } + + public static readonly PropertyInfo TaxableAmountProperty = + RegisterProperty(nameof(TaxableAmount)); + public decimal TaxableAmount + { + get => GetProperty(TaxableAmountProperty); + private set => LoadProperty(TaxableAmountProperty, value); + } + + public static readonly PropertyInfo TaxProperty = + RegisterProperty(nameof(Tax)); + public decimal Tax + { + get => GetProperty(TaxProperty); + private set => LoadProperty(TaxProperty, value); + } + + public static readonly PropertyInfo TotalProperty = + RegisterProperty(nameof(Total)); + public decimal Total + { + get => GetProperty(TotalProperty); + private set => LoadProperty(TotalProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + + // Dependencies - define what triggers what + BusinessRules.AddRule(new Dependency(SubtotalProperty, DiscountAmountProperty)); + BusinessRules.AddRule(new Dependency(DiscountProperty, DiscountAmountProperty)); + BusinessRules.AddRule(new Dependency(DiscountAmountProperty, TaxableAmountProperty)); + BusinessRules.AddRule(new Dependency(TaxableAmountProperty, TaxProperty)); + BusinessRules.AddRule(new Dependency(TaxProperty, TotalProperty)); + + // Priority -4: Calculate discount amount FIRST + BusinessRules.AddRule(new CalcDiscountAmount( + DiscountAmountProperty, SubtotalProperty, DiscountProperty) { Priority = -4 }); + + // Priority -3: Calculate taxable amount SECOND + BusinessRules.AddRule(new CalcTaxableAmount( + TaxableAmountProperty, SubtotalProperty, DiscountAmountProperty) { Priority = -3 }); + + // Priority -2: Calculate tax THIRD + BusinessRules.AddRule(new CalcTax( + TaxProperty, TaxableAmountProperty, 0.08m) { Priority = -2 }); + + // Priority -1: Calculate total FOURTH + BusinessRules.AddRule(new CalcTotal( + TotalProperty, TaxableAmountProperty, TaxProperty) { Priority = -1 }); + + // Priority 0 (default): Validate total LAST + BusinessRules.AddRule(new MinValue(TotalProperty, 0.01m)); + } +} +``` + +**Execution Flow:** +1. User changes `Subtotal` to 100 +2. Priority -4: `CalcDiscountAmount` runs → `DiscountAmount` = 10 +3. Priority -3: `CalcTaxableAmount` runs → `TaxableAmount` = 90 +4. Priority -2: `CalcTax` runs → `Tax` = 7.20 +5. Priority -1: `CalcTotal` runs → `Total` = 97.20 +6. Priority 0: `MinValue` validation runs on `Total` + +## Rule Chaining via AffectedProperties + +Rules can trigger other rules through the `AffectedProperties` list. When a rule completes, CSLA automatically runs rules for all affected properties. + +### Simple Chain + +```csharp +public class FullNameRule : BusinessRule +{ + private IPropertyInfo _firstNameProperty; + private IPropertyInfo _lastNameProperty; + + public FullNameRule( + IPropertyInfo fullNameProperty, + IPropertyInfo firstNameProperty, + IPropertyInfo lastNameProperty) + : base(fullNameProperty) + { + _firstNameProperty = firstNameProperty; + _lastNameProperty = lastNameProperty; + + InputProperties.Add(firstNameProperty); + InputProperties.Add(lastNameProperty); + + // This rule affects FullName + AffectedProperties.Add(fullNameProperty); + } + + protected override void Execute(IRuleContext context) + { + string firstName = (string)context.InputPropertyValues[_firstNameProperty]; + string lastName = (string)context.InputPropertyValues[_lastNameProperty]; + + context.AddOutValue(PrimaryProperty, $"{firstName} {lastName}".Trim()); + } +} + +// In AddBusinessRules +BusinessRules.AddRule(new Dependency(FirstNameProperty, FullNameProperty)); +BusinessRules.AddRule(new Dependency(LastNameProperty, FullNameProperty)); +BusinessRules.AddRule(new FullNameRule(FullNameProperty, FirstNameProperty, LastNameProperty)); + +// Any validation rules on FullName will run AFTER FullNameRule completes +BusinessRules.AddRule(new MaxLength(FullNameProperty, 50)); +``` + +**Rule Chain:** +1. User changes `FirstName` +2. `Dependency` triggers rules for `FullNameProperty` +3. `FullNameRule` runs and updates `FullName` via `AddOutValue` +4. Rules for affected properties run (like `MaxLength` on `FullNameProperty`) + +### Multi-Level Chain + +Rules can cascade through multiple levels: + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Level 1: FirstName/LastName → FullName + BusinessRules.AddRule(new Dependency(FirstNameProperty, FullNameProperty)); + BusinessRules.AddRule(new Dependency(LastNameProperty, FullNameProperty)); + BusinessRules.AddRule(new FullNameRule(FullNameProperty, FirstNameProperty, LastNameProperty) + { Priority = -2 }); + + // Level 2: FullName → DisplayName + BusinessRules.AddRule(new Dependency(FullNameProperty, DisplayNameProperty)); + BusinessRules.AddRule(new FormatDisplayName(DisplayNameProperty, FullNameProperty, TitleProperty) + { Priority = -1 }); + + // Level 3: DisplayName validation + BusinessRules.AddRule(new MaxLength(DisplayNameProperty, 100)); +} +``` + +**Execution Flow:** +1. User changes `FirstName` +2. Priority -2: `FullNameRule` runs → updates `FullName` +3. Priority -1: `FormatDisplayName` runs → updates `DisplayName` +4. Priority 0: `MaxLength` validation runs on `DisplayName` + +## Preventing Infinite Loops + +Be careful not to create circular dependencies where Rule A affects Property B, and a rule on Property B affects Property A. + +### Problem: Infinite Loop + +```csharp +// DON'T DO THIS +BusinessRules.AddRule(new Dependency(Property1, Property2)); +BusinessRules.AddRule(new Rule1(Property2, Property1)); // Sets Property1 + +BusinessRules.AddRule(new Dependency(Property2, Property1)); +BusinessRules.AddRule(new Rule2(Property1, Property2)); // Sets Property2 +// This creates an infinite loop! +``` + +### Solution: One-Way Dependencies + +```csharp +// DO THIS +BusinessRules.AddRule(new Dependency(Property1, Property2)); +BusinessRules.AddRule(new Rule1(Property2, Property1)); // Sets Property2 based on Property1 + +// Don't add a reverse dependency +``` + +## CascadeIfDirty Option + +By default, rules cascade whenever the primary property changes. Use `CascadeIfDirty` to only cascade when the property is truly dirty: + +```csharp +public class MyRule : BusinessRule +{ + public MyRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + // Only cascade if primary property is marked as dirty + CascadeIfDirty = true; + } +} +``` + +This prevents unnecessary rule execution during object initialization or when loading from the database. + +## RunMode: Controlling When Rules Execute + +The `RunMode` property controls when a rule is allowed to execute. This is useful for rules that should only run in specific contexts. + +### RunMode Values + +```csharp +public enum RunModes +{ + Default = 0, // Rule can run in any context + DenyCheckRules = 1, // Don't run during CheckRules + DenyAsAffectedProperty = 2, // Don't run as affected property + DenyOnServerSidePortal = 4 // Don't run on server-side data portal +} +``` + +### Example: Client-Side Only Rule + +A rule that calls a web service shouldn't run on the server: + +```csharp +public class ValidateWithWebService : BusinessRuleAsync +{ + public ValidateWithWebService(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + IsAsync = true; + + // Don't run this rule on the server-side data portal + RunMode = RunModes.DenyOnServerSidePortal; + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + // Call external web service + var value = (string)context.InputPropertyValues[PrimaryProperty]; + var isValid = await _webService.ValidateAsync(value); + + if (!isValid) + context.AddErrorResult("Value is not valid according to web service."); + } +} +``` + +### Example: Manual-Only Rule + +A rule that should only run when explicitly called, not during normal property changes: + +```csharp +public class ComplexValidation : BusinessRule +{ + public ComplexValidation(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + // Only run when CheckRules is explicitly called + RunMode = RunModes.DenyAsAffectedProperty; + } +} +``` + +### Combining RunModes + +Use the `|` operator to combine multiple RunMode values: + +```csharp +// Don't run during CheckRules OR as affected property +RunMode = RunModes.DenyCheckRules | RunModes.DenyAsAffectedProperty; +``` + +## Best Practices + +1. **Use negative priorities for calculations** - Calculations should typically run before validations +2. **Space priorities apart** - Use -10, -9, -8 instead of -3, -2, -1 to leave room for future rules +3. **Document priority decisions** - Add comments explaining why a rule has a specific priority +4. **Avoid deep cascades** - Long chains of rules are hard to debug and maintain +5. **Test execution order** - Unit test that rules run in the expected order +6. **Use Dependency rules** - Always add `Dependency` rules to trigger recalculation +7. **Watch for infinite loops** - Carefully review AffectedProperties to avoid circular dependencies +8. **Set CascadeIfDirty when appropriate** - Prevents unnecessary rule execution during initialization +9. **Use RunMode selectively** - Most rules should use the default RunMode + +## Debugging Rule Execution + +To see the order rules execute: + +```csharp +// In CSLA 9 +BusinessRules.RunRulesComplete += (s, e) => +{ + Console.WriteLine($"Rules completed for {e.Property?.Name}"); +}; + +// In CSLA 10 +BusinessRules.RunRulesComplete += async (s, e) => +{ + Console.WriteLine($"Rules completed for {e.Property?.Name}"); + await Task.CompletedTask; +}; +``` + +## Common Patterns + +### Pattern 1: Calculation → Validation + +```csharp +// Priority -1: Calculate +BusinessRules.AddRule(new CalcTax(TaxProperty, SubtotalProperty, 0.08m) { Priority = -1 }); + +// Priority 0: Validate +BusinessRules.AddRule(new MinValue(TaxProperty, 0)); +``` + +### Pattern 2: Multiple Calculations in Sequence + +```csharp +BusinessRules.AddRule(new CalcStep1(...) { Priority = -5 }); +BusinessRules.AddRule(new CalcStep2(...) { Priority = -4 }); +BusinessRules.AddRule(new CalcStep3(...) { Priority = -3 }); +``` + +### Pattern 3: Validation → Authorization + +```csharp +// Priority 0: Validate data +BusinessRules.AddRule(new Required(NameProperty)); + +// Priority 1: Check authorization after data is valid +BusinessRules.AddRule(new IsInRole(AuthorizationActions.WriteProperty, NameProperty, "Admin") + { Priority = 1 }); +``` + +## Notes + +- Rules with the same priority execute in registration order +- Priority is per-property, not global - two rules on different properties can have the same priority +- `Dependency` rules don't have priorities - they just trigger other rules +- Async rules (see [BusinessRulesAsync.md](BusinessRulesAsync.md)) run after all synchronous rules at their priority level +- Authorization rules (see [BusinessRulesAuthorization.md](BusinessRulesAuthorization.md)) typically use default priority + +## See Also + +- [BusinessRules.md](BusinessRules.md) - Overview of the business rules system +- [BusinessRulesValidation.md](BusinessRulesValidation.md) - Validation rules +- [BusinessRulesCalculation.md](BusinessRulesCalculation.md) - Calculation rules with cascading examples +- [BusinessRulesAsync.md](BusinessRulesAsync.md) - Asynchronous business rules +- [BusinessRulesAuthorization.md](BusinessRulesAuthorization.md) - Authorization rules diff --git a/csla-examples/BusinessRulesValidation.md b/csla-examples/BusinessRulesValidation.md new file mode 100644 index 0000000..f51e0a1 --- /dev/null +++ b/csla-examples/BusinessRulesValidation.md @@ -0,0 +1,458 @@ +# Business Rules: Validation + +Validation rules check property values and produce error, warning, or information messages. This document covers simple single-property validation and complex multi-property validation rules. + +## Simple Validation Rules + +Simple validation rules check a single property value and produce a message. + +### Error-Level Validation + +Error-level validation prevents the object from being saved: + +```csharp +public class SimpleErrorRule : BusinessRule +{ + public SimpleErrorRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override void Execute(IRuleContext context) + { + string value = (string)context.InputPropertyValues[PrimaryProperty]; + + if (string.IsNullOrWhiteSpace(value)) + { + context.AddErrorResult("This field is required."); + } + } +} +``` + +**Usage:** +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + BusinessRules.AddRule(new SimpleErrorRule(NameProperty)); +} +``` + +### Warning-Level Validation + +Warning-level validation provides guidance but doesn't prevent saving: + +```csharp +public class PasswordStrengthRule : BusinessRule +{ + public PasswordStrengthRule(IPropertyInfo primaryProperty) + : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override void Execute(IRuleContext context) + { + string password = (string)context.InputPropertyValues[PrimaryProperty]; + + if (password != null && password.Length < 8) + { + context.AddWarningResult("Password should be at least 8 characters for better security."); + } + } +} +``` + +**Usage:** +```csharp +BusinessRules.AddRule(new PasswordStrengthRule(PasswordProperty)); +``` + +### Information-Level Validation + +Information-level validation provides helpful feedback: + +```csharp +public class CharacterCountRule : BusinessRule +{ + private int _maxLength; + + public CharacterCountRule(IPropertyInfo primaryProperty, int maxLength) + : base(primaryProperty) + { + _maxLength = maxLength; + InputProperties.Add(primaryProperty); + } + + protected override void Execute(IRuleContext context) + { + string value = (string)context.InputPropertyValues[PrimaryProperty]; + int remaining = _maxLength - (value?.Length ?? 0); + + if (remaining >= 0) + { + context.AddInformationResult($"{remaining} characters remaining."); + } + } +} +``` + +**Usage:** +```csharp +BusinessRules.AddRule(new CharacterCountRule(DescriptionProperty, 500)); +``` + +## Complex Multi-Property Validation + +Complex validation rules use multiple property values to perform validation. + +### Two-Property Comparison + +Compare two property values: + +```csharp +public class LessThanProperty : BusinessRule +{ + private IPropertyInfo _compareToProperty; + + public LessThanProperty(IPropertyInfo primaryProperty, IPropertyInfo compareToProperty) + : base(primaryProperty) + { + _compareToProperty = compareToProperty; + + // Add both properties as inputs + InputProperties.Add(primaryProperty); + InputProperties.Add(compareToProperty); + } + + protected override void Execute(IRuleContext context) + { + int value1 = (int)context.InputPropertyValues[PrimaryProperty]; + int value2 = (int)context.InputPropertyValues[_compareToProperty]; + + if (value1 >= value2) + { + context.AddErrorResult($"{PrimaryProperty.FriendlyName} must be less than {_compareToProperty.FriendlyName}."); + } + } +} +``` + +**Usage:** +```csharp +// StartDate must be less than EndDate +BusinessRules.AddRule(new LessThanProperty(StartDateProperty, EndDateProperty)); + +// Num1 must be less than Num2 +BusinessRules.AddRule(new LessThanProperty(Num1Property, Num2Property)); +``` + +### Date Range Validation + +Validate that dates are in a proper range: + +```csharp +public class DateRangeRule : BusinessRule +{ + private IPropertyInfo _startDateProperty; + private IPropertyInfo _endDateProperty; + + public DateRangeRule(IPropertyInfo startDateProperty, IPropertyInfo endDateProperty) + : base(startDateProperty) + { + _startDateProperty = startDateProperty; + _endDateProperty = endDateProperty; + + InputProperties.Add(startDateProperty); + InputProperties.Add(endDateProperty); + } + + protected override void Execute(IRuleContext context) + { + DateTime startDate = (DateTime)context.InputPropertyValues[_startDateProperty]; + DateTime endDate = (DateTime)context.InputPropertyValues[_endDateProperty]; + + if (startDate > endDate) + { + context.AddErrorResult("Start date must be before or equal to end date."); + } + + TimeSpan duration = endDate - startDate; + if (duration.TotalDays > 365) + { + context.AddWarningResult("Date range exceeds one year. This may affect performance."); + } + } +} +``` + +**Usage:** +```csharp +BusinessRules.AddRule(new DateRangeRule(StartDateProperty, EndDateProperty)); +``` + +### Conditional Required Field + +Require a field only when another field has a specific value: + +```csharp +public class StringRequiredIf : BusinessRule +{ + private IPropertyInfo _conditionProperty; + private object _conditionValue; + + public StringRequiredIf(IPropertyInfo primaryProperty, IPropertyInfo conditionProperty, object conditionValue) + : base(primaryProperty) + { + _conditionProperty = conditionProperty; + _conditionValue = conditionValue; + + InputProperties.Add(primaryProperty); + InputProperties.Add(conditionProperty); + } + + protected override void Execute(IRuleContext context) + { + object conditionValue = context.InputPropertyValues[_conditionProperty]; + + // Only validate if condition is met + if (Equals(conditionValue, _conditionValue)) + { + string value = (string)context.InputPropertyValues[PrimaryProperty]; + + if (string.IsNullOrWhiteSpace(value)) + { + context.AddErrorResult($"{PrimaryProperty.FriendlyName} is required when {_conditionProperty.FriendlyName} is {_conditionValue}."); + } + } + } +} +``` + +**Usage:** +```csharp +// State is required when Country is "US" +BusinessRules.AddRule(new StringRequiredIf(StateProperty, CountryProperty, "US")); + +// AdditionalInfo is required when OrderType is "Custom" +BusinessRules.AddRule(new StringRequiredIf(AdditionalInfoProperty, OrderTypeProperty, "Custom")); +``` + +### Using Inner Rules + +Execute existing rules conditionally: + +```csharp +public class StringRequiredIfUS : BusinessRule +{ + private IPropertyInfo _countryProperty; + private IBusinessRule _innerRule; + + public StringRequiredIfUS(IPropertyInfo primaryProperty, IPropertyInfo countryProperty) + : base(primaryProperty) + { + _countryProperty = countryProperty; + _innerRule = new Csla.Rules.CommonRules.Required(primaryProperty); + + // Add condition property as input + InputProperties.Add(countryProperty); + + // Add input properties required by inner rule + foreach (var inputProp in _innerRule.InputProperties) + { + if (!InputProperties.Contains(inputProp)) + InputProperties.Add(inputProp); + } + } + + protected override void Execute(IRuleContext context) + { + string country = (string)context.InputPropertyValues[_countryProperty]; + + if (country == "US") + { + // Execute the inner Required rule + _innerRule.Execute(context.GetChainedContext(_innerRule)); + } + } +} +``` + +**Usage:** +```csharp +BusinessRules.AddRule(new StringRequiredIfUS(ZipCodeProperty, CountryProperty)); +``` + +## Multi-Property Error, Warning, and Information + +Rules can produce different severity levels for different conditions: + +```csharp +public class BudgetValidationRule : BusinessRule +{ + private IPropertyInfo _estimatedCostProperty; + private IPropertyInfo _actualCostProperty; + + public BudgetValidationRule(IPropertyInfo estimatedCostProperty, IPropertyInfo actualCostProperty) + : base(estimatedCostProperty) + { + _estimatedCostProperty = estimatedCostProperty; + _actualCostProperty = actualCostProperty; + + InputProperties.Add(estimatedCostProperty); + InputProperties.Add(actualCostProperty); + } + + protected override void Execute(IRuleContext context) + { + decimal estimated = (decimal)context.InputPropertyValues[_estimatedCostProperty]; + decimal actual = (decimal)context.InputPropertyValues[_actualCostProperty]; + + if (actual <= 0) + return; // No actual cost yet + + decimal variance = actual - estimated; + decimal percentOver = (variance / estimated) * 100; + + if (percentOver > 50) + { + context.AddErrorResult($"Actual cost is {percentOver:F1}% over budget. Manager approval required."); + } + else if (percentOver > 20) + { + context.AddWarningResult($"Actual cost is {percentOver:F1}% over budget. Consider reviewing."); + } + else if (percentOver > 0) + { + context.AddInformationResult($"Actual cost is {percentOver:F1}% over budget."); + } + else + { + context.AddInformationResult($"Project is within budget (under by {Math.Abs(percentOver):F1}%)."); + } + } +} +``` + +**Usage:** +```csharp +BusinessRules.AddRule(new BudgetValidationRule(EstimatedCostProperty, ActualCostProperty)); +``` + +## Common Patterns + +### Pattern 1: Simple Field Validation + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Required fields + BusinessRules.AddRule(new Required(NameProperty)); + BusinessRules.AddRule(new Required(EmailProperty)); + + // String length + BusinessRules.AddRule(new MaxLength(NameProperty, 100)); + BusinessRules.AddRule(new MinLength(PasswordProperty, 8)); + + // Regex patterns + BusinessRules.AddRule(new RegEx(EmailProperty, @"^[^@]+@[^@]+\.[^@]+$")); + BusinessRules.AddRule(new RegEx(PhoneProperty, @"^\d{10}$")); + + // Numeric ranges + BusinessRules.AddRule(new Range(AgeProperty, 0, 120)); + BusinessRules.AddRule(new MinValue(PriceProperty, 0)); +} +``` + +### Pattern 2: Cross-Property Validation + +```csharp +protected override void AddBusinessRules() +{ + base.AddBusinessRules(); + + // Date validation + BusinessRules.AddRule(new LessThanProperty(StartDateProperty, EndDateProperty)); + + // Numeric comparison + BusinessRules.AddRule(new LessThanProperty(MinPriceProperty, MaxPriceProperty)); + + // Conditional requirements + BusinessRules.AddRule(new StringRequiredIf(StateProperty, CountryProperty, "US")); +} +``` + +### Pattern 3: Complex Business Logic + +```csharp +public class OrderValidationRule : BusinessRule +{ + private IPropertyInfo _quantityProperty; + private IPropertyInfo _priceProperty; + private IPropertyInfo _discountProperty; + + public OrderValidationRule(IPropertyInfo quantityProperty, IPropertyInfo priceProperty, IPropertyInfo discountProperty) + : base(quantityProperty) + { + _quantityProperty = quantityProperty; + _priceProperty = priceProperty; + _discountProperty = discountProperty; + + InputProperties.Add(quantityProperty); + InputProperties.Add(priceProperty); + InputProperties.Add(discountProperty); + } + + protected override void Execute(IRuleContext context) + { + int quantity = (int)context.InputPropertyValues[_quantityProperty]; + decimal price = (decimal)context.InputPropertyValues[_priceProperty]; + decimal discount = (decimal)context.InputPropertyValues[_discountProperty]; + + decimal total = quantity * price * (1 - discount); + + if (quantity > 100 && discount < 0.10m) + { + context.AddWarningResult("Orders over 100 units typically qualify for 10% discount."); + } + + if (total > 10000 && discount < 0.15m) + { + context.AddInformationResult("Large orders may qualify for additional discount."); + } + + if (quantity < 1) + { + context.AddErrorResult("Quantity must be at least 1."); + } + + if (discount > 0.5m) + { + context.AddErrorResult("Discount cannot exceed 50%. Manager override required."); + } + } +} +``` + +## Best Practices + +1. **Use appropriate severity** - Errors prevent saving, warnings suggest improvements, information provides feedback +2. **Clear messages** - Provide actionable feedback to users +3. **Declare inputs** - Always add all properties you need to `InputProperties` +4. **Avoid redundancy** - Don't duplicate validation that data annotations already provide +5. **Keep rules focused** - One validation concern per rule +6. **Handle nulls** - Check for null values before using property values +7. **Use friendly names** - `PrimaryProperty.FriendlyName` provides user-friendly property names +8. **Test independently** - Each rule should be testable without creating a full business object + +## Notes + +- Validation rules should not modify property values (use calculation rules for that) +- Rules execute automatically when properties change via `SetProperty()` +- Multiple rules can execute on the same property +- Rule results are cumulative - all broken rules produce messages +- The object is invalid (`IsValid = false`) if any rule produces an error diff --git a/csla-examples/CustomSerializers.md b/csla-examples/CustomSerializers.md new file mode 100644 index 0000000..8a9919a --- /dev/null +++ b/csla-examples/CustomSerializers.md @@ -0,0 +1,389 @@ +# Custom Serializers for MobileFormatter + +CSLA's `MobileFormatter` supports custom serializers for types that are not normally serializable. This allows you to serialize POCO types, third-party types, or any custom types that don't implement `IMobileObject`. + +## Overview + +Custom serializers enable `MobileFormatter` to serialize types that it wouldn't normally be able to handle. CSLA includes two built-in custom serializers: + +- `ClaimsPrincipalSerializer` - Serializes `ClaimsPrincipal` types (configured and active by default) +- `PocoSerializer` - Serializes simple C# classes with public read/write properties using JSON + +## Using the POCO Serializer + +The POCO serializer uses `System.Text.Json` to serialize any simple C# class that has public read/write properties. + +### Basic Configuration + +Configure the POCO serializer in your application's startup code: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize))))); +} +``` + +### Example POCO Type + +Here's an example of a POCO type that can be serialized: + +```csharp +public class CustomerCriteria +{ + public int MinAge { get; set; } + public int MaxAge { get; set; } + public string Region { get; set; } + public bool IsActive { get; set; } +} +``` + +### Using with Data Portal + +Once configured, you can use the POCO type as a criteria parameter: + +```csharp +// Configure the serializer for CustomerCriteria +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize))))); + +// Business object using the criteria +[CslaImplementProperties] +public partial class CustomerList : ReadOnlyListBase +{ + [Fetch] + private async Task Fetch(CustomerCriteria criteria, [Inject] ICustomerDal dal) + { + var customers = await dal.GetByCriteria( + criteria.MinAge, + criteria.MaxAge, + criteria.Region, + criteria.IsActive); + + using (LoadListMode) + { + foreach (var customer in customers) + { + Add(DataPortal.FetchChild(customer)); + } + } + } +} + +// Client code +var criteria = new CustomerCriteria +{ + MinAge = 25, + MaxAge = 65, + Region = "West", + IsActive = true +}; + +var customers = await customerListPortal.FetchAsync(criteria); +``` + +## Multiple Custom Serializers + +You can register multiple custom serializers for different types: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => + { + m.CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize)); + + m.CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize)); + + m.CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize)); + }))); +} +``` + +## Creating Your Own Custom Serializer + +To create a custom serializer, implement the `IMobileSerializer` interface: + +```csharp +using Csla.Serialization.Mobile; + +public class MyCustomSerializer : IMobileSerializer +{ + public static bool CanSerialize(Type type) + { + // Return true if this serializer can handle the type + return type == typeof(MyComplexType); + } + + public void Serialize(object obj, SerializationInfo info) + { + if (obj is MyComplexType complex) + { + // Store serializable data in the SerializationInfo + info.AddValue("Property1", complex.Property1); + info.AddValue("Property2", complex.Property2); + info.AddValue("Timestamp", complex.Timestamp); + } + } + + public object Deserialize(SerializationInfo info) + { + // Reconstruct the object from the SerializationInfo + return new MyComplexType + { + Property1 = info.GetValue("Property1"), + Property2 = info.GetValue("Property2"), + Timestamp = info.GetValue("Timestamp") + }; + } +} +``` + +### Registering a Custom Serializer + +Register your custom serializer in the startup code: + +```csharp +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap( + MyCustomSerializer.CanSerialize))))); +``` + +## SerializationInfo Supported Types + +The `SerializationInfo` class can store the following "primitive" types: + +- All .NET primitive types (int, long, double, bool, etc.) +- `string` +- `DateTime`, `TimeSpan`, `DateTimeOffset`, `DateOnly`, `TimeOnly` +- `Guid` +- `byte[]` +- `char[]` +- `List` (and other primitive type lists) + +## Common Scenarios + +### Scenario 1: Serializing Third-Party DTOs + +When using DTOs from third-party libraries that aren't serializable: + +```csharp +// Third-party DTO +namespace ThirdParty.Models +{ + public class ApiRequest + { + public string Endpoint { get; set; } + public Dictionary Headers { get; set; } + public string Payload { get; set; } + } +} + +// Configuration +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize))))); +``` + +### Scenario 2: Complex Criteria Objects + +For complex search criteria with multiple parameters: + +```csharp +public class AdvancedSearchCriteria +{ + public string SearchTerm { get; set; } + public List Categories { get; set; } + public decimal MinPrice { get; set; } + public decimal MaxPrice { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageSize { get; set; } + public int PageNumber { get; set; } +} + +// Configure serializer +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize))))); + +// Use in business object +[Fetch] +private async Task Fetch(AdvancedSearchCriteria criteria, [Inject] IProductDal dal) +{ + var products = await dal.AdvancedSearch(criteria); + // Load products... +} +``` + +### Scenario 3: Custom Serializer for Binary Data + +When you need to optimize serialization of large binary data: + +```csharp +public class ImageDataSerializer : IMobileSerializer +{ + public static bool CanSerialize(Type type) => type == typeof(ImageData); + + public void Serialize(object obj, SerializationInfo info) + { + var imageData = (ImageData)obj; + info.AddValue("ImageBytes", imageData.Bytes); + info.AddValue("Format", imageData.Format); + info.AddValue("Width", imageData.Width); + info.AddValue("Height", imageData.Height); + } + + public object Deserialize(SerializationInfo info) + { + return new ImageData + { + Bytes = info.GetValue("ImageBytes"), + Format = info.GetValue("Format"), + Width = info.GetValue("Width"), + Height = info.GetValue("Height") + }; + } +} + +// Register +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap( + ImageDataSerializer.CanSerialize))))); +``` + +### Scenario 4: Using JSON for Complex Nested Types + +When you have complex nested types that are easier to serialize with JSON: + +```csharp +public class ComplexConfigSerializer : IMobileSerializer +{ + public static bool CanSerialize(Type type) => type == typeof(ComplexConfig); + + public void Serialize(object obj, SerializationInfo info) + { + var config = (ComplexConfig)obj; + var json = System.Text.Json.JsonSerializer.Serialize(config); + info.AddValue("ConfigJson", json); + } + + public object Deserialize(SerializationInfo info) + { + var json = info.GetValue("ConfigJson"); + return System.Text.Json.JsonSerializer.Deserialize(json); + } +} +``` + +## Best Practices + +1. **Use PocoSerializer for simple types** - If your type has only public read/write properties, use `PocoSerializer` +2. **Implement custom serializers for complex types** - For types with complex logic or non-public members, create a custom serializer +3. **Follow naming conventions** - Implement a static `CanSerialize` method by convention +4. **Store only primitive types** - Only store types supported by `SerializationInfo` in your custom serializer +5. **Register all serializers at startup** - Configure all custom serializers in your `ConfigureServices` method +6. **Test serialization round-trips** - Always test that your serializer can serialize and deserialize objects correctly +7. **Consider performance** - JSON serialization (used by `PocoSerializer`) has a performance cost; for high-performance scenarios, consider custom binary serialization + +## Migration from CriteriaBase + +In CSLA 9, `CriteriaBase` is obsolete. Instead of inheriting from `CriteriaBase`, use one of these approaches: + +### Option 1: Simple Parameters (Recommended) + +Pass criteria values directly as parameters: + +```csharp +[Fetch] +private async Task Fetch(string region, int minAge, int maxAge, [Inject] ICustomerDal dal) +{ + var customers = await dal.GetByCriteria(region, minAge, maxAge); + // Load data... +} +``` + +### Option 2: POCO with Custom Serializer + +Create a POCO type and register a custom serializer: + +```csharp +public class CustomerCriteria +{ + public string Region { get; set; } + public int MinAge { get; set; } + public int MaxAge { get; set; } +} + +// Register serializer +services.AddCsla(options => options + .Serialization(s => s + .UseMobileFormatter(m => m + .CustomSerializers.Add( + new TypeMap>( + PocoSerializer.CanSerialize))))); +``` + +### Option 3: ReadOnlyBase (For Complex Criteria) + +For complex criteria with validation logic, use `ReadOnlyBase`: + +```csharp +[Serializable] +public class CustomerCriteria : ReadOnlyBase +{ + public static readonly PropertyInfo RegionProperty = + RegisterProperty(nameof(Region)); + public string Region + { + get => GetProperty(RegionProperty); + private set => LoadProperty(RegionProperty, value); + } + + public static readonly PropertyInfo MinAgeProperty = + RegisterProperty(nameof(MinAge)); + public int MinAge + { + get => GetProperty(MinAgeProperty); + private set => LoadProperty(MinAgeProperty, value); + } + + // Additional properties and business rules... +} +``` + +## Notes + +- The `ClaimsPrincipalSerializer` is configured and active by default in CSLA 9+ +- Custom serializers are called by `MobileFormatter` when it encounters a type it can't serialize natively +- The `CanSerialize` method is used to determine if a serializer can handle a specific type +- Multiple serializers can be registered, and `MobileFormatter` will use the first one that returns `true` from `CanSerialize` diff --git a/csla-examples/DataMapper.md b/csla-examples/DataMapper.md new file mode 100644 index 0000000..57b82ff --- /dev/null +++ b/csla-examples/DataMapper.md @@ -0,0 +1,254 @@ +# CSLA DataMapper + +The `DataMapper` class in the `Csla.Data` namespace provides utilities for mapping data between objects by copying property and field values. It is commonly used in data portal operation methods to efficiently transfer data from Data Access Layer (DAL) objects to CSLA business objects, and vice versa. + +## Overview + +`DataMapper` simplifies the process of copying property values from one object to another, eliminating the need to manually write `LoadProperty` calls for each property. It supports automatic type coercion, making it useful when working with different object types that share similar property structures. + +## Basic Usage + +The simplest form of `DataMapper.Map()` copies all matching public properties from a source object to a target object: + +**CSLA 9:** + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal) +{ + // Get data from DAL + var customerData = await customerDal.Get(id); + + // Map all matching properties from DAL object to business object + Csla.Data.DataMapper.Map(customerData, this); + + BusinessRules.CheckRules(); +} +``` + +**CSLA 10:** + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal) +{ + // Get data from DAL + var customerData = await customerDal.Get(id); + + // Map all matching properties from DAL object to business object + Csla.Data.DataMapper.Map(customerData, this); + + await CheckRulesAsync(); +} +``` + +> **Note:** In CSLA 10, it is recommended to use `await CheckRulesAsync()` instead of `BusinessRules.CheckRules()` to ensure that asynchronous business rules execute properly. + +This approach works when: +- Property names match between source and target objects +- Property types are compatible or can be coerced +- You want to map all available properties + +## Using an Ignore List + +When you need to exclude certain properties from being mapped, use the `ignoreList` parameter: + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal) +{ + // Get data from DAL + var customerData = await customerDal.Get(id); + + // Map properties, but ignore specific ones + Csla.Data.DataMapper.Map(customerData, this, "CreatedDate", "ModifiedDate"); + + // Set ignored properties manually if needed + LoadProperty(CreatedDateProperty, DateTime.Now); + + await CheckRulesAsync(); // Use BusinessRules.CheckRules() in CSLA 9 +} +``` + +The ignored properties will not be copied from the source to the target object. This is useful when: +- Certain properties should be set separately with custom logic +- Some properties exist on the source but not the target +- You want to prevent overwriting specific target properties + +## Using Explicit Mapping with DataMap + +For more complex scenarios where property names differ or you need fine-grained control over mappings, use the `DataMap` class: + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal) +{ + // Get data from DAL + var customerData = await customerDal.Get(id); + + // Create a DataMap with explicit mappings + var map = new Csla.Data.DataMap(typeof(CustomerData), typeof(CustomerEdit)); + + // Add property-to-property mappings + map.AddPropertyMapping("Id", "Id"); + map.AddPropertyMapping("CustomerName", "Name"); // Different property names + map.AddPropertyMapping("EmailAddr", "Email"); // Different property names + map.AddPropertyMapping("Active", "IsActive"); + + // Add field-to-property mappings if needed + map.AddFieldToPropertyMapping("_created", "CreatedDate"); + + // Perform the mapping using the DataMap + Csla.Data.DataMapper.Map(customerData, this, map); + + await CheckRulesAsync(); // Use BusinessRules.CheckRules() in CSLA 9 +} +``` + +The `DataMap` class provides these mapping methods: + +- `AddPropertyMapping(sourceProperty, targetProperty)` - Maps a property to a property +- `AddFieldMapping(sourceField, targetField)` - Maps a field to a field +- `AddFieldToPropertyMapping(sourceField, targetProperty)` - Maps a field to a property +- `AddPropertyToFieldMapping(sourceProperty, targetField)` - Maps a property to a field + +You can also initialize a `DataMap` with a list of property names for 1:1 mappings: + +```csharp +var map = new Csla.Data.DataMap( + typeof(CustomerData), + typeof(CustomerEdit), + new[] { "Id", "Name", "Email", "IsActive" } +); +``` + +## Type Coercion + +`DataMapper` automatically handles type coercion for common conversions: + +- Numeric types (int, double, decimal, etc.) +- String to numeric conversions +- Nullable types +- Enum conversions (from string or int) +- DateTime and SmartDate conversions +- Guid conversions +- Boolean conversions + +For example, if the source has a `string` property with value `"42"` and the target has an `int` property, `DataMapper` will automatically convert the string to an integer. + +## Working with Dictionaries + +`DataMapper` can also map data from dictionaries to objects: + +```csharp +var source = new Dictionary +{ + { "Id", 1 }, + { "Name", "John Doe" }, + { "Email", "john@example.com" } +}; + +Csla.Data.DataMapper.Map(source, customerObject); +``` + +And from objects to dictionaries: + +```csharp +var target = new Dictionary(); +Csla.Data.DataMapper.Map(customerObject, target); +``` + +## Suppressing Exceptions + +By default, `DataMapper` throws exceptions when property mapping fails. You can suppress exceptions using the `suppressExceptions` parameter: + +```csharp +// Suppress exceptions - failed mappings will be silently ignored +Csla.Data.DataMapper.Map(customerData, this, suppressExceptions: true); +``` + +This can be useful when: +- Source and target objects have different sets of properties +- You want a best-effort mapping without failing on individual property errors +- Working with dynamic data where property existence may vary + +## Additional Utilities + +`DataMapper` also provides utility methods for setting individual properties and fields: + +```csharp +// Set a property value with type coercion +Csla.Data.DataMapper.SetPropertyValue(target, "Age", "25"); + +// Set a field value (including private fields) +Csla.Data.DataMapper.SetFieldValue(target, "_name", "John Doe"); + +// Get a field value +var value = Csla.Data.DataMapper.GetFieldValue(target, "_id"); +``` + +## Best Practices + +1. **Use the simplest approach** - Start with basic `Map(source, target)` unless you need special handling +2. **Use ignore lists** when a few properties need special handling +3. **Use DataMap** when property names differ significantly or you need complex mappings +4. **Always check business rules after mapping** - Use `await CheckRulesAsync()` in CSLA 10 or `BusinessRules.CheckRules()` in CSLA 9 after mapping data in Fetch/Create operations +5. **Consider type compatibility** - While DataMapper handles many conversions, ensure your types are reasonably compatible +6. **Map efficiently** - Mapping in one call is more efficient than multiple `LoadProperty` calls for many properties + +## Common Scenarios + +### Scenario 1: Simple DAL to Business Object Mapping + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] IPersonDal dal) +{ + var data = await dal.Get(id); + Csla.Data.DataMapper.Map(data, this); + await CheckRulesAsync(); // Use BusinessRules.CheckRules() in CSLA 9 +} +``` + +### Scenario 2: Mapping with Some Properties Excluded + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] IOrderDal dal) +{ + var data = await dal.Get(id); + // Don't map Total - it will be calculated by business rules + Csla.Data.DataMapper.Map(data, this, "Total"); + await CheckRulesAsync(); // Use BusinessRules.CheckRules() in CSLA 9 +} +``` + +### Scenario 3: Mapping with Different Property Names + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] IProductDal dal) +{ + var data = await dal.Get(id); + + var map = new Csla.Data.DataMap(typeof(ProductData), typeof(ProductEdit)); + map.AddPropertyMapping("ProductId", "Id"); + map.AddPropertyMapping("ProductName", "Name"); + map.AddPropertyMapping("UnitPrice", "Price"); + + Csla.Data.DataMapper.Map(data, this, map); + await CheckRulesAsync(); // Use BusinessRules.CheckRules() in CSLA 9 +} +``` + +### Scenario 4: Mapping Business Object to DAL Object for Updates + +```csharp +[Update] +private async Task Update([Inject] ICustomerDal dal) +{ + var data = new CustomerData(); + Csla.Data.DataMapper.Map(this, data); + await dal.Update(data); +} +``` diff --git a/csla-examples/DataPortalInterceptor.md b/csla-examples/DataPortalInterceptor.md index dd7eb45..8a97ba1 100644 --- a/csla-examples/DataPortalInterceptor.md +++ b/csla-examples/DataPortalInterceptor.md @@ -91,4 +91,33 @@ In this example, the `AddCsla` method is used to configure the data portal to ad It is important to understand that the server-side data portal allows multiple interceptors to be registered, and they are all invoked, so it is possible to have a number of pre- and post-processing operations occur on each root data portal call. -An interceptor that is registered by default executes all business rules of the business object graph during pre-processing, ensuring that all rules are run even if the logical client somehow didn't run the rules. +## Built-In Interceptors + +CSLA includes a built-in interceptor called the **RevalidatingInterceptor** that is registered by default. This interceptor executes all business rules of the business object graph during pre-processing (before Insert, Update, or Delete operations), ensuring that all rules are run even if the logical client somehow didn't run the rules. + +### RevalidatingInterceptor + +The RevalidatingInterceptor helps ensure data integrity by validating business rules on the server before data portal operations execute. + +**CSLA 9:** +The interceptor validates on all operations (Insert, Update, Delete) with no configuration options. + +**CSLA 10:** +The interceptor can be configured using the .NET Options pattern to skip validation during Delete operations. See [v10/RevalidatingInterceptor.md](v10/RevalidatingInterceptor.md) for details on configuring the `IgnoreDeleteOperation` option. + +**Common Configuration (All Versions):** + +The RevalidatingInterceptor is automatically registered when you call `AddCsla()`. You don't need to manually register it: + +```csharp +builder.Services.AddCsla(); // RevalidatingInterceptor is automatically included +``` + +If you want to disable it entirely, you can clear the interceptor providers (though this is rarely needed): + +```csharp +builder.Services.AddCsla((o) => o + .DataPortal((x) => x + .AddServerSideDataPortal((s) => s + .InterceptorProviders.Clear()))); // Removes all interceptors including RevalidatingInterceptor +``` diff --git a/csla-examples/HttpProxyConfiguration.md b/csla-examples/HttpProxyConfiguration.md new file mode 100644 index 0000000..5524704 --- /dev/null +++ b/csla-examples/HttpProxyConfiguration.md @@ -0,0 +1,398 @@ +# HTTP Proxy Configuration + +The CSLA data portal supports HTTP-based communication between client and server through `HttpProxy` and `HttpCompressionProxy`. These proxies can be configured with custom timeout values and other options. + +## Overview + +When using CSLA in a client-server architecture (such as Blazor WebAssembly, MAUI, or desktop applications), the data portal can communicate with the server over HTTP. The `HttpProxy` and `HttpCompressionProxy` classes handle this communication and can be configured through `HttpProxyOptions`. + +## Basic Configuration + +Configure the HTTP proxy in your client application's startup code: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); +} +``` + +## Configuring Timeouts + +CSLA 9 and later support configuring timeout values for HTTP requests. + +### Using WithTimeout + +Set the overall timeout for the HTTP request: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); +``` + +### Using WithReadWriteTimeout + +Set the read/write timeout for the HTTP request: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithReadWriteTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); +``` + +### Configuring Both Timeouts + +Set both timeout values: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(60)) + .WithReadWriteTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); +``` + +## HttpCompressionProxy + +The `HttpCompressionProxy` compresses request and response data to reduce bandwidth usage. It supports the same timeout configuration: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpCompressionProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(60)) + .WithReadWriteTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); +``` + +## Platform-Specific Configurations + +### Blazor WebAssembly + +Typical configuration for a Blazor WebAssembly client: + +```csharp +// Program.cs in Blazor WebAssembly project +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddCsla(options => options + .AddBlazorWebAssembly(blazor => blazor + .SyncContextWithServer = true) + .Security(security => security + .FlowSecurityPrincipalFromClient = true) + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromMinutes(2)) + .DataPortalUrl = "/api/DataPortal")))); + +await builder.Build().RunAsync(); +``` + +### MAUI Application + +Configuration for a MAUI mobile or desktop application: + +```csharp +// MauiProgram.cs +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp(); + + builder.Services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(45)) + .WithReadWriteTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); + + return builder.Build(); + } +} +``` + +### WPF or Windows Forms + +Configuration for desktop applications: + +```csharp +// App.xaml.cs (WPF) or Program.cs (Windows Forms) +public partial class App : Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + var services = new ServiceCollection(); + + services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromMinutes(1)) + .DataPortalUrl = "https://myserver.com/api/DataPortal")))); + + var serviceProvider = services.BuildServiceProvider(); + // Store serviceProvider for later use + } +} +``` + +## Common Scenarios + +### Scenario 1: Short Timeout for Quick Operations + +For applications where operations should complete quickly: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(10)) + .WithReadWriteTimeout(TimeSpan.FromSeconds(5)) + .DataPortalUrl = "https://api.example.com/DataPortal")))); +``` + +### Scenario 2: Long Timeout for Reports or Batch Operations + +For applications that may have long-running server operations: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromMinutes(5)) + .WithReadWriteTimeout(TimeSpan.FromMinutes(3)) + .DataPortalUrl = "https://api.example.com/DataPortal")))); +``` + +### Scenario 3: Environment-Specific Timeouts + +Different timeouts based on the environment: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + var timeoutSeconds = configuration.GetValue("DataPortal:TimeoutSeconds", 30); + + services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(timeoutSeconds)) + .DataPortalUrl = configuration["DataPortal:Url"])))); +} +``` + +**appsettings.json:** +```json +{ + "DataPortal": { + "Url": "https://api.example.com/DataPortal", + "TimeoutSeconds": 60 + } +} +``` + +**appsettings.Development.json:** +```json +{ + "DataPortal": { + "TimeoutSeconds": 300 + } +} +``` + +### Scenario 4: Retry Logic with Polly + +Combine timeout configuration with retry logic using Polly: + +```csharp +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(30)) + .DataPortalUrl = "https://api.example.com/DataPortal")))); + +// Note: Actual retry logic would be implemented in a custom data portal proxy +// or through HTTP client configuration +``` + +## Server-Side Configuration + +The server must also be configured to handle data portal requests: + +### ASP.NET Core Server + +```csharp +// Program.cs on the server +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCsla(options => options + .AddAspNetCore() + .DataPortal(dp => dp + .AddServerSideDataPortal())); + +var app = builder.Build(); + +// Map the data portal endpoint +app.MapControllers(); + +app.Run(); +``` + +### Data Portal Controller + +```csharp +[Route("api/[controller]")] +[ApiController] +public class DataPortalController : Csla.Server.Hosts.HttpPortalController +{ + public DataPortalController(ApplicationContext applicationContext) + : base(applicationContext) + { + } +} +``` + +## Understanding Timeouts + +### Timeout vs ReadWriteTimeout + +- **Timeout**: The overall time allowed for the entire HTTP request/response cycle +- **ReadWriteTimeout**: The time allowed for reading from or writing to the HTTP stream + +### Default Values + +If you don't configure timeout values, default values from the underlying HTTP client are used. These defaults vary by platform but are typically around 100 seconds. + +### Timeout Behavior + +When a timeout occurs: +1. The HTTP request is cancelled +2. A `TimeoutException` or `TaskCanceledException` is thrown +3. The data portal operation fails +4. The exception bubbles up to the calling code + +Example handling: + +```csharp +try +{ + var customer = await customerPortal.FetchAsync(123); +} +catch (DataPortalException ex) when (ex.InnerException is TimeoutException) +{ + // Handle timeout + MessageBox.Show("The operation took too long. Please try again."); +} +catch (DataPortalException ex) when (ex.InnerException is TaskCanceledException) +{ + // Handle cancellation (which may be due to timeout) + MessageBox.Show("The operation was cancelled or timed out."); +} +``` + +## Best Practices + +1. **Set appropriate timeouts** - Consider your network conditions and operation complexity +2. **Configure timeouts in settings** - Use `appsettings.json` for easy environment-specific configuration +3. **Use compression for large payloads** - `HttpCompressionProxy` can significantly reduce bandwidth +4. **Handle timeout exceptions** - Always catch and handle timeout exceptions gracefully +5. **Test with realistic conditions** - Test timeout behavior under poor network conditions +6. **Consider mobile networks** - Mobile applications may need longer timeouts due to variable network quality +7. **Monitor and adjust** - Track timeout occurrences and adjust values based on real-world usage +8. **Balance user experience and server load** - Very long timeouts can tie up server resources + +## Troubleshooting + +### Frequent Timeouts + +If you're experiencing frequent timeouts: +- Increase the timeout values +- Check server performance and optimize slow operations +- Consider implementing caching +- Review network connectivity issues + +### Timeouts on Mobile Devices + +Mobile devices may require longer timeouts: +- Use at least 60 seconds for mobile applications +- Consider network type detection (WiFi vs cellular) +- Implement offline support with data synchronization + +### Debugging Timeout Issues + +To debug timeout issues: +1. Enable detailed logging on both client and server +2. Measure actual operation duration on the server +3. Compare timeout values to actual operation duration +4. Check for network latency issues +5. Use network monitoring tools to inspect HTTP traffic + +## Related Configuration + +### Authentication Headers + +Configure authentication headers along with timeouts: + +```csharp +services.AddHttpClient("CslaDataPortal", client => +{ + client.Timeout = TimeSpan.FromSeconds(60); + client.DefaultRequestHeaders.Add("Authorization", "Bearer {token}"); +}); + +services.AddCsla(options => options + .DataPortal(dp => dp + .AddClientSideDataPortal(csp => csp + .UseHttpProxy(proxy => proxy + .WithTimeout(TimeSpan.FromSeconds(60)) + .DataPortalUrl = "https://api.example.com/DataPortal")))); +``` + +### Custom Headers + +Add custom headers to data portal requests: + +```csharp +// Custom data portal proxy with headers +public class CustomHttpProxy : Csla.DataPortalClient.HttpProxy +{ + protected override HttpClient GetHttpClient() + { + var client = base.GetHttpClient(); + client.DefaultRequestHeaders.Add("X-Custom-Header", "CustomValue"); + return client; + } +} +``` + +## Notes + +- Timeout configuration is available in CSLA 9 and later +- Both `HttpProxy` and `HttpCompressionProxy` support the same timeout configuration +- Timeout values should be chosen based on expected operation duration and network conditions +- The data portal URL can be relative (for same-origin scenarios) or absolute (for cross-origin scenarios) diff --git a/csla-examples/v10/AsyncRuleExceptionHandler.md b/csla-examples/v10/AsyncRuleExceptionHandler.md new file mode 100644 index 0000000..23cc78e --- /dev/null +++ b/csla-examples/v10/AsyncRuleExceptionHandler.md @@ -0,0 +1,332 @@ +# Asynchronous Rule Exception Handler + +In CSLA 10, you can handle exceptions thrown by asynchronous business rules using the `IUnhandledAsyncRuleExceptionHandler` interface. This allows you to decide whether to handle specific exceptions or let them bubble up, and provides a centralized way to log or respond to rule failures. + +## Overview + +By default, exceptions thrown in asynchronous business rules are unhandled and can cause application crashes. The `IUnhandledAsyncRuleExceptionHandler` interface provides a mechanism to intercept these exceptions and handle them gracefully. + +## Implementing the Interface + +The interface has two methods: + +- `bool CanHandle(Exception, IBusinessRuleBase)` - Determines whether this handler should process the exception +- `ValueTask Handle(Exception, IBusinessRuleBase, IRuleContext)` - Handles the exception when `CanHandle` returns true + +### Basic Implementation + +```csharp +using Csla.Rules; +using System; +using System.Threading.Tasks; + +public class LoggingAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + + public LoggingAsyncRuleExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + // Handle all exceptions + return true; + } + + public ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + // Log the exception + _logger.LogError(exception, + $"Async rule {rule.GetType().Name} failed with exception: {exception.Message}"); + + // Add an error to the rule context + context.AddErrorResult($"A validation error occurred: {exception.Message}"); + + return ValueTask.CompletedTask; + } +} +``` + +## Registering the Handler + +There are two ways to register your exception handler: + +### Method 1: Add to Service Collection + +Register the handler after adding CSLA to the service collection: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + + // Register the handler - must be after AddCsla() + services.AddScoped(); +} +``` + +### Method 2: Use CSLA Configuration + +Use the CSLA configuration options to register the handler: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(options => + { + options.UseUnhandledAsyncRuleExceptionHandler(); + }); +} +``` + +> **Note:** The handler is registered as scoped by default when using the configuration method. + +## Selective Exception Handling + +You can implement `CanHandle` to selectively process specific types of exceptions: + +```csharp +public class SelectiveAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + + public SelectiveAsyncRuleExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + // Only handle timeout and network exceptions + return exception is TimeoutException + || exception is HttpRequestException + || exception is OperationCanceledException; + } + + public ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + _logger.LogWarning(exception, + $"Network/timeout issue in rule {rule.GetType().Name}"); + + // Add a user-friendly error message + context.AddErrorResult( + "Unable to validate this field due to a network issue. Please try again."); + + return ValueTask.CompletedTask; + } +} +``` + +## Advanced Scenarios + +### Scenario 1: Retry Logic + +Handle transient failures by implementing retry logic: + +```csharp +public class RetryAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + private const int MaxRetries = 3; + + public RetryAsyncRuleExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + return exception is HttpRequestException; + } + + public async ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + _logger.LogWarning(exception, + $"Transient failure in async rule {rule.GetType().Name}, will retry"); + + // Note: Actual retry logic would need to be implemented in the rule itself + // This handler can log and add appropriate error messages + + context.AddErrorResult( + "Validation service temporarily unavailable. Please try saving again."); + + await ValueTask.CompletedTask; + } +} +``` + +### Scenario 2: Different Handling Based on Rule Type + +Handle exceptions differently based on the type of rule that failed: + +```csharp +public class RuleTypeSpecificExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + private readonly IMetricsCollector _metrics; + + public RuleTypeSpecificExceptionHandler( + ILogger logger, + IMetricsCollector metrics) + { + _logger = logger; + _metrics = metrics; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + return true; + } + + public ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + // Track metrics + _metrics.Increment("async_rule_failures", new[] + { + ("rule_type", rule.GetType().Name), + ("exception_type", exception.GetType().Name) + }); + + // Different handling based on rule type + if (rule is EmailValidationRule) + { + _logger.LogWarning(exception, "Email validation service failed"); + context.AddErrorResult("Unable to validate email address at this time."); + } + else if (rule is CreditCheckRule) + { + _logger.LogError(exception, "Critical: Credit check service failed"); + context.AddErrorResult("Credit verification unavailable. Please contact support."); + } + else + { + _logger.LogError(exception, $"Unhandled async rule failure: {rule.GetType().Name}"); + context.AddErrorResult("A validation error occurred. Please try again."); + } + + return ValueTask.CompletedTask; + } +} +``` + +### Scenario 3: Graceful Degradation + +Allow the operation to continue with warnings instead of errors: + +```csharp +public class GracefulDegradationHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + + public GracefulDegradationHandler(ILogger logger) + { + _logger = logger; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + // Only handle non-critical validation failures + return rule.Severity == RuleSeverity.Warning || rule.Severity == RuleSeverity.Information; + } + + public ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + _logger.LogInformation(exception, + $"Non-critical async rule {rule.GetType().Name} failed, continuing"); + + // Add a warning instead of an error + context.AddWarningResult( + "Some optional validations could not be completed."); + + return ValueTask.CompletedTask; + } +} +``` + +### Scenario 4: Combining Multiple Concerns + +A comprehensive handler that logs, tracks metrics, and provides user-friendly messages: + +```csharp +public class ComprehensiveAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler +{ + private readonly ILogger _logger; + private readonly IMetricsCollector _metrics; + private readonly IAlertService _alerts; + + public ComprehensiveAsyncRuleExceptionHandler( + ILogger logger, + IMetricsCollector metrics, + IAlertService alerts) + { + _logger = logger; + _metrics = metrics; + _alerts = alerts; + } + + public bool CanHandle(Exception exception, IBusinessRuleBase rule) + { + return true; + } + + public async ValueTask Handle(Exception exception, IBusinessRuleBase rule, IRuleContext context) + { + var ruleName = rule.GetType().Name; + + // Log the exception + _logger.LogError(exception, + $"Async rule {ruleName} failed: {exception.Message}"); + + // Track metrics + _metrics.Increment("async_rule_exception", new[] + { + ("rule", ruleName), + ("exception_type", exception.GetType().Name) + }); + + // Send alert for critical failures + if (exception is not TimeoutException) + { + await _alerts.SendAsync( + $"Async rule failure: {ruleName}", + exception.ToString()); + } + + // Provide user-friendly error message + var userMessage = GetUserFriendlyMessage(exception, rule); + context.AddErrorResult(userMessage); + } + + private string GetUserFriendlyMessage(Exception exception, IBusinessRuleBase rule) + { + return exception switch + { + TimeoutException => "The validation service is taking too long to respond. Please try again.", + HttpRequestException => "Unable to connect to the validation service. Please check your connection.", + UnauthorizedAccessException => "You don't have permission to perform this validation.", + _ => "A validation error occurred. Please try again or contact support." + }; + } +} +``` + +## Best Practices + +1. **Always implement CanHandle thoughtfully** - Return `true` only for exceptions you can meaningfully handle +2. **Log exceptions appropriately** - Use different log levels based on severity +3. **Provide user-friendly messages** - Don't expose technical details to end users via `context.AddErrorResult()` +4. **Track metrics** - Monitor async rule failures to identify problematic services or rules +5. **Consider severity** - Different handling for errors vs. warnings +6. **Be careful with async operations** - The `Handle` method returns a `ValueTask`, but avoid long-running operations +7. **Don't swallow critical exceptions** - If you can't handle an exception meaningfully, let it bubble up +8. **Test your handler** - Unit test different exception scenarios + +## Important Notes + +- By default, CSLA 10 does **not** handle exceptions in asynchronous rules - they remain unhandled +- Implementing a handler doesn't mean all exceptions are automatically handled - `CanHandle` must return `true` +- The handler runs in the same execution context as the rule +- Multiple handlers can be registered, and they will be called in the order they were registered +- The handler is registered as scoped, so it can have scoped dependencies like `DbContext` or user context diff --git a/csla-examples/v10/InjectAttribute.md b/csla-examples/v10/InjectAttribute.md new file mode 100644 index 0000000..5d675ea --- /dev/null +++ b/csla-examples/v10/InjectAttribute.md @@ -0,0 +1,229 @@ +# Inject Attribute + +The `[Inject]` attribute is used in CSLA data portal operation methods to inject dependencies from the dependency injection (DI) container. This allows you to access services like data access layers (DALs), repositories, or other application services within your business object methods. + +## Basic Usage + +The simplest form uses `[Inject]` to inject a required service: + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal) +{ + var customerData = await customerDal.Get(id); + Csla.Data.DataMapper.Map(customerData, this); + await CheckRulesAsync(); +} +``` + +In this example, `ICustomerDal` is resolved from the DI container using `GetRequiredService`, which will throw an exception if the service is not registered. + +## AllowNull Property (CSLA 10) + +In CSLA 10, the `[Inject]` attribute includes an `AllowNull` property that controls whether the service resolution uses `GetService` (can return null) or `GetRequiredService` (throws if not found). + +### Using AllowNull = true + +When a service is optional and you want to handle the case where it might not be registered: + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal customerDal, [Inject(AllowNull = true)] ILogger logger) +{ + var customerData = await customerDal.Get(id); + + // Logger might be null, so check before using + logger?.LogInformation($"Fetching customer {id}"); + + Csla.Data.DataMapper.Map(customerData, this); + await CheckRulesAsync(); +} +``` + +### Using AllowNull = false (Default) + +This is the default behavior and ensures the service must be available: + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject(AllowNull = false)] ICustomerDal customerDal) +{ + // customerDal is guaranteed to be non-null + var customerData = await customerDal.Get(id); + Csla.Data.DataMapper.Map(customerData, this); + await CheckRulesAsync(); +} +``` + +## Nullable Reference Types Integration + +If you're using nullable reference types (`#nullable enable`), the `AllowNull` property is implicitly determined by the parameter's nullability annotation: + +```csharp +#nullable enable + +[Fetch] +private async Task Fetch( + int id, + [Inject] ICustomerDal customerDal, // GetRequiredService - non-nullable + [Inject] ILogger? logger, // GetService - nullable + [Inject] ICache? cache) // GetService - nullable +{ + var customerData = await customerDal.Get(id); + + // customerDal is guaranteed non-null + // logger and cache might be null, so use null-conditional operator + logger?.LogInformation($"Fetching customer {id}"); + + var cachedData = cache?.Get($"customer_{id}"); + if (cachedData == null) + { + Csla.Data.DataMapper.Map(customerData, this); + } + + await CheckRulesAsync(); +} +``` + +### Explicit Override with AllowNull + +You can explicitly set `AllowNull` to override the nullable reference type annotation: + +```csharp +#nullable enable + +[Fetch] +private async Task Fetch( + int id, + [Inject(AllowNull = true)] IService service) // Explicitly allows null +{ + // Even though IService is not marked as nullable, + // AllowNull = true means GetService is used and it can be null + if (service != null) + { + await service.DoSomething(); + } +} +``` + +> **Note:** If you use `[Inject(AllowNull = false)]` with a nullable parameter like `IService?`, the nullable annotation takes precedence and the parameter will still be nullable. The `AllowNull` property cannot make a nullable parameter non-nullable. + +## Multiple Injected Dependencies + +You can inject multiple services in a single data portal operation: + +```csharp +[Fetch] +private async Task Fetch( + int id, + [Inject] ICustomerDal customerDal, + [Inject] IEmailService emailService, + [Inject] ILogger? logger) +{ + logger?.LogInformation($"Fetching customer {id}"); + + var customerData = await customerDal.Get(id); + Csla.Data.DataMapper.Map(customerData, this); + + // Send welcome email if this is a new fetch + await emailService.SendWelcomeEmail(customerData.Email); + + await CheckRulesAsync(); +} +``` + +## Common Scenarios + +### Scenario 1: Required DAL Service + +```csharp +[Fetch] +private async Task Fetch(int id, [Inject] ICustomerDal dal) +{ + var data = await dal.Get(id); + Csla.Data.DataMapper.Map(data, this); + await CheckRulesAsync(); +} +``` + +### Scenario 2: Optional Logging Service + +```csharp +[Update] +private async Task Update([Inject] ICustomerDal dal, [Inject] ILogger? logger) +{ + logger?.LogInformation($"Updating customer {Id}"); + + var data = new CustomerData(); + Csla.Data.DataMapper.Map(this, data); + await dal.Update(data); + + logger?.LogInformation($"Customer {Id} updated successfully"); +} +``` + +### Scenario 3: Optional Caching with Fallback + +```csharp +[Fetch] +private async Task Fetch( + int id, + [Inject] ICustomerDal dal, + [Inject(AllowNull = true)] ICache cache) +{ + CustomerData data; + + // Try to get from cache first + if (cache != null) + { + data = cache.Get($"customer_{id}"); + if (data != null) + { + Csla.Data.DataMapper.Map(data, this); + await CheckRulesAsync(); + return; + } + } + + // Cache miss or no cache - fetch from DAL + data = await dal.Get(id); + Csla.Data.DataMapper.Map(data, this); + + // Store in cache for next time + cache?.Set($"customer_{id}", data, TimeSpan.FromMinutes(5)); + + await CheckRulesAsync(); +} +``` + +### Scenario 4: Mixing Required and Optional Services + +```csharp +#nullable enable + +[Create] +private async Task Create( + [Inject] ICustomerDal dal, // Required + [Inject] IDefaultsProvider defaults, // Required + [Inject] ILogger? logger, // Optional + [Inject] IMetricsCollector? metrics) // Optional +{ + logger?.LogInformation("Creating new customer"); + metrics?.Increment("customer.create"); + + var defaultData = await defaults.GetCustomerDefaults(); + LoadProperty(CreatedDateProperty, defaultData.CreatedDate); + LoadProperty(IsActiveProperty, defaultData.IsActive); + + await CheckRulesAsync(); +} +``` + +## Best Practices + +1. **Use required services for critical dependencies** - If your code cannot function without a service, don't use `AllowNull = true` +2. **Use optional services for cross-cutting concerns** - Logging, metrics, and caching are good candidates for optional services +3. **Check for null before using optional services** - Always use the null-conditional operator (`?.`) or null checks when working with optional services +4. **Leverage nullable reference types** - In CSLA 10, enable nullable reference types to get compiler assistance with null safety +5. **Avoid overriding nullable annotations** - Let the nullable reference type system guide your `AllowNull` settings +6. **Keep injection simple** - Inject only what you need for the specific operation diff --git a/csla-examples/v10/RevalidatingInterceptor.md b/csla-examples/v10/RevalidatingInterceptor.md new file mode 100644 index 0000000..707008f --- /dev/null +++ b/csla-examples/v10/RevalidatingInterceptor.md @@ -0,0 +1,253 @@ +# Revalidating Interceptor + +The `RevalidatingInterceptor` is a data portal interceptor that automatically revalidates business rules before performing data portal operations. In CSLA 10, it has been enhanced with configurable options to control when revalidation occurs. + +## Overview + +By default, the `RevalidatingInterceptor` runs business rule validation before every data portal operation (Insert, Update, Delete). In CSLA 10, you can configure it to skip revalidation during Delete operations. + +## Configuration in CSLA 10 + +CSLA 10 uses the .NET Options pattern to configure the `RevalidatingInterceptor`: + +### Basic Configuration + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + + // Configure RevalidatingInterceptor options + services.Configure(options => + { + options.IgnoreDeleteOperation = true; + }); +} +``` + +### Available Options + +- `IgnoreDeleteOperation` (bool) - When set to `true`, skips business rule validation during Delete operations. Default is `false`. + +## Why Skip Validation on Delete? + +There are several scenarios where you might want to skip validation during delete operations: + +1. **Performance** - Delete operations typically don't need to validate business rules since the object is being removed +2. **Simplified Logic** - Deleted objects may be in an invalid state, and validating them adds unnecessary complexity +3. **User Experience** - Users shouldn't be prevented from deleting an invalid object +4. **Historical Data** - Objects that were valid when created may no longer pass current validation rules + +## Usage Scenarios + +### Scenario 1: Default Behavior (Validate All Operations) + +If you don't configure any options, the interceptor validates on all operations: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + // No configuration - validates on Insert, Update, and Delete +} +``` + +Example business object: + +```csharp +[CslaImplementProperties] +public partial class CustomerEdit : BusinessBase +{ + public partial int Id { get; private set; } + [Required] + public partial string Name { get; set; } + [EmailAddress] + public partial string Email { get; set; } + + [Delete] + private async Task Delete(int id, [Inject] ICustomerDal dal) + { + // Business rules will be validated before this method is called + // If validation fails, the delete operation will not proceed + await dal.Delete(id); + } +} +``` + +### Scenario 2: Skip Validation on Delete + +When you configure `IgnoreDeleteOperation = true`, delete operations skip validation: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + + services.Configure(options => + { + options.IgnoreDeleteOperation = true; + }); +} +``` + +Example business object: + +```csharp +[CslaImplementProperties] +public partial class CustomerEdit : BusinessBase +{ + public partial int Id { get; private set; } + [Required] + public partial string Name { get; set; } + [EmailAddress] + public partial string Email { get; set; } + + [Delete] + private async Task Delete(int id, [Inject] ICustomerDal dal) + { + // Business rules are NOT validated before this method + // The delete will proceed regardless of the object's validation state + await dal.Delete(id); + } + + [Update] + private async Task Update([Inject] ICustomerDal dal) + { + // Business rules ARE still validated before Update + var data = new CustomerData(); + Csla.Data.DataMapper.Map(this, data); + await dal.Update(data); + } +} +``` + +### Scenario 3: Environment-Specific Configuration + +You might want different behavior in different environments: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + + services.Configure(options => + { + // In production, skip validation on delete for better performance + // In development, validate everything to catch potential issues + options.IgnoreDeleteOperation = _environment.IsProduction(); + }); +} +``` + +### Scenario 4: Configuration from Settings + +Load the configuration from application settings: + +**appsettings.json:** +```json +{ + "Csla": { + "RevalidatingInterceptor": { + "IgnoreDeleteOperation": true + } + } +} +``` + +**Startup.cs:** +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddCsla(); + + // Bind configuration from appsettings.json + services.Configure( + Configuration.GetSection("Csla:RevalidatingInterceptor")); +} +``` + +## Understanding the Impact + +### With IgnoreDeleteOperation = false (Default) + +```csharp +// Client code +var customer = await customerPortal.FetchAsync(123); +customer.Name = ""; // Make object invalid + +// This will fail because validation runs and Name is required +await customerPortal.DeleteAsync(customer); +// InvalidOperationException: Cannot delete an invalid object +``` + +### With IgnoreDeleteOperation = true + +```csharp +// Client code +var customer = await customerPortal.FetchAsync(123); +customer.Name = ""; // Make object invalid + +// This will succeed because validation is skipped for delete +await customerPortal.DeleteAsync(customer); +// Delete proceeds successfully despite invalid state +``` + +## Best Practices + +1. **Enable IgnoreDeleteOperation in most cases** - Delete operations typically don't need validation +2. **Document your choice** - Make it clear to your team whether delete validation is enabled +3. **Consider your business rules** - If you have delete-specific business rules, you may want validation enabled +4. **Use configuration files** - Store the setting in `appsettings.json` for easy environment-specific changes +5. **Test both paths** - Ensure your delete logic works correctly with and without validation + +## Migration from CSLA 9 + +In CSLA 9, the `RevalidatingInterceptor` constructor didn't require any parameters: + +```csharp +// CSLA 9 +public RevalidatingInterceptor() +{ + // ... +} +``` + +In CSLA 10, it requires an `IOptions` parameter: + +```csharp +// CSLA 10 +public RevalidatingInterceptor(IOptions options) +{ + // ... +} +``` + +When upgrading from CSLA 9 to CSLA 10, you must configure the options even if you want the default behavior: + +```csharp +// Minimum required configuration for CSLA 10 +services.Configure(options => +{ + // options.IgnoreDeleteOperation defaults to false (same as CSLA 9 behavior) +}); +``` + +Or simply: + +```csharp +// This is sufficient - default options will be used +services.AddCsla(); +``` + +## Related Concepts + +- **Data Portal Interceptors** - The `RevalidatingInterceptor` is one of several interceptors in the CSLA pipeline +- **Business Rules** - Understanding when rules run is important for optimal configuration +- **CheckRulesAsync** - Use `await CheckRulesAsync()` in CSLA 10 for async rule support + +## Notes + +- The `RevalidatingInterceptor` is automatically registered when you call `services.AddCsla()` +- If validation fails during Insert or Update (with default settings), an exception is thrown +- The `IgnoreDeleteOperation` option only affects the `RevalidatingInterceptor` - you can still manually call `CheckRulesAsync()` in your Delete methods if needed +- Custom interceptors can be added alongside the `RevalidatingInterceptor`