diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1f1962..6cddcaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.1.0] - 2025-04-21
+
+### Added
+
+- Added support for branches with isolated local payloads
+ - New `BranchWithLocalPayload` method that allows branches to use their own payload type
+ - Activities within these branches can access and modify both the main payload and the local payload
+ - Local payloads are isolated to their branch and don't affect other parts of the workflow
+
## [2.0.0] - 2025-04-19
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index e1f22a7..56d06cd 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -13,7 +13,7 @@
A .NET library for building robust, functional workflows and processing pipelines.
- 2.0.0
+ 2.1.0
true
diff --git a/README.md b/README.md
index fc96c64..b284bc8 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you t
- **Functional Style**: Uses an Either monad pattern for clear success/failure handling
- **Composable**: Build complex workflows from simple, reusable components
- **Comprehensive**: Support for validations, conditional activities, branches, and finally blocks
+- **Isolated Branches**: Create branches with their own isolated local payload types
- **Async-first**: First-class support for async/await operations
- **Testable**: Workflows built with Zooper.Bee are easy to unit test
- **No Dependencies**: Minimal external dependencies (only uses Zooper.Fox)
@@ -97,6 +98,7 @@ var workflow = new WorkflowBuilder(
.Do(...) // Add activities
.DoIf(...) // Add conditional activities
.Branch(...) // Add branching logic
+ .BranchWithLocalPayload(...) // Add branch with its own isolated payload type
.Finally(...) // Add finally activities
.Build(); // Build the workflow
```
@@ -158,77 +160,47 @@ Create branches for more complex conditional logic:
.EndBranch()
```
-### Finally Blocks
+### Branches with Local Payload
-Activities that execute regardless of workflow success or failure:
+Create isolated branches with their own local payload type that doesn't affect the main workflow payload:
```csharp
-.Finally(LogOrderProcessing)
+.BranchWithLocalPayload(
+ // Condition
+ payload => payload.NeedsCustomization,
+
+ // Local payload factory
+ mainPayload => new CustomizationPayload(
+ AvailableOptions: new[] { "Engraving", "Gift Wrap" },
+ SelectedOptions: new string[0],
+ CustomizationCost: 0m
+ ),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) => {
+ // Activity can access and modify both payloads
+ var selectedOption = "Engraving";
+
+ var updatedLocalPayload = localPayload with {
+ SelectedOptions = new[] { selectedOption },
+ CustomizationCost = 10.00m
+ };
+
+ var updatedMainPayload = mainPayload with {
+ FinalPrice = mainPayload.Price + updatedLocalPayload.CustomizationCost
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, updatedLocalPayload));
+ })
+)
```
-## Advanced Usage
-
-### Asynchronous Operations
-
-Zooper.Bee has first-class support for async operations:
+### Finally Blocks
-```csharp
-private static async Task> ProcessPaymentAsync(
- OrderPayload payload, CancellationToken cancellationToken)
-{
- var result = await paymentService.AuthorizePaymentAsync(
- payload.Request.Amount,
- cancellationToken);
+Activities that execute regardless of workflow success or failure:
- if (result.Success)
- {
- var updatedPayload = payload with { IsProcessed = true };
- return Either.FromRight(updatedPayload);
- }
- else
- {
- return Either.FromLeft(
- new OrderError("PAYMENT_FAILED", result.ErrorMessage));
- }
-}
```
-### Composition
-
-Workflows can be composed by calling one workflow from another:
-
-```csharp
-private static async Task> RunSubWorkflow(
- OrderPayload payload, CancellationToken cancellationToken)
-{
- var subWorkflow = CreateSubWorkflow();
- var result = await subWorkflow.Execute(payload.SubRequest, cancellationToken);
-
- return result.Match(
- error => Either.FromLeft(error),
- success => Either.FromRight(
- payload with { SubResult = success })
- );
-}
```
-
-## Examples
-
-Check out the [Zooper.Bee.Examples](./Zooper.Bee.Examples) project for comprehensive examples including:
-
-- Order processing workflow
-- Different execution patterns
-- Complex branching logic
-- Error handling scenarios
-
-## Contributing
-
-Contributions are welcome! Please feel free to submit a Pull Request.
-
-## License
-
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-
-## Related Projects
-
-- [Zooper.Fox](https://github.com/zooper/fox) - Functional programming primitives used by Zooper.Bee
diff --git a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs b/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs
new file mode 100644
index 0000000..bf09688
--- /dev/null
+++ b/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Threading.Tasks;
+using Zooper.Bee;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Example;
+
+public class BranchWithLocalPayloadExample
+{
+ // Request models
+ public record OrderRequest(
+ int OrderId,
+ string CustomerName,
+ decimal OrderAmount,
+ bool NeedsShipping);
+
+ // Success model
+ public record OrderConfirmation(
+ int OrderId,
+ string CustomerName,
+ decimal TotalAmount,
+ string? ShippingTrackingNumber);
+
+ // Error model
+ public record OrderError(string Code, string Message);
+
+ // Main workflow payload model
+ public record OrderPayload(
+ int OrderId,
+ string CustomerName,
+ decimal OrderAmount,
+ bool NeedsShipping,
+ decimal TotalAmount = 0,
+ string? ShippingTrackingNumber = null);
+
+ // Local payload for shipping branch
+ public record ShippingPayload(
+ string CustomerAddress,
+ decimal ShippingCost,
+ decimal PackagingCost,
+ decimal InsuranceCost,
+ string? TrackingNumber = null);
+
+ public static async Task RunExample()
+ {
+ Console.WriteLine("\n=== Workflow Branch With Local Payload Example ===\n");
+
+ // Create sample requests
+ var standardOrder = new OrderRequest(2001, "Alice Johnson", 75.00m, false);
+ var shippingOrder = new OrderRequest(2002, "Bob Smith", 120.00m, true);
+
+ // Build the order processing workflow
+ var workflow = CreateOrderWorkflow();
+
+ // Process the standard order (no shipping)
+ Console.WriteLine("Processing standard order (no shipping):");
+ await ProcessOrder(workflow, standardOrder);
+
+ Console.WriteLine();
+
+ // Process the order with shipping
+ Console.WriteLine("Processing order with shipping:");
+ await ProcessOrder(workflow, shippingOrder);
+ }
+
+ private static async Task ProcessOrder(
+ Workflow workflow,
+ OrderRequest request)
+ {
+ var result = await workflow.Execute(request);
+
+ if (result.IsRight)
+ {
+ var confirmation = result.Right;
+ Console.WriteLine($"Order {confirmation.OrderId} processed successfully");
+ Console.WriteLine($"Customer: {confirmation.CustomerName}");
+ Console.WriteLine($"Total Amount: ${confirmation.TotalAmount}");
+
+ if (confirmation.ShippingTrackingNumber != null)
+ {
+ Console.WriteLine($"Shipping Tracking Number: {confirmation.ShippingTrackingNumber}");
+ }
+ else
+ {
+ Console.WriteLine("No shipping required");
+ }
+ }
+ else
+ {
+ var error = result.Left;
+ Console.WriteLine($"Order processing failed: [{error.Code}] {error.Message}");
+ }
+ }
+
+ private static Workflow CreateOrderWorkflow()
+ {
+ return new WorkflowBuilder(
+ // Create initial payload from request
+ request => new OrderPayload(
+ request.OrderId,
+ request.CustomerName,
+ request.OrderAmount,
+ request.NeedsShipping),
+
+ // Create result from final payload
+ payload => new OrderConfirmation(
+ payload.OrderId,
+ payload.CustomerName,
+ payload.TotalAmount,
+ payload.ShippingTrackingNumber)
+ )
+ // Validate order amount
+ .Validate(request =>
+ {
+ if (request.OrderAmount <= 0)
+ {
+ return Option.Some(
+ new OrderError("INVALID_AMOUNT", "Order amount must be greater than zero"));
+ }
+
+ return Option.None();
+ })
+ // Process the basic order details
+ .Do(payload =>
+ {
+ Console.WriteLine($"Processing order {payload.OrderId} for {payload.CustomerName}...");
+
+ // Set the initial total amount to the order amount
+ return Either.FromRight(
+ payload with { TotalAmount = payload.OrderAmount });
+ })
+ // Branch with local payload for shipping-specific processing
+ .BranchWithLocalPayload(
+ // Only enter this branch if shipping is needed
+ payload => payload.NeedsShipping,
+
+ // Create the local shipping payload
+ payload => new ShippingPayload(
+ CustomerAddress: "123 Example St, City, Country", // In real world, this would come from a database
+ ShippingCost: 12.50m,
+ PackagingCost: 2.75m,
+ InsuranceCost: 5.00m),
+
+ // Configure the branch with shipping-specific activities
+ branch => branch
+ // First shipping activity - calculate shipping costs
+ .Do((mainPayload, shippingPayload) =>
+ {
+ Console.WriteLine("Calculating shipping costs...");
+
+ // Calculate the total shipping cost
+ decimal totalShippingCost =
+ shippingPayload.ShippingCost +
+ shippingPayload.PackagingCost +
+ shippingPayload.InsuranceCost;
+
+ // Update both payloads
+ var updatedMainPayload = mainPayload with
+ {
+ TotalAmount = mainPayload.OrderAmount + totalShippingCost
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, shippingPayload));
+ })
+ // Second shipping activity - generate tracking number
+ .Do((mainPayload, shippingPayload) =>
+ {
+ Console.WriteLine("Generating shipping tracking number...");
+
+ // Generate a fake tracking number
+ string trackingNumber = $"TRACK-{Guid.NewGuid().ToString()[..8]}";
+
+ // Update both payloads with the tracking number
+ var updatedShippingPayload = shippingPayload with { TrackingNumber = trackingNumber };
+ var updatedMainPayload = mainPayload with { ShippingTrackingNumber = trackingNumber };
+
+ return Either.FromRight(
+ (updatedMainPayload, updatedShippingPayload));
+ })
+ )
+ // Finalize the order
+ .Do(payload =>
+ {
+ Console.WriteLine($"Finalizing order {payload.OrderId}...");
+
+ // In a real system, we would persist the order to a database here
+
+ return Either.FromRight(payload);
+ })
+ .Build();
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Example/Program.cs b/Zooper.Bee.Example/Program.cs
index e96f09e..6703e2b 100644
--- a/Zooper.Bee.Example/Program.cs
+++ b/Zooper.Bee.Example/Program.cs
@@ -42,6 +42,9 @@ public static async Task Main()
// Run the branching example
await BranchingExample.RunExample();
+
+ // Run the branch with local payload example
+ await BranchWithLocalPayloadExample.RunExample();
}
private static async Task ProcessOrder(OrderRequest request)
diff --git a/Zooper.Bee.Tests/BranchTests.cs b/Zooper.Bee.Tests/BranchTests.cs
new file mode 100644
index 0000000..9cf3f7c
--- /dev/null
+++ b/Zooper.Bee.Tests/BranchTests.cs
@@ -0,0 +1,254 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class BranchTests
+{
+ #region Test Models
+ // Request model
+ private record TestRequest(string Name, int Value, string Category);
+
+ // Payload model
+ private record TestPayload(
+ string Name,
+ int Value,
+ string Category,
+ bool IsStandardProcessed = false,
+ bool IsPremiumProcessed = false,
+ string? ProcessingResult = null);
+
+ // Success result model
+ private record TestSuccess(string Name, string ProcessingResult);
+
+ // Error model
+ private record TestError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task Branch_ExecutesWhenConditionIsTrue()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Do(payload => Either.FromRight(payload))
+ .Branch(
+ // Condition: Category is Premium
+ payload => payload.Category == "Premium",
+ branch => branch
+ .Do(payload =>
+ {
+ var processed = payload with
+ {
+ IsPremiumProcessed = true,
+ ProcessingResult = "Premium Processing"
+ };
+ return Either.FromRight(processed);
+ })
+ )
+ .Branch(
+ // Condition: Category is Standard
+ payload => payload.Category == "Standard",
+ branch => branch
+ .Do(payload =>
+ {
+ var processed = payload with
+ {
+ IsStandardProcessed = true,
+ ProcessingResult = "Standard Processing"
+ };
+ return Either.FromRight(processed);
+ })
+ )
+ .Build();
+
+ var premiumRequest = new TestRequest("Premium Test", 100, "Premium");
+ var standardRequest = new TestRequest("Standard Test", 50, "Standard");
+
+ // Act
+ var premiumResult = await workflow.Execute(premiumRequest);
+ var standardResult = await workflow.Execute(standardRequest);
+
+ // Assert
+ premiumResult.IsRight.Should().BeTrue();
+ premiumResult.Right.ProcessingResult.Should().Be("Premium Processing");
+
+ standardResult.IsRight.Should().BeTrue();
+ standardResult.Right.ProcessingResult.Should().Be("Standard Processing");
+ }
+
+ [Fact]
+ public async Task Branch_SkipsWhenConditionIsFalse()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Initial Processing" }))
+ .Branch(
+ // Condition: Category is Premium and Value is over 1000
+ payload => payload.Category == "Premium" && payload.Value > 1000,
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with
+ {
+ ProcessingResult = "VIP Processing"
+ }))
+ )
+ .Build();
+
+ var premiumRequest = new TestRequest("Premium Test", 500, "Premium"); // Doesn't meet Value > 1000 condition
+
+ // Act
+ var result = await workflow.Execute(premiumRequest);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.ProcessingResult.Should().Be("Initial Processing");
+ }
+
+ [Fact]
+ public async Task Branch_UnconditionalBranch_AlwaysExecutes()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Branch(
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Always Processed" }))
+ )
+ .Build();
+
+ var anyRequest = new TestRequest("Test", 50, "Any");
+
+ // Act
+ var result = await workflow.Execute(anyRequest);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.ProcessingResult.Should().Be("Always Processed");
+ }
+
+ [Fact]
+ public async Task Branch_MultipleBranches_CorrectlyExecutes()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Initial" }))
+ .Branch(
+ // First branch - based on Category
+ payload => payload.Category == "Premium",
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " + Premium" }))
+ )
+ .Branch(
+ // Second branch - based on Value
+ payload => payload.Value > 75,
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " + High Value" }))
+ )
+ .Branch(
+ // Third branch - always executes
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " + Standard" }))
+ )
+ .Build();
+
+ var premiumHighValueRequest = new TestRequest("Premium High Value", 100, "Premium");
+
+ // Act
+ var result = await workflow.Execute(premiumHighValueRequest);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ // All three branches should have executed in order
+ result.Right.ProcessingResult.Should().Be("Initial + Premium + High Value + Standard");
+ }
+
+ [Fact]
+ public async Task Branch_WithError_StopsExecutionAndReturnsError()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Branch(
+ payload => payload.Category == "Premium",
+ branch => branch
+ .Do(payload =>
+ {
+ if (payload.Value <= 0)
+ {
+ return Either.FromLeft(
+ new TestError("INVALID_PREMIUM_VALUE", "Premium value must be positive"));
+ }
+ return Either.FromRight(
+ payload with { ProcessingResult = "Premium Processing" });
+ })
+ )
+ .Branch(
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Final Processing" }))
+ )
+ .Build();
+
+ var invalidPremiumRequest = new TestRequest("Invalid Premium", 0, "Premium");
+
+ // Act
+ var result = await workflow.Execute(invalidPremiumRequest);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("INVALID_PREMIUM_VALUE");
+ // The second branch should not have executed
+ }
+
+ [Fact]
+ public async Task Branch_WithMultipleActivities_ExecutesAllInOrder()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value, request.Category),
+ payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
+ )
+ .Branch(
+ payload => true,
+ branch => branch
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Step 1" }))
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " -> Step 2" }))
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " -> Step 3" }))
+ )
+ .Build();
+
+ var request = new TestRequest("Test", 50, "Standard");
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.ProcessingResult.Should().Be("Step 1 -> Step 2 -> Step 3");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
new file mode 100644
index 0000000..0cbfa7f
--- /dev/null
+++ b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
@@ -0,0 +1,411 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class BranchWithLocalPayloadTests
+{
+ #region Test Models
+ // Request model
+ private record ProductRequest(int Id, string Name, decimal Price, bool NeedsCustomProcessing);
+
+ // Main workflow payload model
+ private record ProductPayload(
+ int Id,
+ string Name,
+ decimal Price,
+ bool NeedsCustomProcessing,
+ string? ProcessingResult = null,
+ string? CustomizationDetails = null,
+ decimal FinalPrice = 0);
+
+ // Local payload for customization branch
+ private record CustomizationPayload(
+ string[] AvailableOptions,
+ string[] SelectedOptions,
+ decimal CustomizationCost,
+ string CustomizationDetails);
+
+ // Success result model
+ private record ProductResult(
+ int Id,
+ string Name,
+ decimal FinalPrice,
+ string? ProcessingResult,
+ string? CustomizationDetails);
+
+ // Error model
+ private record ProductError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ // Create the main payload from the request
+ request => new ProductPayload(
+ request.Id,
+ request.Name,
+ request.Price,
+ request.NeedsCustomProcessing),
+
+ // Create the result from the final payload
+ payload => new ProductResult(
+ payload.Id,
+ payload.Name,
+ payload.FinalPrice,
+ payload.ProcessingResult,
+ payload.CustomizationDetails)
+ )
+ .Do(payload =>
+ {
+ // Initial processing
+ return Either.FromRight(payload with
+ {
+ ProcessingResult = "Standard processing complete",
+ FinalPrice = payload.Price
+ });
+ })
+ // Branch with local payload for products that need customization
+ .BranchWithLocalPayload(
+ // Condition: Product needs custom processing
+ payload => payload.NeedsCustomProcessing,
+
+ // Create the local customization payload
+ payload => new CustomizationPayload(
+ AvailableOptions: new[] { "Engraving", "Gift Wrap", "Extended Warranty" },
+ SelectedOptions: new[] { "Engraving", "Gift Wrap" },
+ CustomizationCost: 25.99m,
+ CustomizationDetails: "Custom initialized"
+ ),
+
+ // Branch configuration
+ branch => branch
+ // First customization activity - process options
+ .Do((mainPayload, localPayload) =>
+ {
+ // Process the selected options
+ string optionsProcessed = string.Join(", ", localPayload.SelectedOptions);
+
+ // Update both payloads
+ var updatedLocalPayload = localPayload with
+ {
+ CustomizationDetails = $"Options: {optionsProcessed}"
+ };
+
+ return Either.FromRight(
+ (mainPayload, updatedLocalPayload));
+ })
+ // Second customization activity - apply costs and finalize customization
+ .Do((mainPayload, localPayload) =>
+ {
+ // Calculate total price
+ decimal totalPrice = mainPayload.Price + localPayload.CustomizationCost;
+
+ // Update both payloads
+ var updatedMainPayload = mainPayload with
+ {
+ FinalPrice = totalPrice,
+ CustomizationDetails = localPayload.CustomizationDetails,
+ ProcessingResult = $"{mainPayload.ProcessingResult} with customization"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var customizableProduct = new ProductRequest(1001, "Custom Widget", 99.99m, true);
+ var standardProduct = new ProductRequest(1002, "Standard Widget", 49.99m, false);
+
+ // Act
+ var customResult = await workflow.Execute(customizableProduct);
+ var standardResult = await workflow.Execute(standardProduct);
+
+ // Assert
+
+ // Custom product should go through customization
+ customResult.IsRight.Should().BeTrue();
+ customResult.Right.FinalPrice.Should().Be(125.98m); // 99.99 + 25.99
+ customResult.Right.ProcessingResult.Should().Be("Standard processing complete with customization");
+ customResult.Right.CustomizationDetails.Should().Be("Options: Engraving, Gift Wrap");
+
+ // Standard product should not go through customization
+ standardResult.IsRight.Should().BeTrue();
+ standardResult.Right.FinalPrice.Should().Be(49.99m); // Just base price
+ standardResult.Right.ProcessingResult.Should().Be("Standard processing complete");
+ standardResult.Right.CustomizationDetails.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task BranchWithLocalPayload_LocalPayloadIsolated_NotAffectedByOtherActivities()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing),
+ payload => new ProductResult(
+ payload.Id, payload.Name, payload.FinalPrice,
+ payload.ProcessingResult, payload.CustomizationDetails)
+ )
+ .Do(payload => Either.FromRight(payload with
+ {
+ ProcessingResult = "Initial processing",
+ FinalPrice = payload.Price
+ }))
+ .BranchWithLocalPayload(
+ // Condition
+ payload => true,
+
+ // Create local payload
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Option1", "Option2" },
+ SelectedOptions: new[] { "Option1" },
+ CustomizationCost: 10.00m,
+ CustomizationDetails: "Branch 1 customization"
+ ),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ ProcessingResult = mainPayload.ProcessingResult + " -> Branch 1",
+ FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost,
+ CustomizationDetails = localPayload.CustomizationDetails
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ // Another main activity that changes the main payload but shouldn't affect the next branch's local payload
+ .Do(payload => Either.FromRight(payload with
+ {
+ ProcessingResult = payload.ProcessingResult + " -> Main activity"
+ }))
+ .BranchWithLocalPayload(
+ // Second branch
+ payload => true,
+
+ // Create a different local payload
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "OptionA", "OptionB" },
+ SelectedOptions: new[] { "OptionA", "OptionB" },
+ CustomizationCost: 20.00m,
+ CustomizationDetails: "Branch 2 customization"
+ ),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ ProcessingResult = mainPayload.ProcessingResult + " -> Branch 2",
+ FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost,
+ CustomizationDetails = mainPayload.CustomizationDetails + " + " + localPayload.CustomizationDetails
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var request = new ProductRequest(1001, "Test Product", 100.00m, true);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Branch 1 -> Branch 2");
+ result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20
+ result.Right.CustomizationDetails.Should().Be("Branch 1 customization + Branch 2 customization");
+ }
+
+ [Fact]
+ public async Task BranchWithLocalPayload_ErrorInBranch_StopsExecutionAndReturnsError()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing),
+ payload => new ProductResult(
+ payload.Id, payload.Name, payload.FinalPrice,
+ payload.ProcessingResult, payload.CustomizationDetails)
+ )
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = "Initial processing" }))
+ .BranchWithLocalPayload(
+ payload => payload.NeedsCustomProcessing,
+ _ => new CustomizationPayload(
+ AvailableOptions: new string[0],
+ SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error
+ CustomizationCost: 10.00m,
+ CustomizationDetails: "Should fail"
+ ),
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ // Validate selected options are available
+ foreach (var option in localPayload.SelectedOptions)
+ {
+ if (Array.IndexOf(localPayload.AvailableOptions, option) < 0)
+ {
+ return Either.FromLeft(
+ new ProductError("INVALID_OPTION", $"Option '{option}' is not available"));
+ }
+ }
+
+ return Either.FromRight(
+ (mainPayload, localPayload));
+ })
+ )
+ .Do(payload => Either.FromRight(
+ payload with { ProcessingResult = payload.ProcessingResult + " -> Final processing" }))
+ .Build();
+
+ var request = new ProductRequest(1001, "Test Product", 100.00m, true);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("INVALID_OPTION");
+ result.Left.Message.Should().Be("Option 'Unavailable Option' is not available");
+ }
+
+ [Fact]
+ public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLocalPayload()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing),
+ payload => new ProductResult(
+ payload.Id, payload.Name, payload.FinalPrice,
+ payload.ProcessingResult, payload.CustomizationDetails)
+ )
+ .BranchWithLocalPayload(
+ _ => true,
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Option1", "Option2", "Option3" },
+ SelectedOptions: new string[0], // Start with no selected options
+ CustomizationCost: 0m, // Start with no cost
+ CustomizationDetails: "Start"
+ ),
+ branch => branch
+ // First activity - select Option1
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedOptions = new string[localPayload.SelectedOptions.Length + 1];
+ Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length);
+ updatedOptions[updatedOptions.Length - 1] = "Option1";
+
+ var updatedLocalPayload = localPayload with
+ {
+ SelectedOptions = updatedOptions,
+ CustomizationCost = localPayload.CustomizationCost + 10m,
+ CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option1"
+ };
+
+ return Either.FromRight(
+ (mainPayload, updatedLocalPayload));
+ })
+ // Second activity - select Option2
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedOptions = new string[localPayload.SelectedOptions.Length + 1];
+ Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length);
+ updatedOptions[updatedOptions.Length - 1] = "Option2";
+
+ var updatedLocalPayload = localPayload with
+ {
+ SelectedOptions = updatedOptions,
+ CustomizationCost = localPayload.CustomizationCost + 15m,
+ CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option2"
+ };
+
+ return Either.FromRight(
+ (mainPayload, updatedLocalPayload));
+ })
+ // Third activity - finalize and update main payload
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
+ CustomizationDetails = localPayload.CustomizationDetails,
+ ProcessingResult = $"Processed with {localPayload.SelectedOptions.Length} options"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var request = new ProductRequest(1001, "Test Product", 100.00m, true);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.FinalPrice.Should().Be(125.00m); // 100 + 10 + 15
+ result.Right.ProcessingResult.Should().Be("Processed with 2 options");
+ result.Right.CustomizationDetails.Should().Be("Start -> Added Option1 -> Added Option2");
+ }
+
+ [Fact]
+ public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new ProductPayload(request.Id, request.Name, request.Price, request.NeedsCustomProcessing),
+ payload => new ProductResult(
+ payload.Id, payload.Name, payload.FinalPrice,
+ payload.ProcessingResult, payload.CustomizationDetails)
+ )
+ .BranchWithLocalPayload(
+ // Local payload factory only
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Default Option" },
+ SelectedOptions: new[] { "Default Option" },
+ CustomizationCost: 5.00m,
+ CustomizationDetails: "Default customization"
+ ),
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
+ CustomizationDetails = localPayload.CustomizationDetails,
+ ProcessingResult = "Processed with default customization"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var request = new ProductRequest(1001, "Test Product", 100.00m, false);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5
+ result.Right.ProcessingResult.Should().Be("Processed with default customization");
+ result.Right.CustomizationDetails.Should().Be("Default customization");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/WorkflowInternalsTests.cs b/Zooper.Bee.Tests/WorkflowInternalsTests.cs
new file mode 100644
index 0000000..3f74035
--- /dev/null
+++ b/Zooper.Bee.Tests/WorkflowInternalsTests.cs
@@ -0,0 +1,261 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+///
+/// Tests for the internal execution logic of workflows using end-to-end tests.
+///
+public class WorkflowInternalsTests
+{
+ #region Test Models
+ // Models for the tests
+ private record TestRequest(string Name, int Value);
+ private record TestPayload(string Name, int Value, string? Result = null);
+ private record TestLocalPayload(string LocalData, int ProcessingValue = 0);
+ private record TestSuccess(string Result);
+ private record TestError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task DynamicBranchExecution_ConditionTrue_ExecutesActivities()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ // Create the payload from the request
+ request => new TestPayload(request.Name, request.Value),
+
+ // Create the success result from the payload
+ payload => new TestSuccess(payload.Result ?? "No result")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Result = "Initial processing" }))
+ .BranchWithLocalPayload(
+ // Condition - always true
+ payload => true,
+
+ // Create local payload
+ payload => new TestLocalPayload($"Local data for {payload.Name}"),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ Result = $"Processed {mainPayload.Name} with {localPayload.LocalData}"
+ };
+ var updatedLocalPayload = localPayload with { ProcessingValue = 42 };
+
+ return Either.FromRight(
+ (updatedMainPayload, updatedLocalPayload));
+ })
+ )
+ .Build();
+
+ var request = new TestRequest("TestName", 123);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Result.Should().Be("Processed TestName with Local data for TestName");
+ }
+
+ [Fact]
+ public async Task DynamicBranchExecution_ConditionFalse_SkipsActivities()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "No result")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Result = "Initial processing" }))
+ .BranchWithLocalPayload(
+ // Condition - always false
+ payload => false,
+
+ // Create local payload
+ payload => new TestLocalPayload($"Local data for {payload.Name}"),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ Result = "This should not be set"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var request = new TestRequest("TestName", 123);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Result.Should().Be("Initial processing"); // Should remain unchanged
+ }
+
+ [Fact]
+ public async Task DynamicBranchExecution_ActivityReturnsError_PropagatesError()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "No result")
+ )
+ .BranchWithLocalPayload(
+ // Condition - always true
+ payload => true,
+
+ // Create local payload
+ payload => new TestLocalPayload($"Local data for {payload.Name}"),
+
+ // Branch configuration
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ return Either.FromLeft(
+ new TestError("TEST_ERROR", "This is a test error"));
+ })
+ )
+ .Build();
+
+ var request = new TestRequest("TestName", 123);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("TEST_ERROR");
+ result.Left.Message.Should().Be("This is a test error");
+ }
+
+ [Fact]
+ public async Task DynamicBranchExecution_MultipleActivities_ExecutesInOrder()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "No result")
+ )
+ .BranchWithLocalPayload(
+ // Condition - always true
+ payload => true,
+
+ // Create local payload
+ payload => new TestLocalPayload("Initial local data", 0),
+
+ // Branch configuration
+ branch => branch
+ // First activity
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with { Value = mainPayload.Value + 1 };
+ var updatedLocalPayload = localPayload with
+ {
+ LocalData = localPayload.LocalData + " -> Step 1",
+ ProcessingValue = 10
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, updatedLocalPayload));
+ })
+ // Second activity
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ Value = mainPayload.Value + localPayload.ProcessingValue,
+ Result = $"Result: {localPayload.LocalData}"
+ };
+
+ var updatedLocalPayload = localPayload with
+ {
+ LocalData = localPayload.LocalData + " -> Step 2",
+ ProcessingValue = 20
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, updatedLocalPayload));
+ })
+ )
+ .Build();
+
+ var request = new TestRequest("TestName", 5);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Result.Should().Be("Result: Initial local data -> Step 1");
+ }
+
+ [Fact]
+ public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "No result")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Result = "Start" }))
+ // First branch with first local payload type
+ .BranchWithLocalPayload(
+ payload => true,
+ payload => new TestLocalPayload("Branch 1 data"),
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ Result = mainPayload.Result + " -> Branch 1"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ // Second branch with the same local payload type
+ .BranchWithLocalPayload(
+ payload => payload.Value > 0,
+ payload => new TestLocalPayload("Branch 2 data"),
+ branch => branch
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ Result = mainPayload.Result + " -> Branch 2"
+ };
+
+ return Either.FromRight(
+ (updatedMainPayload, localPayload));
+ })
+ )
+ .Build();
+
+ var request = new TestRequest("TestName", 5);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Result.Should().Be("Start -> Branch 1 -> Branch 2");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/WorkflowTests.cs b/Zooper.Bee.Tests/WorkflowTests.cs
new file mode 100644
index 0000000..40177d2
--- /dev/null
+++ b/Zooper.Bee.Tests/WorkflowTests.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class WorkflowTests
+{
+ #region Test Models
+ // Request model
+ private record TestRequest(string Name, int Value);
+
+ // Payload model
+ private record TestPayload(
+ string Name,
+ int Value,
+ bool IsValidated = false,
+ bool IsProcessed = false,
+ string? Result = null);
+
+ // Success result model
+ private record TestSuccess(string Result);
+
+ // Error model
+ private record TestError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task Execute_ValidRequest_ReturnsSuccessResult()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ // Create the payload from the request
+ request => new TestPayload(request.Name, request.Value),
+
+ // Create the success result from the payload
+ payload => new TestSuccess(payload.Result ?? "Default")
+ )
+ .Do(payload =>
+ {
+ // Validate the payload
+ var validated = payload with { IsValidated = true };
+ return Either.FromRight(validated);
+ })
+ .Do(payload =>
+ {
+ // Process the payload
+ var processed = payload with
+ {
+ IsProcessed = true,
+ Result = $"Processed: {payload.Name}-{payload.Value}"
+ };
+ return Either.FromRight(processed);
+ })
+ .Build();
+
+ var request = new TestRequest("Test", 42);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Result.Should().Be("Processed: Test-42");
+ }
+
+ [Fact]
+ public async Task Execute_WithValidation_RejectsInvalidRequest()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "Default")
+ )
+ .Validate(request =>
+ {
+ if (request.Value <= 0)
+ {
+ return Option.Some(new TestError("INVALID_VALUE", "Value must be positive"));
+ }
+ return Option.None();
+ })
+ .Do(payload => Either.FromRight(payload with { IsProcessed = true }))
+ .Build();
+
+ var invalidRequest = new TestRequest("Test", -5);
+
+ // Act
+ var result = await workflow.Execute(invalidRequest);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("INVALID_VALUE");
+ result.Left.Message.Should().Be("Value must be positive");
+ }
+
+ [Fact]
+ public async Task Execute_WithConditionalActivity_OnlyExecutesWhenConditionMet()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "Default")
+ )
+ .Do(payload => Either.FromRight(
+ payload with { IsValidated = true }))
+ .DoIf(
+ // Condition: Value is greater than 50
+ payload => payload.Value > 50,
+ // Activity to execute when condition is true
+ payload => Either.FromRight(
+ payload with { Result = "High Value Processing" })
+ )
+ .DoIf(
+ // Condition: Value is less than or equal to 50
+ payload => payload.Value <= 50,
+ // Activity to execute when condition is true
+ payload => Either.FromRight(
+ payload with { Result = "Standard Processing" })
+ )
+ .Build();
+
+ var lowValueRequest = new TestRequest("Test", 42);
+ var highValueRequest = new TestRequest("Test", 100);
+
+ // Act
+ var lowValueResult = await workflow.Execute(lowValueRequest);
+ var highValueResult = await workflow.Execute(highValueRequest);
+
+ // Assert
+ lowValueResult.Right.Result.Should().Be("Standard Processing");
+ highValueResult.Right.Result.Should().Be("High Value Processing");
+ }
+
+ [Fact]
+ public async Task Execute_WithErrorInActivity_ReturnsError()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "Default")
+ )
+ .Do(payload =>
+ {
+ if (payload.Value == 0)
+ {
+ return Either.FromLeft(
+ new TestError("ZERO_VALUE", "Value cannot be zero"));
+ }
+ return Either.FromRight(
+ payload with { IsValidated = true });
+ })
+ .Do(payload => Either.FromRight(
+ payload with { IsProcessed = true }))
+ .Build();
+
+ var zeroValueRequest = new TestRequest("Test", 0);
+
+ // Act
+ var result = await workflow.Execute(zeroValueRequest);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("ZERO_VALUE");
+ }
+
+ [Fact]
+ public async Task Execute_WithFinallyActivities_ExecutesThemEvenOnError()
+ {
+ // Arrange
+ bool finallyExecuted = false;
+
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Name, request.Value),
+ payload => new TestSuccess(payload.Result ?? "Default")
+ )
+ .Do(payload =>
+ {
+ if (payload.Value < 0)
+ {
+ return Either.FromLeft(
+ new TestError("NEGATIVE_VALUE", "Value cannot be negative"));
+ }
+ return Either.FromRight(
+ payload with { IsValidated = true });
+ })
+ .Finally(payload =>
+ {
+ finallyExecuted = true;
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ var invalidRequest = new TestRequest("Test", -10);
+
+ // Act
+ var result = await workflow.Execute(invalidRequest);
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ finallyExecuted.Should().BeTrue();
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj
new file mode 100644
index 0000000..7e85112
--- /dev/null
+++ b/Zooper.Bee.Tests/Zooper.Bee.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ latest
+ enable
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/Zooper.Bee.sln b/Zooper.Bee.sln
index 6816e4e..9286a67 100644
--- a/Zooper.Bee.sln
+++ b/Zooper.Bee.sln
@@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee", "Zooper.Bee\Zo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee.Example", "Zooper.Bee.Example\Zooper.Bee.Example.csproj", "{F654D462-40EE-40D1-9871-CF9DFAC82A90}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Zooper.Bee.Tests", "Zooper.Bee.Tests\Zooper.Bee.Tests.csproj", "{3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -18,5 +20,9 @@ Global
{F654D462-40EE-40D1-9871-CF9DFAC82A90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F654D462-40EE-40D1-9871-CF9DFAC82A90}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F654D462-40EE-40D1-9871-CF9DFAC82A90}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3AC893B3-275D-44C6-ACCD-1A0B2A3D0A7C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/Zooper.Bee/BranchWithLocalPayloadBuilder.cs b/Zooper.Bee/BranchWithLocalPayloadBuilder.cs
new file mode 100644
index 0000000..cb5559a
--- /dev/null
+++ b/Zooper.Bee/BranchWithLocalPayloadBuilder.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Bee.Internal;
+using Zooper.Fox;
+
+namespace Zooper.Bee;
+
+///
+/// Builder for a branch with a local payload that enables a fluent API for adding activities.
+///
+/// The type of the request input
+/// The type of the main workflow payload
+/// The type of the local branch payload
+/// The type of the success result
+/// The type of the error result
+public sealed class BranchWithLocalPayloadBuilder
+{
+ private readonly WorkflowBuilder _workflow;
+ private readonly BranchWithLocalPayload _branch;
+
+ internal BranchWithLocalPayloadBuilder(
+ WorkflowBuilder workflow,
+ BranchWithLocalPayload branch)
+ {
+ _workflow = workflow;
+ _branch = branch;
+ }
+
+ ///
+ /// Adds an activity to the branch that operates on both the main and local payloads.
+ ///
+ /// The activity to add
+ /// The branch builder for fluent chaining
+ public BranchWithLocalPayloadBuilder Do(
+ Func>> activity)
+ {
+ _branch.Activities.Add(new BranchActivity(activity));
+ return this;
+ }
+
+ ///
+ /// Adds a synchronous activity to the branch that operates on both the main and local payloads.
+ ///
+ /// The activity to add
+ /// The branch builder for fluent chaining
+ public BranchWithLocalPayloadBuilder Do(
+ Func> activity)
+ {
+ _branch.Activities.Add(new BranchActivity(
+ (mainPayload, localPayload, _) => Task.FromResult(activity(mainPayload, localPayload))
+ ));
+ return this;
+ }
+
+ ///
+ /// Adds multiple activities to the branch.
+ ///
+ /// The activities to add
+ /// The branch builder for fluent chaining
+ public BranchWithLocalPayloadBuilder DoAll(
+ params Func>>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _branch.Activities.Add(new BranchActivity(activity));
+ }
+ return this;
+ }
+
+ ///
+ /// Adds multiple synchronous activities to the branch.
+ ///
+ /// The activities to add
+ /// The branch builder for fluent chaining
+ public BranchWithLocalPayloadBuilder DoAll(
+ params Func>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _branch.Activities.Add(new BranchActivity(
+ (mainPayload, localPayload, _) => Task.FromResult(activity(mainPayload, localPayload))
+ ));
+ }
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/IWorkflowStep.cs b/Zooper.Bee/IWorkflowStep.cs
deleted file mode 100644
index 49d4351..0000000
--- a/Zooper.Bee/IWorkflowStep.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-namespace Zooper.Bee;
-
-// ReSharper disable once UnusedType.Global
-public interface IWorkflowStep;
\ No newline at end of file
diff --git a/Zooper.Bee/Internal/BranchActivity.cs b/Zooper.Bee/Internal/BranchActivity.cs
new file mode 100644
index 0000000..0e6f7d9
--- /dev/null
+++ b/Zooper.Bee/Internal/BranchActivity.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Internal;
+
+///
+/// Represents an activity in a branch that operates on both the main workflow payload and a local branch payload.
+///
+/// Type of the main workflow payload
+/// Type of the local branch payload
+/// Type of the error
+internal sealed class BranchActivity
+{
+ private readonly Func>> _activity;
+ private readonly string? _name;
+
+ ///
+ /// Creates a new branch activity.
+ ///
+ /// The activity function that operates on both payloads
+ /// Optional name for the activity
+ public BranchActivity(
+ Func>> activity,
+ string? name = null)
+ {
+ _activity = activity;
+ _name = name;
+ }
+
+ ///
+ /// Executes the activity with the provided payloads.
+ ///
+ /// The main workflow payload
+ /// The local branch payload
+ /// Cancellation token
+ /// Either an error or the updated payloads
+ public Task> Execute(
+ TPayload mainPayload,
+ TLocalPayload localPayload,
+ CancellationToken token)
+ {
+ return _activity(mainPayload, localPayload, token);
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Internal/BranchWithLocalPayload.cs b/Zooper.Bee/Internal/BranchWithLocalPayload.cs
new file mode 100644
index 0000000..fb1e24d
--- /dev/null
+++ b/Zooper.Bee/Internal/BranchWithLocalPayload.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+namespace Zooper.Bee.Internal;
+
+///
+/// Represents a branch in the workflow with its own condition, activities, and local payload.
+///
+/// Type of the main workflow payload
+/// Type of the local branch payload
+/// Type of the error
+internal sealed class BranchWithLocalPayload
+{
+ ///
+ /// The condition that determines if this branch should execute.
+ ///
+ public Func Condition { get; }
+
+ ///
+ /// The factory function that creates the local payload from the main payload.
+ ///
+ public Func LocalPayloadFactory { get; }
+
+ ///
+ /// The list of activities in this branch that operate on both the main and local payloads.
+ ///
+ public List> Activities { get; } = [];
+
+ ///
+ /// Creates a new branch with a local payload.
+ ///
+ /// The condition that determines if this branch should execute
+ /// The factory function that creates the local payload
+ public BranchWithLocalPayload(Func condition, Func localPayloadFactory)
+ {
+ Condition = condition;
+ LocalPayloadFactory = localPayloadFactory;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Internal/EitherExtensions.cs b/Zooper.Bee/Internal/EitherExtensions.cs
index 2edd18d..0db6a5f 100644
--- a/Zooper.Bee/Internal/EitherExtensions.cs
+++ b/Zooper.Bee/Internal/EitherExtensions.cs
@@ -1,3 +1,4 @@
+using System;
using Zooper.Fox;
namespace Zooper.Bee.Internal;
@@ -5,13 +6,14 @@ namespace Zooper.Bee.Internal;
///
/// Provides extension methods for the class.
///
-static internal class EitherExtensions
+internal static class EitherExtensions
{
///
/// Creates a new Either instance representing a successful result (Right value).
///
/// The type of the error (Left value).
/// The type of the success result (Right value).
+ /// The Either instance this method is called on (ignored).
/// The success value.
/// A new Either instance with the success value in the Right position.
public static Either Success(this Either _, TSuccess success)
@@ -22,6 +24,7 @@ public static Either Success(this Either
/// The type of the error (Left value).
/// The type of the success result (Right value).
+ /// The Either instance this method is called on (ignored).
/// The error value.
/// A new Either instance with the error value in the Left position.
public static Either Fail(this Either _, TError error)
@@ -49,7 +52,14 @@ public static bool IsSuccess(this Either either)
/// Thrown if this Either represents a failure result.
///
public static TRight Value(this Either either)
- => either.Right;
+ {
+ if (!either.IsRight)
+ {
+ throw new InvalidOperationException("Cannot access the Right value of an Either that contains a Left value.");
+ }
+
+ return either.Right;
+ }
///
/// Gets the error value if this Either represents a failure result, or
@@ -63,5 +73,12 @@ public static TRight Value(this Either either)
/// Thrown if this Either represents a success result.
///
public static TLeft Error(this Either either)
- => either.Left;
+ {
+ if (!either.IsLeft)
+ {
+ throw new InvalidOperationException("Cannot access the Left value of an Either that contains a Right value.");
+ }
+
+ return either.Left;
+ }
}
\ No newline at end of file
diff --git a/Zooper.Bee/Properties/AssemblyInfo.cs b/Zooper.Bee/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..cd6dde2
--- /dev/null
+++ b/Zooper.Bee/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+// Make internal classes visible to the test project
+[assembly: InternalsVisibleTo("Zooper.Bee.Tests")]
\ No newline at end of file
diff --git a/Zooper.Bee/WorkflowBuilder.cs b/Zooper.Bee/WorkflowBuilder.cs
index ded3732..da566c0 100644
--- a/Zooper.Bee/WorkflowBuilder.cs
+++ b/Zooper.Bee/WorkflowBuilder.cs
@@ -29,6 +29,7 @@ public sealed class WorkflowBuilder
private readonly List> _conditionalActivities = [];
private readonly List> _finallyActivities = [];
private readonly List> _branches = [];
+ private readonly List