BrandGhost
Error Handling in C# Pipelines: Exceptions, Results, and Short-Circuiting

Error Handling in C# Pipelines: Exceptions, Results, and Short-Circuiting

Error handling in C# pipelines starts with a decision that many implementations leave implicit: what does it mean when a stage cannot produce its normal output? An invalid order, a canceled request, a network failure, and a programming bug are not interchangeable outcomes. Treating all four as either exceptions or result values makes the pipeline harder for callers to understand.

A useful failure model separates expected rejection from unexpected execution failure. Expected rejection becomes a typed result that the pipeline can short-circuit deliberately. Unexpected exceptions remain exceptions. Cancellation stays a distinct cooperative control path. Once stages can create side effects, the design must also identify partial effects, idempotency boundaries, and exactly one owner for any retry.

This article builds one complete example around those rules. It does not promise rollback, exactly-once execution, or a universal retry policy. Those guarantees require concrete mechanisms that a generic pipeline type cannot provide.

Outcome Representation in this article Continue to later stages?
Success Succeeded<T> Yes, when another stage remains
Expected rejection Rejected<T> No
Caller cancellation Canceled task No
Unexpected failure Faulted task with the original exception No

Separate Expected Rejection From Unexpected Failure

An expected rejection is a normal business decision, even when it prevents further processing. Examples include a missing SKU, a quantity outside an accepted range, or a request that violates a documented rule. The application knows these outcomes can happen and usually needs a stable code it can present, log, count, or map to an API response.

An unexpected failure means execution did not behave according to the stage's normal contract. A dependency might be unavailable, a file might be corrupt in an unforeseen way, or an invariant in the code might be broken. The current layer should catch such an exception only when it can recover or add meaningful behavior without destroying the original failure information.

The .NET exception best-practices guidance recommends designing for common conditions rather than relying on exceptions for routine control flow. A typed result follows that direction for expected domain outcomes. It does not mean every possible fault should be converted into an error code.

For this example, the result has two cases:

  • Succeeded<T> carries the next value.
  • Rejected<T> carries a controlled rejection code and a safe message.

There is no Failed<T> case. Unexpected failures propagate as exceptions. Cancellation also propagates instead of being packed into a result. That is one explicit model, not the only possible model.

A team could choose a richer result type containing transient and permanent failure cases. The tradeoff is that every caller must now handle those cases, and stack-rich unexpected failures can become flattened into strings or codes. Another team might use exceptions for everything. That keeps signatures simple but makes expected rejection less visible and encourages exception-driven branching. The right choice depends on what callers need to distinguish.

The most important rule is consistency. A stage should not sometimes return Rejected for the same condition and sometimes throw an exception based on which implementation happened to run.

A Complete Failure-Aware Pipeline

The following code is one conceptual C# file targeting the stable .NET 10 support baseline as of August 2026 with C# 14. It validates an order, reserves inventory through one retry-owning external boundary, and creates a receipt. The in-memory gateway intentionally simulates one transient failure so the repeat-safe retry path is executable.

using System.Diagnostics;

namespace PipelineFailureExample;

public sealed record OrderSubmission(
    Guid OperationId,
    string Sku,
    int Quantity);

public sealed record ValidatedOrder(
    Guid OperationId,
    string Sku,
    int Quantity);

public sealed record InventoryReservation(
    Guid ReservationId,
    Guid OperationId,
    string Sku,
    int Quantity);

public sealed record OrderReceipt(
    Guid OperationId,
    Guid ReservationId);

public abstract record PipelineResult<T>
{
    protected PipelineResult()
    {
    }

    public sealed record Succeeded(T Value) : PipelineResult<T>;

    public sealed record Rejected(
        string Code,
        string Message) : PipelineResult<T>;
}

public sealed class TransientInventoryException : Exception
{
    public TransientInventoryException(string message)
        : base(message)
    {
    }
}

public interface IInventoryGateway
{
    Task<InventoryReservation> ReserveAsync(
        Guid idempotencyKey,
        string sku,
        int quantity,
        CancellationToken cancellationToken);
}

public sealed class InMemoryInventoryGateway : IInventoryGateway
{
    private readonly object _sync = new();
    private readonly Dictionary<Guid, InventoryReservation> _reservations = [];
    private readonly HashSet<Guid> _failedFirstAttempts = [];

    public Task<InventoryReservation> ReserveAsync(
        Guid idempotencyKey,
        string sku,
        int quantity,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        lock (_sync)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (_reservations.TryGetValue(
                idempotencyKey,
                out InventoryReservation? existing))
            {
                return Task.FromResult(existing);
            }

            if (_failedFirstAttempts.Add(idempotencyKey))
            {
                throw new TransientInventoryException(
                    "The inventory dependency was temporarily unavailable.");
            }

            var reservation = new InventoryReservation(
                Guid.NewGuid(),
                idempotencyKey,
                sku,
                quantity);

            _reservations.Add(idempotencyKey, reservation);
            return Task.FromResult(reservation);
        }
    }
}

public sealed class ValidateOrderStage
{
    public Task<PipelineResult<ValidatedOrder>> ExecuteAsync(
        OrderSubmission input,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (string.IsNullOrWhiteSpace(input.Sku))
        {
            return Task.FromResult<PipelineResult<ValidatedOrder>>(
                new PipelineResult<ValidatedOrder>.Rejected(
                    "order.sku_required",
                    "A SKU is required."));
        }

        if (input.Quantity is < 1 or > 100)
        {
            return Task.FromResult<PipelineResult<ValidatedOrder>>(
                new PipelineResult<ValidatedOrder>.Rejected(
                    "order.quantity_out_of_range",
                    "Quantity must be between 1 and 100."));
        }

        return Task.FromResult<PipelineResult<ValidatedOrder>>(
            new PipelineResult<ValidatedOrder>.Succeeded(
                new ValidatedOrder(
                    input.OperationId,
                    input.Sku,
                    input.Quantity)));
    }
}

public sealed class ReserveInventoryStage
{
    private const int MaxAttempts = 2;

    private static readonly TimeSpan RetryDelay =
        TimeSpan.FromMilliseconds(25);

    private readonly IInventoryGateway _gateway;

    public ReserveInventoryStage(IInventoryGateway gateway)
    {
        _gateway = gateway;
    }

    public async Task<InventoryReservation> ExecuteAsync(
        ValidatedOrder input,
        CancellationToken cancellationToken)
    {
        for (int attempt = 1; attempt <= MaxAttempts; attempt++)
        {
            try
            {
                return await _gateway.ReserveAsync(
                    input.OperationId,
                    input.Sku,
                    input.Quantity,
                    cancellationToken);
            }
            catch (TransientInventoryException)
                when (attempt < MaxAttempts)
            {
                await Task.Delay(
                    RetryDelay,
                    cancellationToken);
            }
        }

        throw new InvalidOperationException(
            "The inventory retry loop ended unexpectedly.");
    }
}

public sealed class OrderPipeline
{
    private static readonly ActivitySource ActivitySource =
        new("DevLeader.OrderPipeline", "1.0.0");

    private readonly ValidateOrderStage _validator;
    private readonly ReserveInventoryStage _inventory;

    public OrderPipeline(
        ValidateOrderStage validator,
        ReserveInventoryStage inventory)
    {
        _validator = validator;
        _inventory = inventory;
    }

    public async Task<PipelineResult<OrderReceipt>> RunAsync(
        OrderSubmission input,
        CancellationToken cancellationToken)
    {
        using Activity? activity = ActivitySource.StartActivity(
            "order.pipeline",
            ActivityKind.Internal);

        try
        {
            PipelineResult<ValidatedOrder> validation =
                await _validator.ExecuteAsync(
                    input,
                    cancellationToken);

            if (validation is
                PipelineResult<ValidatedOrder>.Rejected rejected)
            {
                activity?.SetTag(
                    "pipeline.outcome",
                    "rejected");

                activity?.SetTag(
                    "pipeline.rejection.code",
                    rejected.Code);

                return new PipelineResult<OrderReceipt>.Rejected(
                    rejected.Code,
                    rejected.Message);
            }

            ValidatedOrder validated = validation switch
            {
                PipelineResult<ValidatedOrder>.Succeeded succeeded =>
                    succeeded.Value,
                _ => throw new InvalidOperationException(
                    "The validation stage returned an unknown result.")
            };

            InventoryReservation reservation =
                await _inventory.ExecuteAsync(
                    validated,
                    cancellationToken);

            var receipt = new OrderReceipt(
                validated.OperationId,
                reservation.ReservationId);

            activity?.SetTag(
                "pipeline.outcome",
                "succeeded");

            return new PipelineResult<OrderReceipt>.Succeeded(
                receipt);
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            activity?.SetTag(
                "pipeline.outcome",
                "canceled");

            throw;
        }
        catch (Exception exception)
        {
            activity?.SetTag(
                "pipeline.outcome",
                "failed");

            activity?.SetTag(
                "error.type",
                exception.GetType().FullName);

            activity?.SetStatus(ActivityStatusCode.Error);
            throw;
        }
    }
}

public static class Program
{
    public static async Task Main()
    {
        var pipeline = new OrderPipeline(
            new ValidateOrderStage(),
            new ReserveInventoryStage(
                new InMemoryInventoryGateway()));

        using var cancellationSource =
            new CancellationTokenSource(TimeSpan.FromSeconds(10));

        var submission = new OrderSubmission(
            Guid.NewGuid(),
            "BOOK-001",
            2);

        PipelineResult<OrderReceipt> result =
            await pipeline.RunAsync(
                submission,
                cancellationSource.Token);

        switch (result)
        {
            case PipelineResult<OrderReceipt>.Succeeded succeeded:
                Console.WriteLine(
                    $"Reservation: {succeeded.Value.ReservationId}");
                break;

            case PipelineResult<OrderReceipt>.Rejected rejected:
                Console.WriteLine(
                    $"Rejected: {rejected.Code}");
                break;
        }
    }
}

The validation stage owns expected business rejection. When it returns Rejected, OrderPipeline does not invoke the inventory stage. That is deliberate short-circuiting: the next stage requires a ValidatedOrder, and rejection means no such value exists.

The inventory stage owns one narrow retry boundary. It retries only TransientInventoryException, uses the same OperationId as an idempotency key, passes cancellation to both the external call and the delay, and allows the final failed attempt to propagate. Validation is not retried. Receipt creation is not retried. The whole pipeline is not wrapped in another retry.

The runner owns translating its four observable outcomes into controlled telemetry: succeeded, rejected, canceled, and failed. It does not record the SKU, operation ID, exception message, or serialized input.

Typed Result Tradeoffs

A typed result makes expected outcomes visible in the method signature. A caller cannot receive a ValidatedOrder without first handling the possibility of rejection. That is useful when rejected input is common enough to be part of the ordinary application contract.

Typed results also support stable codes drawn from a bounded set, which fits the .NET OpenTelemetry guidance for controlling metric cardinality. order.sku_required can be mapped to a UI message or API response without parsing an exception message. Tests can assert the code without depending on prose.

The cost is additional branching and type design. Every generic result case increases the number of states callers must understand. A result carrying ten loosely defined failure categories can be less clear than a small exception hierarchy. Results can also encourage developers to put raw exception messages into public error values, which leaks implementation details and may expose sensitive data.

Exceptions have different strengths. They preserve diagnostic stack information when rethrown correctly, as described by .NET exception best practices, and interrupt normal control flow. When a dependency violates its contract or an invariant is broken, continuing as if the stage returned an ordinary alternative is usually dangerous.

There is no rule that every pipeline must choose results or exceptions exclusively. The example uses results for expected rejection and exceptions for unexpected failure. The boundary is semantic, not ideological.

If your application already has a well-defined result abstraction, use it consistently. A custom type invented independently for every pipeline creates conversion noise. If exceptions are already the documented domain contract, changing to results should be justified by caller behavior rather than fashion.

Short-Circuiting Must Be Part of the Contract

Short-circuiting means the pipeline intentionally stops invoking later stages after a terminal outcome. It is not the same as silently swallowing an error.

In the example, a validation rejection is terminal because inventory must never be reserved for an invalid order. The runner converts Rejected<ValidatedOrder> into Rejected<OrderReceipt> and returns immediately. That conversion preserves the rejection code while changing the result's generic output type.

Different pipelines may have different terminal rules. A content filter might reject an item but allow the batch to continue with other items. A security check might terminate the entire request. A stage might produce a warning and a usable output. The result type and runner must express those distinctions rather than relying on conventions known only to one developer.

Avoid returning null to mean "stop." Null could mean absent optional data, an implementation defect, or intentional rejection. A named result case communicates intent and carries the information the caller needs.

Also avoid catching an exception and converting it to success with a default output. Later stages then process a value that was never legitimately produced. The eventual failure occurs farther from the cause, or worse, the pipeline creates an incorrect side effect.

Unexpected Exceptions Should Stay Exceptional

The runner catches Exception for one reason: to record a bounded failure type before rethrowing. It uses throw;, which preserves the original stack according to .NET exception best practices. It does not claim to recover.

A stage can catch a narrower exception when it has a specific recovery action. ReserveInventoryStage catches TransientInventoryException before the final attempt because its contract owns that repeat-safe retry. It does not catch Exception, wait, and try again. Authentication failures, invalid configuration, coding defects, and permanent rejection are not made transient by a loop.

Cleanup is another valid reason to use try, finally, using, or await using. Cleanup should not replace the original error unless the cleanup failure is itself the most important contract outcome, which is uncommon and should be documented.

Do not use throw exception; after observing a fault because .NET exception best practices explain that it resets stack information to the current location. Either handle the exception fully or use throw; to let the caller see where it originated.

Cancellation Is Neither Rejection Nor Rollback

Cancellation communicates that a caller, timeout owner, or host requested that work stop. It does not mean the input was invalid, and it does not necessarily mean a dependency failed.

The pipeline catches OperationCanceledException only when its supplied token has been requested, preserving the token-sensitive cancellation semantics documented for .NET tasks. It tags the outcome as canceled and rethrows. An OperationCanceledException associated with an unrelated, non-requested token falls through to the unexpected failure path instead of being mislabeled.

Cancellation remains cooperative. The token is passed to validation, the inventory call, and the retry delay. If a real inventory client ignores that token, requesting cancellation cannot forcibly stop the remote operation. Polly timeout guidance documents the same cooperative limitation: timeout signals through a cancellation token, but work can continue when the callback does not observe it.

Most importantly, managed cancellation is a cooperative stop request, not rollback of completed effects. If inventory was reserved before the token was observed, that reservation remains. The application needs an explicit release operation, compensation workflow, transaction, or expiration policy if the business requires reversal.

Do not return Rejected("canceled"). Doing so mixes caller intent with domain validity and makes operational metrics misleading. Do not return success merely because a stage noticed cancellation after finishing a side effect. The caller needs an honest description of what the pipeline task did, plus a separate way to reconcile any partial effects.

Partial Effects Change the Retry Decision

Pure validation is easy to repeat because it does not change external state. Reserving inventory, charging a payment method, publishing a message, or sending an email is different. The first call may succeed even when its response is lost. A retry can repeat the effect.

The Azure Pipes and Filters guidance specifically warns that work or output can be completed before an acknowledgement fails, creating duplicate processing. This is why retry and idempotency must be designed together.

The example sends a stable OperationId to the inventory boundary. InMemoryInventoryGateway atomically checks simulated-attempt state and stores one reservation per idempotency key, returning the existing reservation when the same key appears again. That demonstrates repeat safety for this one in-memory boundary.

It does not prove exactly-once execution. The gateway can receive multiple calls. The guarantee is narrower: repeated calls with the same key do not create additional reservations in this implementation.

The lock makes the sample concurrency-safe within one process, but it is not durable production idempotency. Real idempotency requires durable coordination appropriate to the dependency. An HTTP service may persist idempotency keys. A database operation may use a unique constraint. A message consumer may store processed message identifiers in the same transaction as its business update. Each mechanism has retention, collision, race, and failure-window considerations.

Partial effects also affect short-circuiting. Before the inventory stage, rejection means no external reservation exists. After the inventory stage, a later exception would leave a reservation that may need reconciliation. The pipeline should expose enough stable identity to find that effect. It should not pretend a generic catch block can roll it back.

Give Retry to One Repeat-Safe Boundary

Retry re-executes work. The Polly retry documentation makes that callback repetition explicit. As a derived consequence of that behavior, if an HttpClient, a stage, and a caller each permit two attempts, the nested policies permit up to 2 x 2 x 2 = 8 dependency calls.

Choose one owner for each retryable boundary. In the example, ReserveInventoryStage owns the inventory retry. Its policy is intentionally narrow:

  • retry one known transient exception;
  • make at most two total attempts;
  • reuse the same idempotency key;
  • honor cancellation during calls and delay;
  • let the final failure escape.

The 25 millisecond delay is an executable example value, not a production recommendation. Real delay, backoff, jitter, and attempt budgets depend on the dependency's behavior and the caller's total deadline.

Do not retry deterministic validation. The same input will produce the same rejection. Do not retry an unknown exception merely because it occurred in an I/O stage. Do not retry a non-idempotent side effect without protection. And do not add another pipeline-wide retry unless you have calculated the combined attempts and confirmed that every repeated effect is safe.

A resilience library can implement the same boundary policy, but the package does not decide where ownership belongs. Architecture still has to identify the smallest repeat-safe operation.

Instrument Outcomes Without Creating Cardinality Problems

Failure semantics become easier to operate when telemetry uses the same outcome vocabulary as the code. The example uses ActivitySource, the .NET tracing instrumentation API, and records one controlled pipeline.outcome value.

For deeper tracing and exporter configuration, see OpenTelemetry in .NET: Complete Observability Guide. The pipeline example intentionally stays at the failure boundary rather than becoming a full observability tutorial.

Avoid recording operation IDs, customer IDs, SKUs, exception messages, or serialized payloads in broadly collected telemetry because OpenTelemetry security guidance recommends data minimization and protection of sensitive data.

Keep metric dimensions bounded. Outcome values, stable stage names, controlled rejection codes, and exception type names come from a finite application-defined set, while per-operation identifiers and arbitrary URLs create high-cardinality dimensions that .NET OpenTelemetry metric guidance recommends avoiding.

Expected rejection does not automatically set tracing status to error in this model. It is a normal terminal business outcome. Unexpected exception does set error status. Cancellation receives its own outcome. This distinction prevents a predictable validation decision from being counted as a system fault.

Frequently Asked Questions About C# Pipeline Error Handling

The following questions summarize the design choices that callers and stage authors need to share.

Should every stage return a Result type?

Not necessarily. Use a typed result where callers need to handle expected alternatives explicitly. A stage that either produces its output or fails unexpectedly may still have a clear Task<T> contract. Consistency across related stages matters more than forcing one wrapper everywhere.

Should a rejection include a human-readable message?

It can, but the stable contract should be the controlled code. Human-readable text changes, may require localization, and should not be used for branching. Keep messages safe for their intended audience and avoid embedding exception details.

Can the pipeline continue after a rejected stage?

Only if the composition contract defines a valid next input for that outcome. In this example, inventory requires a ValidatedOrder, so rejection is terminal. Continuing with a fabricated value would violate the stage contract.

Is cancellation an exception or a result?

In Task-based .NET APIs, cancellation commonly propagates through OperationCanceledException and a canceled task. An application can translate it at an outer boundary, but it should preserve cancellation as distinct from domain rejection and unexpected failure.

Does an idempotency key guarantee exactly-once processing?

No. It can make repeated calls produce one logical effect within a defined boundary, provided the dependency stores and enforces the key correctly. Calls may still occur more than once, and effects outside that boundary need their own protection.

Where should retry live in a pipeline?

Place it around the smallest external operation that is both transiently fallible and repeat-safe. Give that boundary one documented owner. Avoid wrapping deterministic stages, broad multi-effect pipeline regions, or clients that already retry unless the combined policy is deliberate.

Error Handling in C# Pipelines Must Be Visible

Good C# pipeline error handling does not begin with a catch block. It begins with an outcome model. Expected rejection returns a typed result and short-circuits intentionally. Unexpected exceptions preserve their diagnostic path. Cancellation remains cooperative and does not claim rollback.

Once side effects begin, idempotency and partial-effect handling become part of correctness. Retry belongs to one repeat-safe external boundary, not every stage. Finally, use controlled outcome instrumentation so operations can distinguish rejection, cancellation, and failure without recording high-cardinality payload data.

Breaking Free From Exceptions - A Different Way Forward

Exceptions and exception handling are a core part of C# and many other programming languages. But what If we didn't need to be throwing them?

How To Handle Exceptions in CSharp - Tips and Tricks for Streamlined Debugging

Learn about exceptions in CSharp and effective exception handling. We'll cover try-catch blocks and other tips for working with exceptions in C#!

Error Handling in ASP.NET Core Web API: Problem Details and Global Handlers

Master asp.net core error handling with Problem Details (RFC 9457), IExceptionHandler, and global middleware for consistent Web API responses in .NET 10.

An error has occurred. This application may no longer respond until reloaded. Reload