Skip to content

Commit 30220e4

Browse files
authored
Feature/validation final touches (#3)
* Rename ValidationBehavior to ValidationBehaviour Renamed all instances of 'ValidationBehavior' to 'ValidationBehaviour' for consistency with British English spelling across documentation, implementation, and tests. Updated usage examples and references accordingly. * Add common validation rules and tests Introduces new validation rule helpers: PhoneE164, UrlAbsoluteHttpHttps, CultureCode, and SortExpression in CommonRules.cs. Updates documentation to list these new rules. Adds unit tests in CommonRulesTests.cs to verify their behavior. * Add LegacyResult adapters for template compatibility Introduces LegacyResult and LegacyResult<T> classes to provide compatibility with the Jason Taylor template's Result shape. Includes mapping methods to convert between the new core Result types and the legacy format, as well as tests and documentation updates to guide migration and usage. * Add net8.0 target to test project Updated the test project's configuration to target both net8.0 and net10.0 frameworks, improving compatibility and test coverage across multiple .NET versions.
1 parent eb008f2 commit 30220e4

11 files changed

Lines changed: 486 additions & 27 deletions

File tree

docs/extensions/core-result-primitives.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ The extension keeps the success/failure semantics but adds the capabilities team
3838

3939
## Compatibility and migration from the template
4040
You can start with the template’s existing patterns and layer Core Results gradually:
41-
- **Mapping template → Core:** `Result.Success()` becomes `Result.Success(traceId)`; `Result.Failure(strings)` can be projected to `Result.Failure(strings.Select(s => new Error("identity", s)))`.
42-
- **Mapping Core → template:** `Result.Success(traceId)` can return `CleanArchitecture.Application.Common.Models.Result.Success()`. Failures can flatten via `Errors.Select(e => $"{e.Code}: {e.Message}")`.
41+
- **Mapping template → Core:** `Result.Success()` becomes `Result.Success(traceId)`; `Result.Failure(strings)` can be projected to `Result.Failure(strings.Select(s => new Error("identity", s)))` or use `LegacyResult.Failure(strings).ToResult(traceId)`.
42+
- **Mapping Core → template:** `Result.Success(traceId)` can return `CleanArchitecture.Application.Common.Models.Result.Success()` or `LegacyResult.FromResult(result)`. Failures can flatten via `Errors.Select(e => $"{e.Code}: {e.Message}")` or rely on the adapter’s default formatter.
4343
- **Handlers:** Keep return types as your feature needs (DTOs, primitives). Introduce `Result<T>` where you want richer errors without throwing. You can adopt it per handler; nothing requires a big bang change.
4444
- **Pipelines:** Core Results do not change pipeline signatures; they work with the template’s behaviors. Validation that throws still bubbles through `UnhandledExceptionBehaviour`; you can prefer guard/result composition to avoid exceptions when appropriate.
4545

docs/extensions/validation.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Extension: Validation
22

33
## Overview
4-
Validation pipeline and helpers built on FluentValidation for Clean Architecture solutions. Ships a configurable MediatR behavior, a template-shaped `ValidationException`, rule helpers, and a base validator that applies common conventions. Designed to be drop-in compatible with Jason Taylor’s template while enabling Result-based short-circuiting when desired.
4+
Validation pipeline and helpers built on FluentValidation for Clean Architecture solutions. Ships a configurable MediatR behaviour, a template-shaped `ValidationException`, rule helpers, and a base validator that applies common conventions. Designed to be drop-in compatible with Jason Taylor’s template while enabling Result-based short-circuiting when desired.
55

66
## When to use
77
- You follow the template’s MediatR pipeline and want richer control over how validation failures surface (throw vs Result vs notify).
@@ -20,10 +20,10 @@ dotnet add src/YourProject/YourProject.csproj package CleanArchitecture.Extensio
2020

2121
## Usage
2222

23-
### Wire up validators and behavior (DI)
23+
### Wire up validators and behaviour (DI)
2424
```csharp
2525
services.AddValidatorsFromAssemblyContaining<Startup>(); // or your Application assembly marker
26-
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
26+
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
2727

2828
// Optional: configure strategy/options
2929
services.Configure<ValidationOptions>(options =>
@@ -54,12 +54,17 @@ Key options: `MaxFailures`, `IncludePropertyName`, `IncludeAttemptedValue`, `Inc
5454
- `PositiveId`
5555
- `PageNumber`
5656
- `PageSize`
57+
- `PhoneE164`
58+
- `UrlAbsoluteHttpHttps`
59+
- `CultureCode`
60+
- `SortExpression` (whitelist allowed fields)
61+
- Tenant-aware rules (planned with the Multitenancy module)
5762

5863
### Result short-circuit example
5964
```csharp
6065
var options = new ValidationOptions { Strategy = ValidationStrategy.ReturnResult };
61-
var behavior = new ValidationBehavior<CreateTodo, Result<TodoVm>>(validators, options);
62-
// When validation fails, the behavior returns Result<T>.Failure(errors) instead of throwing.
66+
var behavior = new ValidationBehaviour<CreateTodo, Result<TodoVm>>(validators, options);
67+
// When validation fails, the behaviour returns Result<T>.Failure(errors) instead of throwing.
6368
```
6469

6570
## Troubleshooting

src/CleanArchitecture.Extensions.Core/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Core primitives for Clean Architecture apps built on MediatR.
55
- Pipeline behaviors for logging, performance timing, and correlation IDs.
66
- Result model with trace identifiers, error aggregation, and success/failure helpers.
77
- Domain event support, guard helpers, and clock abstractions for deterministic tests.
8+
- Legacy template shims (`LegacyResult`/`LegacyResult<T>`) to ease migration from Jason Taylor’s `Result` shape.
89
- Ships with SourceLink, XML docs, and snupkg symbols for a smooth debugging experience.
910

1011
## Install
@@ -37,6 +38,10 @@ if (result.IsFailure)
3738
{
3839
return Results.BadRequest(result.Errors);
3940
}
41+
42+
// Map to/from template-style Result shape during migration.
43+
var legacy = LegacyResult.FromResult(result);
44+
var backToRich = legacy.ToResult(result.TraceId);
4045
```
4146

4247
## Target frameworks
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace CleanArchitecture.Extensions.Core.Results;
4+
5+
/// <summary>
6+
/// Compatibility result that mirrors the Jason Taylor template shape (Succeeded + string[] Errors) for drop-in use.
7+
/// </summary>
8+
public class LegacyResult
9+
{
10+
/// <summary>
11+
/// Gets a value indicating whether the operation succeeded.
12+
/// </summary>
13+
public bool Succeeded { get; init; }
14+
15+
/// <summary>
16+
/// Gets the error messages when the operation failed.
17+
/// </summary>
18+
public string[] Errors { get; init; } = Array.Empty<string>();
19+
20+
/// <summary>
21+
/// Creates a successful result.
22+
/// </summary>
23+
public static LegacyResult Success() => new() { Succeeded = true };
24+
25+
/// <summary>
26+
/// Creates a failed result with the provided errors.
27+
/// </summary>
28+
/// <param name="errors">Error messages describing the failure.</param>
29+
public static LegacyResult Failure(params string[] errors) =>
30+
new() { Succeeded = false, Errors = errors ?? Array.Empty<string>() };
31+
32+
/// <summary>
33+
/// Creates a failed result with the provided errors.
34+
/// </summary>
35+
/// <param name="errors">Error messages describing the failure.</param>
36+
public static LegacyResult Failure(IEnumerable<string> errors) =>
37+
new() { Succeeded = false, Errors = errors?.ToArray() ?? Array.Empty<string>() };
38+
39+
/// <summary>
40+
/// Creates a legacy result from a rich core <see cref="Result"/>, mapping errors with an optional formatter.
41+
/// </summary>
42+
/// <param name="result">Core result to convert.</param>
43+
/// <param name="errorFormatter">Optional formatter for converting <see cref="Error"/> to string messages.</param>
44+
public static LegacyResult FromResult(Result result, Func<Error, string>? errorFormatter = null)
45+
{
46+
ArgumentNullException.ThrowIfNull(result);
47+
var formatter = errorFormatter ?? DefaultFormatter;
48+
return result.IsSuccess ? Success() : Failure(result.Errors.Select(formatter));
49+
}
50+
51+
/// <summary>
52+
/// Converts this legacy result into a rich core <see cref="Result"/>.
53+
/// </summary>
54+
/// <param name="traceId">Optional trace identifier to attach to errors.</param>
55+
/// <param name="errorCode">Error code to apply when mapping string messages to <see cref="Error"/>.</param>
56+
public Result ToResult(string? traceId = null, string errorCode = "legacy.failure")
57+
{
58+
if (Succeeded)
59+
{
60+
return Result.Success(traceId);
61+
}
62+
63+
var errors = Errors.Length == 0
64+
? new[] { new Error(errorCode, "An error occurred.", traceId) }
65+
: Errors.Select(message => new Error(errorCode, message, traceId));
66+
67+
return Result.Failure(errors, traceId);
68+
}
69+
70+
/// <summary>
71+
/// Default mapping from structured <see cref="Error"/> to legacy string message.
72+
/// </summary>
73+
/// <param name="error">Error to map.</param>
74+
/// <returns>Error message when present; otherwise the error code.</returns>
75+
protected internal static string DefaultFormatter(Error error) =>
76+
string.IsNullOrWhiteSpace(error.Message) ? error.Code : error.Message;
77+
}
78+
79+
/// <summary>
80+
/// Compatibility result with value payload mirroring the Jason Taylor template shape.
81+
/// </summary>
82+
/// <typeparam name="T">Type of the value returned on success.</typeparam>
83+
public sealed class LegacyResult<T> : LegacyResult
84+
{
85+
/// <summary>
86+
/// Gets the value when the operation succeeds.
87+
/// </summary>
88+
[AllowNull]
89+
public T Value { get; init; } = default!;
90+
91+
/// <summary>
92+
/// Creates a successful result with a value.
93+
/// </summary>
94+
/// <param name="value">Value to return.</param>
95+
public static LegacyResult<T> Success(T value) => new() { Succeeded = true, Value = value };
96+
97+
/// <summary>
98+
/// Creates a failed result with the provided errors.
99+
/// </summary>
100+
/// <param name="errors">Error messages describing the failure.</param>
101+
public new static LegacyResult<T> Failure(params string[] errors) =>
102+
new() { Succeeded = false, Errors = errors ?? Array.Empty<string>() };
103+
104+
/// <summary>
105+
/// Creates a failed result with the provided errors.
106+
/// </summary>
107+
/// <param name="errors">Error messages describing the failure.</param>
108+
public new static LegacyResult<T> Failure(IEnumerable<string> errors) =>
109+
new() { Succeeded = false, Errors = errors?.ToArray() ?? Array.Empty<string>() };
110+
111+
/// <summary>
112+
/// Creates a legacy result from a rich core <see cref="Result{T}"/>, mapping errors with an optional formatter.
113+
/// </summary>
114+
/// <param name="result">Core result to convert.</param>
115+
/// <param name="errorFormatter">Optional formatter for converting <see cref="Error"/> to string messages.</param>
116+
public static LegacyResult<T> FromResult(Result<T> result, Func<Error, string>? errorFormatter = null)
117+
{
118+
ArgumentNullException.ThrowIfNull(result);
119+
var formatter = errorFormatter ?? DefaultFormatter;
120+
return result.IsSuccess
121+
? Success(result.Value)
122+
: Failure(result.Errors.Select(formatter));
123+
}
124+
125+
/// <summary>
126+
/// Converts this legacy result into a rich core <see cref="Result{T}"/>.
127+
/// </summary>
128+
/// <param name="traceId">Optional trace identifier to attach to errors.</param>
129+
/// <param name="errorCode">Error code to apply when mapping string messages to <see cref="Error"/>.</param>
130+
public new Result<T> ToResult(string? traceId = null, string errorCode = "legacy.failure")
131+
{
132+
if (Succeeded)
133+
{
134+
return Result.Success(Value, traceId);
135+
}
136+
137+
var errors = Errors.Length == 0
138+
? new[] { new Error(errorCode, "An error occurred.", traceId) }
139+
: Errors.Select(message => new Error(errorCode, message, traceId));
140+
141+
return Result.Failure<T>(errors, traceId);
142+
}
143+
}

src/CleanArchitecture.Extensions.Validation/Behaviors/ValidationBehavior.cs renamed to src/CleanArchitecture.Extensions.Validation/Behaviors/ValidationBehaviour.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
namespace CleanArchitecture.Extensions.Validation.Behaviors;
1616

1717
/// <summary>
18-
/// MediatR pipeline behavior that executes FluentValidation validators and surfaces failures using configured strategy.
18+
/// MediatR pipeline behaviour that executes FluentValidation validators and surfaces failures using configured strategy.
1919
/// </summary>
2020
/// <typeparam name="TRequest">Request type.</typeparam>
2121
/// <typeparam name="TResponse">Response type.</typeparam>
22-
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
22+
public sealed class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
2323
where TRequest : notnull
2424
{
2525
private readonly IReadOnlyCollection<IValidator<TRequest>> _validators;
@@ -30,15 +30,15 @@ public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<
3030
private readonly IAppLogger<TRequest>? _logger;
3131

3232
/// <summary>
33-
/// Initializes a new instance of the <see cref="ValidationBehavior{TRequest, TResponse}"/> class.
33+
/// Initializes a new instance of the <see cref="ValidationBehaviour{TRequest, TResponse}"/> class.
3434
/// </summary>
3535
/// <param name="validators">Validators to execute.</param>
3636
/// <param name="options">Validation behavior options.</param>
3737
/// <param name="notificationPublisher">Optional publisher for validation notifications.</param>
3838
/// <param name="logContext">Optional log context providing correlation identifiers.</param>
3939
/// <param name="coreOptions">Optional shared core options for default trace identifiers.</param>
4040
/// <param name="logger">Optional logger for emitting validation summaries.</param>
41-
public ValidationBehavior(
41+
public ValidationBehaviour(
4242
IEnumerable<IValidator<TRequest>> validators,
4343
IOptions<ValidationOptions>? options = null,
4444
IValidationNotificationPublisher? notificationPublisher = null,
@@ -131,7 +131,7 @@ private TResponse CreateResultOrThrow(IReadOnlyCollection<ValidationError> error
131131
}
132132

133133
throw new InvalidOperationException(
134-
"ValidationBehavior is configured to return a Result, but TResponse is not a Result or Result<T>. " +
134+
"ValidationBehaviour is configured to return a Result, but TResponse is not a Result or Result<T>. " +
135135
"Use ValidationStrategy.Throw for non-Result handlers or update handlers to return Result.");
136136
}
137137

src/CleanArchitecture.Extensions.Validation/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ using FluentValidation;
2424
using MediatR;
2525

2626
services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
27-
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
27+
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
2828

2929
services.Configure<ValidationOptions>(options =>
3030
{
@@ -37,6 +37,20 @@ services.Configure<ValidationOptions>(options =>
3737

3838
Implement `IValidationNotificationPublisher` if you want to capture validation failures without throwing.
3939

40+
## Rule helpers
41+
42+
- `NotEmptyTrimmed`
43+
- `EmailAddressBasic`
44+
- `OptionalEmailAddress`
45+
- `PositiveId`
46+
- `PageNumber`
47+
- `PageSize`
48+
- `PhoneE164`
49+
- `UrlAbsoluteHttpHttps`
50+
- `CultureCode`
51+
- `SortExpression` (allowed field whitelist)
52+
- Tenant-aware rules (planned alongside the Multitenancy module)
53+
4054
## Target frameworks
4155

4256
- net8.0

0 commit comments

Comments
 (0)