Resilient Error Handling, Records & Pattern Matching in C# 12

10 min read CSHARP 3 views

Resilient Error Handling, Records & Pattern Matching in C# 12

Writing resilient enterprise software requires building predictable error handling workflows and concise domain data models. Modern C# has embraced functional programming idioms with records, pattern matching, and rich exception filters. In this lesson, we study modern structured exception handling, custom exception modeling, record immutability, and pattern matching architectures.

The .NET Exception Hierarchy and Lifecycle

All exceptions in C# derive from System.Exception. The framework provides specialized standard subclasses for common operational failure modes:

  • ArgumentNullException / ArgumentOutOfRangeException: Thrown when method arguments violate validation preconditions.
  • InvalidOperationException: Thrown when an invocation is inappropriate given the current internal state of the object.
  • TimeoutException: Signals that an asynchronous or synchronous operation exceeded its allocated execution deadline.
  • HttpRequestException: Raised when outbound HTTP communication fails at the network transport level.

Exception Filters: Clean and Diagnostic-Friendly Catch Blocks

C# provides the when keyword, enabling exception filters that conditionally handle exceptions based on runtime criteria without unwinding the call stack:

try {
    await paymentGateway.ChargeAsync(orderId, amount);
} catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestTimeout) {
    logger.LogWarning("Payment gateway timed out. Scheduling background retry.");
    await scheduleRetryQueue.EnqueueAsync(orderId);
} catch (HttpRequestException ex) {
    logger.LogError(ex, "Fatal HTTP failure during payment processing.");
    throw;
}

Because the exception filter evaluates before stack unwinding occurs, diagnostic crash dumps and telemetry loggers retain the full original stack trace context, making root cause analysis in production significantly easier.

Records: Immutable Domain Modeling

Introduced in C# 9 and enhanced in C# 10, 11, and 12, records provide first-class immutability and value-based equality semantics. Unlike standard classes where two distinct instances are compared by memory reference, records evaluate equality based on their properties:

public record ShippingAddress(string Street, string City, string PostalCode);

var addr1 = new ShippingAddress("123 Main St", "Seattle", "98101");
var addr2 = new ShippingAddress("123 Main St", "Seattle", "98101");

Console.WriteLine(addr1 == addr2); // Evaluates to True (Value Equality)

Records also support non-destructive mutation via the with expression: var updatedAddr = addr1 with { PostalCode = "98102" };. This constructs a new immutable record instance while preserving unmentioned fields without tedious copying.

Advanced Pattern Matching in C# 12

Pattern matching has transformed how C# developers branch logic. Modern pattern matching combines type tests, property constraints, relational operators, and logical combinators into exhaustive, compiler-verified switch expressions:

public static decimal CalculateDiscount(Customer customer, decimal orderTotal) => (customer, orderTotal) switch {
    ({ IsVip: true }, > 1000m) => 0.20m, // 20% discount for VIP large orders
    ({ IsVip: true }, _)       => 0.10m, // 10% discount for standard VIP orders
    ({ LoyaltyYears: >= 5 }, _) => 0.08m, // 8% loyalty discount
    _                          => 0.00m  // Standard pricing
};

Result Patterns Over Exceptions for Domain Flow

While exceptions represent exceptional operational failures, expected business outcomes (such as failed authentication or insufficient account balances) are more cleanly modeled using discriminated record results (like Result<T> or OneOf). This pattern makes failure handling explicit in method signatures, avoids expensive exception stack trace allocation, and pairs seamlessly with exhaustive pattern matching switch expressions.

Custom Domain Exceptions and Rethrow Semantics

When modeling custom application faults, always inherit from System.Exception and provide constructors supporting inner exceptions. When rethrowing an exception in a catch block after logging, use the bare throw; keyword. Writing throw ex; wipes out the original call stack trace, resetting the exception source to the catch point and obscuring the root cause.

Resource Management with IAsyncDisposable

For resources that require asynchronous cleanup—such as remote streams or database transaction scopes—modern C# supports IAsyncDisposable and the await using statement. This guarantees that unmanaged network handles and database locks are released asynchronously without blocking thread pool execution.

csharp Example
Run Code
using System;

namespace CodingRider.ResilienceDemo {
    // Discriminated union style hierarchy with records
    public abstract record PaymentResult;
    public record PaymentSuccess(string TransactionId, decimal Amount, DateTime Timestamp) : PaymentResult;
    public record PaymentFailure(string ReasonCode, string ErrorMessage, bool IsRetryable) : PaymentResult;

    public class Program {
        public static void Main() {
            Console.WriteLine("=== C# Records, Pattern Matching & Error Handling ===");

            PaymentResult result1 = new PaymentSuccess("TX-9021", 1250.00m, DateTime.UtcNow);
            PaymentResult result2 = new PaymentFailure("GATEWAY_TIMEOUT", "Remote bank unavailable", true);

            HandlePaymentOutcome(result1);
            HandlePaymentOutcome(result2);
        }

        public static void HandlePaymentOutcome(PaymentResult result) {
            // Type pattern matching with property filtering
            string auditLog = result switch {
                PaymentSuccess { Amount: > 1000m } s => 
                    $"[AUDIT HIGH-VALUE] Transaction {s.TransactionId} settled for ${s.Amount:N2}",
                PaymentSuccess s => 
                    $"[AUDIT STANDARD] Transaction {s.TransactionId} settled for ${s.Amount:N2}",
                PaymentFailure { IsRetryable: true } f => 
                    $"[RETRY QUEUED] Failed with {f.ReasonCode}: {f.ErrorMessage}",
                PaymentFailure f => 
                    $"[FATAL FAILURE] Unrecoverable error: {f.ErrorMessage}",
                _ => "Unknown transaction state"
            };

            Console.WriteLine(auditLog);
        }
    }
}

Related Tutorials