Chaining and Composing Operations - ulfbou/Zentient.Results GitHub Wiki

Chaining and Composing Operations

One of the most powerful features of Zentient.Results is its fluent API, which enables you to chain and compose operations in a declarative, readable, and highly robust manner. This approach dramatically reduces boilerplate if (result.IsSuccess) checks, simplifies error propagation, and promotes a more functional programming style.


1. The Core Idea: The "Result Monad" and Short-Circuiting

While we won't delve into category theory, Zentient.Results implements patterns inspired by the "Result Monad." The fundamental principle is short-circuiting:

  • If an operation in a chain succeeds, the success value is passed to the next operation in the chain.
  • If an operation in a chain fails, the failure (including its ErrorInfo and IResultStatus) is immediately propagated through the rest of the chain, and subsequent success-path operations are skipped.

This means you write your happy path linearly, and the error handling is managed implicitly by the chaining methods.


2. Map: Transforming the Value on Success

The Map (also known as Select) method allows you to transform the successful value inside a Result<T> into a new type U, producing a Result<U>. If the original result was a failure, Map simply passes that failure along unchanged.

Purpose: To apply a non-fallible transformation to the data.

Signature: IResult<T>.Map<U>(Func<T, U> selector) returns IResult<U>

Analogy: "If there's a treasure, polish it. If not, just pass the empty chest along."

using Zentient.Results;
using System;

public class DataProcessor
{
    public IResult<string> GetRawData(int id)
    {
        if (id == 1) return Result<string>.Success("{"name":"Alice", "age":30}");
        return Result<string>.NotFound(new ErrorInfo(ErrorCategory.NotFound, "DataNotFound", "Data not found."));
    }

    public IResult<int> GetUserAge(int userId)
    {
        return GetRawData(userId)
            .Map(jsonData => // This lambda is only executed if GetRawData succeeds
            {
                // Simulate JSON parsing and extraction
                // In a real app, use System.Text.Json or similar
                var ageString = jsonData.Split(',')[1].Split(':')[1].Replace("}", "");
                return int.Parse(ageString);
            });
    }

    public void Run()
    {
        IResult<int> ageResult = GetUserAge(1);
        if (ageResult.IsSuccess)
        {
            Console.WriteLine($"User age: {ageResult.Value}"); // Output: User age: 30
        }

        IResult<int> failedAgeResult = GetUserAge(99);
        if (failedAgeResult.IsFailure)
        {
            Console.WriteLine($"Failed to get age: {failedAgeResult.Error}"); // Output: Failed to get age: Data not found.
        }
    }
}

3. Bind (or SelectMany): Chaining Operations that Return a Result

The Bind method is the cornerstone of sequential, fallible operations. It allows you to chain an operation that also returns a Result. If the initial result is a success, the Bind function is executed, and its Result is then returned. If the initial result was a failure, the Bind function is skipped, and the original failure is propagated.

Purpose: To chain operations where each step can introduce its own success or failure.

Signature: IResult<T>.Bind<U>(Func<T, IResult<U>> selector) returns IResult<U>

Analogy: "If there's a treasure, take it to the next safe, but that safe might be empty or locked. If the first chest was empty, forget the next safe entirely."

using Zentient.Results;
using System;

public class CustomerService
{
    // Step 1: Fetch a customer by ID (can fail if not found)
    public IResult<Customer> GetCustomer(Guid customerId)
    {
        if (customerId == Guid.Empty)
            return Result<Customer>.BadRequest(new ErrorInfo(ErrorCategory.Validation, "InvalidId", "Customer ID cannot be empty."));
        if (customerId == new Guid("00000000-0000-0000-0000-000000000001"))
            return Result<Customer>.Success(new Customer { Id = customerId, Name = "Alice" });
        return Result<Customer>.NotFound(new ErrorInfo(ErrorCategory.NotFound, "CustomerNotFound", "Customer not found."));
    }

    // Step 2: Check customer eligibility for a discount (can fail if not eligible)
    public IResult<Discount> CheckEligibility(Customer customer)
    {
        if (customer.Name == "Alice")
            return Result<Discount>.Success(new Discount { Amount = 10.0m });
        return Result<Discount>.Forbidden(new ErrorInfo(ErrorCategory.Authorization, "NotEligible", "Customer not eligible for discount."));
    }

    // Composed operation: Get customer and then check discount eligibility
    public IResult<Discount> GetCustomerDiscount(Guid customerId)
    {
        return GetCustomer(customerId) // Returns IResult<Customer>
            .Bind(customer => CheckEligibility(customer)); // If successful, pass customer to next fallible operation
    }

    public void Run()
    {
        // Scenario 1: Success
        var discountResult1 = GetCustomerDiscount(new Guid("00000000-0000-0000-0000-000000000001"));
        if (discountResult1.IsSuccess)
        {
            Console.WriteLine($"Discount granted: ${discountResult1.Value.Amount}"); // Output: Discount granted: $10.0
        }

        // Scenario 2: First step fails (Customer Not Found)
        var discountResult2 = GetCustomerDiscount(new Guid("00000000-0000-0000-0000-000000000002"));
        if (discountResult2.IsFailure)
        {
            Console.WriteLine($"Failed to get discount: {discountResult2.Error}"); // Output: Failed to get discount: Customer not found.
        }

        // Scenario 3: Second step fails (Not Eligible)
        var discountResult3 = GetCustomerDiscount(new Guid("00000000-0000-0000-0000-000000000003")); // Assume this ID leads to a non-Alice customer
        if (discountResult3.IsFailure)
        {
            Console.WriteLine($"Failed to get discount: {discountResult3.Error}"); // Output: Failed to get discount: Customer not eligible for discount.
        }
    }
}
public class Customer { public Guid Id { get; set; } public string Name { get; set; } }
public class Discount { public decimal Amount { get; set; } }

4. OnSuccess (Tap / Do): Performing Side Effects on Success

OnSuccess allows you to execute an action (a void method) only if the Result is successful, without altering the Result itself. It's excellent for injecting side effects like logging, auditing, or dispatching events.

Purpose: Perform an action upon success without breaking the chain.

Signature: IResult<T>.OnSuccess(Action<T> action) returns IResult<T> (the original result) Signature: IResult.OnSuccess(Action action) returns IResult (the original result)

Analogy: "If you find the treasure, celebrate! Then, pass the treasure along."

using Zentient.Results;
using System;

public class Workflow
{
    public IResult<string> SaveData(string data)
    {
        if (string.IsNullOrWhiteSpace(data))
            return Result<string>.BadRequest(new ErrorInfo(ErrorCategory.Validation, "NoData", "Data cannot be empty."));

        // Simulate save operation
        Console.WriteLine($"Saving data: {data.Substring(0, Math.Min(data.Length, 10))}...");
        return Result<string>.Success(data);
    }

    public void ProcessData(string dataToProcess)
    {
        SaveData(dataToProcess)
            .OnSuccess(savedData => Console.WriteLine($"Successfully saved data: {savedData.Length} chars. Dispatching event.")) // This runs on success
            .OnFailure(errors => Console.Error.WriteLine($"Failed to save data: {errors.First().Message}")); // This runs on failure
            // The original Result<string> is still returned by OnSuccess/OnFailure
    }

    public void Run()
    {
        ProcessData("important information"); // Will log success and event
        ProcessData(""); // Will log failure
    }
}

5. OnFailure (Tap / Do): Performing Side Effects on Failure

Similar to OnSuccess, OnFailure allows you to execute an action only if the Result is a failure. This is perfect for logging errors, updating failure metrics, or performing specific cleanup.

Purpose: Perform an action upon failure without breaking the chain.

Signature: IResult<T>.OnFailure(Action<IReadOnlyList<ErrorInfo>> action) returns IResult<T> (the original result) Signature: IResult.OnFailure(Action<IReadOnlyList<ErrorInfo>> action) returns IResult (the original result)

Analogy: "If the chest was empty, cry about it! Then, pass the empty chest along."

(See example in OnSuccess section above for combined usage.)


6. Then (Non-Generic Chaining)

The Then method is an extension specifically for chaining non-generic IResult operations. It behaves like Bind but for Result (no value transformation). If the preceding IResult is a success, the Func<IResult> is executed.

Purpose: Chain IResult operations.

Signature: IResult.Then(Func<IResult> nextOperation) returns IResult

using Zentient.Results;
using System;

public class TransactionService
{
    public IResult DebitAccount(decimal amount)
    {
        if (amount <= 0) return Result.BadRequest(new ErrorInfo(ErrorCategory.Validation, "InvalidAmount", "Amount must be positive."));
        // Simulate debit
        Console.WriteLine($"Account debited: ${amount}");
        return Result.Success();
    }

    public IResult CreditAccount(decimal amount)
    {
        // Simulate credit
        Console.WriteLine($"Account credited: ${amount}");
        return Result.Success();
    }

    public IResult LogTransaction(Guid transactionId)
    {
        // Simulate logging
        Console.WriteLine($"Transaction {transactionId} logged.");
        return Result.Success();
    }

    public IResult PerformTransfer(decimal amount)
    {
        Guid transactionId = Guid.NewGuid();
        return DebitAccount(amount)
            .Then(() => CreditAccount(amount)) // Only credits if debit was successful
            .Then(() => LogTransaction(transactionId)) // Only logs if both debit and credit were successful
            .OnFailure(errors => Console.Error.WriteLine($"Transfer failed: {errors.First().Message}"));
    }

    public void Run()
    {
        PerformTransfer(100.0m); // Success
        PerformTransfer(-50.0m); // Failure due to invalid amount
    }
}

7. Match: Exhaustive Handling of Outcomes

The Match method provides a clean, expressive way to handle both the success and failure paths of a Result<T> in a single construct. It forces you to define logic for both scenarios, enhancing robustness.

Purpose: Define distinct actions for success and failure branches.

Signature: IResult<T>.Match(Action<T> onSuccess, Action<IReadOnlyList<ErrorInfo>> onFailure)

using Zentient.Results;
using System;
using System.Linq; // For .First() or .Any()

public class ReportService
{
    public IResult<ReportData> GenerateDailyReport()
    {
        // Simulate potential failures
        if (DateTime.Today.DayOfWeek == DayOfWeek.Saturday)
            return Result<ReportData>.Failure(
                default,
                new ErrorInfo(ErrorCategory.Validation, "NoWeekendReport", "Reports cannot be generated on weekends."),
                ResultStatuses.BadRequest);

        // Simulate success
        return Result<ReportData>.Success(new ReportData { Id = Guid.NewGuid(), Title = "Daily Sales Report" });
    }

    public void DisplayReportOutcome()
    {
        GenerateDailyReport().Match(
            onSuccess: report =>
            {
                Console.WriteLine($"Report '{report.Title}' ({report.Id}) generated successfully.");
                // Further processing of 'report'
            },
            onFailure: errors =>
            {
                Console.Error.WriteLine("Report generation failed:");
                foreach (var error in errors)
                {
                    Console.Error.WriteLine($"- {error.Message} (Code: {error.Code}, Category: {error.Category})");
                    if (error.Data != null) Console.Error.WriteLine($"  Data: {error.Data}");
                }
            }
        );
    }
}
public class ReportData { public Guid Id { get; set; } public string Title { get; set; } }

8. Combining Generic and Non-Generic Operations

It's common to have a chain that starts with a Result<T> but ends with a Result (e.g., fetching data, then performing a void-returning update).

You can use Bind where the lambda returns an IResult, effectively converting IResult<T> to IResult in the chain:

using Zentient.Results;
using System;

public class AccountManager
{
    public IResult<Account> GetAccountById(Guid id)
    {
        if (id == Guid.Empty) return Result<Account>.BadRequest(new ErrorInfo(ErrorCategory.Validation, "InvalidId", "ID cannot be empty."));
        if (id == new Guid("00000000-0000-0000-0000-000000000001")) return Result<Account>.Success(new Account { Id = id, Balance = 1000m });
        return Result<Account>.NotFound(new ErrorInfo(ErrorCategory.NotFound, "AccountNotFound", "Account not found."));
    }

    public IResult DeductBalance(Account account, decimal amount)
    {
        if (account.Balance < amount) return Result.BadRequest(new ErrorInfo(ErrorCategory.Conflict, "InsufficientFunds", "Insufficient funds."));
        account.Balance -= amount; // Simulate state change
        Console.WriteLine($"Deducted {amount} from account {account.Id}. New balance: {account.Balance}");
        return Result.Success();
    }

    public IResult PerformTransaction(Guid accountId, decimal amountToDeduct)
    {
        return GetAccountById(accountId) // IResult<Account>
            .Bind(account => DeductBalance(account, amountToDeduct)) // IResult (deducts, returns non-generic Result)
            .OnFailure(errors => Console.Error.WriteLine($"Transaction failed: {errors.First().Message}"));
    }

    public void Run()
    {
        PerformTransaction(new Guid("00000000-0000-0000-0000-000000000001"), 500m); // Success
        PerformTransaction(new Guid("00000000-0000-0000-0000-000000000001"), 2000m); // Insufficient funds
        PerformTransaction(Guid.Empty, 100m); // Invalid ID
    }
}
public class Account { public Guid Id { get; set; } public decimal Balance { get; set; } }

9. Benefits of Chaining

  • Readability: Code flows linearly from left to right, representing the sequence of operations.
  • Robustness: Automatic short-circuiting ensures that subsequent operations are not executed if an earlier one failed.
  • Reduced Boilerplate: Significantly cuts down on if (result.IsSuccess) checks, leading to cleaner code.
  • Testability: Each step in the chain can be tested in isolation, and the composition itself is declarative.
  • Error Propagation: Failures, along with their structured ErrorInfo, are naturally passed through the chain.

Next Steps

Mastering chaining and composition is key to building powerful applications with Zentient.Results. Continue your learning by exploring:

⚠️ **GitHub.com Fallback** ⚠️