From ec21f7a86ea0441da958db89776d9e1ad163148e Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Apr 2026 14:07:18 +0800 Subject: [PATCH] Guards fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Added - **`Railway.Create()` — two-phase builder factory** that enforces a clear separation between the guard/validation phase and the step execution phase at the type-system level - `Railway.Create(factory, selector, guards, steps)` — with guards - `Railway.Create(factory, selector, steps)` — without guards (convenience overload) - Parameterless variants (`Func` factory) for railways with no request input - New `RailwayGuardBuilder<...>` — only exposes `Guard()` and `Validate()` - New `RailwayStepsBuilder<...>` — only exposes `Do()`, `DoIf()`, `DoAll()`, `Group()`, `WithContext()`, `Detach()`, `Parallel()`, `ParallelDetached()`, `Finally()`, and `Build()` ### Fixed - **Registration-order bug**: `Group()`, `WithContext()`, `Detach()`, `Parallel()`, and `ParallelDetached()` steps now execute in the exact order they were registered, interleaved correctly with `Do()` steps. Previously all `Do()` steps ran before all feature steps regardless of registration order. ### Deprecated - `RailwayBuilder` — use `Railway.Create()` instead - `RailwayBuilderFactory` — use `Railway.Create()` instead --- CHANGELOG.md | 23 + README.md | 146 +++-- .../BranchWithLocalPayloadTests.cs | 2 +- Zooper.Bee.Tests/RailwayWithContextTests.cs | 2 +- Zooper.Bee.Tests/Zooper.Bee.Tests.csproj | 2 +- Zooper.Bee/Features/Context/ContextBuilder.cs | 6 +- .../Features/Detached/DetachedBuilder.cs | 6 +- Zooper.Bee/Features/Group/GroupBuilder.cs | 6 +- .../Features/Parallel/ParallelBuilder.cs | 10 +- .../Parallel/ParallelDetachedBuilder.cs | 10 +- Zooper.Bee/RailwayBuilder.cs | 153 ++--- Zooper.Bee/RailwayBuilderFactory.cs | 1 + Zooper.Bee/RailwayFactory.cs | 117 ++++ Zooper.Bee/RailwayGuardBuilder.cs | 78 +++ Zooper.Bee/RailwayStepsBuilder.cs | 522 ++++++++++++++++++ 15 files changed, 909 insertions(+), 175 deletions(-) create mode 100644 Zooper.Bee/RailwayFactory.cs create mode 100644 Zooper.Bee/RailwayGuardBuilder.cs create mode 100644 Zooper.Bee/RailwayStepsBuilder.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ae381..a344288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`Railway.Create()` — two-phase builder factory** that enforces a clear separation between the + guard/validation phase and the step execution phase at the type-system level + - `Railway.Create(factory, selector, guards, steps)` — with guards + - `Railway.Create(factory, selector, steps)` — without guards (convenience overload) + - Parameterless variants (`Func` factory) for railways with no request input + - New `RailwayGuardBuilder<...>` — only exposes `Guard()` and `Validate()` + - New `RailwayStepsBuilder<...>` — only exposes `Do()`, `DoIf()`, `DoAll()`, `Group()`, + `WithContext()`, `Detach()`, `Parallel()`, `ParallelDetached()`, `Finally()`, and `Build()` + +### Fixed + +- **Registration-order bug**: `Group()`, `WithContext()`, `Detach()`, `Parallel()`, and + `ParallelDetached()` steps now execute in the exact order they were registered, interleaved + correctly with `Do()` steps. Previously all `Do()` steps ran before all feature steps + regardless of registration order. + +### Deprecated + +- `RailwayBuilder` — use `Railway.Create()` instead +- `RailwayBuilderFactory` — use `Railway.Create()` instead + ## [3.4.1] - 2026-03-21 ### Changed diff --git a/README.md b/README.md index 59a2e38..acf5f55 100644 --- a/README.md +++ b/README.md @@ -32,28 +32,27 @@ dotnet add package Zooper.Bee ```csharp // Define a simple railway -var railway = new RailwayBuilder( +var railway = Railway.Create( // Factory function that creates the initial payload from the request - request => new Payload { Data = request.Data }, + factory: request => new Payload { Data = request.Data }, // Selector function that creates the success result from the final payload - payload => new SuccessResult { ProcessedData = payload.Data } -) -.Validate(request => -{ - // Validate the request - if (string.IsNullOrEmpty(request.Data)) - return Option.Some(new ErrorResult { Message = "Data is required" }); + selector: payload => new SuccessResult { ProcessedData = payload.Data }, - return Option.None; -}) -.Do(payload => -{ - // Process the payload - payload.Data = payload.Data.ToUpper(); - return Either.FromRight(payload); -}) -.Build(); + // Step execution phase + steps: s => s + .Validate(request => + { + if (string.IsNullOrEmpty(request.Data)) + return Option.Some(new ErrorResult { Message = "Data is required" }); + return Option.None; + }) + .Do(payload => + { + payload.Data = payload.Data.ToUpper(); + return Either.FromRight(payload); + }) +); // Execute the railway var result = await railway.Execute(new Request { Data = "hello world" }, CancellationToken.None); @@ -69,9 +68,45 @@ else ## Building Railways +Railways are created with `Railway.Create()`, which takes two separate configuration lambdas: + +- **`guards`** — optional; declares guards and validations that run before the payload is created +- **`steps`** — required; declares all activities that transform the payload + +This two-phase separation makes it structurally impossible to mix guard registration with step +registration. `Guard()` and `Validate()` are not available inside `steps`, and `Do()`/`Group()`/etc. +are not available inside `guards`. + +```csharp +var railway = Railway.Create( + factory: request => new Payload(request), + selector: payload => new Success(payload.Result), + guards: g => g + .Guard(request => /* auth check */) + .Validate(request => /* input validation */), + steps: s => s + .Do(payload => /* step 1 */) + .Group(null, g => g + .Do(payload => /* step 2a */) + .Do(payload => /* step 2b */)) + .Do(payload => /* step 3 */) +); +``` + +When no guards are needed, omit the `guards` parameter: + +```csharp +var railway = Railway.Create( + factory: request => new Payload(request), + selector: payload => new Success(payload.Result), + steps: s => s + .Do(payload => /* ... */) +); +``` + ### Validation -Validates the incoming request before processing begins. +Validations run before any step and reject the request early when invalid. ```csharp // Asynchronous validation @@ -91,31 +126,36 @@ Validates the incoming request before processing begins. ### Guards -Guards allow you to define checks that run before a railway begins execution. They're ideal for authentication, -authorization, account validation, or any other requirement that must be satisfied before a railway can proceed. +Guards check whether the railway is allowed to execute at all — authentication, +authorization, feature flags, etc. They always run before any step, regardless of +where they appear in the `guards` lambda. ```csharp -// Asynchronous guard -.Guard(async (request, cancellationToken) => -{ - var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken); - return isAuthorized ? Option.None : Option.Some(new ErrorResult()); -}) - -// Synchronous guard -.Guard(request => -{ - var isAuthorized = CheckAuthorization(request); - return isAuthorized ? Option.None : Option.Some(new ErrorResult()); -}) +guards: g => g + // Asynchronous guard + .Guard(async (request, cancellationToken) => + { + var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken); + return isAuthorized + ? Either.FromRight(Unit.Value) + : Either.FromLeft(new ErrorResult { Message = "Unauthorized" }); + }) + // Synchronous guard + .Guard(request => + { + var isAuthorized = CheckAuthorization(request); + return isAuthorized + ? Either.FromRight(Unit.Value) + : Either.FromLeft(new ErrorResult { Message = "Unauthorized" }); + }) ``` #### Benefits of Guards -- Guards run before creating the railway context, providing early validation -- They provide a clear separation between "can this railway run?" and the actual railway logic +- Guards run before the payload is created, providing the earliest possible short-circuit +- The `guards` phase is structurally separate from the `steps` phase — it is impossible to + accidentally register a guard after a step - Common checks like authentication can be standardized and reused -- Failures short-circuit the railway, preventing unnecessary work ### Activities @@ -349,6 +389,38 @@ services.AddRailways(lifetime: ServiceLifetime.Singleton); 6. Validate requests early to fail fast 7. Use contextual state to avoid passing too many parameters +## Migration from `RailwayBuilder` to `Railway.Create()` + +As of the latest version, `RailwayBuilder` and `RailwayBuilderFactory` are `[Obsolete]`. +Use `Railway.Create()` instead. + +### Before + +```csharp +var railway = new RailwayBuilder( + request => new Payload(request), + payload => new Success(payload.Result)) + .Guard(request => /* ... */) + .Validate(request => /* ... */) + .Do(payload => /* ... */) + .Group(null, g => g.Do(payload => /* ... */)) + .Build(); +``` + +### After + +```csharp +var railway = Railway.Create( + factory: request => new Payload(request), + selector: payload => new Success(payload.Result), + guards: g => g + .Guard(request => /* ... */) + .Validate(request => /* ... */), + steps: s => s + .Do(payload => /* ... */) + .Group(null, g => g.Do(payload => /* ... */))); +``` + ## Migration from Workflow to Railway As of the latest version, all `Workflow` classes have been renamed to `Railway` to better reflect the railway-oriented programming pattern used by the library. The old `Workflow` names are preserved as `[Obsolete]` shims for backward compatibility. diff --git a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs index 720591b..589eafb 100644 --- a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs +++ b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs @@ -196,7 +196,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities( // Assert result.IsRight.Should().BeTrue(); - result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2"); + result.Right.ProcessingResult.Should().Be("Initial processing -> Context 1 -> Main activity -> Context 2"); result.Right.FinalPrice.Should().Be(130.00m); // Base (100) + Context 1 (10) + Context 2 (20) } diff --git a/Zooper.Bee.Tests/RailwayWithContextTests.cs b/Zooper.Bee.Tests/RailwayWithContextTests.cs index a9c5855..fec811b 100644 --- a/Zooper.Bee.Tests/RailwayWithContextTests.cs +++ b/Zooper.Bee.Tests/RailwayWithContextTests.cs @@ -225,7 +225,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities( // Assert result.IsRight.Should().BeTrue(); - result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2"); + result.Right.ProcessingResult.Should().Be("Initial processing -> Context 1 -> Main activity -> Context 2"); result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20 result.Right.CustomizationDetails.Should().Be("Context 1 customization + Context 2 customization"); } diff --git a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj index 7e85112..24b4e52 100644 --- a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj +++ b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 latest enable false diff --git a/Zooper.Bee/Features/Context/ContextBuilder.cs b/Zooper.Bee/Features/Context/ContextBuilder.cs index 16fde96..a1397eb 100644 --- a/Zooper.Bee/Features/Context/ContextBuilder.cs +++ b/Zooper.Bee/Features/Context/ContextBuilder.cs @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Context; /// The type of the error result public sealed class ContextBuilder { - private readonly RailwayBuilder _workflow; private readonly Context _context; - internal ContextBuilder( - RailwayBuilder workflow, - Context context) + internal ContextBuilder(Context context) { - _workflow = workflow; _context = context; } diff --git a/Zooper.Bee/Features/Detached/DetachedBuilder.cs b/Zooper.Bee/Features/Detached/DetachedBuilder.cs index fcd0eed..ffb3d42 100644 --- a/Zooper.Bee/Features/Detached/DetachedBuilder.cs +++ b/Zooper.Bee/Features/Detached/DetachedBuilder.cs @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Detached; /// The type of the error result public sealed class DetachedBuilder { - private readonly RailwayBuilder _workflow; private readonly Detached _detached; - internal DetachedBuilder( - RailwayBuilder workflow, - Detached detached) + internal DetachedBuilder(Detached detached) { - _workflow = workflow; _detached = detached; } diff --git a/Zooper.Bee/Features/Group/GroupBuilder.cs b/Zooper.Bee/Features/Group/GroupBuilder.cs index af08df3..58d5299 100644 --- a/Zooper.Bee/Features/Group/GroupBuilder.cs +++ b/Zooper.Bee/Features/Group/GroupBuilder.cs @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Group; /// The type of the error result public sealed class GroupBuilder { - private readonly RailwayBuilder _workflow; private readonly Group _group; - internal GroupBuilder( - RailwayBuilder workflow, - Group group) + internal GroupBuilder(Group group) { - _workflow = workflow; _group = group; } diff --git a/Zooper.Bee/Features/Parallel/ParallelBuilder.cs b/Zooper.Bee/Features/Parallel/ParallelBuilder.cs index 81f5786..50358b5 100644 --- a/Zooper.Bee/Features/Parallel/ParallelBuilder.cs +++ b/Zooper.Bee/Features/Parallel/ParallelBuilder.cs @@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel; /// The type of the error result public sealed class ParallelBuilder { - private readonly RailwayBuilder _workflow; private readonly Parallel _parallel; - internal ParallelBuilder( - RailwayBuilder workflow, - Parallel parallel) + internal ParallelBuilder(Parallel parallel) { - _workflow = workflow; _parallel = parallel; } @@ -34,7 +30,7 @@ public ParallelBuilder Group( var group = new Group(); _parallel.Groups.Add(group); - var groupBuilder = new GroupBuilder(_workflow, group); + var groupBuilder = new GroupBuilder(group); groupConfiguration(groupBuilder); return this; @@ -53,7 +49,7 @@ public ParallelBuilder Group( var group = new Group(condition); _parallel.Groups.Add(group); - var groupBuilder = new GroupBuilder(_workflow, group); + var groupBuilder = new GroupBuilder(group); groupConfiguration(groupBuilder); return this; diff --git a/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs b/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs index ec97c03..7866e4b 100644 --- a/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs +++ b/Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs @@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel; /// The type of the error result public sealed class ParallelDetachedBuilder { - private readonly RailwayBuilder _workflow; private readonly ParallelDetached _parallelDetached; - internal ParallelDetachedBuilder( - RailwayBuilder workflow, - ParallelDetached parallelDetached) + internal ParallelDetachedBuilder(ParallelDetached parallelDetached) { - _workflow = workflow; _parallelDetached = parallelDetached; } @@ -34,7 +30,7 @@ public ParallelDetachedBuilder Detached( var detached = new Detached(); _parallelDetached.DetachedGroups.Add(detached); - var detachedBuilder = new DetachedBuilder(_workflow, detached); + var detachedBuilder = new DetachedBuilder(detached); detachedConfiguration(detachedBuilder); return this; @@ -53,7 +49,7 @@ public ParallelDetachedBuilder Detached( var detached = new Detached(condition); _parallelDetached.DetachedGroups.Add(detached); - var detachedBuilder = new DetachedBuilder(_workflow, detached); + var detachedBuilder = new DetachedBuilder(detached); detachedConfiguration(detachedBuilder); return this; diff --git a/Zooper.Bee/RailwayBuilder.cs b/Zooper.Bee/RailwayBuilder.cs index c21a7c1..e9a9115 100644 --- a/Zooper.Bee/RailwayBuilder.cs +++ b/Zooper.Bee/RailwayBuilder.cs @@ -24,6 +24,7 @@ namespace Zooper.Bee; /// /// Initializes a new instance of the class. /// +[Obsolete("Use Railway.Create() instead, which enforces a clear separation between the guard/validation phase and the step execution phase.")] public class RailwayBuilder { private readonly Func _contextFactory; @@ -31,15 +32,12 @@ public class RailwayBuilder private readonly List> _guards = []; private readonly List> _validations = []; - private readonly List> _activities = []; - private readonly List> _conditionalActivities = []; + private readonly List>>> _steps = []; + private readonly FeatureExecutorFactory _featureExecutorFactory = new(); private readonly List> _finallyActivities = []; private readonly List> _branches = []; private readonly List _branchesWithLocalPayload = []; - // Collections for new features - private readonly List> _features = []; - /// /// Initializes a new instance of the class. /// @@ -125,7 +123,7 @@ public RailwayBuilder Guard(Func Do( Func>> activity) { - _activities.Add(new(activity)); + _steps.Add(activity); return this; } @@ -136,12 +134,7 @@ public RailwayBuilder Do( /// The builder instance for method chaining public RailwayBuilder Do(Func> activity) { - _activities.Add( - new(( - payload, - _) => Task.FromResult(activity(payload)) - ) - ); + _steps.Add((payload, _) => Task.FromResult(activity(payload))); return this; } @@ -155,7 +148,7 @@ public RailwayBuilder DoAll( { foreach (var activity in activities) { - _activities.Add(new(activity)); + _steps.Add(activity); } return this; @@ -170,12 +163,7 @@ public RailwayBuilder DoAll(params Func Task.FromResult(activity(payload)) - ) - ); + _steps.Add((payload, _) => Task.FromResult(activity(payload))); } return this; @@ -191,12 +179,10 @@ public RailwayBuilder DoIf( Func condition, Func>> activity) { - _conditionalActivities.Add( - new( - condition, - new(activity) - ) - ); + _steps.Add((payload, ct) => + condition(payload) + ? activity(payload, ct) + : Task.FromResult(Either.FromRight(payload))); return this; } @@ -210,15 +196,10 @@ public RailwayBuilder DoIf( Func condition, Func> activity) { - _conditionalActivities.Add( - new( - condition, - new(( - payload, - _) => Task.FromResult(activity(payload)) - ) - ) - ); + _steps.Add((payload, _) => + Task.FromResult(condition(payload) + ? activity(payload) + : Either.FromRight(payload))); return this; } @@ -276,9 +257,9 @@ public RailwayBuilder Group( Action> groupConfiguration) { var group = new Features.Group.Group(condition); - _features.Add(group); - var groupBuilder = new Features.Group.GroupBuilder(this, group); + var groupBuilder = new Features.Group.GroupBuilder(group); groupConfiguration(groupBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(group, payload, ct)); return this; } @@ -377,9 +358,9 @@ public RailwayBuilder WithContext> contextConfiguration) { var context = new Features.Context.Context(condition, localStateFactory); - _features.Add(context); - var contextBuilder = new Features.Context.ContextBuilder(this, context); + var contextBuilder = new Features.Context.ContextBuilder(context); contextConfiguration(contextBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(context, payload, ct)); return this; } @@ -409,9 +390,9 @@ public RailwayBuilder Detach( Action> detachedConfiguration) { var detached = new Features.Detached.Detached(condition); - _features.Add(detached); - var detachedBuilder = new Features.Detached.DetachedBuilder(this, detached); + var detachedBuilder = new Features.Detached.DetachedBuilder(detached); detachedConfiguration(detachedBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(detached, payload, ct)); return this; } @@ -439,9 +420,9 @@ public RailwayBuilder Parallel( Action> parallelConfiguration) { var parallel = new Features.Parallel.Parallel(condition); - _features.Add(parallel); - var parallelBuilder = new Features.Parallel.ParallelBuilder(this, parallel); + var parallelBuilder = new Features.Parallel.ParallelBuilder(parallel); parallelConfiguration(parallelBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(parallel, payload, ct)); return this; } @@ -469,10 +450,10 @@ public RailwayBuilder ParallelDetached( Action> parallelDetachedConfiguration) { var parallelDetached = new Features.Parallel.ParallelDetached(condition); - _features.Add(parallelDetached); var parallelDetachedBuilder = - new Features.Parallel.ParallelDetachedBuilder(this, parallelDetached); + new Features.Parallel.ParallelDetachedBuilder(parallelDetached); parallelDetachedConfiguration(parallelDetachedBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(parallelDetached, payload, ct)); return this; } @@ -551,17 +532,11 @@ private async Task> ExecuteRailwayAsync( try { - var activitiesResult = await RunActivitiesAsync(payload, cancellationToken); - if (activitiesResult.IsLeft) - return Either.FromLeft(activitiesResult.Left!); - - payload = activitiesResult.Right!; + var stepsResult = await RunStepsAsync(payload, cancellationToken); + if (stepsResult.IsLeft) + return Either.FromLeft(stepsResult.Left!); - var conditionalResult = await RunConditionalActivitiesAsync(payload, cancellationToken); - if (conditionalResult.IsLeft) - return Either.FromLeft(conditionalResult.Left!); - - payload = conditionalResult.Right!; + payload = stepsResult.Right!; var branchesResult = await RunBranchesAsync(payload, cancellationToken); if (branchesResult.IsLeft) @@ -575,12 +550,6 @@ private async Task> ExecuteRailwayAsync( payload = branchLocalsResult.Right!; - var featuresResult = await RunFeaturesAsync(payload, cancellationToken); - if (featuresResult.IsLeft) - return Either.FromLeft(featuresResult.Left!); - - payload = featuresResult.Right!; - var successValue = _resultSelector(payload); return Either.FromRight(successValue ?? default!); } @@ -635,20 +604,20 @@ private async Task> RunGuardsAsync( } /// - /// Executes all registered activities in sequence, returning either the first encountered error or the transformed payload. + /// Executes all registered steps in insertion order, returning either the first encountered error or the transformed payload. /// /// The initial payload to process. /// Token to observe for cancellation. /// - /// An Either containing the error (Left) if any activity fails, or the final payload (Right) on success. + /// An Either containing the error (Left) if any step fails, or the final payload (Right) on success. /// - private async Task> RunActivitiesAsync( + private async Task> RunStepsAsync( TPayload payload, CancellationToken cancellationToken) { - foreach (var activity in _activities) + foreach (var step in _steps) { - var result = await activity.Execute(payload, cancellationToken); + var result = await step(payload, cancellationToken); if (result.IsLeft && result.Left != null) return Either.FromLeft(result.Left); @@ -659,30 +628,27 @@ private async Task> RunActivitiesAsync( } /// - /// Executes conditional activities when their condition is met. + /// Executes a feature as a step, respecting . /// + /// The feature to execute. /// The current payload. /// Token to observe for cancellation. /// - /// An Either containing the error (Left) if any conditional activity fails, or the updated payload (Right) on success. + /// An Either containing the error (Left) if the feature fails, or the payload (Right) — merged when + /// is , unchanged otherwise. /// - private async Task> RunConditionalActivitiesAsync( + private async Task> ExecuteFeatureStepAsync( + IRailwayFeature feature, TPayload payload, CancellationToken cancellationToken) { - foreach (var conditionalActivity in _conditionalActivities) - { - if (!conditionalActivity.ShouldExecute(payload)) - continue; + var result = await _featureExecutorFactory.ExecuteFeature(feature, payload, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); - var result = await conditionalActivity.Activity.Execute(payload, cancellationToken); - if (result.IsLeft && result.Left != null) - return Either.FromLeft(result.Left); - - payload = result.Right!; - } - - return Either.FromRight(payload); + return feature.ShouldMerge + ? Either.FromRight(result.Right!) + : Either.FromRight(payload); } /// @@ -739,32 +705,7 @@ private async Task> RunBranchesWithLocalPayloadAsync( return Either.FromRight(payload); } - /// - /// Executes railway features like Group, Context, Detach, Parallel, merging results when applicable. - /// - /// The current payload. - /// Token to observe for cancellation. - /// - /// An Either containing the error (Left) if any feature fails, or the updated payload (Right) on success. - /// - private async Task> RunFeaturesAsync( - TPayload payload, - CancellationToken cancellationToken) - { - var factory = new FeatureExecutorFactory(); - - foreach (var feature in _features) - { - var result = await factory.ExecuteFeature(feature, payload, cancellationToken); - if (result.IsLeft && result.Left != null) - return Either.FromLeft(result.Left); - - if (feature.ShouldMerge) - payload = result.Right!; - } - return Either.FromRight(payload); - } /// /// Executes all the "finally" activities, ignoring errors. @@ -802,7 +743,7 @@ private async Task> ExecuteBranchWithLocalPayloadDynami var branchType = branchObject.GetType(); if (branchType.IsGenericType && - branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) + branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) { var methodInfo = typeof(RailwayBuilder) .GetMethod(nameof(ExecuteBranchWithLocalPayload), BindingFlags.NonPublic | BindingFlags.Instance); diff --git a/Zooper.Bee/RailwayBuilderFactory.cs b/Zooper.Bee/RailwayBuilderFactory.cs index a46e291..5c9b35b 100644 --- a/Zooper.Bee/RailwayBuilderFactory.cs +++ b/Zooper.Bee/RailwayBuilderFactory.cs @@ -6,6 +6,7 @@ namespace Zooper.Bee; /// /// Provides factory methods for creating railways without requiring a request parameter. /// +[Obsolete("Use Railway.Create() instead, which enforces a clear separation between the guard/validation phase and the step execution phase.")] public static class RailwayBuilderFactory { /// diff --git a/Zooper.Bee/RailwayFactory.cs b/Zooper.Bee/RailwayFactory.cs new file mode 100644 index 0000000..7946d0b --- /dev/null +++ b/Zooper.Bee/RailwayFactory.cs @@ -0,0 +1,117 @@ +using System; +using Zooper.Fox; + +namespace Zooper.Bee; + +/// +/// Provides factory methods for creating railways using a two-phase builder pattern: +/// a guard/validation phase followed by a step execution phase. +/// +public static class Railway +{ + /// + /// Creates a new railway with distinct guard and step phases. + /// + /// The type of the request input. + /// The type of the payload used to carry intermediate data. + /// The type of the success result. + /// The type of the error result. + /// + /// Factory function that takes a and produces + /// the initial . + /// + /// + /// Selector function that converts the final + /// into a success result of type . + /// + /// + /// Optional action to configure guards and validations that run before any step. + /// Pass when no guards are needed. + /// + /// + /// Action to configure the step execution phase. + /// + /// A built ready for execution. + public static Railway Create( + Func factory, + Func selector, + Action>? guards, + Action> steps) + { + var guardBuilder = new RailwayGuardBuilder(); + guards?.Invoke(guardBuilder); + + var stepsBuilder = new RailwayStepsBuilder( + factory, selector, guardBuilder.Guards, guardBuilder.Validations); + steps(stepsBuilder); + + return stepsBuilder.Build(); + } + + /// + /// Creates a new railway with only a step phase and no guards. + /// + /// The type of the request input. + /// The type of the payload used to carry intermediate data. + /// The type of the success result. + /// The type of the error result. + /// + /// Factory function that takes a and produces + /// the initial . + /// + /// + /// Selector function that converts the final + /// into a success result of type . + /// + /// + /// Action to configure the step execution phase. + /// + /// A built ready for execution. + public static Railway Create( + Func factory, + Func selector, + Action> steps) + { + return Create(factory, selector, null, steps); + } + + /// + /// Creates a new parameterless railway (no request input) with distinct guard and step phases. + /// + /// The type of the payload used to carry intermediate data. + /// The type of the success result. + /// The type of the error result. + /// Factory function that creates the initial payload. + /// Selector function that converts the final payload into a success result. + /// + /// Optional action to configure guards and validations that run before any step. + /// + /// Action to configure the step execution phase. + /// A built ready for execution. + public static Railway Create( + Func factory, + Func selector, + Action>? guards, + Action> steps) + { + return Create(_ => factory(), selector, guards, steps); + } + + /// + /// Creates a new parameterless railway (no request input) with only a step phase. + /// + /// The type of the payload used to carry intermediate data. + /// The type of the success result. + /// The type of the error result. + /// Factory function that creates the initial payload. + /// Selector function that converts the final payload into a success result. + /// Action to configure the step execution phase. + /// A built ready for execution. + public static Railway Create( + Func factory, + Func selector, + Action> steps) + { + return Create(factory, selector, null, steps); + } +} diff --git a/Zooper.Bee/RailwayGuardBuilder.cs b/Zooper.Bee/RailwayGuardBuilder.cs new file mode 100644 index 0000000..1632c75 --- /dev/null +++ b/Zooper.Bee/RailwayGuardBuilder.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Internal; +using Zooper.Fox; + +// ReSharper disable MemberCanBePrivate.Global + +namespace Zooper.Bee; + +/// +/// Builds the guard and validation phase of a railway. +/// Obtained via . +/// Guards and validations registered here always execute before any step registered on +/// . +/// +/// The type of the request input. +/// The type of the payload used to carry intermediate data. +/// The type of the success result. +/// The type of the error result. +public sealed class RailwayGuardBuilder +{ + internal List> Guards { get; } = []; + internal List> Validations { get; } = []; + + internal RailwayGuardBuilder() { } + + /// + /// Adds a guard to check if the railway can be executed. + /// If a guard fails, the railway will not execute and will return the error. + /// + /// The guard function that returns Either an error or Unit + /// The builder instance for method chaining + public RailwayGuardBuilder Guard( + Func>> guard) + { + Guards.Add(new(guard)); + return this; + } + + /// + /// Adds a synchronous guard to check if the railway can be executed. + /// If a guard fails, the railway will not execute and will return the error. + /// + /// The guard function that returns Either an error or Unit + /// The builder instance for method chaining + public RailwayGuardBuilder Guard( + Func> guard) + { + Guards.Add(new((request, _) => Task.FromResult(guard(request)))); + return this; + } + + /// + /// Adds a validation rule to the railway. + /// + /// The validation function + /// The builder instance for method chaining + public RailwayGuardBuilder Validate( + Func>> validation) + { + Validations.Add(new(validation)); + return this; + } + + /// + /// Adds a synchronous validation rule to the railway. + /// + /// The validation function + /// The builder instance for method chaining + public RailwayGuardBuilder Validate( + Func> validation) + { + Validations.Add(new((request, _) => Task.FromResult(validation(request)))); + return this; + } +} diff --git a/Zooper.Bee/RailwayStepsBuilder.cs b/Zooper.Bee/RailwayStepsBuilder.cs new file mode 100644 index 0000000..cff754d --- /dev/null +++ b/Zooper.Bee/RailwayStepsBuilder.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Zooper.Bee.Features; +using Zooper.Bee.Internal; +using Zooper.Bee.Internal.Executors; +using Zooper.Fox; + +// ReSharper disable MemberCanBePrivate.Global + +namespace Zooper.Bee; + +/// +/// Builds the step execution phase of a railway. +/// Obtained via . +/// All steps are executed in registration order after the guard phase completes. +/// +/// The type of the request input. +/// The type of the payload used to carry intermediate data. +/// The type of the success result. +/// The type of the error result. +public sealed class RailwayStepsBuilder +{ + private readonly Func _contextFactory; + private readonly Func _resultSelector; + private readonly List> _guards; + private readonly List> _validations; + + private readonly List>>> _steps = []; + private readonly FeatureExecutorFactory _featureExecutorFactory = new(); + private readonly List> _finallyActivities = []; + private readonly List> _branches = []; + private readonly List _branchesWithLocalPayload = []; + + internal RailwayStepsBuilder( + Func contextFactory, + Func resultSelector, + List> guards, + List> validations) + { + _contextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); + _resultSelector = resultSelector ?? throw new ArgumentNullException(nameof(resultSelector)); + _guards = guards; + _validations = validations; + } + + /// + /// Adds an activity to the railway. + /// + /// The activity function + /// The builder instance for method chaining + public RailwayStepsBuilder Do( + Func>> activity) + { + _steps.Add(activity); + return this; + } + + /// + /// Adds a synchronous activity to the railway. + /// + /// The activity function + /// The builder instance for method chaining + public RailwayStepsBuilder Do(Func> activity) + { + _steps.Add((payload, _) => Task.FromResult(activity(payload))); + return this; + } + + /// + /// Adds multiple activities to the railway. + /// + /// The activity functions + /// The builder instance for method chaining + public RailwayStepsBuilder DoAll( + params Func>>[] activities) + { + foreach (var activity in activities) + { + _steps.Add(activity); + } + + return this; + } + + /// + /// Adds multiple synchronous activities to the railway. + /// + /// The activity functions + /// The builder instance for method chaining + public RailwayStepsBuilder DoAll( + params Func>[] activities) + { + foreach (var activity in activities) + { + _steps.Add((payload, _) => Task.FromResult(activity(payload))); + } + + return this; + } + + /// + /// Adds a conditional activity to the railway that will only execute if the condition returns true. + /// + /// The condition to evaluate + /// The activity to execute if the condition is true + /// The builder instance for method chaining + public RailwayStepsBuilder DoIf( + Func condition, + Func>> activity) + { + _steps.Add((payload, ct) => + condition(payload) + ? activity(payload, ct) + : Task.FromResult(Either.FromRight(payload))); + return this; + } + + /// + /// Adds a synchronous conditional activity to the railway that will only execute if the condition returns true. + /// + /// The condition to evaluate + /// The activity to execute if the condition is true + /// The builder instance for method chaining + public RailwayStepsBuilder DoIf( + Func condition, + Func> activity) + { + _steps.Add((payload, _) => + Task.FromResult(condition(payload) + ? activity(payload) + : Either.FromRight(payload))); + return this; + } + + /// + /// Creates a group of activities in the railway with an optional condition. + /// + /// The condition to evaluate. If null, the group always executes. + /// An action that configures the group + /// The builder instance for method chaining + public RailwayStepsBuilder Group( + Func? condition, + Action> groupConfiguration) + { + var group = new Features.Group.Group(condition); + var groupBuilder = new Features.Group.GroupBuilder(group); + groupConfiguration(groupBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(group, payload, ct)); + return this; + } + + /// + /// Creates a group of activities in the railway that always executes. + /// + /// An action that configures the group + /// The builder instance for method chaining + public RailwayStepsBuilder Group( + Action> groupConfiguration) + { + return Group(null, groupConfiguration); + } + + /// + /// Creates a context with local state in the railway and an optional condition. + /// + /// The type of the local context state + /// The condition to evaluate. If null, the context always executes. + /// The factory function that creates the local state + /// An action that configures the context + /// The builder instance for method chaining + public RailwayStepsBuilder WithContext( + Func? condition, + Func localStateFactory, + Action> contextConfiguration) + { + var context = new Features.Context.Context(condition, localStateFactory); + var contextBuilder = new Features.Context.ContextBuilder(context); + contextConfiguration(contextBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(context, payload, ct)); + return this; + } + + /// + /// Creates a context with local state in the railway that always executes. + /// + /// The type of the local context state + /// The factory function that creates the local state + /// An action that configures the context + /// The builder instance for method chaining + public RailwayStepsBuilder WithContext( + Func localStateFactory, + Action> contextConfiguration) + { + return WithContext(null, localStateFactory, contextConfiguration); + } + + /// + /// Creates a detached group of activities in the railway with an optional condition. + /// Detached groups don't merge their results back into the main railway. + /// + /// The condition to evaluate. If null, the detached group always executes. + /// An action that configures the detached group + /// The builder instance for method chaining + public RailwayStepsBuilder Detach( + Func? condition, + Action> detachedConfiguration) + { + var detached = new Features.Detached.Detached(condition); + var detachedBuilder = new Features.Detached.DetachedBuilder(detached); + detachedConfiguration(detachedBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(detached, payload, ct)); + return this; + } + + /// + /// Creates a detached group of activities in the railway that always executes. + /// Detached groups don't merge their results back into the main railway. + /// + /// An action that configures the detached group + /// The builder instance for method chaining + public RailwayStepsBuilder Detach( + Action> detachedConfiguration) + { + return Detach(null, detachedConfiguration); + } + + /// + /// Creates a parallel execution of multiple groups with an optional condition. + /// All groups execute in parallel, and their results are merged back into the main railway. + /// + /// The condition to evaluate. If null, the parallel execution always occurs. + /// An action that configures the parallel execution + /// The builder instance for method chaining + public RailwayStepsBuilder Parallel( + Func? condition, + Action> parallelConfiguration) + { + var parallel = new Features.Parallel.Parallel(condition); + var parallelBuilder = new Features.Parallel.ParallelBuilder(parallel); + parallelConfiguration(parallelBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(parallel, payload, ct)); + return this; + } + + /// + /// Creates a parallel execution of multiple groups that always executes. + /// All groups execute in parallel, and their results are merged back into the main railway. + /// + /// An action that configures the parallel execution + /// The builder instance for method chaining + public RailwayStepsBuilder Parallel( + Action> parallelConfiguration) + { + return Parallel(null, parallelConfiguration); + } + + /// + /// Creates a parallel execution of multiple detached groups with an optional condition. + /// All detached groups execute in parallel, and their results are NOT merged back. + /// + /// The condition to evaluate. If null, the parallel detached execution always occurs. + /// An action that configures the parallel detached execution + /// The builder instance for method chaining + public RailwayStepsBuilder ParallelDetached( + Func? condition, + Action> parallelDetachedConfiguration) + { + var parallelDetached = new Features.Parallel.ParallelDetached(condition); + var parallelDetachedBuilder = + new Features.Parallel.ParallelDetachedBuilder(parallelDetached); + parallelDetachedConfiguration(parallelDetachedBuilder); + _steps.Add((payload, ct) => ExecuteFeatureStepAsync(parallelDetached, payload, ct)); + return this; + } + + /// + /// Creates a parallel execution of multiple detached groups that always executes. + /// All detached groups execute in parallel, and their results are NOT merged back. + /// + /// An action that configures the parallel detached execution + /// The builder instance for method chaining + public RailwayStepsBuilder ParallelDetached( + Action> parallelDetachedConfiguration) + { + return ParallelDetached(null, parallelDetachedConfiguration); + } + + /// + /// Adds an activity to the "finally" block that will always execute, even if the railway fails. + /// + /// The activity to execute + /// The builder instance for method chaining + public RailwayStepsBuilder Finally( + Func>> activity) + { + _finallyActivities.Add(new(activity)); + return this; + } + + /// + /// Adds a synchronous activity to the "finally" block that will always execute, even if the railway fails. + /// + /// The activity to execute + /// The builder instance for method chaining + public RailwayStepsBuilder Finally( + Func> activity) + { + _finallyActivities.Add(new((payload, _) => Task.FromResult(activity(payload)))); + return this; + } + + /// + /// Builds a railway that processes a request and returns either a success or an error. + /// + public Railway Build() + { + return new(ExecuteRailwayAsync); + } + + private async Task> ExecuteRailwayAsync( + TRequest request, + CancellationToken cancellationToken) + { + var validationResult = await RunValidationsAsync(request, cancellationToken); + if (validationResult.IsLeft) + return Either.FromLeft(validationResult.Left!); + + var guardResult = await RunGuardsAsync(request, cancellationToken); + if (guardResult.IsLeft) + return Either.FromLeft(guardResult.Left!); + + var payload = _contextFactory(request); + if (payload == null) + return Either.FromRight(_resultSelector(default!)); + + try + { + var stepsResult = await RunStepsAsync(payload, cancellationToken); + if (stepsResult.IsLeft) + return Either.FromLeft(stepsResult.Left!); + + payload = stepsResult.Right!; + + var branchesResult = await RunBranchesAsync(payload, cancellationToken); + if (branchesResult.IsLeft) + return Either.FromLeft(branchesResult.Left!); + + payload = branchesResult.Right!; + + var branchLocalsResult = await RunBranchesWithLocalPayloadAsync(payload, cancellationToken); + if (branchLocalsResult.IsLeft) + return Either.FromLeft(branchLocalsResult.Left!); + + payload = branchLocalsResult.Right!; + + var successValue = _resultSelector(payload); + return Either.FromRight(successValue ?? default!); + } + finally + { + _ = await RunFinallyActivitiesAsync(payload, cancellationToken); + } + } + + private async Task> RunValidationsAsync( + TRequest request, + CancellationToken cancellationToken) + { + foreach (var validation in _validations) + { + var validationOption = await validation.Validate(request, cancellationToken); + if (validationOption.IsSome && validationOption.Value != null) + return Either.FromLeft(validationOption.Value); + } + + return Either.FromRight(default!); + } + + private async Task> RunGuardsAsync( + TRequest request, + CancellationToken cancellationToken) + { + foreach (var guard in _guards) + { + var result = await guard.Check(request, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); + } + + return Either.FromRight(Unit.Value); + } + + private async Task> RunStepsAsync( + TPayload payload, + CancellationToken cancellationToken) + { + foreach (var step in _steps) + { + var result = await step(payload, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); + + payload = result.Right!; + } + + return Either.FromRight(payload); + } + + private async Task> ExecuteFeatureStepAsync( + IRailwayFeature feature, + TPayload payload, + CancellationToken cancellationToken) + { + var result = await _featureExecutorFactory.ExecuteFeature(feature, payload, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); + + return feature.ShouldMerge + ? Either.FromRight(result.Right!) + : Either.FromRight(payload); + } + + private async Task> RunBranchesAsync( + TPayload payload, + CancellationToken cancellationToken) + { + foreach (var branch in _branches) + { + if (!branch.Condition(payload)) + continue; + + foreach (var activity in branch.Activities) + { + var result = await activity.Execute(payload, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); + + payload = result.Right!; + } + } + + return Either.FromRight(payload); + } + + private async Task> RunBranchesWithLocalPayloadAsync( + TPayload payload, + CancellationToken cancellationToken) + { + foreach (var branchObject in _branchesWithLocalPayload) + { + var result = await ExecuteBranchWithLocalPayloadDynamic(branchObject, payload, cancellationToken); + if (result.IsLeft && result.Left != null) + return Either.FromLeft(result.Left); + + payload = result.Right!; + } + + return Either.FromRight(payload); + } + + private async Task RunFinallyActivitiesAsync( + TPayload payload, + CancellationToken cancellationToken) + { + foreach (var finallyActivity in _finallyActivities) + _ = await finallyActivity.Execute(payload, cancellationToken); + + return payload; + } + + private async Task> ExecuteBranchWithLocalPayloadDynamic( + object branchObject, + TPayload payload, + CancellationToken cancellationToken) + { + var branchType = branchObject.GetType(); + + if (branchType.IsGenericType && + branchType.GetGenericTypeDefinition() == typeof(BranchWithLocalPayload<,,>)) + { + var methodInfo = typeof(RailwayStepsBuilder) + .GetMethod(nameof(ExecuteBranchWithLocalPayload), BindingFlags.NonPublic | BindingFlags.Instance); + if (methodInfo == null) + throw new InvalidOperationException($"Method {nameof(ExecuteBranchWithLocalPayload)} not found."); + + var localPayloadType = branchType.GetGenericArguments()[1]; + var genericMethod = methodInfo.MakeGenericMethod(localPayloadType); + var task = (Task>)genericMethod.Invoke( + this, + [branchObject, payload, cancellationToken] + )!; + return await task.ConfigureAwait(false); + } + + return Either.FromRight(payload); + } + + private async Task> ExecuteBranchWithLocalPayload( + BranchWithLocalPayload branch, + TPayload payload, + CancellationToken cancellationToken) + { + if (!branch.Condition(payload)) + return Either.FromRight(payload); + + var localPayload = branch.LocalPayloadFactory(payload); + + foreach (var activity in branch.Activities) + { + var result = await activity.Execute(payload, localPayload, cancellationToken); + if (result.IsLeft) + return Either.FromLeft(result.Left); + + (payload, localPayload) = result.Right; + } + + return Either.FromRight(payload); + } +}