Skip to content

Commit 5b45c07

Browse files
authored
fix: preserve single errors through Combine and mint validation failures as validation errors (#324)
1 parent 131e966 commit 5b45c07

9 files changed

Lines changed: 164 additions & 29 deletions

File tree

docs/articles/result-pattern.md

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ Result<User> failed = Result.Failure<User>(error);
2424

2525
// Validation failure
2626
var validation = new ValidationError([
27-
Error.Problem("Email.Required", "Email is required"),
28-
Error.Problem("Name.TooLong", "Name exceeds 100 characters")
27+
Error.Validation("Email.Required", "Email is required"),
28+
Error.Validation("Name.TooLong", "Name exceeds 100 characters")
2929
]);
3030
Result<User> invalid = Result.ValidationFailure<User>(validation);
3131
```
@@ -34,13 +34,17 @@ Result<User> invalid = Result.ValidationFailure<User>(validation);
3434

3535
`ErrorType` determines how the error maps to HTTP status codes in the API layer:
3636

37-
| ErrorType | Factory Method | HTTP Status |
38-
|---|---|---|
39-
| `Failure` | `Error.Failure(...)` | 500 Internal Server Error |
40-
| `NotFound` | `Error.NotFound(...)` | 404 Not Found |
41-
| `Problem` | `Error.Problem(...)` | 500 Internal Server Error |
42-
| `Conflict` | `Error.Conflict(...)` | 409 Conflict |
43-
| `Validation` | `ValidationError(...)` | 400 Bad Request |
37+
| ErrorType | Factory Method | HTTP Status | Meaning |
38+
|---|---|---|---|
39+
| `Failure` | `Error.Failure(...)` | 500 Internal Server Error | Unclassified/unexpected failure |
40+
| `NotFound` | `Error.NotFound(...)` | 404 Not Found | The requested resource doesn't exist |
41+
| `Problem` | `Error.Problem(...)` | 400 Bad Request | Client-addressable business-rule violation; full error detail is returned |
42+
| `Conflict` | `Error.Conflict(...)` | 409 Conflict | The request conflicts with current state |
43+
| `Validation` | `Error.Validation(...)` / `ValidationError` | 400 Bad Request | Input validation failure; `ValidationError.Errors` carries the per-field details |
44+
45+
`Error.Validation` mints the *inner* errors of a `ValidationError` (this is what `ValidationPipelineBehavior` uses for
46+
FluentValidation failures). `Error.Problem` is for a single, standalone business-rule error — it is a different
47+
classification from `Validation`, not a supertype of it.
4448

4549
## Defining Domain Errors
4650

@@ -94,6 +98,44 @@ string message = result.Match(
9498

9599
All extensions have synchronous and asynchronous overloads so they compose naturally with `Task<Result<T>>`.
96100

101+
### Combine / Zip – aggregate multiple results
102+
103+
`Combine` (a sequence of `Result`) and `Zip` (exactly two `Result<T>`) both follow the same aggregation rule:
104+
105+
- **All succeed** → success.
106+
- **Exactly one fails** → that failure's **original error** propagates unwrapped. Combining a single `NotFound`
107+
failure still surfaces as `NotFound`, not as a validation failure.
108+
- **More than one fails** → the failures are aggregated into a single `ValidationError` whose `Errors` list holds
109+
every failed error in order.
110+
111+
```csharp
112+
Result combined = ResultExtensions.Combine(CheckName(), CheckEmail(), CheckAge());
113+
// combined.Error is the original NotFound/Conflict/etc. error if only one check failed,
114+
// or a ValidationError aggregating all of them if more than one failed.
115+
116+
Result<(User, Order)> zipped = GetUser(userId).Zip(GetOrder(orderId));
117+
// same rule: a single failure propagates as-is; two failures aggregate into a ValidationError.
118+
```
119+
120+
## Sharp Edges
121+
122+
A few behaviors are easy to trip over. They are intentional for now; genuinely breaking fixes (marked below) are
123+
deferred to a future major version.
124+
125+
- **Implicit `TValue? → Result<TValue>` conversion swallows `null` as a failure.** Assigning a `null` value to a
126+
`Result<TValue>` compiles and silently produces a *failed* result carrying `Error.NullValue` — the conversion
127+
picks the error for you, and there is no compiler warning at the call site. Prefer constructing the result
128+
explicitly (`Result.Failure<TValue>(someDomainError)`) when the caller should choose the error. *(Making this
129+
conversion explicit is a tracked future breaking change.)*
130+
- **`Result` and `Result<T>` have no value equality.** Both are plain classes with no `Equals`/`GetHashCode`
131+
override, so two results are only equal by reference — two separately-constructed successes (or two
132+
identically-failed results) are never `Equals`-equal. Compare `IsSuccess`/`Error`/`Value` instead of the result
133+
itself. *(Adding value equality is a tracked future breaking change.)*
134+
- **A custom error that "looks like" `Error.None` is a valid failure error.** The `Result` constructor rejects a
135+
failure whose error is literally the `Error.None` singleton, but a custom error with the same empty code and
136+
description (e.g. `Error.Failure(string.Empty, string.Empty)`) is a distinct object and is accepted — it is not
137+
treated as "no error" just because its field values match.
138+
97139
## Mapping to HTTP Responses
98140

99141
`Vulthil.SharedKernel.Api` provides helpers that turn a `Result` into the correct HTTP response with typed results for OpenAPI documentation:

src/Vulthil.Results/Error.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ protected internal Error(string code, string description, ErrorType type)
6464
/// <returns>A new <see cref="Error"/> with <see cref="ErrorType.Problem"/> classification.</returns>
6565
public static Error Problem(string code, string description) => new(code, description, ErrorType.Problem);
6666
/// <summary>
67-
/// Creates a problem error.
67+
/// Creates a validation error.
6868
/// </summary>
6969
/// <param name="code">The error code.</param>
7070
/// <param name="description">The error description.</param>
@@ -144,28 +144,34 @@ public override int GetHashCode()
144144
}
145145

146146
/// <summary>
147-
/// Specifies values for ErrorType.
147+
/// Classifies an <see cref="Error"/> so that presentation layers (e.g. <c>Vulthil.SharedKernel.Api</c>) can map it
148+
/// to the appropriate HTTP status code without inspecting its code or description.
148149
/// </summary>
149150
public enum ErrorType
150151
{
151152
/// <summary>
152-
/// Specifies the Failure value.
153+
/// An unclassified or unexpected failure with no more specific meaning. Maps to HTTP 500 Internal Server Error.
154+
/// This is the default classification produced by <see cref="Error.Failure(string, string)"/>.
153155
/// </summary>
154156
Failure = 0,
155157
/// <summary>
156-
/// Specifies the Validation value.
158+
/// One or more input values failed validation (e.g. FluentValidation rule failures). Carried by
159+
/// <see cref="ValidationError"/>, whose <see cref="ValidationError.Errors"/> lists every individual violation.
160+
/// Maps to HTTP 400 Bad Request as a validation-problem response with per-field details.
157161
/// </summary>
158162
Validation = 1,
159163
/// <summary>
160-
/// Specifies the Problem value.
164+
/// A client-addressable business-rule violation the caller can act on (distinct from a plain validation
165+
/// failure). Maps to HTTP 400 Bad Request with the error's full detail in the problem response.
161166
/// </summary>
162167
Problem = 2,
163168
/// <summary>
164-
/// Specifies the NotFound value.
169+
/// The requested resource does not exist. Maps to HTTP 404 Not Found.
165170
/// </summary>
166171
NotFound = 3,
167172
/// <summary>
168-
/// Specifies the Conflict value.
173+
/// The request conflicts with the current state of the resource (e.g. a uniqueness violation). Maps to
174+
/// HTTP 409 Conflict.
169175
/// </summary>
170176
Conflict = 4,
171177
}

src/Vulthil.Results/Extensions/ResultExtensions.Combine.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,30 @@ namespace Vulthil.Results;
66
public static partial class ResultExtensions
77
{
88
/// <summary>
9-
/// Combines multiple results, returning success when all succeed; otherwise a failure with a <see cref="ValidationError"/> aggregating every failed result's error.
9+
/// Combines multiple results, returning success when all succeed; a single failure's original error when exactly
10+
/// one fails; otherwise a failure with a <see cref="ValidationError"/> aggregating every failed result's error.
1011
/// </summary>
1112
public static Result Combine(params Result[] results) =>
1213
Combine((IEnumerable<Result>)results);
1314
/// <summary>
14-
/// Combines a sequence of results, returning success when all succeed; otherwise a failure with a <see cref="ValidationError"/> aggregating every failed result's error.
15+
/// Combines a sequence of results, returning success when all succeed; a single failure's original error when
16+
/// exactly one fails; otherwise a failure with a <see cref="ValidationError"/> aggregating every failed result's error.
1517
/// </summary>
1618
public static Result Combine(this IEnumerable<Result> results)
1719
{
1820
Error[] failedErrors = [.. results.Where(result => result.IsFailure).Select(result => result.Error)];
19-
return failedErrors.Length == 0 ? Result.Success() : Result.Failure(new ValidationError(failedErrors));
21+
return failedErrors.Length switch
22+
{
23+
0 => Result.Success(),
24+
1 => Result.Failure(failedErrors[0]),
25+
_ => Result.Failure(new ValidationError(failedErrors))
26+
};
2027
}
2128

2229
/// <summary>
23-
/// Asynchronously awaits and combines a sequence of result tasks, returning success when all succeed; otherwise a failure with a <see cref="ValidationError"/> aggregating every failed result's error.
30+
/// Asynchronously awaits and combines a sequence of result tasks, returning success when all succeed; a single
31+
/// failure's original error when exactly one fails; otherwise a failure with a <see cref="ValidationError"/>
32+
/// aggregating every failed result's error.
2433
/// </summary>
2534
public static async Task<Result> CombineAsync(this IEnumerable<Task<Result>> resultTasks) =>
2635
(await Task.WhenAll(resultTasks)).Combine();

src/Vulthil.Results/Result.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ namespace Vulthil.Results;
44
/// <summary>
55
/// Represents the outcome of an operation that may succeed or fail.
66
/// </summary>
7+
/// <remarks>
8+
/// <see cref="Result"/> does not define value equality: two instances compare by reference, so two successful (or
9+
/// two identically-failed) results built separately are never <c>Equals</c>-equal. Compare <see cref="IsSuccess"/>
10+
/// and <see cref="Error"/> instead of the result itself. Value equality is tracked as a future breaking change.
11+
/// </remarks>
712
public class Result
813
{
914
/// <summary>
@@ -25,11 +30,14 @@ public class Result
2530
/// </summary>
2631
/// <param name="isSuccess">Whether the result represents success.</param>
2732
/// <param name="error">The error, or <see cref="Error.None"/> for success.</param>
28-
/// <exception cref="ArgumentException">Thrown when the success state and error are inconsistent.</exception>
33+
/// <exception cref="ArgumentException">Thrown when the success state and error are inconsistent: a success
34+
/// carrying an error other than the <see cref="Error.None"/> instance, or a failure carrying the
35+
/// <see cref="Error.None"/> instance itself. The check is an identity check, so a custom error that merely
36+
/// looks like <see cref="Error.None"/> (e.g. an empty code and description) is accepted as a valid failure.</exception>
2937
protected internal Result(bool isSuccess, Error error)
3038
{
31-
if (isSuccess && error != Error.None ||
32-
!isSuccess && error == Error.None)
39+
if (isSuccess && !ReferenceEquals(error, Error.None) ||
40+
!isSuccess && ReferenceEquals(error, Error.None))
3341
{
3442
throw new ArgumentException("Invalid error", nameof(error));
3543
}
@@ -78,6 +86,12 @@ protected internal Result(bool isSuccess, Error error)
7886
/// Represents the outcome of an operation that returns a value of type <typeparamref name="TValue"/> on success.
7987
/// </summary>
8088
/// <typeparam name="TValue">The type of the success value.</typeparam>
89+
/// <remarks>
90+
/// Like <see cref="Result"/>, <see cref="Result{TValue}"/> does not define value equality: two instances compare
91+
/// by reference, even when they carry the same value or error. Compare <see cref="Result.IsSuccess"/> and
92+
/// <see cref="Value"/>/<see cref="Result.Error"/> instead of the result itself. Value equality is tracked as a
93+
/// future breaking change.
94+
/// </remarks>
8195
public class Result<TValue> : Result
8296
{
8397
#pragma warning disable IDE0032 // Use auto property
@@ -105,6 +119,13 @@ protected internal Result(TValue? value, bool isSuccess, Error error)
105119
/// <summary>
106120
/// Implicitly converts a value to a successful result, or a failed result if the value is <see langword="null"/>.
107121
/// </summary>
122+
/// <remarks>
123+
/// <b>Sharp edge:</b> a <see langword="null"/> value silently converts to a <em>failed</em> result carrying
124+
/// <see cref="Error.NullValue"/> — the conversion picks the error for you, and the failure is easy to miss at
125+
/// the call site since it looks like an ordinary assignment. Prefer an explicit <see cref="Result.Failure{TValue}"/>
126+
/// call with a domain-specific error when the caller should choose the error. Making this conversion explicit
127+
/// (removing the implicit null-to-failure behavior) is tracked as a future breaking change.
128+
/// </remarks>
108129
/// <param name="value">The value to convert.</param>
109130
public static implicit operator Result<TValue>(TValue? value) =>
110131
value is not null ? Success(value) : Failure<TValue>(Error.NullValue);

src/Vulthil.SharedKernel.Application/Behaviors/ValidationPipelineBehavior.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,5 @@ private async Task<ValidationFailure[]> ValidateAsync(TCommand command, Cancella
8080
}
8181

8282
private static ValidationError CreateValidationError(ValidationFailure[] validationFailures) =>
83-
new(validationFailures.Select(f => Error.Problem(f.ErrorCode, f.ErrorMessage)));
83+
new(validationFailures.Select(f => Error.Validation(f.ErrorCode, f.ErrorMessage)));
8484
}

tests/Vulthil.Results.Tests/Errors/ErrorTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public void ErrorNullValueShouldBeFailure()
3737
{ Error.Problem("C", "D"), ("C", "D"), ErrorType.Problem },
3838
{ Error.Conflict("C", "D"), ("C", "D"), ErrorType.Conflict },
3939
{ Error.Failure("C", "D"), ("C", "D"), ErrorType.Failure },
40+
{ Error.Validation("C", "D"), ("C", "D"), ErrorType.Validation },
4041
{ new ValidationError([Error.NullValue]), ("Validation.General", "One or more validation errors occurred"), ErrorType.Validation },
4142
};
4243

tests/Vulthil.Results.Tests/Extensions/CombinatorTests.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,32 @@ public void CombineAggregatesFailedErrors()
293293
}
294294

295295
[Fact]
296-
public async Task CombineAsyncAggregatesFailedErrors()
296+
public void CombinePropagatesOriginalErrorForSingleFailure()
297+
{
298+
// Arrange
299+
var results = new[] { Result.Success(), Result.Failure(FirstError) };
300+
301+
// Act
302+
var combined = results.Combine();
303+
304+
// Assert
305+
combined.IsFailure.ShouldBeTrue();
306+
combined.Error.ShouldBeSameAs(FirstError);
307+
}
308+
309+
[Fact]
310+
public void CombineParamsPropagatesOriginalErrorForSingleFailure()
311+
{
312+
// Act
313+
var combined = ResultExtensions.Combine(Result.Success(), Result.Failure(FirstError));
314+
315+
// Assert
316+
combined.IsFailure.ShouldBeTrue();
317+
combined.Error.ShouldBeSameAs(FirstError);
318+
}
319+
320+
[Fact]
321+
public async Task CombineAsyncPropagatesOriginalErrorForSingleFailure()
297322
{
298323
// Arrange
299324
var resultTasks = new[]
@@ -305,10 +330,27 @@ public async Task CombineAsyncAggregatesFailedErrors()
305330
// Act
306331
var combined = await resultTasks.CombineAsync();
307332

333+
// Assert
334+
combined.IsFailure.ShouldBeTrue();
335+
combined.Error.ShouldBeSameAs(FirstError);
336+
}
337+
338+
[Fact]
339+
public async Task CombineAsyncAggregatesMultipleFailures()
340+
{
341+
// Arrange
342+
var resultTasks = new[]
343+
{
344+
Task.FromResult(Result.Failure(FirstError)),
345+
Task.FromResult(Result.Failure(SecondError))
346+
};
347+
348+
// Act
349+
var combined = await resultTasks.CombineAsync();
350+
308351
// Assert
309352
combined.Error.ShouldBeOfType<ValidationError>()
310-
.Errors.ShouldHaveSingleItem()
311-
.ShouldBe(FirstError);
353+
.Errors.ShouldBe([FirstError, SecondError]);
312354
}
313355

314356
[Fact]

tests/Vulthil.Results.Tests/Results/ResultTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ public void ResultShouldThrowIfFailureIsNone()
7272
act.ShouldThrow<ArgumentException>();
7373
}
7474

75+
[Fact]
76+
public void ResultShouldAllowFailureWithCustomErrorThatLooksLikeNone()
77+
{
78+
// Arrange
79+
var lookAlike = Error.Failure(string.Empty, string.Empty);
80+
81+
// Act
82+
var result = Result.Failure(lookAlike);
83+
84+
// Assert
85+
result.IsFailure.ShouldBeTrue();
86+
result.Error.ShouldBeSameAs(lookAlike);
87+
}
88+
7589
[Fact]
7690
public void ResultShouldThrowFailureWithValue()
7791
{

tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public async Task WithInvalidRequestReturnsValidationError()
6565
// Assert
6666
Assert.True(result.IsFailure);
6767
var validationError = Assert.IsType<ValidationError>(result.Error);
68-
Assert.Contains(validationError.Errors, e => e.Code == "Name" && e.Description == "Name is required");
68+
Assert.Contains(validationError.Errors, e => e.Code == "Name" && e.Description == "Name is required" && e.Type == ErrorType.Validation);
6969
}
7070
}
7171

@@ -125,7 +125,7 @@ public async Task WithInvalidRequestReturnsFailedResultCarryingTheValidationErro
125125
// Assert
126126
Assert.True(result.IsFailure);
127127
var validationError = Assert.IsType<ValidationError>(result.Error);
128-
Assert.Contains(validationError.Errors, e => e.Code == "Name" && e.Description == "Name is required");
128+
Assert.Contains(validationError.Errors, e => e.Code == "Name" && e.Description == "Name is required" && e.Type == ErrorType.Validation);
129129
}
130130
}
131131

0 commit comments

Comments
 (0)