|
| 1 | +## OneOf Refactor Discussion and Plan (2025-09-20) |
| 2 | + |
| 3 | +### Context |
| 4 | +- Project: CleanArchitectureWithBlazorServer |
| 5 | +- Current result pattern: `Result` / `Result<T>` with `Succeeded`, `Errors` (strings), `Match/Map/Bind` helpers. |
| 6 | +- UI usages: `Products.razor`, `ProductFormDialog.razor` consume `Result/Result<T>`. |
| 7 | +- Exception handling: MediatR `IRequestExceptionHandler` implementations for: |
| 8 | + - `DbExceptionHandler<TRequest, TResponse, TException>` |
| 9 | + - `ValidationExceptionHandler<TRequest, TResponse, TException>` |
| 10 | + - `NotFoundExceptionHandler<TRequest, TResponse, TException>` |
| 11 | + - `FallbackExceptionHandler<TRequest, TResponse, TException>` |
| 12 | +- Goal: Adopt `OneOf`-style discriminated unions to enable strong, exhaustive, multi-branch results without pervasive try/catch in handlers. |
| 13 | + |
| 14 | +### Problems Identified |
| 15 | +- `Result` is binary (success/failure) and errors are plain strings, limiting type-safety and exhaustiveness. |
| 16 | +- `Result.Failure` signatures currently using `params IEnumerable<string>` conflict with handlers that reflect for `Failure(string[])`. |
| 17 | +- Handlers must branch on strings instead of strong error types. |
| 18 | +- Try/catch is used (or would be needed) to map EF exceptions; we want interceptor-based mapping. |
| 19 | + |
| 20 | +### Design Direction |
| 21 | +Adopt `OneOf` with a pair of reusable, generic result types for the whole application: |
| 22 | +- `AppResult` (no payload) |
| 23 | +- `AppResult<T>` (payload) |
| 24 | + |
| 25 | +And a small, reusable set of error union cases: |
| 26 | +- `Success` (marker success) |
| 27 | +- `ValidationFailed` (collection of messages) |
| 28 | +- `NotFound` |
| 29 | +- `Conflict` (unique constraint, duplicates, business conflict) |
| 30 | +- `HasDependents` (FK reference prevents deletion) |
| 31 | +- `Unexpected` (fallback) |
| 32 | + |
| 33 | +This avoids defining a distinct `XxxResult` per entity/feature, while preserving strong typing and exhaustive matching. |
| 34 | + |
| 35 | +### Phased Plan |
| 36 | +1) Stabilize current `Result` (backward-compatible) |
| 37 | + - Change method signatures to align with existing exception handlers' reflection: |
| 38 | + - `public static Result Failure(params string[] errors)` |
| 39 | + - `public static Result<T> Failure(params string[] errors)` |
| 40 | + - And corresponding `FailureAsync(params string[] errors)` methods. |
| 41 | + - Optional convenience additions: |
| 42 | + - `Match<TResult>(Func<TResult> onSuccess, Func<string, TResult> onFailure)` |
| 43 | + - `Switch(Action onSuccess, Action<string> onFailure)` |
| 44 | + - `TryPickSuccess(out T)` / `TryPickFailure(out IReadOnlyList<string>)` for `Result<T>`. |
| 45 | + |
| 46 | +2) Introduce OneOf |
| 47 | + - Add NuGet packages in Application project: |
| 48 | + - `OneOf` |
| 49 | + - `OneOf.SourceGenerator` (optional but recommended) |
| 50 | + - Define common error types in `Application/Common/Errors/`: |
| 51 | + ```csharp |
| 52 | + public readonly record struct Success; |
| 53 | + public readonly record struct NotFound(string Message); |
| 54 | + public readonly record struct ValidationFailed(IReadOnlyList<string> Messages); |
| 55 | + public readonly record struct Conflict(string Message); |
| 56 | + public readonly record struct HasDependents(string Message); |
| 57 | + public readonly record struct Unexpected(string Message); |
| 58 | + ``` |
| 59 | + - Define reusable result unions in `Application/Common/Results/`: |
| 60 | + ```csharp |
| 61 | + [GenerateOneOf] |
| 62 | + public partial class AppResult : OneOfBase< |
| 63 | + Success, ValidationFailed, NotFound, Conflict, HasDependents, Unexpected> { } |
| 64 | + |
| 65 | + [GenerateOneOf] |
| 66 | + public partial class AppResult<T> : OneOfBase< |
| 67 | + T, ValidationFailed, NotFound, Conflict, HasDependents, Unexpected> { } |
| 68 | + ``` |
| 69 | + |
| 70 | +3) OneOf-based Exception Handlers (no try/catch in handlers) |
| 71 | + - Add parallel MediatR exception handlers targeting `AppResult` / `AppResult<T>`: |
| 72 | + - Validation → `new ValidationFailed(errors)` |
| 73 | + - NotFound → `new NotFound(message)` |
| 74 | + - DbUpdateException family → `new Conflict(...)`, `new HasDependents(...)`, or `new ValidationFailed(...)` as appropriate |
| 75 | + - Fallback → `new Unexpected(ex.Message)` |
| 76 | + - Keep existing `IResult`-based handlers for legacy `Result` usage. |
| 77 | + - Register both; MediatR will pick handlers based on `TResponse`. |
| 78 | + |
| 79 | +4) Pilot Migration (Products feature) |
| 80 | + - Change handler signatures to return `AppResult<T>` / `AppResult`. |
| 81 | + - Remove explicit try/catch in handlers; rely on OneOf exception handlers. |
| 82 | + - Update `ProductFormDialog.razor` to exhaustively match OneOf results: |
| 83 | + ```csharp |
| 84 | + AppResult<int> r = await Mediator.Send(_model); |
| 85 | + r.Match( |
| 86 | + id => { MudDialog.Close(DialogResult.Ok(true)); Snackbar.Add(ConstantString.SaveSuccess, Severity.Info); }, |
| 87 | + v => Snackbar.Add(string.Join("\n", v.Messages), Severity.Error), |
| 88 | + nf => Snackbar.Add(nf.Message, Severity.Error), |
| 89 | + c => Snackbar.Add(c.Message, Severity.Error), |
| 90 | + hd => Snackbar.Add(hd.Message, Severity.Error), |
| 91 | + u => Snackbar.Add(u.Message, Severity.Error) |
| 92 | + ); |
| 93 | + ``` |
| 94 | + - Keep `Products.razor` export/query paths on `Result<byte[]>` initially; migrate later. |
| 95 | + |
| 96 | +5) Gradual Rollout |
| 97 | + - Adopt `AppResult` / `AppResult<T>` across other features incrementally. |
| 98 | + - Optionally provide extension bridge methods to convert between `Result<T>` and `AppResult<T>` during transition. |
| 99 | + |
| 100 | +### File References (current) |
| 101 | +- `src/Application/Common/Models/Result.cs` |
| 102 | +- `src/Application/Common/Interfaces/IResult.cs` |
| 103 | +- `src/Application/Common/ExceptionHandlers/DbExceptionHandler.cs` |
| 104 | +- `src/Application/Common/ExceptionHandlers/ValidationExceptionHandler.cs` |
| 105 | +- `src/Application/Common/ExceptionHandlers/NotFoundExceptionHandler.cs` |
| 106 | +- `src/Application/Common/ExceptionHandlers/FallbackExceptionHandler.cs` |
| 107 | +- `src/Application/Features/Products/Commands/AddEdit/AddEditProductCommand.cs` |
| 108 | +- `src/Application/Features/Products/Commands/Delete/DeleteProductCommand.cs` |
| 109 | +- `src/Server.UI/Pages/Products/Products.razor` |
| 110 | +- `src/Server.UI/Pages/Products/Components/ProductFormDialog.razor` |
| 111 | + |
| 112 | +### Notes and Rationale |
| 113 | +- Using two generic OneOf-based result types allows reuse across all entities without creating per-entity result classes. |
| 114 | +- Strongly-typed error cases improve readability, localization, and testability. |
| 115 | +- Exhaustive matching in UI enforces handling of all branches, avoiding silent fallbacks. |
| 116 | +- OneOf exception handlers eliminate repetitive try/catch in handlers, centralizing mapping of EF and domain exceptions. |
| 117 | + |
| 118 | +### Next Actions (Proposed) |
| 119 | +1) Update `Result.cs` failure signatures to `params string[]` and add convenience APIs. |
| 120 | +2) Add `OneOf` packages and define `AppResult` / `AppResult<T>` and common error types. |
| 121 | +3) Implement OneOf-based MediatR exception handlers for `AppResult` / `AppResult<T>`. |
| 122 | +4) Pilot migrate Products feature (handlers + `ProductFormDialog.razor`). |
| 123 | +5) Roll out to other features. |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +This document captures the discussion and actionable plan for adopting OneOf while maintaining backward compatibility and leveraging MediatR exception handling to avoid per-handler try/catch blocks. |
| 128 | + |
| 129 | + |
| 130 | + |
0 commit comments