diff --git a/CHANGELOG.md b/CHANGELOG.md
index 331c676..bc9d82b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,60 @@ 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).
+## [3.0.0] - 2025-05-01
+
+### Added
+
+- Complete redesign of the workflow feature API
+ - New `Group` method that groups activities with an optional condition
+ - New `WithContext` method for isolated contexts with their own local state
+ - New `Detach` method for executing activities without merging their results back
+ - New `Parallel` method for parallel execution of multiple groups
+ - New `ParallelDetached` method for parallel execution of detached activities
+- Better support for nullable conditions - all new methods accept nullable condition
+- Clear separation of merged and non-merged execution paths
+- Improved naming consistency across the API
+
+### Changed
+
+- **BREAKING CHANGE**: Reorganized internal class structure
+ - Added feature-specific namespaces and folders
+ - Created a consistent `IWorkflowFeature` interface for all features
+- **BREAKING CHANGE**: Renamed `Branch` to `Group` for better clarity
+- **BREAKING CHANGE**: Renamed `BranchWithLocalPayload` to `WithContext` to better express intention
+
+### Deprecated
+
+- The old `Branch` method is now marked as obsolete and will be removed in a future version
+- The old `BranchWithLocalPayload` method is now marked as obsolete and will be removed in a future version
+
+### Compatibility
+
+- All existing code using the deprecated methods will continue to work, but will show deprecation warnings
+- To migrate, replace:
+
+ ```csharp
+ .Branch(condition, branch => branch.Do(...))
+ ```
+
+ With:
+
+ ```csharp
+ .Group(condition, group => group.Do(...))
+ ```
+
+ And replace:
+
+ ```csharp
+ .BranchWithLocalPayload(condition, factory, branch => branch.Do(...))
+ ```
+
+ With:
+
+ ```csharp
+ .WithContext(condition, factory, context => context.Do(...))
+ ```
+
## [2.2.0] - 2025-04-25
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index e941de7..a8250fe 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.2.0
+ 3.0.0
true
diff --git a/README.md b/README.md
index b284bc8..9108603 100644
--- a/README.md
+++ b/README.md
@@ -5,19 +5,19 @@
[](https://www.nuget.org/packages/Zooper.Bee/)
[](https://opensource.org/licenses/MIT)
-Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you to build type-safe, declarative business workflows with robust error handling.
+A flexible and powerful workflow library for .NET that allows you to define complex business processes with a fluent API.
-## Key Features
+## Overview
-- **Fluent Builder API**: Create workflows with an intuitive, chainable syntax
-- **Type-safe**: Leverage C#'s static typing for error-resistant workflows
-- **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)
+Zooper.Bee lets you create workflows that process requests and produce either successful results or meaningful errors. The library uses a builder pattern to construct workflows with various execution patterns including sequential, conditional, parallel, and detached operations.
+
+## Key Concepts
+
+- **Workflow**: A sequence of operations that process a request to produce a result or error
+- **Request**: The input data to the workflow
+- **Payload**: Data that passes through and gets modified by workflow activities
+- **Success**: The successful result of the workflow
+- **Error**: The error result if the workflow fails
## Installation
@@ -25,182 +25,267 @@ Zooper.Bee is a fluent, lightweight workflow framework for C# that enables you t
dotnet add package Zooper.Bee
```
-## Quick Start
-
-Here's a basic example of creating and executing a workflow:
+## Getting Started
```csharp
-// Define your models
-public record OrderRequest(string CustomerId, decimal Amount);
-public record OrderPayload(OrderRequest Request, Guid OrderId, bool IsProcessed);
-public record OrderConfirmation(Guid OrderId, DateTime ProcessedAt);
-public record OrderError(string Code, string Message);
-
-// Create a workflow
-var workflow = new WorkflowBuilder(
- // Initial payload factory
- request => new OrderPayload(request, Guid.NewGuid(), false),
- // Result selector
- payload => new OrderConfirmation(payload.OrderId, DateTime.UtcNow))
-
- // Add validations
- .Validate(request =>
- string.IsNullOrEmpty(request.CustomerId)
- ? Option.Some(new OrderError("INVALID_CUSTOMER", "Customer ID required"))
- : Option.None())
-
- // Add activities
- .Do(ProcessPayment)
- .Do(UpdateInventory)
-
- // Add conditional activities
- .DoIf(
- payload => payload.Request.Amount > 1000,
- ApplyHighValueDiscount)
-
- // Add finally activities
- .Finally(LogOrderProcessing)
-
- // Build the workflow
- .Build();
+// Define a simple workflow
+var workflow = new WorkflowBuilder(
+ // Factory function that creates the initial payload from the request
+ request => new Payload { Data = request.Data },
-// Execute the workflow
-var result = await workflow.Execute(new OrderRequest("CUST123", 299.99));
+ // 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" });
+
+ return Option.None;
+})
+.Do(payload =>
+{
+ // Process the payload
+ payload.Data = payload.Data.ToUpper();
+ return Either.FromRight(payload);
+})
+.Build();
-// Handle the result
+// Execute the workflow
+var result = await workflow.Execute(new Request { Data = "hello world" }, CancellationToken.None);
if (result.IsRight)
{
- var confirmation = result.Right;
- Console.WriteLine($"Order {confirmation.OrderId} processed at {confirmation.ProcessedAt}");
+ Console.WriteLine($"Success: {result.Right.ProcessedData}"); // Output: Success: HELLO WORLD
}
else
{
- var error = result.Left;
- Console.WriteLine($"Error: {error.Code} - {error.Message}");
+ Console.WriteLine($"Error: {result.Left.Message}");
}
```
-## Core Concepts
-
-### Workflow
+## Building Workflows
-A workflow represents a sequence of operations that processes a request and produces either a success result or an error. It's created using the `WorkflowBuilder`.
+### Basic Operations
-### WorkflowBuilder
+#### Validation
-The builder provides a fluent API for constructing workflows:
+Validates the incoming request before processing begins.
```csharp
-var workflow = new WorkflowBuilder(
- contextFactory, // Function that creates the initial payload from the request
- resultSelector) // Function that creates the success result from the final payload
- .Validate(...) // Add validations
- .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
+// Asynchronous validation
+.Validate(async (request, cancellationToken) =>
+{
+ var isValid = await ValidateAsync(request, cancellationToken);
+ return isValid ? Option.None : Option.Some(new ErrorResult());
+})
+
+// Synchronous validation
+.Validate(request =>
+{
+ var isValid = Validate(request);
+ return isValid ? Option.None : Option.Some(new ErrorResult());
+})
```
-### Validations
+#### Activities
-Validations check if the request is valid before processing begins. They return an `Option`:
+Activities are the building blocks of a workflow. They process the payload and can produce either a success (with modified payload) or an error.
```csharp
-.Validate(request =>
- string.IsNullOrEmpty(request.CustomerId)
- ? Option.Some(new OrderError("INVALID_CUSTOMER", "Customer ID required"))
- : Option.None())
+// Asynchronous activity
+.Do(async (payload, cancellationToken) =>
+{
+ var result = await ProcessAsync(payload, cancellationToken);
+ return Either.FromRight(result);
+})
+
+// Synchronous activity
+.Do(payload =>
+{
+ var result = Process(payload);
+ return Either.FromRight(result);
+})
+
+// Multiple activities
+.DoAll(
+ payload => DoFirstThing(payload),
+ payload => DoSecondThing(payload),
+ payload => DoThirdThing(payload)
+)
```
-### Activities
+#### Conditional Activities
-Activities are the primary building blocks of workflows. They process the payload and return either a success or failure result:
+Activities that only execute if a condition is met.
```csharp
-private static Either ProcessPayment(OrderPayload payload)
-{
- // Process payment logic
-
- if (successful)
- {
- var updatedPayload = payload with { IsProcessed = true };
- return Either.FromRight(updatedPayload);
- }
- else
+.DoIf(
+ payload => payload.ShouldProcess, // Condition
+ payload =>
{
- return Either.FromLeft(
- new OrderError("PAYMENT_FAILED", "Failed to process payment"));
+ // Activity that only executes if the condition is true
+ payload.Data = Process(payload.Data);
+ return Either.FromRight(payload);
}
-}
+)
```
-### Conditional Activities
+### Advanced Features
+
+#### Groups
-Execute activities only when specific conditions are met:
+Organize related activities into logical groups. Groups can have conditions and always merge their results back to the main workflow.
```csharp
-.DoIf(
- payload => payload.Request.Amount > 1000, // Condition
- ApplyHighValueDiscount) // Activity
+.Group(
+ payload => payload.ShouldProcessGroup, // Optional condition
+ group => group
+ .Do(payload => FirstActivity(payload))
+ .Do(payload => SecondActivity(payload))
+ .Do(payload => ThirdActivity(payload))
+)
+```
+
+#### Contexts with Local State
+
+Create a context with local state that is accessible to all activities within the context. This helps encapsulate related operations.
+
+```csharp
+.WithContext(
+ null, // No condition, always execute
+ payload => new LocalState { Counter = 0 }, // Create local state
+ context => context
+ .Do((payload, state) =>
+ {
+ state.Counter++;
+ return (payload, state);
+ })
+ .Do((payload, state) =>
+ {
+ payload.Result = $"Counted to {state.Counter}";
+ return (payload, state);
+ })
+)
```
-### Branches
+#### Parallel Execution
-Create branches for more complex conditional logic:
+Execute multiple groups of activities in parallel and merge the results.
```csharp
-.Branch(payload => payload.IsExpressShipping)
- .Do(CalculateExpressShippingFee)
- .Do(PrioritizeOrder)
- .EndBranch()
-.Branch(payload => !payload.IsExpressShipping)
- .Do(CalculateStandardShippingFee)
- .EndBranch()
+.Parallel(
+ null, // No condition, always execute
+ parallel => parallel
+ .Group(group => group
+ .Do(payload => { payload.Result1 = "Result 1"; return payload; })
+ )
+ .Group(group => group
+ .Do(payload => { payload.Result2 = "Result 2"; return payload; })
+ )
+)
```
-### Branches with Local Payload
+#### Detached Execution
-Create isolated branches with their own local payload type that doesn't affect the main workflow payload:
+Execute activities in the background without waiting for their completion. Results from detached activities are not merged back into the main workflow.
```csharp
-.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));
+.Detach(
+ null, // No condition, always execute
+ detached => detached
+ .Do(payload =>
+ {
+ // This runs in the background
+ LogActivity(payload);
+ return payload;
})
)
```
-### Finally Blocks
+#### Parallel Detached Execution
+
+Execute multiple groups of detached activities in parallel without waiting for completion.
+
+```csharp
+.ParallelDetached(
+ null, // No condition, always execute
+ parallelDetached => parallelDetached
+ .Detached(detached => detached
+ .Do(payload => { LogActivity1(payload); return payload; })
+ )
+ .Detached(detached => detached
+ .Do(payload => { LogActivity2(payload); return payload; })
+ )
+)
+```
+
+#### Finally Block
+
+Activities that always execute, even if the workflow fails.
+
+```csharp
+.Finally(payload =>
+{
+ // Cleanup or logging
+ CleanupResources(payload);
+ return Either.FromRight(payload);
+})
+```
+
+## Advanced Patterns
-Activities that execute regardless of workflow success or failure:
+### Error Handling
+```csharp
+.Do(payload =>
+{
+ try
+ {
+ var result = RiskyOperation(payload);
+ return Either.FromRight(result);
+ }
+ catch (Exception ex)
+ {
+ return Either.FromLeft(new ErrorResult { Message = ex.Message });
+ }
+})
```
+### Conditional Branching
+
+Use conditions to determine which path to take in a workflow.
+
+```csharp
+.Group(
+ payload => payload.Type == "TypeA",
+ group => group
+ .Do(payload => ProcessTypeA(payload))
+)
+.Group(
+ payload => payload.Type == "TypeB",
+ group => group
+ .Do(payload => ProcessTypeB(payload))
+)
```
+
+## Performance Considerations
+
+- Use `Parallel` for CPU-bound operations that can benefit from parallel execution
+- Use `Detach` for I/O operations that don't affect the main workflow
+- Be mindful of resource contention in parallel operations
+- Consider using `WithContext` to maintain state between related activities
+
+## Best Practices
+
+1. Keep activities small and focused on a single responsibility
+2. Use descriptive names for your workflow methods
+3. Group related activities together
+4. Handle errors at appropriate levels
+5. Use `Finally` for cleanup operations
+6. Validate requests early to fail fast
+7. Use contextual state to avoid passing too many parameters
+
+## License
+
+MIT License (Copyright details here)
diff --git a/Zooper.Bee.Example/BranchingExample.cs b/Zooper.Bee.Example/BranchingExample.cs
index 3ed59fa..be76edc 100644
--- a/Zooper.Bee.Example/BranchingExample.cs
+++ b/Zooper.Bee.Example/BranchingExample.cs
@@ -1,74 +1,79 @@
-using System;
-using System.Threading.Tasks;
-using Zooper.Bee;
using Zooper.Fox;
namespace Zooper.Bee.Example;
public class BranchingExample
{
- // Request models
- public record UserRegistrationRequest(
- string Username,
+ // Request model
+ public record RegistrationRequest(
string Email,
+ string Password,
bool IsVipMember);
// Success model
- public record RegistrationResult(
- string Username,
+ public record RegistrationSuccess(
+ Guid UserId,
string Email,
- string AccountType,
- bool WelcomeEmailSent,
- bool VipBenefitsActivated);
+ bool IsVipMember,
+ string? WelcomeMessage);
// Error model
public record RegistrationError(string Code, string Message);
- // Payload model
+ // Registration payload model
public record RegistrationPayload(
- string Username,
+ Guid UserId,
string Email,
+ string Password,
bool IsVipMember,
- bool IsRegistered = false,
- bool WelcomeEmailSent = false,
- bool VipBenefitsActivated = false,
- string AccountType = "Standard");
+ string? WelcomeMessage = null);
public static async Task RunExample()
{
- Console.WriteLine("\n=== Workflow Branching Example ===\n");
+ Console.WriteLine("\n=== Workflow Grouping Example ===\n");
// Create sample requests
- var standardUser = new UserRegistrationRequest("john_doe", "john@example.com", false);
- var vipUser = new UserRegistrationRequest("jane_smith", "jane@example.com", true);
+ var standardUserRequest = new RegistrationRequest("user@example.com", "Password123!", false);
+ var vipUserRequest = new RegistrationRequest("vip@example.com", "VIPPassword123!", true);
+ var invalidEmailRequest = new RegistrationRequest("invalid-email", "Password123!", false);
// Build the registration workflow
var workflow = CreateRegistrationWorkflow();
- // Process the standard user
- Console.WriteLine("Processing standard user registration:");
- await ProcessRegistration(workflow, standardUser);
+ // Process standard user registration
+ Console.WriteLine("Registering standard user:");
+ await ProcessRegistration(workflow, standardUserRequest);
+
+ Console.WriteLine();
+
+ // Process VIP user registration
+ Console.WriteLine("Registering VIP user:");
+ await ProcessRegistration(workflow, vipUserRequest);
Console.WriteLine();
- // Process the VIP user
- Console.WriteLine("Processing VIP user registration:");
- await ProcessRegistration(workflow, vipUser);
+ // Process invalid registration
+ Console.WriteLine("Attempting to register user with invalid email:");
+ await ProcessRegistration(workflow, invalidEmailRequest);
}
private static async Task ProcessRegistration(
- Workflow workflow,
- UserRegistrationRequest request)
+ Workflow workflow,
+ RegistrationRequest request)
{
var result = await workflow.Execute(request);
if (result.IsRight)
{
- var registration = result.Right;
- Console.WriteLine($"Registration successful for {registration.Username}");
- Console.WriteLine($"Account Type: {registration.AccountType}");
- Console.WriteLine($"Welcome Email Sent: {registration.WelcomeEmailSent}");
- Console.WriteLine($"VIP Benefits Activated: {registration.VipBenefitsActivated}");
+ var success = result.Right;
+ Console.WriteLine($"Registration successful for {success.Email}");
+ Console.WriteLine($"User ID: {success.UserId}");
+ Console.WriteLine($"VIP Member: {success.IsVipMember}");
+
+ if (success.WelcomeMessage != null)
+ {
+ Console.WriteLine($"Welcome message: {success.WelcomeMessage}");
+ }
}
else
{
@@ -77,22 +82,22 @@ private static async Task ProcessRegistration(
}
}
- private static Workflow CreateRegistrationWorkflow()
+ private static Workflow CreateRegistrationWorkflow()
{
- return new WorkflowBuilder(
+ return new WorkflowBuilder(
// Create initial payload from request
request => new RegistrationPayload(
- request.Username,
+ Guid.NewGuid(), // Generate a new unique ID
request.Email,
+ request.Password,
request.IsVipMember),
// Create result from final payload
- payload => new RegistrationResult(
- payload.Username,
+ payload => new RegistrationSuccess(
+ payload.UserId,
payload.Email,
- payload.AccountType,
- payload.WelcomeEmailSent,
- payload.VipBenefitsActivated)
+ payload.IsVipMember,
+ payload.WelcomeMessage)
)
// Validate email format
.Validate(request =>
@@ -100,7 +105,7 @@ private static Workflow.Some(
- new RegistrationError("INVALID_EMAIL", "Email address is not in a valid format"));
+ new RegistrationError("INVALID_EMAIL", "Email must contain @ symbol"));
}
return Option.None();
@@ -108,58 +113,53 @@ private static Workflow
{
- Console.WriteLine($"Registering user {payload.Username}...");
+ Console.WriteLine($"Registering user with email: {payload.Email}");
- // Simulate registration
- return Either.FromRight(
- payload with { IsRegistered = true });
+ // In a real app, this would save the user to a database
+ return Either.FromRight(payload);
})
- // Branch the workflow based on membership type
- .Branch(
+ // Conditional group for VIP members
+ .Group(
+ // Condition: only execute for VIP members
payload => payload.IsVipMember,
- branch => branch
- .Do(payload =>
- {
- Console.WriteLine("Activating VIP benefits...");
- return Either.FromRight(
- payload with
- {
- VipBenefitsActivated = true,
- AccountType = "VIP"
- });
- })
+ // Configure the group with VIP-specific activities
+ group => group
.Do(payload =>
{
- Console.WriteLine("Setting up premium support access...");
+ Console.WriteLine("Activating VIP benefits...");
+ // In a real app, this would activate VIP-specific features
return Either.FromRight(payload);
})
- )
- // Branch for standard users
- .Branch(
- payload => !payload.IsVipMember,
- branch => branch
.Do(payload =>
{
- Console.WriteLine("Setting up standard account features...");
+ Console.WriteLine("Sending VIP welcome package notification...");
- return Either.FromRight(payload);
+ // Update the welcome message for VIP users
+ return Either.FromRight(
+ payload with { WelcomeMessage = "Welcome to our VIP program! Your welcome package is on the way." });
})
)
// Send welcome email to all users
.Do(payload =>
{
- Console.WriteLine($"Sending welcome email to {payload.Email}...");
+ Console.WriteLine($"Sending welcome email to: {payload.Email}");
- // Simulate sending email
- return Either.FromRight(
- payload with { WelcomeEmailSent = true });
+ // Only set a default welcome message if one hasn't been set (for non-VIP users)
+ if (payload.WelcomeMessage == null)
+ {
+ payload = payload with { WelcomeMessage = "Welcome to our platform!" };
+ }
+
+ return Either.FromRight(payload);
})
- // Finally log the registration
+ // Log the registration
.Finally(payload =>
{
- Console.WriteLine($"Logging registration of {payload.Username} ({payload.AccountType} account)");
+ Console.WriteLine($"Logging registration for user: {payload.Email} (ID: {payload.UserId})");
+
+ // Return the unmodified payload to satisfy the lambda return type
return Either.FromRight(payload);
})
.Build();
diff --git a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs b/Zooper.Bee.Example/ContextLocalPayloadExample.cs
similarity index 92%
rename from Zooper.Bee.Example/BranchWithLocalPayloadExample.cs
rename to Zooper.Bee.Example/ContextLocalPayloadExample.cs
index bf09688..e65e054 100644
--- a/Zooper.Bee.Example/BranchWithLocalPayloadExample.cs
+++ b/Zooper.Bee.Example/ContextLocalPayloadExample.cs
@@ -1,11 +1,8 @@
-using System;
-using System.Threading.Tasks;
-using Zooper.Bee;
using Zooper.Fox;
namespace Zooper.Bee.Example;
-public class BranchWithLocalPayloadExample
+public class ContextLocalPayloadExample
{
// Request models
public record OrderRequest(
@@ -33,7 +30,7 @@ public record OrderPayload(
decimal TotalAmount = 0,
string? ShippingTrackingNumber = null);
- // Local payload for shipping branch
+ // Local payload for shipping context
public record ShippingPayload(
string CustomerAddress,
decimal ShippingCost,
@@ -43,7 +40,7 @@ public record ShippingPayload(
public static async Task RunExample()
{
- Console.WriteLine("\n=== Workflow Branch With Local Payload Example ===\n");
+ Console.WriteLine("\n=== Workflow With Context Local Payload Example ===\n");
// Create sample requests
var standardOrder = new OrderRequest(2001, "Alice Johnson", 75.00m, false);
@@ -129,9 +126,9 @@ private static Workflow CreateOrder
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
+ // Use specialized context for shipping-specific processing
+ .WithContext(
+ // Only enter this context if shipping is needed
payload => payload.NeedsShipping,
// Create the local shipping payload
@@ -141,8 +138,8 @@ private static Workflow CreateOrder
PackagingCost: 2.75m,
InsuranceCost: 5.00m),
- // Configure the branch with shipping-specific activities
- branch => branch
+ // Configure the context with shipping-specific activities
+ context => context
// First shipping activity - calculate shipping costs
.Do((mainPayload, shippingPayload) =>
{
diff --git a/Zooper.Bee.Example/ParallelExecutionExample.cs b/Zooper.Bee.Example/ParallelExecutionExample.cs
new file mode 100644
index 0000000..07fa77d
--- /dev/null
+++ b/Zooper.Bee.Example/ParallelExecutionExample.cs
@@ -0,0 +1,231 @@
+using Zooper.Fox;
+
+namespace Zooper.Bee.Example;
+
+public class ParallelExecutionExample
+{
+ // Request model
+ public record DataProcessingRequest(string DataId, string[] Segments, bool NotifyOnCompletion);
+
+ // Success model
+ public record DataProcessingResult(string DataId, int ProcessedSegments, DateTime CompletedAt);
+
+ // Error model
+ public record DataProcessingError(string Code, string Message);
+
+ // Main payload model
+ public record DataProcessingPayload(
+ string DataId,
+ string[] Segments,
+ bool NotifyOnCompletion,
+ int ProcessedSegments = 0,
+ bool Validated = false,
+ DateTime? CompletedAt = null);
+
+ public static async Task RunExample()
+ {
+ Console.WriteLine("\n=== Parallel Execution Example ===\n");
+
+ // Create a sample request
+ var request = new DataProcessingRequest(
+ "DATA-12345",
+ new[] { "Segment1", "Segment2", "Segment3", "Segment4" },
+ true
+ );
+
+ // Build the workflows
+ var parallelWorkflow = CreateParallelWorkflow();
+ var parallelDetachedWorkflow = CreateParallelDetachedWorkflow();
+
+ // Process with parallel execution
+ Console.WriteLine("Processing with parallel execution:");
+ await ProcessData(parallelWorkflow, request);
+
+ Console.WriteLine();
+
+ // Process with parallel detached execution
+ Console.WriteLine("Processing with parallel detached execution:");
+ await ProcessData(parallelDetachedWorkflow, request);
+ }
+
+ private static async Task ProcessData(
+ Workflow workflow,
+ DataProcessingRequest request)
+ {
+ var result = await workflow.Execute(request);
+
+ if (result.IsRight)
+ {
+ var success = result.Right;
+ Console.WriteLine($"Data {success.DataId} processed successfully");
+ Console.WriteLine($"Processed {success.ProcessedSegments} segments");
+ Console.WriteLine($"Completed at: {success.CompletedAt}");
+ }
+ else
+ {
+ var error = result.Left;
+ Console.WriteLine($"Data processing failed: [{error.Code}] {error.Message}");
+ }
+ }
+
+ private static Workflow CreateParallelWorkflow()
+ {
+ return new WorkflowBuilder(
+ // Create initial payload from request
+ request => new DataProcessingPayload(
+ request.DataId,
+ request.Segments,
+ request.NotifyOnCompletion),
+
+ // Create result from final payload
+ payload => new DataProcessingResult(
+ payload.DataId,
+ payload.ProcessedSegments,
+ payload.CompletedAt ?? DateTime.UtcNow)
+ )
+ .Do(payload =>
+ {
+ Console.WriteLine($"Preparing to process data {payload.DataId}...");
+
+ // Validate data
+ return Either.FromRight(
+ payload with { Validated = true });
+ })
+ // Use parallel execution to process segments in parallel
+ .Parallel(
+ // Configure parallel execution groups
+ parallel => parallel
+ // First parallel group - process first half of segments
+ .Group(
+ // Create a group for the first half
+ group => group
+ .Do(payload =>
+ {
+ var halfwayPoint = payload.Segments.Length / 2;
+ var firstHalf = payload.Segments[..halfwayPoint];
+
+ Console.WriteLine($"Processing first half ({firstHalf.Length} segments) in parallel...");
+ // Simulate processing time
+ Task.Delay(500).GetAwaiter().GetResult();
+
+ return Either.FromRight(
+ payload with { ProcessedSegments = payload.ProcessedSegments + firstHalf.Length });
+ })
+ )
+ // Second parallel group - process second half of segments
+ .Group(
+ // Create a group for the second half
+ group => group
+ .Do(payload =>
+ {
+ var halfwayPoint = payload.Segments.Length / 2;
+ var secondHalf = payload.Segments[halfwayPoint..];
+
+ Console.WriteLine($"Processing second half ({secondHalf.Length} segments) in parallel...");
+ // Simulate processing time
+ Task.Delay(300).GetAwaiter().GetResult();
+
+ return Either.FromRight(
+ payload with { ProcessedSegments = payload.ProcessedSegments + secondHalf.Length });
+ })
+ )
+ )
+ // Finalize the processing
+ .Do(payload =>
+ {
+ Console.WriteLine($"Finalizing data processing for {payload.DataId}...");
+ var completedAt = DateTime.UtcNow;
+
+ return Either.FromRight(
+ payload with { CompletedAt = completedAt });
+ })
+ // Send notification if requested
+ .DoIf(
+ payload => payload.NotifyOnCompletion,
+ payload =>
+ {
+ Console.WriteLine($"Sending completion notification for data {payload.DataId}...");
+ return Either.FromRight(payload);
+ }
+ )
+ .Build();
+ }
+
+ private static Workflow CreateParallelDetachedWorkflow()
+ {
+ return new WorkflowBuilder(
+ // Create initial payload from request
+ request => new DataProcessingPayload(
+ request.DataId,
+ request.Segments,
+ request.NotifyOnCompletion),
+
+ // Create result from final payload
+ payload => new DataProcessingResult(
+ payload.DataId,
+ payload.ProcessedSegments,
+ payload.CompletedAt ?? DateTime.UtcNow)
+ )
+ .Do(payload =>
+ {
+ Console.WriteLine($"Preparing to process data {payload.DataId} with detached parallel execution...");
+
+ // Since detached execution doesn't wait for results, we'll count all segments as processed
+ // in the main workflow
+ return Either.FromRight(
+ payload with
+ {
+ Validated = true,
+ ProcessedSegments = payload.Segments.Length
+ });
+ })
+ // Use parallel detached execution for background tasks
+ .ParallelDetached(
+ // Configure parallel detached execution groups
+ parallelDetached => parallelDetached
+ // First background task - log processing start
+ .Detached(
+ group => group
+ .Do(payload =>
+ {
+ Console.WriteLine($"BACKGROUND: Logging processing start for {payload.DataId}...");
+ // Simulate logging delay
+ Task.Delay(200).GetAwaiter().GetResult();
+ Console.WriteLine($"BACKGROUND: Logging completed for {payload.DataId}");
+
+ return Either.FromRight(payload);
+ })
+ )
+ // Second background task - generate analytics
+ .Detached(
+ group => group
+ .Do(payload =>
+ {
+ Console.WriteLine($"BACKGROUND: Generating analytics for {payload.DataId}...");
+ // Simulate analytics generation
+ Task.Delay(1000).GetAwaiter().GetResult();
+ Console.WriteLine($"BACKGROUND: Analytics completed for {payload.DataId}");
+
+ return Either.FromRight(payload);
+ })
+ )
+ )
+ // Finalize the processing (this runs immediately, not waiting for detached tasks)
+ .Do(payload =>
+ {
+ Console.WriteLine($"Main workflow: Finalizing data processing for {payload.DataId}...");
+ var completedAt = DateTime.UtcNow;
+
+ return Either.FromRight(
+ payload with { CompletedAt = completedAt });
+ })
+ // Wait briefly to allow background tasks to make progress before example ends
+ .Finally(payload =>
+ {
+ // Just a small delay so we can see some background task output
+ Task.Delay(500).GetAwaiter().GetResult();
+ return Either.FromRight(payload);
+ })
+ .Build();
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Example/ParameterlessWorkflowExample.cs b/Zooper.Bee.Example/ParameterlessWorkflowExample.cs
new file mode 100644
index 0000000..d28e749
--- /dev/null
+++ b/Zooper.Bee.Example/ParameterlessWorkflowExample.cs
@@ -0,0 +1,148 @@
+using Zooper.Fox;
+
+namespace Zooper.Bee.Example;
+
+public class ParameterlessWorkflowExample
+{
+ // Success model
+ public record ProcessingResult(DateTime ProcessedAt, string Status);
+
+ // Error model
+ public record ProcessingError(string Code, string Message);
+
+ // Payload model
+ public record ProcessingPayload(
+ DateTime StartedAt,
+ bool IsCompleted = false,
+ string Status = "Pending");
+
+ public static async Task RunExample()
+ {
+ Console.WriteLine("\n=== Parameterless Workflow Example ===\n");
+
+ Console.WriteLine("Example 1: Using WorkflowBuilderFactory.Create");
+ await RunExampleWithFactory();
+
+ Console.WriteLine("\nExample 2: Using Unit type directly");
+ await RunExampleWithUnit();
+
+ Console.WriteLine("\nExample 3: Using extension method for execution");
+ await RunExampleWithExtension();
+ }
+
+ private static async Task RunExampleWithFactory()
+ {
+ // Create a workflow that doesn't need input parameters
+ var workflow = WorkflowBuilderFactory.CreateWorkflow(
+ // Initial payload factory - no parameters needed
+ () => new ProcessingPayload(StartedAt: DateTime.UtcNow),
+
+ // Result selector - convert final payload to success result
+ payload => new ProcessingResult(DateTime.UtcNow, payload.Status),
+
+ // Configure the workflow
+ builder => builder
+ .Do(payload =>
+ {
+ Console.WriteLine("Processing step 1...");
+ return Either.FromRight(
+ payload with { Status = "Step 1 completed" });
+ })
+ .Do(payload =>
+ {
+ Console.WriteLine("Processing step 2...");
+ return Either.FromRight(
+ payload with { Status = "Step 2 completed", IsCompleted = true });
+ })
+ );
+
+ // Execute without parameters
+ var result = await workflow.Execute();
+
+ if (result.IsRight)
+ {
+ Console.WriteLine($"Workflow completed successfully: {result.Right.Status}");
+ Console.WriteLine($"Processed at: {result.Right.ProcessedAt}");
+ }
+ else
+ {
+ Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}");
+ }
+ }
+
+ private static async Task RunExampleWithUnit()
+ {
+ // Create a workflow with Unit type as request
+ var workflow = new WorkflowBuilder(
+ // Use Unit parameter (ignored)
+ _ => new ProcessingPayload(StartedAt: DateTime.UtcNow),
+
+ // Result selector
+ payload => new ProcessingResult(DateTime.UtcNow, payload.Status)
+ )
+ .Do(payload =>
+ {
+ Console.WriteLine("Executing task A...");
+ return Either.FromRight(
+ payload with { Status = "Task A completed" });
+ })
+ .Do(payload =>
+ {
+ Console.WriteLine("Executing task B...");
+ return Either.FromRight(
+ payload with { Status = "Task B completed", IsCompleted = true });
+ })
+ .Build();
+
+ // Execute with Unit.Value
+ var result = await workflow.Execute(Unit.Value);
+
+ if (result.IsRight)
+ {
+ Console.WriteLine($"Workflow completed successfully: {result.Right.Status}");
+ Console.WriteLine($"Processed at: {result.Right.ProcessedAt}");
+ }
+ else
+ {
+ Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}");
+ }
+ }
+
+ private static async Task RunExampleWithExtension()
+ {
+ // Create a workflow with Unit type as request
+ var workflow = new WorkflowBuilder(
+ // Use Unit parameter (ignored)
+ _ => new ProcessingPayload(StartedAt: DateTime.UtcNow),
+
+ // Result selector
+ payload => new ProcessingResult(DateTime.UtcNow, payload.Status)
+ )
+ .Do(payload =>
+ {
+ Console.WriteLine("Running process X...");
+ return Either.FromRight(
+ payload with { Status = "Process X completed" });
+ })
+ .Do(payload =>
+ {
+ Console.WriteLine("Running process Y...");
+ return Either.FromRight(
+ payload with { Status = "Process Y completed", IsCompleted = true });
+ })
+ .Build();
+
+ // Execute using the extension method (no parameters)
+ var result = await workflow.Execute();
+
+ if (result.IsRight)
+ {
+ Console.WriteLine($"Workflow completed successfully: {result.Right.Status}");
+ Console.WriteLine($"Processed at: {result.Right.ProcessedAt}");
+ }
+ else
+ {
+ Console.WriteLine($"Workflow failed: [{result.Left.Code}] {result.Left.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Example/Program.cs b/Zooper.Bee.Example/Program.cs
index 6703e2b..333c484 100644
--- a/Zooper.Bee.Example/Program.cs
+++ b/Zooper.Bee.Example/Program.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Threading.Tasks;
-using Zooper.Bee;
-using Zooper.Fox;
+using Zooper.Fox;
namespace Zooper.Bee.Example;
@@ -44,7 +41,13 @@ public static async Task Main()
await BranchingExample.RunExample();
// Run the branch with local payload example
- await BranchWithLocalPayloadExample.RunExample();
+ await ContextLocalPayloadExample.RunExample();
+
+ // Run the parallel execution example
+ await ParallelExecutionExample.RunExample();
+
+ // Run the parameterless workflow example
+ await ParameterlessWorkflowExample.RunExample();
}
private static async Task ProcessOrder(OrderRequest request)
diff --git a/Zooper.Bee.Tests/BranchTests.cs b/Zooper.Bee.Tests/BranchTests.cs
index 9cf3f7c..406f65a 100644
--- a/Zooper.Bee.Tests/BranchTests.cs
+++ b/Zooper.Bee.Tests/BranchTests.cs
@@ -1,4 +1,3 @@
-using System;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
@@ -37,7 +36,7 @@ public async Task Branch_ExecutesWhenConditionIsTrue()
payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
)
.Do(payload => Either.FromRight(payload))
- .Branch(
+ .Group(
// Condition: Category is Premium
payload => payload.Category == "Premium",
branch => branch
@@ -51,7 +50,7 @@ public async Task Branch_ExecutesWhenConditionIsTrue()
return Either.FromRight(processed);
})
)
- .Branch(
+ .Group(
// Condition: Category is Standard
payload => payload.Category == "Standard",
branch => branch
@@ -92,7 +91,7 @@ public async Task Branch_SkipsWhenConditionIsFalse()
)
.Do(payload => Either.FromRight(
payload with { ProcessingResult = "Initial Processing" }))
- .Branch(
+ .Group(
// Condition: Category is Premium and Value is over 1000
payload => payload.Category == "Premium" && payload.Value > 1000,
branch => branch
@@ -122,7 +121,7 @@ public async Task Branch_UnconditionalBranch_AlwaysExecutes()
request => new TestPayload(request.Name, request.Value, request.Category),
payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
)
- .Branch(
+ .Group(
branch => branch
.Do(payload => Either.FromRight(
payload with { ProcessingResult = "Always Processed" }))
@@ -149,21 +148,21 @@ public async Task Branch_MultipleBranches_CorrectlyExecutes()
)
.Do(payload => Either.FromRight(
payload with { ProcessingResult = "Initial" }))
- .Branch(
+ .Group(
// First branch - based on Category
payload => payload.Category == "Premium",
branch => branch
.Do(payload => Either.FromRight(
payload with { ProcessingResult = payload.ProcessingResult + " + Premium" }))
)
- .Branch(
+ .Group(
// Second branch - based on Value
payload => payload.Value > 75,
branch => branch
.Do(payload => Either.FromRight(
payload with { ProcessingResult = payload.ProcessingResult + " + High Value" }))
)
- .Branch(
+ .Group(
// Third branch - always executes
branch => branch
.Do(payload => Either.FromRight(
@@ -190,7 +189,7 @@ public async Task Branch_WithError_StopsExecutionAndReturnsError()
request => new TestPayload(request.Name, request.Value, request.Category),
payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
)
- .Branch(
+ .Group(
payload => payload.Category == "Premium",
branch => branch
.Do(payload =>
@@ -204,7 +203,7 @@ public async Task Branch_WithError_StopsExecutionAndReturnsError()
payload with { ProcessingResult = "Premium Processing" });
})
)
- .Branch(
+ .Group(
branch => branch
.Do(payload => Either.FromRight(
payload with { ProcessingResult = "Final Processing" }))
@@ -230,7 +229,7 @@ public async Task Branch_WithMultipleActivities_ExecutesAllInOrder()
request => new TestPayload(request.Name, request.Value, request.Category),
payload => new TestSuccess(payload.Name, payload.ProcessingResult ?? "Not processed")
)
- .Branch(
+ .Group(
payload => true,
branch => branch
.Do(payload => Either.FromRight(
diff --git a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
index 2febd21..122d59c 100644
--- a/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
+++ b/Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
@@ -6,7 +6,7 @@
namespace Zooper.Bee.Tests;
-public class BranchWithLocalPayloadTests
+public class ContextTests
{
#region Test Models
// Request model
@@ -19,30 +19,27 @@ private record ProductPayload(
decimal Price,
bool NeedsCustomProcessing,
string? ProcessingResult = null,
- string? CustomizationDetails = null,
decimal FinalPrice = 0);
- // Local payload for customization branch
+ // Local payload for customization context
private record CustomizationPayload(
string[] AvailableOptions,
string[] SelectedOptions,
- decimal CustomizationCost,
- string CustomizationDetails);
+ decimal CustomizationCost);
// Success result model
private record ProductResult(
int Id,
string Name,
decimal FinalPrice,
- string? ProcessingResult,
- string? CustomizationDetails);
+ string? ProcessingResult);
// Error model
private record ProductError(string Code, string Message);
#endregion
[Fact]
- public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
+ public async Task WithContext_ExecutesWhenConditionIsTrue()
{
// Arrange
var workflow = new WorkflowBuilder(
@@ -58,8 +55,7 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
payload.Id,
payload.Name,
payload.FinalPrice,
- payload.ProcessingResult,
- payload.CustomizationDetails)
+ payload.ProcessingResult)
)
.Do(payload =>
{
@@ -70,8 +66,8 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
FinalPrice = payload.Price
});
})
- // Branch with local payload for products that need customization
- .BranchWithLocalPayload(
+ // Context with local payload for products that need customization
+ .WithContext(
// Condition: Product needs custom processing
payload => payload.NeedsCustomProcessing,
@@ -79,38 +75,21 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
payload => new CustomizationPayload(
AvailableOptions: new[] { "Engraving", "Gift Wrap", "Extended Warranty" },
SelectedOptions: new[] { "Engraving", "Gift Wrap" },
- CustomizationCost: 25.99m,
- CustomizationDetails: "Custom initialized"
+ CustomizationCost: 25.99m
),
- // 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
+ // Context configuration
+ context => context
+ // Apply customization costs
.Do((mainPayload, localPayload) =>
{
// Calculate total price
decimal totalPrice = mainPayload.Price + localPayload.CustomizationCost;
- // Update both payloads
+ // Update the main payload with customization results
var updatedMainPayload = mainPayload with
{
FinalPrice = totalPrice,
- CustomizationDetails = localPayload.CustomizationDetails,
ProcessingResult = $"{mainPayload.ProcessingResult} with customization"
};
@@ -128,88 +107,82 @@ public async Task BranchWithLocalPayload_ExecutesWhenConditionIsTrue()
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()
+ public async Task WithContext_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)
+ payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult)
)
.Do(payload => Either.FromRight(payload with
{
ProcessingResult = "Initial processing",
FinalPrice = payload.Price
}))
- .BranchWithLocalPayload(
- // Condition
+ // First context
+ .WithContext(
+ // Always execute
payload => true,
- // Create local payload
+ // Create local payload for first context
_ => new CustomizationPayload(
AvailableOptions: new[] { "Option1", "Option2" },
SelectedOptions: new[] { "Option1" },
- CustomizationCost: 10.00m,
- CustomizationDetails: "Branch 1 customization"
+ CustomizationCost: 10.00m
),
- // Branch configuration
- branch => branch
+ // Configure first context
+ context => context
.Do((mainPayload, localPayload) =>
{
var updatedMainPayload = mainPayload with
{
- ProcessingResult = mainPayload.ProcessingResult + " -> Branch 1",
- FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost,
- CustomizationDetails = localPayload.CustomizationDetails
+ ProcessingResult = mainPayload.ProcessingResult + " -> Context 1",
+ FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost
};
return Either.FromRight(
(updatedMainPayload, localPayload));
})
)
- // Another main activity that changes the main payload but shouldn't affect the next branch's local payload
+ // Main activity that changes the main payload but shouldn't affect the next context's local payload
.Do(payload => Either.FromRight(payload with
{
ProcessingResult = payload.ProcessingResult + " -> Main activity"
}))
- .BranchWithLocalPayload(
- // Second branch
+ // Second context - should have its own isolated local payload
+ .WithContext(
+ // Always execute
payload => true,
// Create a different local payload
_ => new CustomizationPayload(
AvailableOptions: new[] { "OptionA", "OptionB" },
SelectedOptions: new[] { "OptionA", "OptionB" },
- CustomizationCost: 20.00m,
- CustomizationDetails: "Branch 2 customization"
+ CustomizationCost: 20.00m
),
- // Branch configuration
- branch => branch
+ // Configure second context
+ context => context
.Do((mainPayload, localPayload) =>
{
var updatedMainPayload = mainPayload with
{
- ProcessingResult = mainPayload.ProcessingResult + " -> Branch 2",
- FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost,
- CustomizationDetails = mainPayload.CustomizationDetails + " + " + localPayload.CustomizationDetails
+ ProcessingResult = mainPayload.ProcessingResult + " -> Context 2",
+ FinalPrice = mainPayload.FinalPrice + localPayload.CustomizationCost
};
return Either.FromRight(
@@ -218,131 +191,144 @@ public async Task BranchWithLocalPayload_LocalPayloadIsolated_NotAffectedByOther
)
.Build();
- var request = new ProductRequest(1001, "Test Product", 100.00m, true);
-
// Act
- var result = await workflow.Execute(request);
+ var result = await workflow.Execute(new ProductRequest(1, "Test Product", 100.00m, false));
// 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");
+ result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2");
+ result.Right.FinalPrice.Should().Be(130.00m); // Base (100) + Context 1 (10) + Context 2 (20)
}
[Fact]
- public async Task BranchWithLocalPayload_ErrorInBranch_StopsExecutionAndReturnsError()
+ public async Task WithContext_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)
+ payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult)
)
- .Do(payload => Either.FromRight(
- payload with { ProcessingResult = "Initial processing" }))
- .BranchWithLocalPayload(
+ .Do(payload => Either.FromRight(payload with
+ {
+ ProcessingResult = "Initial processing",
+ FinalPrice = payload.Price
+ }))
+ .WithContext(
+ // Condition
payload => payload.NeedsCustomProcessing,
- _ => new CustomizationPayload(
- AvailableOptions: new string[0],
- SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error
- CustomizationCost: 10.00m,
- CustomizationDetails: "Should fail"
+
+ // Create local payload
+ payload => new CustomizationPayload(
+ AvailableOptions: new[] { "Option1", "Option2" },
+ SelectedOptions: new[] { "Option1" },
+ CustomizationCost: payload.Price * 0.10m // 10% surcharge
),
- branch => branch
+
+ // Configure context
+ context => context
.Do((mainPayload, localPayload) =>
{
- // Validate selected options are available
- foreach (var option in localPayload.SelectedOptions)
+ // Simulate error if price is too high
+ if (mainPayload.Price + localPayload.CustomizationCost > 150)
{
- if (Array.IndexOf(localPayload.AvailableOptions, option) < 0)
- {
- return Either.FromLeft(
- new ProductError("INVALID_OPTION", $"Option '{option}' is not available"));
- }
+ return Either.FromLeft(
+ new ProductError("PRICE_TOO_HIGH", "Product with customization exceeds price limit"));
}
+ var updatedMainPayload = mainPayload with
+ {
+ ProcessingResult = "Customization applied",
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost
+ };
+
return Either.FromRight(
- (mainPayload, localPayload));
+ (updatedMainPayload, localPayload));
})
)
- .Do(payload => Either.FromRight(
- payload with { ProcessingResult = payload.ProcessingResult + " -> Final processing" }))
+ // This activity should not execute if the context returns an error
+ .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);
+ var expensiveProduct = new ProductRequest(1, "Expensive Product", 150.00m, true);
+ var result = await workflow.Execute(expensiveProduct);
// Assert
result.IsLeft.Should().BeTrue();
- result.Left.Code.Should().Be("INVALID_OPTION");
- result.Left.Message.Should().Be("Option 'Unavailable Option' is not available");
+ result.Left.Code.Should().Be("PRICE_TOO_HIGH");
+ result.Left.Message.Should().Be("Product with customization exceeds price limit");
}
[Fact]
- public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLocalPayload()
+ public async Task WithContext_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)
+ payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult)
)
- .BranchWithLocalPayload(
- _ => true,
+ .Do(payload => Either.FromRight(payload with
+ {
+ ProcessingResult = "Initial processing",
+ FinalPrice = payload.Price
+ }))
+ .WithContext(
+ // Always execute
+ payload => true,
+
+ // Create local payload
_ => new CustomizationPayload(
AvailableOptions: new[] { "Option1", "Option2", "Option3" },
- SelectedOptions: new string[0], // Start with no selected options
- CustomizationCost: 0m, // Start with no cost
- CustomizationDetails: "Start"
+ SelectedOptions: Array.Empty(), // Start with no selections
+ CustomizationCost: 0 // Start with no cost
),
- branch => branch
- // First activity - select Option1
+
+ // Configure context with multiple activities that share local payload
+ context => context
+ // First activity selects options
.Do((mainPayload, localPayload) =>
{
- var updatedOptions = new string[localPayload.SelectedOptions.Length + 1];
- Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length);
- updatedOptions[updatedOptions.Length - 1] = "Option1";
+ // Add options based on product price
+ var selectedOptions = mainPayload.Price > 100
+ ? new[] { "Option1", "Option2" }
+ : new[] { "Option1" };
var updatedLocalPayload = localPayload with
{
- SelectedOptions = updatedOptions,
- CustomizationCost = localPayload.CustomizationCost + 10m,
- CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option1"
+ SelectedOptions = selectedOptions
};
return Either.FromRight(
(mainPayload, updatedLocalPayload));
})
- // Second activity - select Option2
+ // Second activity calculates cost based on selections
.Do((mainPayload, localPayload) =>
{
- var updatedOptions = new string[localPayload.SelectedOptions.Length + 1];
- Array.Copy(localPayload.SelectedOptions, updatedOptions, localPayload.SelectedOptions.Length);
- updatedOptions[updatedOptions.Length - 1] = "Option2";
+ // Calculate cost based on selected options
+ decimal cost = localPayload.SelectedOptions.Length * 15.00m;
var updatedLocalPayload = localPayload with
{
- SelectedOptions = updatedOptions,
- CustomizationCost = localPayload.CustomizationCost + 15m,
- CustomizationDetails = localPayload.CustomizationDetails + " -> Added Option2"
+ CustomizationCost = cost
};
return Either.FromRight(
(mainPayload, updatedLocalPayload));
})
- // Third activity - finalize and update main payload
+ // Third activity applies the customization to the main payload
.Do((mainPayload, localPayload) =>
{
+ string optionsDescription = string.Join(", ", localPayload.SelectedOptions);
+
var updatedMainPayload = mainPayload with
{
- FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
- CustomizationDetails = localPayload.CustomizationDetails,
- ProcessingResult = $"Processed with {localPayload.SelectedOptions.Length} options"
+ ProcessingResult = $"Customized with options: {optionsDescription}",
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost
};
return Either.FromRight(
@@ -351,44 +337,49 @@ public async Task BranchWithLocalPayload_MultipleActivitiesInSameBranch_ShareLoc
)
.Build();
- var request = new ProductRequest(1001, "Test Product", 100.00m, true);
-
// Act
- var result = await workflow.Execute(request);
+ var expensiveProduct = new ProductRequest(1, "Expensive Product", 150.00m, true);
+ var cheapProduct = new ProductRequest(2, "Cheap Product", 50.00m, true);
+
+ var expensiveResult = await workflow.Execute(expensiveProduct);
+ var cheapResult = await workflow.Execute(cheapProduct);
// 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");
+ expensiveResult.IsRight.Should().BeTrue();
+ expensiveResult.Right.ProcessingResult.Should().Be("Customized with options: Option1, Option2");
+ expensiveResult.Right.FinalPrice.Should().Be(180.00m); // 150 + (2 options * 15)
+
+ cheapResult.IsRight.Should().BeTrue();
+ cheapResult.Right.ProcessingResult.Should().Be("Customized with options: Option1");
+ cheapResult.Right.FinalPrice.Should().Be(65.00m); // 50 + (1 option * 15)
}
[Fact]
- public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes()
+ public async Task WithContext_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)
+ payload.Id, payload.Name, payload.FinalPrice, payload.ProcessingResult)
)
- .BranchWithLocalPayload(
- // Local payload factory only
+ // Use WithContext without condition (which means it always executes)
+ .WithContext(
+ // Create local payload
_ => new CustomizationPayload(
- AvailableOptions: new[] { "Default Option" },
- SelectedOptions: new[] { "Default Option" },
- CustomizationCost: 5.00m,
- CustomizationDetails: "Default customization"
+ AvailableOptions: new[] { "Standard Option" },
+ SelectedOptions: new[] { "Standard Option" },
+ CustomizationCost: 5.00m
),
- branch => branch
+
+ // Configure context
+ context => context
.Do((mainPayload, localPayload) =>
{
var updatedMainPayload = mainPayload with
{
- FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
- CustomizationDetails = localPayload.CustomizationDetails,
- ProcessingResult = "Processed with default customization"
+ ProcessingResult = "Standard processing applied",
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost
};
return Either.FromRight(
@@ -397,61 +388,13 @@ public async Task BranchWithLocalPayload_UnconditionalBranch_AlwaysExecutes()
)
.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");
- }
-
- [Fact]
- public async Task BranchWithLocalPayload_UnconditionalBranchFluentApi_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 (no condition parameter)
- _ => new CustomizationPayload(
- AvailableOptions: new[] { "Default Option" },
- SelectedOptions: new[] { "Default Option" },
- CustomizationCost: 5.00m,
- CustomizationDetails: "Default customization (fluent API)"
- ),
- // Use callback pattern instead of fluent API
- branch => branch.Do((mainPayload, localPayload) =>
- {
- var updatedMainPayload = mainPayload with
- {
- FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
- CustomizationDetails = localPayload.CustomizationDetails,
- ProcessingResult = "Processed with fluent API"
- };
-
- return Either.FromRight(
- (updatedMainPayload, localPayload));
- })
- )
- .Build();
-
- var request = new ProductRequest(1001, "Test Product", 100.00m, false);
-
// Act
- var result = await workflow.Execute(request);
+ var product = new ProductRequest(1, "Test Product", 100.00m, false);
+ var result = await workflow.Execute(product);
// Assert
result.IsRight.Should().BeTrue();
+ result.Right.ProcessingResult.Should().Be("Standard processing applied");
result.Right.FinalPrice.Should().Be(105.00m); // 100 + 5
- result.Right.ProcessingResult.Should().Be("Processed with fluent API");
- result.Right.CustomizationDetails.Should().Be("Default customization (fluent API)");
}
}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/DetachedExecutionTests.cs b/Zooper.Bee.Tests/DetachedExecutionTests.cs
new file mode 100644
index 0000000..4446dfb
--- /dev/null
+++ b/Zooper.Bee.Tests/DetachedExecutionTests.cs
@@ -0,0 +1,387 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class DetachedExecutionTests
+{
+ #region Test Models
+ // Request model
+ private record NotificationRequest(string UserId, string Message, bool IsUrgent);
+
+ // Main payload model
+ private record NotificationPayload(
+ string UserId,
+ string Message,
+ bool IsUrgent,
+ bool IsProcessed = false,
+ string Status = "Pending");
+
+ // Success result model
+ private record NotificationResult(string UserId, string Status);
+
+ // Error model
+ private record NotificationError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task Detached_ExecutesInBackground_DoesNotAffectMainWorkflow()
+ {
+ // Arrange
+ var backgroundTaskCompleted = new TaskCompletionSource();
+ var syncObj = new object();
+ var backgroundTaskRan = false;
+
+ var workflow = new WorkflowBuilder(
+ request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent),
+ payload => new NotificationResult(payload.UserId, payload.Status)
+ )
+ .Do(payload =>
+ {
+ // Main workflow processing
+ return Either.FromRight(
+ payload with
+ {
+ IsProcessed = true,
+ Status = "Processed"
+ });
+ })
+ // Detached execution that won't affect the main workflow result
+ .Detach(
+ detached => detached
+ .Do(payload =>
+ {
+ try
+ {
+ // This task runs in the background
+ lock (syncObj)
+ {
+ backgroundTaskRan = true;
+ }
+
+ // Simulate some work
+ Thread.Sleep(100);
+
+ // In a real application, this might send an email or log to a database
+ Console.WriteLine($"Background notification sent to: {payload.UserId}");
+
+ // This Status change should NOT affect the main workflow result
+ backgroundTaskCompleted.SetResult(true);
+ return Either.FromRight(
+ payload with { Status = "Background task executed" });
+ }
+ catch (Exception ex)
+ {
+ backgroundTaskCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ .Finally(payload =>
+ {
+ // Add a small delay to allow the background task to start
+ Thread.Sleep(200);
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ var request = new NotificationRequest("user-123", "Test message", false);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Wait for the background task to complete or timeout after 2 seconds
+ var timeoutTask = Task.Delay(2000);
+ var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask);
+ var timedOut = completedTask == timeoutTask;
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Status.Should().Be("Processed"); // Should have the status from the main workflow
+
+ // Verify the background task ran
+ lock (syncObj)
+ {
+ backgroundTaskRan.Should().BeTrue();
+ }
+
+ timedOut.Should().BeFalse("Background task timed out");
+ }
+
+ [Fact]
+ public async Task Detached_WithCondition_OnlyExecutesWhenConditionIsTrue()
+ {
+ // Arrange
+ var urgentTaskCompleted = new TaskCompletionSource();
+ var regularTaskCompleted = new TaskCompletionSource();
+ var syncObj = new object();
+ var urgentTaskRan = false;
+ var regularTaskRan = false;
+
+ var workflow = new WorkflowBuilder(
+ request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent),
+ payload => new NotificationResult(payload.UserId, payload.Status)
+ )
+ .Do(payload =>
+ {
+ // Main workflow processing
+ return Either.FromRight(
+ payload with
+ {
+ IsProcessed = true,
+ Status = "Processed"
+ });
+ })
+ // Conditional detached execution for urgent notifications
+ .Detach(
+ // Only execute for urgent notifications
+ payload => payload.IsUrgent,
+ detached => detached
+ .Do(payload =>
+ {
+ try
+ {
+ lock (syncObj)
+ {
+ urgentTaskRan = true;
+ }
+
+ // Simulate some work
+ Thread.Sleep(100);
+
+ Console.WriteLine($"URGENT notification sent to: {payload.UserId}");
+
+ urgentTaskCompleted.SetResult(true);
+ return Either.FromRight(payload);
+ }
+ catch (Exception ex)
+ {
+ urgentTaskCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ // Unconditional detached execution for all notifications
+ .Detach(
+ detached => detached
+ .Do(payload =>
+ {
+ try
+ {
+ lock (syncObj)
+ {
+ regularTaskRan = true;
+ }
+
+ // Simulate some work
+ Thread.Sleep(100);
+
+ Console.WriteLine($"Regular notification processing for: {payload.UserId}");
+
+ regularTaskCompleted.SetResult(true);
+ return Either.FromRight(payload);
+ }
+ catch (Exception ex)
+ {
+ regularTaskCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ .Finally(payload =>
+ {
+ // Add a small delay to allow the background task to start
+ Thread.Sleep(200);
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ // Act & Assert for urgent request
+ var urgentRequest = new NotificationRequest("user-urgent", "Urgent message", true);
+ var urgentResult = await workflow.Execute(urgentRequest);
+
+ // Wait for the background tasks to complete or timeout
+ var timeoutTask = Task.Delay(2000);
+ await Task.WhenAny(
+ Task.WhenAll(urgentTaskCompleted.Task, regularTaskCompleted.Task),
+ timeoutTask);
+
+ urgentResult.IsRight.Should().BeTrue();
+ urgentResult.Right.Status.Should().Be("Processed");
+
+ lock (syncObj)
+ {
+ urgentTaskRan.Should().BeTrue(); // Urgent task should run for urgent requests
+ regularTaskRan.Should().BeTrue(); // Regular task should run for all requests
+ }
+
+ // Reset for next test
+ urgentTaskCompleted = new TaskCompletionSource();
+ regularTaskCompleted = new TaskCompletionSource();
+ lock (syncObj)
+ {
+ urgentTaskRan = false;
+ regularTaskRan = false;
+ }
+
+ // Act & Assert for regular request
+ var regularRequest = new NotificationRequest("user-regular", "Regular message", false);
+ var regularResult = await workflow.Execute(regularRequest);
+
+ // Wait for the background tasks to complete or timeout
+ timeoutTask = Task.Delay(2000);
+ await Task.WhenAny(regularTaskCompleted.Task, timeoutTask);
+
+ regularResult.IsRight.Should().BeTrue();
+ regularResult.Right.Status.Should().Be("Processed");
+
+ lock (syncObj)
+ {
+ urgentTaskRan.Should().BeFalse(); // Urgent task should NOT run for regular requests
+ regularTaskRan.Should().BeTrue(); // Regular task should run for all requests
+ }
+ }
+
+ [Fact]
+ public async Task Detached_WithMultipleActivities_ExecutesAllInOrder()
+ {
+ // Arrange
+ var detachedTasksCompleted = new TaskCompletionSource();
+ var syncObj = new object();
+ var executionOrder = new List();
+
+ var workflow = new WorkflowBuilder(
+ request => new NotificationPayload(request.UserId, request.Message, request.IsUrgent),
+ payload => new NotificationResult(payload.UserId, payload.Status)
+ )
+ .Do(payload =>
+ {
+ // Main workflow processing
+ lock (syncObj)
+ {
+ executionOrder.Add("Main");
+ }
+
+ return Either.FromRight(
+ payload with { IsProcessed = true, Status = "Processed" });
+ })
+ // Detached execution with multiple activities
+ .Detach(
+ detached => detached
+ .Do(payload =>
+ {
+ try
+ {
+ // First detached activity
+ lock (syncObj)
+ {
+ executionOrder.Add("Detached1");
+ }
+
+ // Simulate some work
+ Thread.Sleep(50);
+
+ return Either.FromRight(payload);
+ }
+ catch (Exception)
+ {
+ detachedTasksCompleted.SetException(new Exception("Failed in Detached1"));
+ throw;
+ }
+ })
+ .Do(payload =>
+ {
+ try
+ {
+ // Second detached activity - should run after the first one
+ lock (syncObj)
+ {
+ executionOrder.Add("Detached2");
+ }
+
+ // Simulate some work
+ Thread.Sleep(50);
+
+ return Either.FromRight(payload);
+ }
+ catch (Exception)
+ {
+ detachedTasksCompleted.SetException(new Exception("Failed in Detached2"));
+ throw;
+ }
+ })
+ .Do(payload =>
+ {
+ try
+ {
+ // Third detached activity - should run after the second one
+ lock (syncObj)
+ {
+ executionOrder.Add("Detached3");
+ }
+
+ // Notify that all detached tasks completed
+ detachedTasksCompleted.SetResult(true);
+
+ return Either.FromRight(payload);
+ }
+ catch (Exception ex)
+ {
+ detachedTasksCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ .Finally(payload =>
+ {
+ // Add a small delay to allow the background task to start
+ Thread.Sleep(200);
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ var request = new NotificationRequest("user-123", "Test message", false);
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Wait for the detached tasks to complete or timeout after 2 seconds
+ var timeoutTask = Task.Delay(2000);
+ var completedTask = await Task.WhenAny(detachedTasksCompleted.Task, timeoutTask);
+ var timedOut = completedTask == timeoutTask;
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ timedOut.Should().BeFalse("Detached tasks timed out");
+
+ // Lock to access shared state
+ List capturedOrder;
+ lock (syncObj)
+ {
+ capturedOrder = new List(executionOrder);
+ }
+
+ // Check that the main activity executed first
+ capturedOrder[0].Should().Be("Main");
+
+ // Check that the detached activities executed in order relative to each other
+ // We need to find the indices of each detached activity
+ int detached1Index = capturedOrder.IndexOf("Detached1");
+ int detached2Index = capturedOrder.IndexOf("Detached2");
+ int detached3Index = capturedOrder.IndexOf("Detached3");
+
+ // All detached activities should be found
+ detached1Index.Should().BeGreaterThan(0, "Detached1 should have executed");
+ detached2Index.Should().BeGreaterThan(0, "Detached2 should have executed");
+ detached3Index.Should().BeGreaterThan(0, "Detached3 should have executed");
+
+ // Check the order
+ detached1Index.Should().BeLessThan(detached2Index, "Detached1 should execute before Detached2");
+ detached2Index.Should().BeLessThan(detached3Index, "Detached2 should execute before Detached3");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/ParallelExecutionTests.cs b/Zooper.Bee.Tests/ParallelExecutionTests.cs
new file mode 100644
index 0000000..84ccd48
--- /dev/null
+++ b/Zooper.Bee.Tests/ParallelExecutionTests.cs
@@ -0,0 +1,403 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class ParallelExecutionTests
+{
+ #region Test Models
+ // Request model
+ private record TestRequest(string Id, int[] Values);
+
+ // Main payload model
+ private record TestPayload(
+ string Id,
+ int[] Values,
+ int Sum = 0,
+ int Product = 0,
+ bool IsProcessed = false);
+
+ // Success result model
+ private record TestSuccess(string Id, int Sum, int Product, bool IsProcessed);
+
+ // Error model
+ private record TestError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task Parallel_ExecutesGroupsInParallel_CombinesResults()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Id, request.Values),
+ payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed)
+ )
+ .Do(payload => Either.FromRight(
+ payload with { IsProcessed = true }))
+ .Parallel(
+ parallel => parallel
+ // First parallel group - calculate sum
+ .Group(
+ group => group
+ .Do(payload =>
+ {
+ int sum = 0;
+ foreach (var value in payload.Values)
+ {
+ sum += value;
+ }
+ return Either.FromRight(
+ payload with { Sum = sum });
+ })
+ )
+ // Second parallel group - calculate product
+ .Group(
+ group => group
+ .Do(payload =>
+ {
+ int product = 1;
+ foreach (var value in payload.Values)
+ {
+ product *= value;
+ }
+ return Either.FromRight(
+ payload with { Product = product });
+ })
+ )
+ )
+ .Build();
+
+ var request = new TestRequest("test-123", new[] { 2, 3, 5 });
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Sum.Should().Be(10); // 2 + 3 + 5
+ result.Right.Product.Should().Be(30); // 2 * 3 * 5
+ result.Right.IsProcessed.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Parallel_WithConditionalGroups_OnlyExecutesMatchingGroups()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Id, request.Values),
+ payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed)
+ )
+ .Parallel(
+ parallel => parallel
+ // Group that only runs when the ID starts with "sum-"
+ .Group(
+ // Condition
+ payload => payload.Id.StartsWith("sum-"),
+ group => group
+ .Do(payload =>
+ {
+ int sum = 0;
+ foreach (var value in payload.Values)
+ {
+ sum += value;
+ }
+ return Either.FromRight(
+ payload with { Sum = sum });
+ })
+ )
+ // Group that only runs when the ID starts with "product-"
+ .Group(
+ // Condition
+ payload => payload.Id.StartsWith("product-"),
+ group => group
+ .Do(payload =>
+ {
+ int product = 1;
+ foreach (var value in payload.Values)
+ {
+ product *= value;
+ }
+ return Either.FromRight(
+ payload with { Product = product });
+ })
+ )
+ // Group that always runs
+ .Group(
+ group => group
+ .Do(payload => Either.FromRight(
+ payload with { IsProcessed = true }))
+ )
+ )
+ .Build();
+
+ var sumRequest = new TestRequest("sum-123", new[] { 2, 3, 5 });
+ var productRequest = new TestRequest("product-456", new[] { 2, 3, 5 });
+ var otherRequest = new TestRequest("other-789", new[] { 2, 3, 5 });
+
+ // Act
+ var sumResult = await workflow.Execute(sumRequest);
+ var productResult = await workflow.Execute(productRequest);
+ var otherResult = await workflow.Execute(otherRequest);
+
+ // Assert
+ sumResult.IsRight.Should().BeTrue();
+ sumResult.Right.Sum.Should().Be(10); // 2 + 3 + 5
+ sumResult.Right.Product.Should().Be(0); // Not calculated
+ sumResult.Right.IsProcessed.Should().BeTrue();
+
+ productResult.IsRight.Should().BeTrue();
+ productResult.Right.Sum.Should().Be(0); // Not calculated
+ productResult.Right.Product.Should().Be(30); // 2 * 3 * 5
+ productResult.Right.IsProcessed.Should().BeTrue();
+
+ otherResult.IsRight.Should().BeTrue();
+ otherResult.Right.Sum.Should().Be(0); // Not calculated
+ otherResult.Right.Product.Should().Be(0); // Not calculated
+ otherResult.Right.IsProcessed.Should().BeTrue(); // Only the unconditional group ran
+ }
+
+ [Fact]
+ public async Task Parallel_ErrorInOneGroup_StopsExecutionAndReturnsError()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Id, request.Values),
+ payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed)
+ )
+ .Parallel(
+ parallel => parallel
+ // First group - calculate sum (will succeed)
+ .Group(
+ group => group
+ .Do(payload =>
+ {
+ int sum = 0;
+ foreach (var value in payload.Values)
+ {
+ sum += value;
+ }
+ return Either.FromRight(
+ payload with { Sum = sum });
+ })
+ )
+ // Second group - will fail if values contain zero
+ .Group(
+ group => group
+ .Do(payload =>
+ {
+ foreach (var value in payload.Values)
+ {
+ if (value == 0)
+ {
+ return Either.FromLeft(
+ new TestError("ZERO_VALUE", "Cannot process values containing zero"));
+ }
+ }
+
+ int product = 1;
+ foreach (var value in payload.Values)
+ {
+ product *= value;
+ }
+ return Either.FromRight(
+ payload with { Product = product });
+ })
+ )
+ )
+ .Build();
+
+ var validRequest = new TestRequest("valid", new[] { 2, 3, 5 });
+ var invalidRequest = new TestRequest("invalid", new[] { 2, 0, 5 });
+
+ // Act
+ var validResult = await workflow.Execute(validRequest);
+ var invalidResult = await workflow.Execute(invalidRequest);
+
+ // Assert
+ validResult.IsRight.Should().BeTrue();
+ validResult.Right.Sum.Should().Be(10);
+ validResult.Right.Product.Should().Be(30);
+
+ invalidResult.IsLeft.Should().BeTrue();
+ invalidResult.Left.Code.Should().Be("ZERO_VALUE");
+ invalidResult.Left.Message.Should().Be("Cannot process values containing zero");
+ }
+
+ [Fact]
+ public async Task ParallelDetached_DetachedGroupsDoNotAffectResult()
+ {
+ // Arrange
+ var backgroundTaskCompleted = new TaskCompletionSource();
+ var syncObj = new object();
+ var backgroundTaskRan = false;
+
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Id, request.Values),
+ payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed)
+ )
+ .Do(payload =>
+ {
+ int sum = 0;
+ foreach (var value in payload.Values)
+ {
+ sum += value;
+ }
+ return Either.FromRight(
+ payload with { Sum = sum, IsProcessed = true });
+ })
+ .ParallelDetached(
+ parallelDetached => parallelDetached
+ .Detached(
+ detachedGroup => detachedGroup
+ .Do(payload =>
+ {
+ try
+ {
+ // This is a detached task, its changes should not affect the main workflow
+ lock (syncObj)
+ {
+ backgroundTaskRan = true;
+ }
+
+ // Simulate some work
+ Thread.Sleep(100);
+
+ // This modification to Product should NOT be reflected in the final result
+ int product = 1;
+ foreach (var value in payload.Values)
+ {
+ product *= value;
+ }
+
+ backgroundTaskCompleted.SetResult(true);
+ return Either.FromRight(
+ payload with { Product = product });
+ }
+ catch (Exception ex)
+ {
+ backgroundTaskCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ )
+ .Finally(payload =>
+ {
+ // Add a small delay to allow the background task to start
+ Thread.Sleep(200);
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ var request = new TestRequest("test-123", new[] { 2, 3, 5 });
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Wait for the background task to complete or timeout after 2 seconds
+ var timeoutTask = Task.Delay(2000);
+ var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask);
+ var timedOut = completedTask == timeoutTask;
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Sum.Should().Be(10); // 2 + 3 + 5
+ result.Right.Product.Should().Be(0); // Should NOT be updated by detached group
+ result.Right.IsProcessed.Should().BeTrue();
+
+ // Verify that the background task did run (or was at least started)
+ lock (syncObj)
+ {
+ backgroundTaskRan.Should().BeTrue();
+ }
+
+ timedOut.Should().BeFalse("Background task timed out");
+ }
+
+ [Fact]
+ public async Task ParallelDetached_ErrorInDetachedGroup_DoesNotAffectMainWorkflow()
+ {
+ // Arrange
+ var backgroundTaskCompleted = new TaskCompletionSource();
+ var syncObj = new object();
+ var backgroundTaskRan = false;
+
+ var workflow = new WorkflowBuilder(
+ request => new TestPayload(request.Id, request.Values),
+ payload => new TestSuccess(payload.Id, payload.Sum, payload.Product, payload.IsProcessed)
+ )
+ .Do(payload =>
+ {
+ int sum = 0;
+ foreach (var value in payload.Values)
+ {
+ sum += value;
+ }
+ return Either.FromRight(
+ payload with { Sum = sum, IsProcessed = true });
+ })
+ .ParallelDetached(
+ parallelDetached => parallelDetached
+ .Detached(
+ detachedGroup => detachedGroup
+ .Do(payload =>
+ {
+ try
+ {
+ lock (syncObj)
+ {
+ backgroundTaskRan = true;
+ }
+
+ // Simulate some work
+ Thread.Sleep(100);
+
+ // This error should NOT affect the main workflow
+ backgroundTaskCompleted.SetResult(true);
+ return Either.FromLeft(
+ new TestError("BACKGROUND_ERROR", "This error occurs in background"));
+ }
+ catch (Exception ex)
+ {
+ backgroundTaskCompleted.SetException(ex);
+ throw;
+ }
+ })
+ )
+ )
+ .Finally(payload =>
+ {
+ // Add a small delay to allow the background task to start
+ Thread.Sleep(200);
+ return Either.FromRight(payload);
+ })
+ .Build();
+
+ var request = new TestRequest("test-123", new[] { 2, 3, 5 });
+
+ // Act
+ var result = await workflow.Execute(request);
+
+ // Wait for the background task to complete or timeout after 2 seconds
+ var timeoutTask = Task.Delay(2000);
+ var completedTask = await Task.WhenAny(backgroundTaskCompleted.Task, timeoutTask);
+ var timedOut = completedTask == timeoutTask;
+
+ // Assert
+ result.IsRight.Should().BeTrue(); // Main workflow should succeed
+ result.Right.Sum.Should().Be(10);
+ result.Right.IsProcessed.Should().BeTrue();
+
+ // Verify that the background task did run
+ lock (syncObj)
+ {
+ backgroundTaskRan.Should().BeTrue();
+ }
+
+ timedOut.Should().BeFalse("Background task timed out");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs b/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs
new file mode 100644
index 0000000..0c92d92
--- /dev/null
+++ b/Zooper.Bee.Tests/ParameterlessWorkflowTests.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class ParameterlessWorkflowTests
+{
+ #region Test Models
+ // Payload model for tests
+ private record TestPayload(DateTime StartTime, string Status = "Waiting");
+
+ // Success result model
+ private record TestSuccess(string Status, bool IsComplete);
+
+ // Error model
+ private record TestError(string Code, string Message);
+ #endregion
+
+ [Fact]
+ public async Task ParameterlessWorkflow_UsingUnitType_CanBeExecuted()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ // Convert Unit to initial payload
+ _ => new TestPayload(DateTime.UtcNow),
+
+ // Convert final payload to success result
+ payload => new TestSuccess(payload.Status, true)
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Processing" }))
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Completed" }))
+ .Build();
+
+ // Act
+ var result = await workflow.Execute(Unit.Value);
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Status.Should().Be("Completed");
+ result.Right.IsComplete.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ParameterlessWorkflow_UsingFactory_CanBeExecuted()
+ {
+ // Arrange
+ var workflow = WorkflowBuilderFactory.CreateWorkflow(
+ // Initial payload factory
+ () => new TestPayload(DateTime.UtcNow),
+
+ // Result selector
+ payload => new TestSuccess(payload.Status, true),
+
+ // Configure the workflow
+ builder => builder
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Processing" }))
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Completed" }))
+ );
+
+ // Act
+ var result = await workflow.Execute();
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Status.Should().Be("Completed");
+ result.Right.IsComplete.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ParameterlessWorkflow_UsingExtensionMethod_CanBeExecuted()
+ {
+ // Arrange
+ var workflow = new WorkflowBuilder(
+ _ => new TestPayload(DateTime.UtcNow),
+ payload => new TestSuccess(payload.Status, true)
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Processing" }))
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Completed" }))
+ .Build();
+
+ // Act - using extension method (no parameters)
+ var result = await workflow.Execute();
+
+ // Assert
+ result.IsRight.Should().BeTrue();
+ result.Right.Status.Should().Be("Completed");
+ result.Right.IsComplete.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ParameterlessWorkflow_WithError_ReturnsError()
+ {
+ // Arrange
+ var workflow = WorkflowBuilderFactory.Create(
+ () => new TestPayload(DateTime.UtcNow),
+ payload => new TestSuccess(payload.Status, true)
+ )
+ .Do(payload => Either.FromRight(
+ payload with { Status = "Processing" }))
+ .Do(payload =>
+ {
+ // Simulate an error in the workflow
+ return Either.FromLeft(
+ new TestError("PROCESSING_FAILED", "Failed to complete processing"));
+ })
+ .Build();
+
+ // Act
+ var result = await workflow.Execute();
+
+ // Assert
+ result.IsLeft.Should().BeTrue();
+ result.Left.Code.Should().Be("PROCESSING_FAILED");
+ result.Left.Message.Should().Be("Failed to complete processing");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee.Tests/WorkflowInternalsTests.cs b/Zooper.Bee.Tests/WorkflowInternalsTests.cs
index 3f74035..b0f8bde 100644
--- a/Zooper.Bee.Tests/WorkflowInternalsTests.cs
+++ b/Zooper.Bee.Tests/WorkflowInternalsTests.cs
@@ -1,4 +1,3 @@
-using System;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
@@ -33,7 +32,7 @@ public async Task DynamicBranchExecution_ConditionTrue_ExecutesActivities()
)
.Do(payload => Either.FromRight(
payload with { Result = "Initial processing" }))
- .BranchWithLocalPayload(
+ .WithContext(
// Condition - always true
payload => true,
@@ -76,7 +75,7 @@ public async Task DynamicBranchExecution_ConditionFalse_SkipsActivities()
)
.Do(payload => Either.FromRight(
payload with { Result = "Initial processing" }))
- .BranchWithLocalPayload(
+ .WithContext(
// Condition - always false
payload => false,
@@ -116,7 +115,7 @@ public async Task DynamicBranchExecution_ActivityReturnsError_PropagatesError()
request => new TestPayload(request.Name, request.Value),
payload => new TestSuccess(payload.Result ?? "No result")
)
- .BranchWithLocalPayload(
+ .WithContext(
// Condition - always true
payload => true,
@@ -152,7 +151,7 @@ public async Task DynamicBranchExecution_MultipleActivities_ExecutesInOrder()
request => new TestPayload(request.Name, request.Value),
payload => new TestSuccess(payload.Result ?? "No result")
)
- .BranchWithLocalPayload(
+ .WithContext(
// Condition - always true
payload => true,
@@ -216,7 +215,7 @@ public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently()
.Do(payload => Either.FromRight(
payload with { Result = "Start" }))
// First branch with first local payload type
- .BranchWithLocalPayload(
+ .WithContext(
payload => true,
payload => new TestLocalPayload("Branch 1 data"),
branch => branch
@@ -232,7 +231,7 @@ public async Task DynamicBranchExecution_MultipleBranches_ExecuteIndependently()
})
)
// Second branch with the same local payload type
- .BranchWithLocalPayload(
+ .WithContext(
payload => payload.Value > 0,
payload => new TestLocalPayload("Branch 2 data"),
branch => branch
diff --git a/Zooper.Bee.Tests/WorkflowTests.cs b/Zooper.Bee.Tests/WorkflowTests.cs
index 40177d2..fc28439 100644
--- a/Zooper.Bee.Tests/WorkflowTests.cs
+++ b/Zooper.Bee.Tests/WorkflowTests.cs
@@ -1,5 +1,3 @@
-using System;
-using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
diff --git a/Zooper.Bee.Tests/WorkflowWithContextTests.cs b/Zooper.Bee.Tests/WorkflowWithContextTests.cs
new file mode 100644
index 0000000..93cf514
--- /dev/null
+++ b/Zooper.Bee.Tests/WorkflowWithContextTests.cs
@@ -0,0 +1,457 @@
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Tests;
+
+public class WorkflowWithContextTests
+{
+ #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 context
+ 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 WithContext_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
+ });
+ })
+ // Context with local payload for products that need customization
+ .WithContext(
+ // 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"
+ ),
+
+ // Context configuration
+ context => context
+ // 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 WithContext_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
+ }))
+ .WithContext(
+ // Condition
+ payload => true,
+
+ // Create local payload
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Option1", "Option2" },
+ SelectedOptions: new[] { "Option1" },
+ CustomizationCost: 10.00m,
+ CustomizationDetails: "Context 1 customization"
+ ),
+
+ // Context configuration
+ context => context
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ ProcessingResult = mainPayload.ProcessingResult + " -> Context 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 context's local payload
+ .Do(payload => Either.FromRight(payload with
+ {
+ ProcessingResult = payload.ProcessingResult + " -> Main activity"
+ }))
+ .WithContext(
+ // Second context
+ payload => true,
+
+ // Create a different local payload
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "OptionA", "OptionB" },
+ SelectedOptions: new[] { "OptionA", "OptionB" },
+ CustomizationCost: 20.00m,
+ CustomizationDetails: "Context 2 customization"
+ ),
+
+ // Context configuration
+ context => context
+ .Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ ProcessingResult = mainPayload.ProcessingResult + " -> Context 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 -> Context 1 -> Context 2");
+ result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20
+ result.Right.CustomizationDetails.Should().Be("Context 1 customization + Context 2 customization");
+ }
+
+ [Fact]
+ public async Task WithContext_ErrorInContext_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" }))
+ .WithContext(
+ payload => payload.NeedsCustomProcessing,
+ _ => new CustomizationPayload(
+ AvailableOptions: new string[0],
+ SelectedOptions: new[] { "Unavailable Option" }, // This will cause an error
+ CustomizationCost: 10.00m,
+ CustomizationDetails: "Should fail"
+ ),
+ context => context
+ .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 WithContext_MultipleActivitiesInSameContext_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)
+ )
+ .WithContext(
+ _ => 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"
+ ),
+ context => context
+ // 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 WithContext_UnconditionalContext_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)
+ )
+ .WithContext(
+ // Local payload factory only
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Default Option" },
+ SelectedOptions: new[] { "Default Option" },
+ CustomizationCost: 5.00m,
+ CustomizationDetails: "Default customization"
+ ),
+ context => context
+ .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");
+ }
+
+ [Fact]
+ public async Task WithContext_UnconditionalContextFluentApi_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)
+ )
+ .WithContext(
+ // Local payload factory only (no condition parameter)
+ _ => new CustomizationPayload(
+ AvailableOptions: new[] { "Default Option" },
+ SelectedOptions: new[] { "Default Option" },
+ CustomizationCost: 5.00m,
+ CustomizationDetails: "Default customization (fluent API)"
+ ),
+ // Use callback pattern instead of fluent API
+ context => context.Do((mainPayload, localPayload) =>
+ {
+ var updatedMainPayload = mainPayload with
+ {
+ FinalPrice = mainPayload.Price + localPayload.CustomizationCost,
+ CustomizationDetails = localPayload.CustomizationDetails,
+ ProcessingResult = "Processed with fluent API"
+ };
+
+ 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 fluent API");
+ result.Right.CustomizationDetails.Should().Be("Default customization (fluent API)");
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Context/Context.cs b/Zooper.Bee/Features/Context/Context.cs
new file mode 100644
index 0000000..c5442fa
--- /dev/null
+++ b/Zooper.Bee/Features/Context/Context.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+
+namespace Zooper.Bee.Features.Context;
+
+///
+/// Represents a context in the workflow with its own local state and an optional condition.
+///
+/// Type of the main workflow payload
+/// Type of the local context state
+/// Type of the error
+internal sealed class Context : IWorkflowFeature
+{
+ ///
+ /// The condition that determines if this context should execute.
+ ///
+ public Func? Condition { get; }
+
+ ///
+ /// Contexts always merge back into the main workflow.
+ ///
+ public bool ShouldMerge => true;
+
+ ///
+ /// The factory function that creates the local state from the main payload.
+ ///
+ public Func LocalStateFactory { get; }
+
+ ///
+ /// The list of activities in this context that operate on both the main and local states.
+ ///
+ public List> Activities { get; } = new();
+
+ ///
+ /// Creates a new context with an optional condition.
+ ///
+ /// The condition that determines if this context should execute. If null, the context always executes.
+ /// The factory function that creates the local state
+ public Context(Func? condition, Func localStateFactory)
+ {
+ Condition = condition;
+ LocalStateFactory = localStateFactory ?? throw new ArgumentNullException(nameof(localStateFactory));
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Context/ContextActivity.cs b/Zooper.Bee/Features/Context/ContextActivity.cs
new file mode 100644
index 0000000..72d1c21
--- /dev/null
+++ b/Zooper.Bee/Features/Context/ContextActivity.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Features.Context;
+
+///
+/// Represents an activity in a context that operates on both the main workflow payload and a local state.
+///
+/// Type of the main workflow payload
+/// Type of the local context state
+/// Type of the error
+internal sealed class ContextActivity
+{
+ private readonly Func>> _activity;
+ private readonly string? _name;
+
+ ///
+ /// Creates a new context activity.
+ ///
+ /// The activity function that operates on both the main payload and local state
+ /// Optional name for the activity
+ public ContextActivity(
+ Func>> activity,
+ string? name = null)
+ {
+ _activity = activity ?? throw new ArgumentNullException(nameof(activity));
+ _name = name;
+ }
+
+ ///
+ /// Executes the activity with the provided payloads.
+ ///
+ /// The main workflow payload
+ /// The local context state
+ /// Cancellation token
+ /// Either an error or the updated payload and state
+ public Task> Execute(
+ TPayload mainPayload,
+ TLocalState localState,
+ CancellationToken token)
+ {
+ return _activity(mainPayload, localState, token);
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Context/ContextBuilder.cs b/Zooper.Bee/Features/Context/ContextBuilder.cs
new file mode 100644
index 0000000..fea807a
--- /dev/null
+++ b/Zooper.Bee/Features/Context/ContextBuilder.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Features.Context;
+
+///
+/// Builder for a context with a local state 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 context state
+/// The type of the success result
+/// The type of the error result
+public sealed class ContextBuilder
+{
+ private readonly WorkflowBuilder _workflow;
+ private readonly Context _context;
+
+ internal ContextBuilder(
+ WorkflowBuilder workflow,
+ Context context)
+ {
+ _workflow = workflow;
+ _context = context;
+ }
+
+ ///
+ /// Adds an activity to the context that operates on both the main payload and local state.
+ ///
+ /// The activity to add
+ /// The context builder for fluent chaining
+ public ContextBuilder Do(
+ Func>> activity)
+ {
+ _context.Activities.Add(new ContextActivity(activity));
+ return this;
+ }
+
+ ///
+ /// Adds a synchronous activity to the context that operates on both the main payload and local state.
+ ///
+ /// The activity to add
+ /// The context builder for fluent chaining
+ public ContextBuilder Do(
+ Func> activity)
+ {
+ _context.Activities.Add(new ContextActivity(
+ (mainPayload, localState, _) => Task.FromResult(activity(mainPayload, localState))
+ ));
+ return this;
+ }
+
+ ///
+ /// Adds multiple activities to the context.
+ ///
+ /// The activities to add
+ /// The context builder for fluent chaining
+ public ContextBuilder DoAll(
+ params Func>>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _context.Activities.Add(new ContextActivity(activity));
+ }
+ return this;
+ }
+
+ ///
+ /// Adds multiple synchronous activities to the context.
+ ///
+ /// The activities to add
+ /// The context builder for fluent chaining
+ public ContextBuilder DoAll(
+ params Func>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _context.Activities.Add(new ContextActivity(
+ (mainPayload, localState, _) => Task.FromResult(activity(mainPayload, localState))
+ ));
+ }
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Detached/Detached.cs b/Zooper.Bee/Features/Detached/Detached.cs
new file mode 100644
index 0000000..1a9fa44
--- /dev/null
+++ b/Zooper.Bee/Features/Detached/Detached.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using Zooper.Bee.Internal;
+
+namespace Zooper.Bee.Features.Detached;
+
+///
+/// Represents a detached group of activities in the workflow that doesn't merge back.
+///
+/// Type of the main workflow payload
+/// Type of the error
+internal sealed class Detached : IWorkflowFeature
+{
+ ///
+ /// The condition that determines if this detached group should execute.
+ ///
+ public Func? Condition { get; }
+
+ ///
+ /// Detached groups never merge back into the main workflow.
+ ///
+ public bool ShouldMerge => false;
+
+ ///
+ /// The list of activities in this detached group.
+ ///
+ public List> Activities { get; } = new();
+
+ ///
+ /// Creates a new detached group with an optional condition.
+ ///
+ /// The condition that determines if this detached group should execute. If null, the group always executes.
+ public Detached(Func? condition = null)
+ {
+ Condition = condition;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Detached/DetachedBuilder.cs b/Zooper.Bee/Features/Detached/DetachedBuilder.cs
new file mode 100644
index 0000000..bf395b2
--- /dev/null
+++ b/Zooper.Bee/Features/Detached/DetachedBuilder.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Bee.Internal;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Features.Detached;
+
+///
+/// Builder for a detached group 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 success result
+/// The type of the error result
+public sealed class DetachedBuilder
+{
+ private readonly WorkflowBuilder _workflow;
+ private readonly Detached _detached;
+
+ internal DetachedBuilder(
+ WorkflowBuilder workflow,
+ Detached detached)
+ {
+ _workflow = workflow;
+ _detached = detached;
+ }
+
+ ///
+ /// Adds an activity to the detached group.
+ ///
+ /// The activity to add
+ /// The detached builder for fluent chaining
+ public DetachedBuilder Do(
+ Func>> activity)
+ {
+ _detached.Activities.Add(new WorkflowActivity(activity));
+ return this;
+ }
+
+ ///
+ /// Adds a synchronous activity to the detached group.
+ ///
+ /// The activity to add
+ /// The detached builder for fluent chaining
+ public DetachedBuilder Do(
+ Func> activity)
+ {
+ _detached.Activities.Add(new WorkflowActivity(
+ (payload, _) => Task.FromResult(activity(payload))
+ ));
+ return this;
+ }
+
+ ///
+ /// Adds multiple activities to the detached group.
+ ///
+ /// The activities to add
+ /// The detached builder for fluent chaining
+ public DetachedBuilder DoAll(
+ params Func>>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _detached.Activities.Add(new WorkflowActivity(activity));
+ }
+ return this;
+ }
+
+ ///
+ /// Adds multiple synchronous activities to the detached group.
+ ///
+ /// The activities to add
+ /// The detached builder for fluent chaining
+ public DetachedBuilder DoAll(
+ params Func>[] activities)
+ {
+ foreach (var activity in activities)
+ {
+ _detached.Activities.Add(new WorkflowActivity(
+ (payload, _) => Task.FromResult(activity(payload))
+ ));
+ }
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Group/Group.cs b/Zooper.Bee/Features/Group/Group.cs
new file mode 100644
index 0000000..8c2d225
--- /dev/null
+++ b/Zooper.Bee/Features/Group/Group.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using Zooper.Bee.Internal;
+
+namespace Zooper.Bee.Features.Group;
+
+///
+/// Represents a group of activities in the workflow with an optional condition.
+///
+/// Type of the main workflow payload
+/// Type of the error
+internal sealed class Group : IWorkflowFeature
+{
+ ///
+ /// The condition that determines if this group should execute.
+ ///
+ public Func? Condition { get; }
+
+ ///
+ /// Groups always merge back into the main workflow.
+ ///
+ public bool ShouldMerge => true;
+
+ ///
+ /// The list of activities in this group.
+ ///
+ public List> Activities { get; } = new();
+
+ ///
+ /// Creates a new group with an optional condition.
+ ///
+ /// The condition that determines if this group should execute. If null, the group always executes.
+ public Group(Func? condition = null)
+ {
+ Condition = condition;
+ }
+}
\ No newline at end of file
diff --git a/Zooper.Bee/Features/Group/GroupBuilder.cs b/Zooper.Bee/Features/Group/GroupBuilder.cs
new file mode 100644
index 0000000..c81e983
--- /dev/null
+++ b/Zooper.Bee/Features/Group/GroupBuilder.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Zooper.Bee.Internal;
+using Zooper.Fox;
+
+namespace Zooper.Bee.Features.Group;
+
+///
+/// Builder for a group 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 success result
+/// The type of the error result
+public sealed class GroupBuilder
+{
+ private readonly WorkflowBuilder _workflow;
+ private readonly Group _group;
+
+ internal GroupBuilder(
+ WorkflowBuilder workflow,
+ Group group)
+ {
+ _workflow = workflow;
+ _group = group;
+ }
+
+ ///
+ /// Adds an activity to the group.
+ ///
+ /// The activity to add
+ /// The group builder for fluent chaining
+ public GroupBuilder Do(
+ Func>> activity)
+ {
+ _group.Activities.Add(new WorkflowActivity(activity));
+ return this;
+ }
+
+ ///
+ /// Adds a synchronous activity to the group.
+ ///
+ /// The activity to add
+ /// The group builder for fluent chaining
+ public GroupBuilder Do(
+ Func> activity)
+ {
+ _group.Activities.Add(new WorkflowActivity(
+ (payload, _) => Task.FromResult(activity(payload))
+ ));
+ return this;
+ }
+
+ ///