Chaining and Composing Operations - ulfbou/Zentient.Results GitHub Wiki
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.
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
andIResultStatus
) 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.
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.
}
}
}
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; } }
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
}
}
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.)
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
}
}
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; } }
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; } }
- 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.
Mastering chaining and composition is key to building powerful applications with Zentient.Results
. Continue your learning by exploring:
- Basic Usage Patterns: Reinforce your understanding of common result creation and consumption.
-
Structured Error Handling with
ErrorInfo
andErrorCategory
: Deepen your knowledge of the error details propagated through chains. - Integrating with ASP.NET Core: See how these patterns are applied in web API contexts.