LINQ is Not Just for SQL: Elegant Error Workflows

Introduction

Modern C# development often falls into the “Pyramid of Doom” – a series of nested if statements, checking for nulls or success flags which push your actual business logic to the far right of the screen.

By using Esox.SharpAndRusty you can treat Result<T,E> as a “container” that contains either one success or one error. This allows you to use query syntac to chain complex operations into a single, declarative pipeline.

The power of from...select: Logic as Narrative

In standard LINQ from iterates over a collection. When applied to a Result from acts a gatekeeper. If the previous operation return Ok the gate opens and the value is unwrapped for the next step. If an Err occurs, the gate stays close, the rest of the chain is short-circuited and the error propagates to the end automatically:

            var greetingResult = from id in GetUserId("admin")
                from user in FetchUser(id)
                select $"Hello {user.Name}";

            greetingResult.Match<Unit>(
                success: greeting =>
                {
                     Console.WriteLine(greeting);
                     return Unit.Value;
                },
                failure: error =>
                {
                    Console.WriteLine(error.Message);
                    return Unit.Value;
                });

Both GetUserId() and FetchUser() return a Result<>. Therefore greetingResult is also a Result<>, and so we can use match to evaluate the result.

Multi-Step Validations: Orchestrating Services

Real-world workflows often require data from multiple independent services. Using Select() or SelectMany() SharpAndRusty lets you maintain access to all previous success values throughout the chain. You don’t have to pass variables down through nested lambdas, they are all available in the final select scope:

var profileResult= from id in FirstService(10)
    from userGuid in SecondService("userId")
    select new Profile(id,userGuid);

profileResult.Match<Unit>(
    success: profile =>
    {
        Console.WriteLine($"Profile Id: {profile.Id}, UserGuid: {profile.UserGuid}");
        return Unit.Value;
    },
    failure: error =>
    {
        Console.WriteLine(error.Message);
        return Unit.Value;
    });

In this example, both FirstService() and SecondService() return a Result<>. As you can see both the id and the userGuid are passed down to the final select.

Why there is no where clause.

In SharpAndRusty the where clause is intentionally omitted. A where clause requires a boolean predicate. If it returns false, the system would not now which Error to return to the caller. To maintain a high-quality error-context, you should use Bind() with a validation function which returns a specific descriptive Error object:

var validatedResult = GetAge()
    .Bind(age => age >= 18 
        ? Result<int, Error>.Ok(age) 
        : Result<int, Error>.Err(Error.New("Must be an adult", ErrorKind.InvalidInput)));

Rich Error Context and Metadata

The SharpAndRusty library includes an Error type that allows to chain contexts as a failure travels back up the call stack. You can attach type-safe metadata, like IDs or timestamps, and categorize errors using ErrorKind, like NotFound, PermissionDenied or Timeout:

var richResult = GetOrder(orderId)
    .Context("Checkout failed at the retrieval stage") // Adds to the error chain
    .WithMetadata("OrderId", orderId)                  // Type-safe metadata
    .WithKind(ErrorKind.NotFound);                     // Categorizes the error

Extensive Example: Production Checkout Workflow

Let’s have a look at a more extensive example, an example checkout workflow:

using Esox.SharpAndRusty.Types;
using Esox.SharpAndRusty.Extensions;

public async Task<Result<Invoice, Error>> ProcessOrderAsync(int userId, Guid productId, CancellationToken ct)
{
    // The entire narrative is captured in a single LINQ expression
    var workflow = 
        from user in _db.GetUserAsync(userId, ct)
            .Context("Failed to identify customer")
        
        from product in _db.GetProductAsync(productId, ct)
            .Context($"Product {productId} is unavailable")
            
        // Explicit validation step instead of 'where'
        from _ in EnsureEligible(user, product)
            
        from payment in _paymentGateway.ChargeAsync(user.CreditCard, product.Price, ct)
            .WithMetadata("Retryable", true)
            .Context("Payment processor rejected the transaction")
            
        select new Invoice 
        { 
            CustomerName = user.Name, 
            AmountPaid = product.Price, 
            TransactionId = payment.TransactionId,
            Timestamp = DateTime.UtcNow
        };

    return await workflow; // Short-circuits at the first failure
}

private Result<bool, Error> EnsureEligible(User user, Product product)
{
    if (user.IsBanned)
        return Result<bool, Error>.Err(Error.New("Account is restricted", ErrorKind.PermissionDenied));
    
    if (product.StockLevel <= 0)
        return Result<bool, Error>.Err(Error.New("Item out of stock", ErrorKind.ResourceExhausted));

    return Result<bool, Error>.Ok(true);
}

Some notes:

  • The invoice is constructed in a single asynchronous LINQ query.
  • Note the use of Context() and WithMetadata() to add context and data to possible errors.
  • The EnsureEligible() method is used to make sure the person has the right age and the product is available. If this method returns Err the rest of the LINQ query is short circuited and an Err is returned.

You can see how validation and error handling is now handles from within the LINQ query making it easier to read and easier to maintain. Other benefits of this approach are:

  • Immutability: Implemented as readonly structs for optimal performance.
  • Safety: Eliminates NullReferenceExceptions by using Option<T> and Result<T,E>.
  • Observability: If the process fails, error.GetFullMessage() provides a deep metadata-rich trace of exactly where and why the failure occurred.

Conclusion

By repurposing LINQ from a simple data-querying tool into a functional pipeline for error handling, Esox.SharpAndRusty effectively dismantles the “Pyramid of Doom”. This approach transforms complex, brittle branching logic into a declarative narrative that is as easy to read as a recipe. Instead of obscuring business logic with boilerplate null checks and try-catch bloks, developers can focus on the “Happy Path” while the underlying Result<T,E> container handles short-circuiting and error propagation automatically.

The shift towards Railway Oriented Programming in C# provides more than just aesthetic “syntactic sugar”. It introduces a standar for observability and safetly. By leveraging rich error context, type-safe metadata and immutable structures, you can ensure that failures are not just caught, but are meaningful and traceable. As modern applications grow in complexity, moving away from exception-based flow control towards explicit LINQ-driven workflows allows for codebases which are basically self-documenting, easier to test, and significantly more resilient. Embracing this pattern means treating error not a unexpected disruptions, but as first-class citizens in your application’s logic.

The Code Nomad
The Code Nomad
Articles: 165

Leave a Reply

Your email address will not be published. Required fields are marked *