diff --git a/README.md b/README.md index b3cb743..4a854c2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ Some key features are: * Dependency injection * Early returns and cancellation * Child pipelines +* Declarative and imperative validation, with a FluentValidation adapter +* Command pattern with assembly scanning and DI registration +* ASP.NET Core integration with RFC 7807 result mapping (`ToResult()`) ## Why Use Pipelines @@ -33,7 +36,7 @@ pipelines are not only robust and maintainable but also highly adaptable to chan ## Getting Started -To get started with Hyperbee.Json, refer to the [documentation](https://stillpoint-software.github.io/hyperbee.pipeline) for +To get started with Hyperbee.Pipeline, refer to the [documentation site](https://stillpoint-software.github.io/hyperbee.pipeline) for detailed instructions and examples. Install via NuGet: @@ -139,6 +142,37 @@ These methods provide powerful functionality for manipulating data as it passes - Reduce - Parallel execution +## Validation and Error Handling + +Pipelines validate their inputs declaratively, and commands surface failure state as data — +a command returns a result plus diagnostics instead of throwing: + +```csharp +var pipeline = PipelineFactory + .Start() + .ValidateAsync() // FluentValidation or custom validators + .PipeAsync( ( ctx, request ) => CreateUser( request ) ) + .Build(); +``` + +Project the diagnostics at your boundary: `ToResult()` maps validation failures to RFC 7807 +responses (422/404/403/401) at the HTTP edge, while `context.ThrowIfError()` and +`context.ThrowIfInvalid()` surface errors and validation failures to in-process callers. See the +[Validation documentation](https://stillpoint-software.github.io/hyperbee.pipeline/validation.html) +for details. + +## Packages + +| Package | Description | +|---------|-------------| +| `Hyperbee.Pipeline` | Core pipeline builders, commands, middleware, and context | +| `Hyperbee.Pipeline.Validation` | Framework-agnostic validation: `ValidateAsync`, failure types, `ThrowIfInvalid` | +| `Hyperbee.Pipeline.Validation.FluentValidation` | FluentValidation adapter with assembly scanning | +| `Hyperbee.Pipeline.AspNetCore` | `ToResult()` command-result mapping for minimal APIs, with customizable `ResultMapper` | +| `Hyperbee.Pipeline.Auth` | Claims-based pipeline steps (`WithAuth`, `PipeIfClaim`) | +| `Hyperbee.Pipeline.Caching` | Memory and distributed cache pipeline steps | +| `Hyperbee.Pipeline.TestHelpers` | Test fixtures and `CommandResult` helpers | + ## Credits Hyperbee.Pipeline is built upon the great work of several open-source projects. Special thanks to: @@ -156,7 +190,3 @@ for more details. | Branch | Action | |------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `main` | [![Build status](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/actions/workflows/pack_publish.yml/badge.svg)](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/actions/workflows/pack_publish.yml) | - -# Help - See [Todo](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/blob/main/docs/todo.md) - diff --git a/docs/site/index.md b/docs/site/index.md index f9ab2d5..eb44fe2 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -8,9 +8,13 @@ nav_order: 1 ## Documentation Structure +- [Syntax](syntax.md): The full builder syntax — `Pipe`, `Call`, conditional flow, iterators, reduce, and parallel execution. - [Conventions](conventions.md): Guidelines for creating builders, binders, and middleware. -- [Validation](validation.md): How to validate pipeline inputs using FluentValidation or custom validators. +- [Command Pattern](command-pattern.md): Commands as injectable pipeline units — `CommandResult`, boundaries, and composition. +- [Dependency Injection](dependency-injection.md): Registering pipelines and exposing container services. +- [Validation](validation.md): Validating pipeline inputs, failure types, and consuming validation results inside and outside HTTP. - [Middleware](middleware.md): How to use and implement middleware in pipelines. +- [Child Pipelines](child-pipeline.md): Composing pipelines from other pipelines. - [Extending Pipelines](extending.md): How to add new steps, middleware, or binders. - [Advanced Patterns](advanced-patterns.md): Real-world examples combining extension methods, custom binders, and middleware. @@ -32,6 +36,9 @@ Some key features are: - Dependency injection - Early returns and cancellation - Child pipelines +- Declarative and imperative validation, with a FluentValidation adapter +- Command pattern with assembly scanning and DI registration +- ASP.NET Core integration with RFC 7807 result mapping (`ToResult()`) ## Why Use Pipelines diff --git a/docs/site/validation.md b/docs/site/validation.md index 141123e..f99b122 100644 --- a/docs/site/validation.md +++ b/docs/site/validation.md @@ -393,6 +393,47 @@ Any lambda not provided falls back to the default behavior. See the [AspNetCore README](../src/Hyperbee.Pipeline.AspNetCore/README.md) for full details on `ResultMapper` and all `ToResult` overloads. +## Consuming Commands Outside HTTP + +`ToResult()` projects validation state at the HTTP edge. When a command is consumed in-process — +from a background job, a workflow activity, or another service — use the throw helpers to surface +failure state instead: + +```csharp +using Hyperbee.Pipeline.Validation; + +var commandResult = await _calculateCommand.ExecuteAsync(input); + +commandResult.Context.ThrowIfError(); // rethrows context.Exception with its original stack trace +commandResult.Context.ThrowIfInvalid(); // throws PipelineValidationException on validation failures + +return commandResult.Result; +``` + +`ThrowIfInvalid()` is the validation counterpart to `ThrowIfError()`. It throws a +`PipelineValidationException` that carries the `IValidationResult`, so callers can inspect +individual failures — including `ErrorCode` — from the exception: + +```csharp +catch (PipelineValidationException ex) +{ + foreach (var failure in ex.ValidationResult.Errors) + _logger.LogWarning("{Property}: {Message}", failure.PropertyName, failure.ErrorMessage); +} +``` + +Two things `ThrowIfInvalid()` deliberately does not do: + +- **Cancellation is not a failure.** A context canceled without validation failures (for example + by `Cancel()` or `CancelWith()`) does not throw; cancellation values surface through the + pipeline result as usual. +- **It does not replace `ThrowIfError()`.** Exceptions and validation failures are independent + axes of the context; call both to surface all failure state. + +If a `PipelineValidationException` reaches a pipeline boundary — for example, a parent pipeline +step calls `ThrowIfInvalid()` on a nested command's result — `ToResult()` recognizes it and maps +the carried failures as a validation response (422/404/403/401) rather than a 500 error. + ## Advanced Scenarios ### Custom Validators @@ -484,20 +525,28 @@ services.AddPipelineValidation(config => ## Exception Handling -Validation integrates with pipeline exception handling: +The `WithExceptionHandling` middleware maps configured exception types to validation failures, +so expected exceptions surface as validation state instead of errors: ```csharp +using Hyperbee.Pipeline.Middleware; + var command = PipelineFactory .Start() + .WithExceptionHandling(config => config + .AddException(errorcode: 504) + .AddException() + ) .ValidateAsync() .PipeAsync(async (ctx, order) => await ProcessOrder(order)) - .HandleExceptionsAsync(async (ctx, exception) => { - // Log exception - ctx.FailAfter(new ValidationFailure("System", exception.Message)); - }) .Build(); ``` +A matched exception is recorded as a validation failure (`"{ExceptionType}: {message}"` with the +configured error code) and the pipeline cancels; unmatched exceptions are re-thrown. +`WithExceptionHandling` is a hook and must be chained on the start builder, before the +pipeline's steps. + ## Testing The `Hyperbee.Pipeline.TestHelpers` package provides utilities for testing validated pipelines: diff --git a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs index 94205a5..81f17f6 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs @@ -171,6 +171,15 @@ internal IResult HandleCommand( IPipelineContext context, MemberInfo commandType // 3. Exceptions if ( context.IsError ) { + // A PipelineValidationException carries validation failures as data (typically thrown by + // ThrowIfInvalid on a nested command); map it as a validation response, not a server error. + if ( context.Exception is PipelineValidationException validationException ) + { + var carriedFailures = validationException.ValidationResult.Errors; + + return MapValidationFailures( carriedFailures, GetPriorityStatusCode( carriedFailures ) ); + } + var exceptionResult = MapException( context.Exception ); if ( exceptionResult is null ) diff --git a/src/Hyperbee.Pipeline.Validation/Extensions/PipelineValidationExtensions.cs b/src/Hyperbee.Pipeline.Validation/Extensions/PipelineValidationExtensions.cs index 7244aad..2a6393e 100644 --- a/src/Hyperbee.Pipeline.Validation/Extensions/PipelineValidationExtensions.cs +++ b/src/Hyperbee.Pipeline.Validation/Extensions/PipelineValidationExtensions.cs @@ -514,6 +514,43 @@ public static IEnumerable ValidationFailures( this IPipeline return context.GetValidationResult()?.Errors ?? Enumerable.Empty(); } + /// + /// Throws a if the pipeline context contains validation failures. + /// + /// + /// + /// This is the validation counterpart to , which considers + /// only . Use both to surface all failure state when consuming + /// a command outside of HTTP, where ToResult() mapping does not apply. + /// + /// + /// Cancellation is not considered a failure; a context that was canceled without validation failures + /// (for example by Cancel() or CancelWith()) does not throw. + /// + /// + /// The pipeline context. + /// + /// Thrown when the context contains a validation result with one or more failures. The exception + /// carries the . + /// + /// + /// + /// var commandResult = await command.ExecuteAsync(input); + /// + /// commandResult.Context.ThrowIfError(); + /// commandResult.Context.ThrowIfInvalid(); + /// + /// return commandResult.Result; + /// + /// + public static void ThrowIfInvalid( this IPipelineContext context ) + { + var validationResult = context.GetValidationResult(); + + if ( validationResult is { IsValid: false } ) + throw new PipelineValidationException( validationResult ); + } + // pipeline builder extensions /// diff --git a/src/Hyperbee.Pipeline.Validation/PipelineValidationException.cs b/src/Hyperbee.Pipeline.Validation/PipelineValidationException.cs new file mode 100644 index 0000000..f16f47e --- /dev/null +++ b/src/Hyperbee.Pipeline.Validation/PipelineValidationException.cs @@ -0,0 +1,47 @@ +namespace Hyperbee.Pipeline.Validation; + +/// +/// The exception thrown by when a +/// pipeline context contains validation failures. +/// +/// +/// Carries the so callers can inspect the individual +/// entries, including . +/// +[Serializable] +public class PipelineValidationException : Exception +{ + /// + /// The validation result containing the failures that caused the exception. + /// + public IValidationResult ValidationResult { get; } + + /// + /// Initializes a new instance with a message composed from the failure messages. + /// + /// The validation result containing the failures. + public PipelineValidationException( IValidationResult validationResult ) + : this( CreateMessage( validationResult ), validationResult ) + { + } + + /// + /// Initializes a new instance with a custom message. + /// + /// The exception message. + /// The validation result containing the failures. + public PipelineValidationException( string message, IValidationResult validationResult ) + : base( message ) + { + ArgumentNullException.ThrowIfNull( validationResult ); + + ValidationResult = validationResult; + } + + private static string CreateMessage( IValidationResult validationResult ) + { + ArgumentNullException.ThrowIfNull( validationResult ); + + return $"Validation failed: {string.Join( "; ", validationResult.Errors.Select( failure => failure.ErrorMessage ) )}"; + } +} diff --git a/src/Hyperbee.Pipeline.Validation/README.md b/src/Hyperbee.Pipeline.Validation/README.md index c4338a1..9581065 100644 --- a/src/Hyperbee.Pipeline.Validation/README.md +++ b/src/Hyperbee.Pipeline.Validation/README.md @@ -6,7 +6,7 @@ The `Hyperbee.Pipeline.Validation` library provides base validation implementati - **Base Implementations**: Concrete `ValidationFailure` and `ValidationResult` classes implementing the abstractions - **Pipeline Extensions**: `ValidateAsync`, `IfValidAsync`, `ValidateAndCancelOnFailureAsync` for declarative validation -- **Context Extensions**: Methods to store and retrieve validation results from pipeline contexts +- **Context Extensions**: Methods to store, retrieve, and throw on validation results from pipeline contexts - **Domain Failure Types**: `ApplicationValidationFailure`, `NotFoundValidationFailure`, `UnauthorizedValidationFailure`, `ForbiddenValidationFailure` - **Exception Handling**: Middleware to map exceptions to validation failures @@ -95,6 +95,20 @@ if (!context.IsValid()) } ``` +### Throwing on Validation Failures + +`ThrowIfInvalid` is the validation counterpart to `ThrowIfError` — use both when consuming a +command outside of HTTP: + +```csharp +var commandResult = await command.ExecuteAsync(input); + +commandResult.Context.ThrowIfError(); +commandResult.Context.ThrowIfInvalid(); // throws PipelineValidationException carrying the IValidationResult + +return commandResult.Result; +``` + ### Exception Handling Middleware ```csharp diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs index fd4c581..9514396 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs @@ -393,6 +393,87 @@ public void Custom_MapValidationFailures_should_override_problem_details_format( Assert.IsInstanceOfType( result, typeof( JsonHttpResult ) ); } + // PipelineValidationException mapping + + [TestMethod] + public void ToResult_should_map_PipelineValidationException_as_validation_response() + { + var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } ); + var commandResult = CreateErrorResult( new PipelineValidationException( validationResult ) ); + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); + } + + [TestMethod] + public void ToResult_should_use_priority_status_code_for_PipelineValidationException() + { + var validationResult = new ValidationResult( new IValidationFailure[] + { + new ApplicationValidationFailure( "Field", "Invalid." ), + new NotFoundValidationFailure( "Item", "Not found." ) + } ); + var commandResult = CreateErrorResult( new PipelineValidationException( validationResult ) ); + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status404NotFound, problem.StatusCode ); + } + + [TestMethod] + public void ToResult_should_map_PipelineValidationException_when_context_is_also_canceled() + { + // halt-on-error leaves an errored context canceled as well; the cancellation + // branch falls through (no IResult cancellation value) to the exception branch + var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } ); + var context = new PipelineContext { Exception = new PipelineValidationException( validationResult ) }; + context.CancelAfter(); + var commandResult = new CommandResult + { + Context = context, + CommandType = typeof( ResultMapperTests ) + }; + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); + } + + [TestMethod] + public void ToResult_non_generic_should_map_PipelineValidationException_as_validation_response() + { + var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } ); + var commandResult = CreateNonGenericErrorResult( new PipelineValidationException( validationResult ) ); + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); + } + + [TestMethod] + public void Custom_MapException_should_not_intercept_PipelineValidationException() + { + // Carried validation failures are mapped before MapException is consulted + var mapper = new RethrowMapper(); + var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } ); + var commandResult = CreateErrorResult( new PipelineValidationException( validationResult ) ); + + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); + } + // ContentSelector + mapper on error path [TestMethod] diff --git a/test/Hyperbee.Pipeline.Tests/PipelineMiddlewareTests.cs b/test/Hyperbee.Pipeline.Tests/PipelineMiddlewareTests.cs index 2ae4238..c0353f9 100644 --- a/test/Hyperbee.Pipeline.Tests/PipelineMiddlewareTests.cs +++ b/test/Hyperbee.Pipeline.Tests/PipelineMiddlewareTests.cs @@ -71,7 +71,7 @@ public async Task Pipeline_nested_hook_should_only_hook_the_inner_builder() } [TestMethod] - public async Task Pipeline_nested_wrap_should_only_wrap_the_inner_builder() + public async Task Pipeline_nested_wrap_should_be_scopped_to_inner_builder() { var command = PipelineFactory .Start() @@ -89,4 +89,25 @@ public async Task Pipeline_nested_wrap_should_only_wrap_the_inner_builder() Assert.AreEqual( "12{34}56", result ); } + + [TestMethod] + public async Task Pipeline_nested_hook_with_wrap_should_be_scopped_to_inner_builder() + { + var command = PipelineFactory + .Start() + .Pipe( ( ctx, arg ) => arg + "1" ) + .Pipe( ( ctx, arg ) => arg + "2" ) + .Pipe( builder => builder + .HookAsync( async ( ctx, arg, next ) => await next( ctx, arg + "[" ) + "]" ) + .Pipe( ( ctx, arg ) => arg + "3" ) + .Pipe( ( ctx, arg ) => arg + "4" ) + .Pipe( ( ctx, arg ) => arg + "5" ) + .WrapAsync( async ( ctx, arg, next ) => await next( ctx, arg + "{" ) + "}" ) ) + .Pipe( ( ctx, arg ) => arg + "6" ) + .Build(); + + var result = await command( new PipelineContext() ); + + Assert.AreEqual( "12{[3][4][5]}6", result ); + } } diff --git a/test/Hyperbee.Pipeline.Validation.Tests/PipelineValidationExceptionTests.cs b/test/Hyperbee.Pipeline.Validation.Tests/PipelineValidationExceptionTests.cs new file mode 100644 index 0000000..b3176bd --- /dev/null +++ b/test/Hyperbee.Pipeline.Validation.Tests/PipelineValidationExceptionTests.cs @@ -0,0 +1,44 @@ +using Hyperbee.Pipeline.Validation; + +namespace Hyperbee.Pipeline.Validation.Tests; + +[TestClass] +public class PipelineValidationExceptionTests +{ + [TestMethod] + public void Constructor_should_throw_for_null_validation_result() + { + Assert.ThrowsExactly( () => _ = new PipelineValidationException( null! ) ); + } + + [TestMethod] + public void Constructor_with_message_should_throw_for_null_validation_result() + { + Assert.ThrowsExactly( () => _ = new PipelineValidationException( "message", null! ) ); + } + + [TestMethod] + public void Default_message_should_join_failure_messages() + { + var result = new ValidationResult( new[] + { + new ValidationFailure( "Name", "Name is required." ), + new ValidationFailure( "Age", "Age must be positive." ) + } ); + + var exception = new PipelineValidationException( result ); + + Assert.AreEqual( "Validation failed: Name is required.; Age must be positive.", exception.Message ); + } + + [TestMethod] + public void Custom_message_should_be_preserved() + { + var result = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } ); + + var exception = new PipelineValidationException( "custom message", result ); + + Assert.AreEqual( "custom message", exception.Message ); + Assert.AreSame( result, exception.ValidationResult ); + } +} diff --git a/test/Hyperbee.Pipeline.Validation.Tests/ThrowIfInvalidTests.cs b/test/Hyperbee.Pipeline.Validation.Tests/ThrowIfInvalidTests.cs new file mode 100644 index 0000000..dd5542a --- /dev/null +++ b/test/Hyperbee.Pipeline.Validation.Tests/ThrowIfInvalidTests.cs @@ -0,0 +1,115 @@ +using Hyperbee.Pipeline.Context; +using Hyperbee.Pipeline.Validation; + +namespace Hyperbee.Pipeline.Validation.Tests; + +[TestClass] +public class ThrowIfInvalidTests +{ + [TestMethod] + public void ThrowIfInvalid_should_not_throw_with_no_validation_result() + { + var context = new PipelineContext(); + + context.ThrowIfInvalid(); + } + + [TestMethod] + public void ThrowIfInvalid_should_not_throw_with_valid_result() + { + var context = new PipelineContext(); + context.SetValidationResult( new ValidationResult() ); + + context.ThrowIfInvalid(); + } + + [TestMethod] + public void ThrowIfInvalid_should_throw_with_invalid_result() + { + var context = new PipelineContext(); + context.SetValidationResult( new ValidationFailure( "Name", "Name is required." ) ); + + var exception = Assert.ThrowsExactly( () => context.ThrowIfInvalid() ); + + Assert.AreSame( context.GetValidationResult(), exception.ValidationResult ); + StringAssert.Contains( exception.Message, "Name is required." ); + } + + [TestMethod] + public void ThrowIfInvalid_message_should_join_all_failure_messages() + { + var context = new PipelineContext(); + context.SetValidationResult( new ValidationFailure( "Name", "Name is required." ) ); + context.AddValidationResult( new ValidationFailure( "Age", "Age must be positive." ) ); + + var exception = Assert.ThrowsExactly( () => context.ThrowIfInvalid() ); + + StringAssert.Contains( exception.Message, "Name is required." ); + StringAssert.Contains( exception.Message, "Age must be positive." ); + } + + [TestMethod] + public void ThrowIfInvalid_should_throw_after_FailAfter() + { + var context = new PipelineContext(); + context.FailAfter( "Something went wrong.", propertyName: "Operation" ); + + var exception = Assert.ThrowsExactly( () => context.ThrowIfInvalid() ); + + Assert.HasCount( 1, exception.ValidationResult.Errors ); + } + + [TestMethod] + public void ThrowIfInvalid_should_not_throw_when_canceled_without_validation_result() + { + // Cancellation is control flow, not a validation failure + var context = new PipelineContext(); + context.CancelAfter(); + + context.ThrowIfInvalid(); + } + + [TestMethod] + public void ThrowIfInvalid_should_not_throw_when_error_without_validation_result() + { + // Exceptions are surfaced by ThrowIfError; the validation axis is independent + var context = new PipelineContext { Exception = new InvalidOperationException( "boom" ) }; + + context.ThrowIfInvalid(); + } + + [TestMethod] + public async Task ThrowIfInvalid_inside_pipeline_step_should_be_captured_as_context_exception() + { + // The nested-command shape: a pipeline step surfaces an inner command's validation + // failure with ThrowIfInvalid; Build() captures the throw on the outer context + var innerContext = new PipelineContext(); + innerContext.SetValidationResult( new ValidationFailure( "Name", "Name is required." ) ); + + var followUpRan = false; + + var pipeline = PipelineFactory + .Start() + .Pipe( ( _, arg ) => + { + innerContext.ThrowIfInvalid(); + return arg; + } ) + .Pipe( ( _, arg ) => + { + followUpRan = true; + return arg; + } ) + .Build(); + + var context = new PipelineContext(); + var result = await pipeline( context, "input" ); + + Assert.IsNull( result ); + Assert.IsFalse( followUpRan ); + Assert.IsTrue( context.IsError ); + Assert.IsInstanceOfType( context.Exception, typeof( PipelineValidationException ) ); + Assert.IsTrue( context.IsCanceled ); // halt-on-error + Assert.IsTrue( context.IsValid() ); // outer context carries no validation item + } +}