
Dotnet Exception Handling
- 96 installs
- 70 repo stars
- Updated July 26, 2026
- rysweet/amplihack
Helps with ai & agent building tasks.
About
dotnet-exception-handling is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- dotnet-exception-handling
- AI & Agent Building
- AI-coding skill
Dotnet Exception Handling by the numbers
- 96 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #4,418 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rysweet/amplihack --skill dotnet-exception-handlingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 96 |
|---|---|
| repo stars | ★ 70 |
| Last updated | July 26, 2026 |
| Repository | rysweet/amplihack ↗ |
What it does
Helps with ai & agent building tasks.
Files
.NET Exception Handling Quality Improvement
Purpose
Systematic investigation and remediation of .NET exception handling anti-patterns. Detects, documents, and fixes the 10 most common exception handling mistakes in .NET applications.
Use when: Preparing for production, code quality audits, security reviews, or onboarding to a .NET codebase.
Usage
# Investigation only (default)
/dotnet-exception-handling <path-to-dotnet-project>
# Filter by severity
/dotnet-exception-handling <project-path> --priority critical
# Auto-implement all fixes
/dotnet-exception-handling <project-path> --fix-allArguments:
project-path: Directory containing .csproj or .sln (default: current directory)--priority:critical|high|medium|low|all(default:all)--fix-all: Implement fixes automatically (default: investigate only)
The 10 Common Mistakes
1. Catching Exception Too Broadly - Base Exception instead of specific types 2. Swallowing Exceptions Silently - Empty catch blocks hiding errors 3. Using `throw ex;` - Resets stack traces (use throw;) 4. Wrapping Everything in Try/Catch - Defensive coding clutter 5. Exceptions for Control Flow - Performance overhead for expected conditions 6. Forgetting to Await Async - Unhandled exceptions on background threads 7. Ignoring Background Task Exceptions - Fire-and-forget losing errors 8. Generic Exception Types - Vague new Exception() instead of specific types 9. Losing Inner Exceptions - Breaking exception chains 10. Missing Global Handler - No centralized error handling (stack traces exposed)
Detailed descriptions, detection patterns, and fix templates → see reference.md
Execution Workflow
Phase 1: Investigation (6 Steps)
Step 1: Project Detection
- Scan for .csproj, .sln files
- Identify project types (ASP.NET Core, worker services, libraries)
- Count C# files for scope estimation
Step 2: Parallel Analysis
- Deploy 5 specialized agents:
- Background Worker Specialist
- API Layer Specialist
- Service Layer Specialist
- Data Layer Specialist
- Infrastructure Specialist
Step 3: Violation Detection
- Use
rg -P(ripgrep PCRE mode) for pattern matching:
# Mistake #1: Broad catches
rg -P 'catch\s*\(Exception\b' --glob '*.cs'
# Mistake #2: Empty catches
rg -P 'catch[^{]*\{\s*(//[^\n]*)?\s*\}' --glob '*.cs'
# Mistake #3: throw ex
rg 'throw\s+ex;' --glob '*.cs'- See
reference.mdfor complete pattern list
Step 4: Severity Classification
- CRITICAL: Security (stack trace exposure, missing global handler)
- HIGH: Reliability (swallowed exceptions, broad catches)
- MEDIUM: Code quality (excessive try/catch)
- LOW: Style issues
Step 5: Findings Report
- Generate markdown with file:line references
- Code snippets + recommended fixes
- Priority-based roadmap
Step 6: Knowledge Capture
- Store in
.claude/runtime/logs/EXCEPTION_INVESTIGATION_YYYY-MM-DD.md - Update project memory
Phase 2: Development (If --fix-all)
Step 7: Orchestrate Default Workflow
- Create GitHub issue with findings
- Set up worktree for fixes
- Implement GlobalExceptionHandler, Result<T>, etc.
- Write comprehensive tests (TDD)
- Three-agent review (reviewer, security, philosophy)
Step 8: Validation
- All tests pass
- Security: Zero stack traces
- Performance: <5ms p99 overhead
Quick Start Examples
Example 1: Investigation Only
/dotnet-exception-handling ./src/MyApiOutput: Investigation report with 23 violations (1 CRITICAL, 8 HIGH, 12 MEDIUM, 2 LOW)
Example 2: Fix Critical Only
/dotnet-exception-handling ./src/MyApi --priority critical --fix-allOutput: GitHub issue + PR implementing GlobalExceptionHandler + 15 tests
Example 3: Complete Fix
/dotnet-exception-handling ./src/MyApi --fix-allOutput: 23 violations fixed, 67 tests, PR ready (CI passing)
Core Architecture Patterns
GlobalExceptionHandler (IExceptionHandler)
Centralized exception-to-HTTP mapping for ASP.NET Core:
public class GlobalExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
ArgumentException => (400, "Invalid request"),
NotFoundException => (404, "Not found"),
ConflictException => (409, "Conflict"),
_ => (500, "Internal server error")
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode >= 500
? "An error occurred"
: exception.Message
}, cancellationToken);
return true;
}
}
// Registration in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();Benefits: Zero stack traces, consistent responses, no try/catch in controllers
Result<T> Pattern
Railway-oriented programming for validation (no exceptions for expected conditions):
public Result<Order> ValidateOrder(CreateOrderDto dto)
{
if (dto.Items.Count == 0)
return Result<Order>.Failure("Order must have items");
return Result<Order>.Success(new Order(dto));
}
// Controller usage
var result = await _service.ValidateOrder(dto);
return result.Match(
onSuccess: order => Ok(order),
onFailure: error => BadRequest(error)
);Benefits: 100x faster than exceptions, explicit error handling, better composition
Complete implementations → see examples.md
Navigation Guide
When to Read Supporting Files
reference.md - Read when you need:
- Detailed descriptions of all 10 exception handling mistakes
- Complete detection patterns for ripgrep/grep
- Fix templates for each mistake type
- Security considerations (OWASP compliance, stack trace prevention)
- Severity classification reference
- Integration patterns (Azure SDK, EF Core, Service Bus)
examples.md - Read when you need:
- Before/after code examples for each mistake
- Complete working implementations (GlobalExceptionHandler, Result<T>, DbContextExtensions)
- Real-world scenarios (order processing, payment systems)
- Unit and integration testing patterns
- Copy-paste ready code
patterns.md - Read when you need:
- Architecture decision trees (global handler vs try/catch, Result<T> vs exceptions)
- Background worker exception handling patterns
- Azure SDK exception translation patterns
- EF Core concurrency and transaction patterns
- Performance benchmarks (Result<T> vs exceptions)
- Anti-patterns to avoid
Workflow Integration
This skill orchestrates two canonical workflows:
1. Investigation Workflow (Phase 1): Scope → Explore → Analyze → Classify → Report → Capture 2. Default Workflow (Phase 2, if --fix-all): Requirements → Architecture → TDD → Implementation → Review → CI/CD
References
- Article: Top 10 Exception Handling Mistakes in .NET
- Microsoft: Best Practices for Exceptions
- ASP.NET Core: Handle Errors in Web APIs
- Investigation Workflow:
~/.amplihack/.claude/workflow/INVESTIGATION_WORKFLOW.md - Default Workflow:
~/.amplihack/.claude/workflow/DEFAULT_WORKFLOW.md
Version History
- v1.0.0 (2026-02-10): Initial implementation based on CyberGym investigation (52 violations fixed, 87 tests)
Known Limitations
- Requires .NET 6+ for IExceptionHandler
- Result<T> pattern targets C# 7.0+ (struct readonly, expression-bodied members)
- Patterns specific to ASP.NET Core (may not apply to class libraries)
.NET Exception Handling - Working Examples
Practical before/after code examples and complete implementations.
---
Table of Contents
1. Before/After Examples 2. Complete Implementations 3. Real-World Scenarios 4. Testing Patterns
---
Before/After Examples
Example 1: API Controller with Global Handler
Before (Defensive try/catch everywhere):
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly ILogger<UsersController> _logger;
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
try
{
var user = await _userService.GetByIdAsync(id);
if (user == null)
return NotFound($"User {id} not found");
return Ok(user);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid user ID");
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get user");
return StatusCode(500, "An error occurred");
}
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto)
{
try
{
if (string.IsNullOrEmpty(dto.Email))
return BadRequest("Email is required");
var user = await _userService.CreateAsync(dto);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
catch (ConflictException ex)
{
_logger.LogWarning(ex, "User already exists");
return Conflict(ex.Message);
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid user data");
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create user");
return StatusCode(500, "An error occurred");
}
}
}After (Trust global handler):
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var user = await _userService.GetByIdAsync(id);
// NotFoundException thrown by service → Global handler → 404
return Ok(user);
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto)
{
var user = await _userService.CreateAsync(dto);
// ConflictException → Global handler → 409
// ArgumentException → Global handler → 400
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
}
// Custom exceptions
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class ConflictException : Exception
{
public ConflictException(string message) : base(message) { }
}Lines Saved: 30+ lines removed, cleaner code, consistent error responses.
---
Example 2: Service Layer with Result<T>
Before (Exceptions for validation):
public class OrderService
{
public async Task ProcessOrderAsync(Order order)
{
if (order.Items.Count == 0)
throw new InvalidOperationException("Order must have at least one item");
if (order.TotalAmount <= 0)
throw new InvalidOperationException("Order total must be positive");
if (!CanShipTo(order.ShippingAddress))
throw new InvalidOperationException($"Cannot ship to {order.ShippingAddress.Country}");
await _orderRepository.SaveAsync(order);
}
}
// Controller catches and maps
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
{
try
{
var order = MapToOrder(dto);
await _orderService.ProcessOrderAsync(order);
return Ok();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}After (Result<T> for validation):
public class OrderService
{
public async Task<Result<Order>> ProcessOrderAsync(Order order)
{
if (order.Items.Count == 0)
return Result<Order>.Failure("Order must have at least one item");
if (order.TotalAmount <= 0)
return Result<Order>.Failure("Order total must be positive");
if (!CanShipTo(order.ShippingAddress))
return Result<Order>.Failure($"Cannot ship to {order.ShippingAddress.Country}");
await _orderRepository.SaveAsync(order);
return Result<Order>.Success(order);
}
}
// Controller uses Match pattern
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
{
var order = MapToOrder(dto);
var result = await _orderService.ProcessOrderAsync(order);
return result.Match(
onSuccess: order => Ok(order),
onFailure: error => BadRequest(error)
);
}Benefits: No exception overhead, clearer intent, better performance.
---
Example 3: Background Worker Exception Handling
Before (Silent failures):
public class EventPublisherWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var events = await _queue.GetPendingAsync();
foreach (var evt in events)
{
Task.Run(async () => await PublishAsync(evt)); // Fire-and-forget
}
await Task.Delay(1000, stoppingToken);
}
}
}After (Proper exception boundaries):
public class EventPublisherWorker : BackgroundService
{
private readonly ILogger<EventPublisherWorker> _logger;
private readonly IEventQueue _queue;
private readonly IEventPublisher _publisher;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Event publisher starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var events = await _queue.GetPendingAsync(stoppingToken);
foreach (var evt in events)
{
await PublishWithRetryAsync(evt, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
break;
}
catch (Exception ex)
{
// Log error but continue processing
_logger.LogError(ex, "Event publishing cycle failed");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Event publisher stopped gracefully");
}
private async Task PublishWithRetryAsync(Event evt, CancellationToken ct)
{
const int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await _publisher.PublishAsync(evt, ct);
_logger.LogInformation("Event {EventId} published successfully", evt.Id);
return;
}
catch (Exception ex) when (attempt < maxRetries)
{
_logger.LogWarning(ex,
"Event {EventId} publish failed (attempt {Attempt}/{Max})",
evt.Id, attempt, maxRetries);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
catch (Exception ex)
{
// Final attempt failed
_logger.LogError(ex,
"Event {EventId} publish failed after {Max} attempts",
evt.Id, maxRetries);
throw; // Critical failure
}
}
}
}Improvements: No silent failures, retry logic, proper logging, graceful shutdown.
---
Example 4: EF Core Exception Translation
Before (Generic database errors):
public async Task<User> CreateUserAsync(CreateUserDto dto)
{
try
{
var user = new User
{
Email = dto.Email,
Username = dto.Username
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
catch (DbUpdateException ex)
{
throw new Exception("Database error occurred", ex);
}
}After (Specific error mapping):
public async Task<Result<User>> CreateUserAsync(CreateUserDto dto)
{
var user = new User
{
Email = dto.Email,
Username = dto.Username
};
_context.Users.Add(user);
try
{
await _context.SaveChangesAsync();
return Result<User>.Success(user);
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx)
{
var message = sqlEx.Number switch
{
2601 or 2627 => "A user with this email or username already exists",
547 => "Cannot create user: referenced entity does not exist",
_ => "A database error occurred"
};
_logger.LogWarning(ex, "User creation failed: {SqlError}", sqlEx.Number);
return Result<User>.Failure(message);
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogWarning(ex, "Concurrency conflict creating user");
return Result<User>.Failure("The operation failed due to a conflict. Please retry.");
}
}Benefits: User-friendly error messages, preserved exception chains, specific handling.
---
Complete Implementations
GlobalExceptionHandler (Production-Ready)
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace MyApi.Infrastructure;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
private readonly IHostEnvironment _environment;
public GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IHostEnvironment environment)
{
_logger = logger;
_environment = environment;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
_logger.LogError(exception,
"Unhandled exception for {Method} {Path}. TraceId: {TraceId}",
httpContext.Request.Method,
httpContext.Request.Path,
traceId);
var (statusCode, title, detail) = MapException(exception);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
Instance = httpContext.Request.Path,
Extensions =
{
["traceId"] = traceId
}
};
// Include exception details only in development
if (_environment.IsDevelopment())
{
problemDetails.Extensions["exceptionType"] = exception.GetType().Name;
problemDetails.Extensions["stackTrace"] = exception.StackTrace;
}
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // Exception handled
}
private static (int StatusCode, string Title, string Detail) MapException(Exception exception)
=> exception switch
{
ArgumentException or ArgumentNullException =>
(400, "Bad Request", exception.Message),
UnauthorizedAccessException =>
(401, "Unauthorized", "You are not authorized to access this resource"),
NotFoundException =>
(404, "Not Found", exception.Message),
ConflictException =>
(409, "Conflict", exception.Message),
ValidationException validationEx =>
(422, "Validation Failed", FormatValidationErrors(validationEx)),
OperationCanceledException =>
(499, "Client Closed Request", "The request was cancelled"),
_ =>
(500, "Internal Server Error", "An unexpected error occurred")
};
private static string FormatValidationErrors(ValidationException exception)
{
if (exception.Errors?.Any() != true)
return exception.Message;
return string.Join("; ", exception.Errors.Select(e => $"{e.PropertyName}: {e.ErrorMessage}"));
}
}
// Registration in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // Must be early in pipeline
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();---
Result<T> with Chaining Support
namespace MyApi.Core;
public readonly struct Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public string ErrorMessage { get; }
private Result(bool isSuccess, T value, string errorMessage)
{
IsSuccess = isSuccess;
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Success(T value) =>
new(true, value, string.Empty);
public static Result<T> Failure(string errorMessage) =>
new(false, default!, errorMessage);
// Pattern matching
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<string, TResult> onFailure) =>
IsSuccess ? onSuccess(Value) : onFailure(ErrorMessage);
// Async pattern matching
public async Task<TResult> MatchAsync<TResult>(
Func<T, Task<TResult>> onSuccess,
Func<string, Task<TResult>> onFailure) =>
IsSuccess ? await onSuccess(Value) : await onFailure(ErrorMessage);
// Map/Select (LINQ)
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
IsSuccess
? Result<TNew>.Success(mapper(Value))
: Result<TNew>.Failure(ErrorMessage);
// Bind/SelectMany (LINQ)
public Result<TNew> Bind<TNew>(Func<T, Result<TNew>> binder) =>
IsSuccess
? binder(Value)
: Result<TNew>.Failure(ErrorMessage);
// Async bind
public async Task<Result<TNew>> BindAsync<TNew>(Func<T, Task<Result<TNew>>> binder) =>
IsSuccess
? await binder(Value)
: Result<TNew>.Failure(ErrorMessage);
// Implicit conversion to boolean
public static implicit operator bool(Result<T> result) => result.IsSuccess;
// LINQ query syntax support
public Result<TNew> Select<TNew>(Func<T, TNew> selector) => Map(selector);
public Result<TNew> SelectMany<TNew>(Func<T, Result<TNew>> selector) => Bind(selector);
public Result<TFinal> SelectMany<TNew, TFinal>(
Func<T, Result<TNew>> selector,
Func<T, TNew, TFinal> resultSelector) =>
Bind(value => selector(value).Map(newValue => resultSelector(value, newValue)));
}
// Extension methods for common scenarios
public static class ResultExtensions
{
public static Result<T> ToResult<T>(this T? value, string errorMessage)
where T : class =>
value is not null
? Result<T>.Success(value)
: Result<T>.Failure(errorMessage);
public static Result<T> ToResult<T>(this T? value, string errorMessage)
where T : struct =>
value.HasValue
? Result<T>.Success(value.Value)
: Result<T>.Failure(errorMessage);
public static async Task<Result<T>> AsResult<T>(this Task<T> task)
{
try
{
var value = await task;
return Result<T>.Success(value);
}
catch (Exception ex)
{
return Result<T>.Failure(ex.Message);
}
}
}
// Usage examples
public class Examples
{
// Simple usage
public Result<User> GetUser(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
return user.ToResult($"User {id} not found");
}
// Chaining with LINQ query syntax
public Result<decimal> CalculateDiscount(int userId, decimal amount)
{
var result =
from user in GetUser(userId)
from tier in GetLoyaltyTier(user.Points)
from rate in GetDiscountRate(tier, amount)
select amount * rate;
return result;
}
// Chaining with method syntax
public async Task<Result<Order>> ProcessOrderAsync(CreateOrderDto dto)
{
return await ValidateOrder(dto)
.BindAsync(order => ApplyDiscountAsync(order))
.BindAsync(order => SaveOrderAsync(order));
}
}---
DbContext Extensions with Result<T>
namespace MyApi.Infrastructure.Data;
public static class DbContextExtensions
{
public static async Task<Result<int>> SaveChangesWithResultAsync(
this DbContext context,
CancellationToken ct = default)
{
try
{
var changes = await context.SaveChangesAsync(ct);
return Result<int>.Success(changes);
}
catch (DbUpdateConcurrencyException ex)
{
var logger = context.GetService<ILogger<DbContext>>();
logger?.LogWarning(ex, "Concurrency conflict during save");
return Result<int>.Failure(
"The record was modified by another user. Please refresh and try again.");
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx)
{
var logger = context.GetService<ILogger<DbContext>>();
logger?.LogWarning(ex, "Database constraint violation: {SqlError}", sqlEx.Number);
var message = sqlEx.Number switch
{
2601 or 2627 => "A record with this unique key already exists",
547 => "Cannot complete operation: this record is referenced by other data",
515 => "Cannot insert NULL into required field",
_ => $"A database error occurred (Code: {sqlEx.Number})"
};
return Result<int>.Failure(message);
}
}
public static async Task<Result<T>> FindByIdWithResultAsync<T>(
this DbContext context,
object id,
CancellationToken ct = default)
where T : class
{
var entity = await context.FindAsync<T>(new[] { id }, ct);
return entity.ToResult($"{typeof(T).Name} with ID {id} not found");
}
}---
Real-World Scenarios
Scenario 1: Order Processing System
Complete example with validation, persistence, and external service calls.
// Domain
public class Order
{
public int Id { get; set; }
public string CustomerId { get; set; } = string.Empty;
public List<OrderItem> Items { get; set; } = new();
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; }
}
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }
// Service
public class OrderService
{
private readonly ApplicationDbContext _context;
private readonly IPaymentGateway _paymentGateway;
private readonly IInventoryService _inventory;
private readonly ILogger<OrderService> _logger;
public async Task<Result<Order>> CreateOrderAsync(
CreateOrderDto dto,
CancellationToken ct = default)
{
// Step 1: Validate input
var validationResult = ValidateOrderDto(dto);
if (!validationResult.IsSuccess)
return Result<Order>.Failure(validationResult.ErrorMessage);
// Step 2: Check inventory
var inventoryResult = await _inventory.ReserveItemsAsync(dto.Items, ct);
if (!inventoryResult.IsSuccess)
return Result<Order>.Failure(inventoryResult.ErrorMessage);
// Step 3: Process payment
var paymentResult = await ProcessPaymentAsync(dto.PaymentInfo, dto.TotalAmount, ct);
if (!paymentResult.IsSuccess)
{
// Rollback inventory reservation
await _inventory.ReleaseItemsAsync(inventoryResult.Value, ct);
return Result<Order>.Failure(paymentResult.ErrorMessage);
}
// Step 4: Create order
var order = MapToOrder(dto, paymentResult.Value);
_context.Orders.Add(order);
var saveResult = await _context.SaveChangesWithResultAsync(ct);
if (!saveResult.IsSuccess)
{
// Rollback payment and inventory
await _paymentGateway.RefundAsync(paymentResult.Value, ct);
await _inventory.ReleaseItemsAsync(inventoryResult.Value, ct);
return Result<Order>.Failure(saveResult.ErrorMessage);
}
_logger.LogInformation(
"Order {OrderId} created for customer {CustomerId}",
order.Id,
order.CustomerId);
return Result<Order>.Success(order);
}
private Result<CreateOrderDto> ValidateOrderDto(CreateOrderDto dto)
{
if (string.IsNullOrWhiteSpace(dto.CustomerId))
return Result<CreateOrderDto>.Failure("Customer ID is required");
if (dto.Items == null || dto.Items.Count == 0)
return Result<CreateOrderDto>.Failure("Order must have at least one item");
if (dto.TotalAmount <= 0)
return Result<CreateOrderDto>.Failure("Order total must be positive");
return Result<CreateOrderDto>.Success(dto);
}
private async Task<Result<string>> ProcessPaymentAsync(
PaymentInfo payment,
decimal amount,
CancellationToken ct)
{
try
{
var transactionId = await _paymentGateway.ChargeAsync(payment, amount, ct);
return Result<string>.Success(transactionId);
}
catch (PaymentDeclinedException ex)
{
_logger.LogWarning(ex, "Payment declined for amount {Amount}", amount);
return Result<string>.Failure("Payment was declined");
}
catch (PaymentGatewayException ex)
{
_logger.LogError(ex, "Payment gateway error");
return Result<string>.Failure("Payment processing failed. Please try again.");
}
}
}
// Controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderDto dto,
CancellationToken ct)
{
var result = await _orderService.CreateOrderAsync(dto, ct);
return result.Match(
onSuccess: order => CreatedAtAction(
nameof(GetOrder),
new { id = order.Id },
order),
onFailure: error => BadRequest(new { error })
);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await _orderService.GetByIdAsync(id, ct);
// NotFoundException thrown → Global handler → 404
return Ok(order);
}
}---
Testing Patterns
Unit Testing GlobalExceptionHandler
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using System.IO;
using System.Text.Json;
using Xunit;
public class GlobalExceptionHandlerTests
{
private readonly GlobalExceptionHandler _handler;
private readonly DefaultHttpContext _httpContext;
public GlobalExceptionHandlerTests()
{
var environment = new Mock<IHostEnvironment>();
environment.Setup(e => e.EnvironmentName).Returns("Production");
_handler = new GlobalExceptionHandler(
NullLogger<GlobalExceptionHandler>.Instance,
environment.Object);
_httpContext = new DefaultHttpContext();
_httpContext.Response.Body = new MemoryStream();
}
[Fact]
public async Task ArgumentException_Returns400()
{
// Arrange
var exception = new ArgumentException("Invalid parameter");
// Act
await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
// Assert
Assert.Equal(400, _httpContext.Response.StatusCode);
_httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
var problemDetails = await JsonSerializer.DeserializeAsync<ProblemDetails>(
_httpContext.Response.Body);
Assert.NotNull(problemDetails);
Assert.Equal(400, problemDetails.Status);
Assert.Equal("Bad Request", problemDetails.Title);
Assert.Equal("Invalid parameter", problemDetails.Detail);
}
[Fact]
public async Task NotFoundException_Returns404()
{
// Arrange
var exception = new NotFoundException("User not found");
// Act
await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
// Assert
Assert.Equal(404, _httpContext.Response.StatusCode);
}
[Fact]
public async Task UnhandledException_Returns500_WithGenericMessage()
{
// Arrange
var exception = new InvalidOperationException("Internal error details");
// Act
await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
// Assert
Assert.Equal(500, _httpContext.Response.StatusCode);
_httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
var problemDetails = await JsonSerializer.DeserializeAsync<ProblemDetails>(
_httpContext.Response.Body);
Assert.Equal("An unexpected error occurred", problemDetails!.Detail);
// Should NOT contain exception details in production
Assert.DoesNotContain("Internal error details", problemDetails.Detail);
}
}Unit Testing Result<T>
public class ResultTests
{
[Fact]
public void Success_CreatesSuccessResult()
{
var result = Result<int>.Success(42);
Assert.True(result.IsSuccess);
Assert.Equal(42, result.Value);
Assert.Empty(result.ErrorMessage);
}
[Fact]
public void Failure_CreatesFailureResult()
{
var result = Result<int>.Failure("Error occurred");
Assert.False(result.IsSuccess);
Assert.Equal("Error occurred", result.ErrorMessage);
}
[Fact]
public void Match_CallsOnSuccess_WhenSuccess()
{
var result = Result<int>.Success(42);
var output = result.Match(
onSuccess: value => $"Success: {value}",
onFailure: error => $"Failure: {error}"
);
Assert.Equal("Success: 42", output);
}
[Fact]
public void Match_CallsOnFailure_WhenFailure()
{
var result = Result<int>.Failure("Not found");
var output = result.Match(
onSuccess: value => $"Success: {value}",
onFailure: error => $"Failure: {error}"
);
Assert.Equal("Failure: Not found", output);
}
[Fact]
public void Map_TransformsSuccessValue()
{
var result = Result<int>.Success(42);
var mapped = result.Map(x => x * 2);
Assert.True(mapped.IsSuccess);
Assert.Equal(84, mapped.Value);
}
[Fact]
public void Map_PropagatesFailure()
{
var result = Result<int>.Failure("Error");
var mapped = result.Map(x => x * 2);
Assert.False(mapped.IsSuccess);
Assert.Equal("Error", mapped.ErrorMessage);
}
[Fact]
public void Bind_ChainsSuccessResults()
{
var result = Result<int>.Success(10);
var chained = result.Bind(x =>
x > 0
? Result<string>.Success($"Positive: {x}")
: Result<string>.Failure("Not positive")
);
Assert.True(chained.IsSuccess);
Assert.Equal("Positive: 10", chained.Value);
}
[Fact]
public void LinqQuery_ChainsResults()
{
var result =
from x in Result<int>.Success(10)
from y in Result<int>.Success(5)
select x + y;
Assert.True(result.IsSuccess);
Assert.Equal(15, result.Value);
}
}Integration Testing with GlobalExceptionHandler
public class ExceptionHandlingIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ExceptionHandlingIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task NotFound_Returns404ProblemDetails()
{
// Act
var response = await _client.GetAsync("/api/users/99999");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problemDetails);
Assert.Equal(404, problemDetails.Status);
Assert.Equal("Not Found", problemDetails.Title);
Assert.Contains("traceId", problemDetails.Extensions);
}
[Fact]
public async Task ValidationError_Returns422ProblemDetails()
{
// Arrange
var dto = new CreateUserDto { Email = "invalid-email" }; // Invalid
// Act
var response = await _client.PostAsJsonAsync("/api/users", dto);
// Assert
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(422, problemDetails!.Status);
}
[Fact]
public async Task UnhandledException_Returns500_WithoutStackTrace()
{
// Act - Trigger internal error
var response = await _client.GetAsync("/api/test/throw-error");
// Assert
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
// Should NOT contain stack trace in production
Assert.DoesNotContain("at ", content); // Stack trace indicator
Assert.DoesNotContain(".cs:line", content); // File/line indicator
}
}---
References:
.NET Exception Handling - Production Patterns
Production-ready patterns, architectural guidance, and platform-specific implementations.
---
Table of Contents
1. Architecture Decision Trees 2. Background Worker Patterns 3. Azure SDK Patterns 4. EF Core Patterns 5. Performance Optimization 6. Anti-Patterns to Avoid
---
Architecture Decision Trees
Global Handler vs Try/Catch Decision Tree
Do you need to handle this exception?
│
├─ Is this an ASP.NET Core application?
│ ├─ YES → Use GlobalExceptionHandler (IExceptionHandler)
│ │ ├─ Controllers: No try/catch needed
│ │ ├─ Services: Throw specific exceptions
│ │ └─ Global handler maps exceptions to HTTP responses
│ │
│ └─ NO → Is this a console/worker app?
│ ├─ YES → Top-level try/catch in Main/ExecuteAsync
│ └─ NO → Library → Let caller decide
│
├─ Do you need to clean up resources?
│ └─ YES → Use using statement or try/finally (not try/catch)
│
├─ Can you recover from this specific exception?
│ ├─ YES → Catch specific exception type
│ │ └─ Example: Retry on HttpRequestException
│ └─ NO → Let it bubble up
│
└─ Is this for logging only?
└─ Don't catch - use GlobalExceptionHandler or top-level handler---
Result<T> vs Exception Decision Tree
Is this an expected condition in normal operation?
│
├─ YES → Use Result<T>
│ ├─ Examples:
│ │ ├─ Validation failures (email format, required fields)
│ │ ├─ Business rule violations (insufficient funds, invalid state)
│ │ ├─ Optional lookups (user might not exist)
│ │ └─ State checks (can transition from A to B?)
│ │
│ └─ Benefits:
│ ├─ No exception overhead (100x faster)
│ ├─ Explicit error handling in type system
│ └─ Better for functional composition
│
└─ NO → Use Exception
├─ Examples:
│ ├─ Programming errors (ArgumentNullException, InvalidOperationException)
│ ├─ Infrastructure failures (DbException, HttpRequestException)
│ ├─ Security violations (UnauthorizedAccessException)
│ └─ Unrecoverable errors (OutOfMemoryException)
│
└─ Benefits:
├─ Fail-fast principle
├─ Stack trace for debugging
└─ Exception filters and handlers---
Exception Handling Layer Responsibilities
┌─────────────────────────────────────────────────┐
│ API Layer (Controllers) │
├─────────────────────────────────────────────────┤
│ Responsibilities: │
│ • NO try/catch (trust global handler) │
│ • Return domain objects/DTOs │
│ • Let exceptions bubble up │
│ │
│ GlobalExceptionHandler: │
│ • Maps exceptions → HTTP status codes │
│ • Creates ProblemDetails responses │
│ • Logs with correlation IDs │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Service Layer (Business Logic) │
├─────────────────────────────────────────────────┤
│ Responsibilities: │
│ • Use Result<T> for validation │
│ • Throw domain exceptions for violations │
│ • Try/catch only for: │
│ - Wrapping infrastructure exceptions │
│ - Adding context to exceptions │
│ - Retry logic │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Repository Layer (Data Access) │
├─────────────────────────────────────────────────┤
│ Responsibilities: │
│ • Catch DbUpdateException → translate to domain │
│ • Use Result<T> for SaveChanges operations │
│ • Preserve inner exceptions │
│ • Map SQL errors to friendly messages │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Infrastructure Layer (External Services) │
├─────────────────────────────────────────────────┤
│ Responsibilities: │
│ • Catch platform exceptions (Azure, AWS) │
│ • Retry with exponential backoff │
│ • Circuit breaker for cascading failures │
│ • Translate to domain exceptions │
└─────────────────────────────────────────────────┘---
Background Worker Patterns
Pattern 1: Long-Running Background Service
Use Case: Continuous processing (message queue, event processing)
public class MessageProcessorService : BackgroundService
{
private readonly ILogger<MessageProcessorService> _logger;
private readonly IServiceProvider _serviceProvider;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Message processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService<IMessageQueue>();
var processor = scope.ServiceProvider.GetRequiredService<IMessageProcessor>();
var message = await queue.DequeueAsync(stoppingToken);
if (message != null)
{
await ProcessWithRetryAsync(processor, message, stoppingToken);
}
else
{
// No messages, wait before polling again
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
break;
}
catch (Exception ex)
{
// Log error but continue processing
_logger.LogError(ex, "Error processing message, will retry");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Message processor stopped gracefully");
}
private async Task ProcessWithRetryAsync(
IMessageProcessor processor,
Message message,
CancellationToken ct)
{
const int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await processor.ProcessAsync(message, ct);
_logger.LogInformation(
"Message {MessageId} processed successfully",
message.Id);
return;
}
catch (TransientException ex) when (attempt < maxRetries)
{
_logger.LogWarning(ex,
"Transient error processing message {MessageId} (attempt {Attempt}/{Max})",
message.Id, attempt, maxRetries);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process message {MessageId} after {Max} attempts",
message.Id, maxRetries);
// Move to dead letter queue
throw;
}
}
}
}Key Points:
- Outer loop never crashes (except on cancellation)
- Scoped DI for each message (prevent memory leaks)
- Retry logic with exponential backoff
- Differentiate transient vs permanent failures
- Graceful shutdown on cancellation
---
Pattern 2: Scheduled Background Task
Use Case: Periodic cleanup, data synchronization
public class DataSyncService : BackgroundService
{
private readonly ILogger<DataSyncService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly TimeSpan _interval = TimeSpan.FromHours(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Data sync service starting (interval: {Interval})", _interval);
using var timer = new PeriodicTimer(_interval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ExecuteSyncJobAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Data sync job failed");
}
try
{
// Wait for next interval or cancellation
await timer.WaitForNextTickAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// Normal shutdown
break;
}
}
_logger.LogInformation("Data sync service stopped");
}
private async Task ExecuteSyncJobAsync(CancellationToken ct)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var syncService = scope.ServiceProvider.GetRequiredService<ISyncService>();
var sw = Stopwatch.StartNew();
try
{
var result = await syncService.SyncAsync(ct);
_logger.LogInformation(
"Data sync completed in {Duration}ms: {RecordsSynced} records",
sw.ElapsedMilliseconds,
result.RecordsSynced);
}
catch (Exception ex)
{
_logger.LogError(ex, "Data sync failed after {Duration}ms", sw.ElapsedMilliseconds);
throw; // Re-throw to be caught by outer handler
}
}
}Key Points:
- PeriodicTimer for scheduled execution (.NET 6+)
- Each job execution isolated in try/catch
- Job failures don't crash the service
- Performance metrics (duration logging)
- Scoped services for each execution
---
Pattern 3: Event Publishing with Resilience
Use Case: Publishing domain events to external systems
public class EventPublisherService : BackgroundService
{
private readonly ILogger<EventPublisherService> _logger;
private readonly IEventQueue _queue;
private readonly IEventBus _eventBus;
private readonly IOptions<EventPublisherOptions> _options;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Event publisher starting");
await foreach (var evt in _queue.GetEventsAsync(stoppingToken))
{
_ = PublishEventAsync(evt, stoppingToken); // Fire-and-forget with exception handling
}
_logger.LogInformation("Event publisher stopped");
}
private async Task PublishEventAsync(DomainEvent evt, CancellationToken ct)
{
const int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
await _eventBus.PublishAsync(evt, ct);
_logger.LogInformation(
"Event {EventType} (ID: {EventId}) published successfully",
evt.GetType().Name,
evt.Id);
await _queue.MarkCompletedAsync(evt.Id, ct);
return;
}
catch (Exception ex) when (attempt < maxRetries && IsTransient(ex))
{
_logger.LogWarning(ex,
"Transient error publishing event {EventId} (attempt {Attempt}/{Max})",
evt.Id, attempt, maxRetries);
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(delay, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to publish event {EventType} (ID: {EventId}) after {Max} attempts",
evt.GetType().Name,
evt.Id,
maxRetries);
// Move to dead letter queue
await _queue.MoveToDeadLetterAsync(evt.Id, ex.Message, ct);
// Critical events should alert
if (IsCriticalEvent(evt))
{
// Trigger alert (metrics, PagerDuty, etc.)
_logger.LogCritical(ex, "CRITICAL event publishing failed: {EventType}", evt.GetType().Name);
}
}
}
}
private static bool IsTransient(Exception ex) =>
ex is HttpRequestException or TimeoutException or OperationCanceledException;
private static bool IsCriticalEvent(DomainEvent evt) =>
evt is AuditEvent or SecurityEvent or PaymentEvent;
}Key Points:
- Fire-and-forget with proper exception boundaries
- Retry only transient failures
- Dead letter queue for permanent failures
- Critical event alerting
- Metrics and observability
---
Azure SDK Patterns
Pattern 1: Azure Storage Blob Operations
public class BlobStorageService
{
private readonly BlobContainerClient _containerClient;
private readonly ILogger<BlobStorageService> _logger;
public async Task<Result<BlobInfo>> UploadBlobAsync(
string blobName,
Stream content,
CancellationToken ct = default)
{
try
{
var blobClient = _containerClient.GetBlobClient(blobName);
var response = await blobClient.UploadAsync(
content,
overwrite: false,
cancellationToken: ct);
return Result<BlobInfo>.Success(new BlobInfo
{
Name = blobName,
ETag = response.Value.ETag.ToString(),
LastModified = response.Value.LastModified
});
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
_logger.LogWarning("Blob {BlobName} already exists", blobName);
return Result<BlobInfo>.Failure($"Blob '{blobName}' already exists");
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogError(ex, "Blob container not found");
return Result<BlobInfo>.Failure("Storage container not found");
}
catch (RequestFailedException ex) when (ex.Status >= 500)
{
// Transient Azure error
_logger.LogError(ex, "Azure Storage service error");
throw new TransientException("Storage service temporarily unavailable", ex);
}
catch (AuthenticationFailedException ex)
{
_logger.LogCritical(ex, "Azure authentication failed");
throw new UnauthorizedAccessException("Failed to authenticate with Azure Storage", ex);
}
}
public async Task<Result<Stream>> DownloadBlobAsync(
string blobName,
CancellationToken ct = default)
{
try
{
var blobClient = _containerClient.GetBlobClient(blobName);
var response = await blobClient.DownloadStreamingAsync(cancellationToken: ct);
return Result<Stream>.Success(response.Value.Content);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return Result<Stream>.Failure($"Blob '{blobName}' not found");
}
catch (RequestFailedException ex) when (ex.Status >= 500)
{
throw new TransientException("Storage service temporarily unavailable", ex);
}
}
}
// Custom transient exception for retry policies
public class TransientException : Exception
{
public TransientException(string message, Exception innerException)
: base(message, innerException)
{
}
}---
Pattern 2: Service Bus with Retry Policies
public class ServiceBusPublisher
{
private readonly ServiceBusSender _sender;
private readonly ILogger<ServiceBusPublisher> _logger;
public async Task<Result<bool>> PublishMessageAsync<T>(
T message,
CancellationToken ct = default)
{
try
{
var json = JsonSerializer.Serialize(message);
var serviceBusMessage = new ServiceBusMessage(json)
{
ContentType = "application/json",
MessageId = Guid.NewGuid().ToString()
};
await _sender.SendMessageAsync(serviceBusMessage, ct);
_logger.LogInformation(
"Message {MessageId} published to Service Bus",
serviceBusMessage.MessageId);
return Result<bool>.Success(true);
}
catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.MessagingEntityNotFound)
{
_logger.LogError(ex, "Service Bus queue/topic not found");
return Result<bool>.Failure("Message destination not found");
}
catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.QuotaExceeded)
{
_logger.LogWarning(ex, "Service Bus quota exceeded");
return Result<bool>.Failure("Message queue is full. Please try again later.");
}
catch (ServiceBusException ex) when (ex.IsTransient)
{
_logger.LogWarning(ex, "Transient Service Bus error");
throw new TransientException("Service Bus temporarily unavailable", ex);
}
catch (ServiceBusException ex)
{
_logger.LogError(ex, "Service Bus error: {Reason}", ex.Reason);
throw;
}
}
}---
EF Core Patterns
Pattern 1: Optimistic Concurrency Handling
public class OrderRepository
{
private readonly ApplicationDbContext _context;
private readonly ILogger<OrderRepository> _logger;
public async Task<Result<Order>> UpdateOrderAsync(
Order order,
CancellationToken ct = default)
{
const int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
_context.Orders.Update(order);
await _context.SaveChangesAsync(ct);
return Result<Order>.Success(order);
}
catch (DbUpdateConcurrencyException ex) when (attempt < maxRetries)
{
_logger.LogWarning(ex,
"Concurrency conflict updating order {OrderId} (attempt {Attempt}/{Max})",
order.Id, attempt, maxRetries);
// Refresh entity from database
await ex.Entries.Single().ReloadAsync(ct);
// Optionally: merge changes or let business logic decide
// For now, retry with fresh data
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogError(ex,
"Concurrency conflict persists for order {OrderId} after {Max} attempts",
order.Id, maxRetries);
return Result<Order>.Failure(
"This record was modified by another user. Please refresh and try again.");
}
}
return Result<Order>.Failure("Update failed");
}
}---
Pattern 2: Transaction with Rollback
public class OrderService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<OrderService> _logger;
public async Task<Result<Order>> PlaceOrderAsync(
CreateOrderDto dto,
CancellationToken ct = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
try
{
// Step 1: Create order
var order = new Order { /* ... */ };
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
// Step 2: Reduce inventory
foreach (var item in dto.Items)
{
var product = await _context.Products.FindAsync(new object[] { item.ProductId }, ct);
if (product == null)
return Result<Order>.Failure($"Product {item.ProductId} not found");
if (product.Stock < item.Quantity)
return Result<Order>.Failure($"Insufficient stock for {product.Name}");
product.Stock -= item.Quantity;
}
await _context.SaveChangesAsync(ct);
// Step 3: Create payment record
var payment = new Payment { OrderId = order.Id, /* ... */ };
_context.Payments.Add(payment);
await _context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("Order {OrderId} placed successfully", order.Id);
return Result<Order>.Success(order);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(ct);
_logger.LogError(ex, "Failed to place order, transaction rolled back");
if (ex.InnerException is SqlException sqlEx)
{
var message = sqlEx.Number switch
{
547 => "Invalid product reference",
2601 or 2627 => "Duplicate order detected",
_ => "A database error occurred"
};
return Result<Order>.Failure(message);
}
return Result<Order>.Failure("Failed to place order");
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
_logger.LogError(ex, "Unexpected error placing order");
throw;
}
}
}---
Performance Optimization
Benchmark: Result<T> vs Exception
[MemoryDiagnoser]
public class ExceptionVsResultBenchmark
{
[Benchmark]
public bool ValidateWithException()
{
try
{
ValidateAndThrow("invalid-email");
return true;
}
catch (ValidationException)
{
return false;
}
}
[Benchmark]
public bool ValidateWithResult()
{
var result = ValidateWithResult("invalid-email");
return result.IsSuccess;
}
private void ValidateAndThrow(string email)
{
if (!email.Contains("@"))
throw new ValidationException("Invalid email");
}
private Result<string> ValidateWithResult(string email)
{
if (!email.Contains("@"))
return Result<string>.Failure("Invalid email");
return Result<string>.Success(email);
}
}
/*
BenchmarkDotNet Results:
| Method | Mean | Error | StdDev | Gen 0 | Allocated |
|-------------------- |-----------:|---------:|---------:|-------:|----------:|
| ValidateWithException | 3,842.3 ns | 12.34 ns | 10.93 ns | 0.0763 | 480 B |
| ValidateWithResult | 26.8 ns | 0.21 ns | 0.18 ns | - | - |
Result<T> is 143x faster with zero allocations!
*/---
Anti-Patterns to Avoid
Anti-Pattern 1: Exception for Flow Control
// ❌ BAD: Using exceptions for expected logic
public User GetUserOrDefault(int id)
{
try
{
return GetUser(id); // Throws if not found
}
catch (NotFoundException)
{
return CreateGuestUser();
}
}
// ✅ GOOD: Explicit flow control
public User GetUserOrDefault(int id)
{
var result = TryGetUser(id);
return result.IsSuccess ? result.Value : CreateGuestUser();
}---
Anti-Pattern 2: Swallowing Without Logging
// ❌ BAD: Silent failure
try
{
await _auditLogger.LogAsync(auditEvent);
}
catch
{
// Ignore audit failures
}
// ✅ GOOD: Log and decide
try
{
await _auditLogger.LogAsync(auditEvent);
}
catch (Exception ex)
{
_logger.LogError(ex, "Audit logging failed for {EventType}", auditEvent.Type);
// Decision: Is this critical?
if (auditEvent.IsCritical)
throw; // Fail the request if audit is critical
}---
Anti-Pattern 3: Generic Exception Messages
// ❌ BAD: Vague message
throw new Exception("Error occurred");
// ✅ GOOD: Specific exception with context
throw new OrderProcessingException(
$"Failed to process order {orderId} for customer {customerId}: insufficient stock",
ex);---
Anti-Pattern 4: Losing Inner Exceptions
// ❌ BAD: Lost context
catch (SqlException ex)
{
throw new DatabaseException("Database error");
}
// ✅ GOOD: Preserved chain
catch (SqlException ex)
{
throw new DatabaseException(
$"Database error during {operation}",
ex); // Inner exception preserved
}---
References:
.NET Exception Handling - Complete Reference
Comprehensive reference for .NET exception handling investigation and remediation patterns.
---
Table of Contents
1. The 10 Common Mistakes (Detailed) 2. Detection Patterns 3. Fix Templates 4. Architecture Patterns 5. Security Considerations 6. Integration Patterns 7. Validation Rules
---
The 10 Common Mistakes (Detailed)
Mistake #1: Catching Exception Too Broadly
Problem: Catching base Exception type instead of specific exceptions.
Why It's Bad:
- Masks programming errors (NullReferenceException, ArgumentException)
- Makes debugging difficult
- Hides unexpected failures
- Violates fail-fast principle
Detection Pattern:
catch \(Exception[^\)]*\)Common Locations:
- Background workers
- Service layer methods
- Generic repositories
Severity: HIGH
Fix: Catch only specific exceptions you can handle:
// BAD
try { await ProcessAsync(); }
catch (Exception ex) { Logger.LogError(ex, "Failed"); }
// GOOD
try { await ProcessAsync(); }
catch (DbUpdateException ex) { /* Handle DB errors */ }
catch (HttpRequestException ex) { /* Handle HTTP errors */ }---
Mistake #2: Swallowing Exceptions Silently
Problem: Empty catch blocks that hide errors.
Why It's Bad:
- Silent failures in production
- No observability of issues
- Corrupted state continues executing
- Impossible to diagnose problems
Detection Pattern:
catch[^{]*\{\s*(//[^\n]*)?\s*\}Common Locations:
- Event publishing code
- Cleanup/disposal code
- Legacy migration code
Severity: HIGH
Fix: Always log exceptions at minimum:
// BAD
try { await PublishEventAsync(evt); }
catch { /* Ignore event failures */ }
// GOOD
try { await PublishEventAsync(evt); }
catch (Exception ex)
{
Logger.LogError(ex, "Event publishing failed for {EventType}", evt.GetType().Name);
throw; // Re-throw if critical
}---
Mistake #3: Using throw ex; Instead of throw;
Problem: Re-throwing with throw ex; resets the stack trace.
Why It's Bad:
- Loses original stack trace
- Makes debugging impossible
- Hides root cause location
- Breaks exception analysis tools
Detection Pattern:
throw ex;Common Locations:
- Legacy code
- Refactored methods
- Copy-pasted exception handlers
Severity: MEDIUM
Fix: Use throw; to preserve stack trace:
// BAD
catch (Exception ex)
{
Logger.LogError(ex, "Failed");
throw ex; // Resets stack trace
}
// GOOD
catch (Exception ex)
{
Logger.LogError(ex, "Failed");
throw; // Preserves original stack trace
}---
Mistake #4: Wrapping Everything in Try/Catch
Problem: Defensive try/catch blocks throughout codebase.
Why It's Bad:
- Clutters code (200+ lines in typical projects)
- Hides programming errors
- Duplicates exception handling logic
- Violates DRY principle
Detection: Manual code review for excessive try/catch density.
Common Locations:
- Every controller action
- Every service method
- Repository methods
Severity: MEDIUM
Fix: Use global exception handler instead:
// BAD - In every controller action
[HttpPost]
public async Task<IActionResult> CreateUser(UserDto dto)
{
try
{
var user = await userService.CreateAsync(dto);
return Ok(user);
}
catch (ArgumentException ex) { return BadRequest(ex.Message); }
catch (ConflictException ex) { return Conflict(ex.Message); }
catch (Exception ex) { return StatusCode(500); }
}
// GOOD - Let global handler manage it
[HttpPost]
public async Task<IActionResult> CreateUser(UserDto dto)
{
var user = await userService.CreateAsync(dto);
return Ok(user);
}
// GlobalExceptionHandler maps exceptions to HTTP responses---
Mistake #5: Using Exceptions for Control Flow
Problem: Throwing exceptions for expected business validation.
Why It's Bad:
- Performance overhead (exceptions are expensive)
- Semantic confusion (not exceptional)
- Stack trace pollution in logs
- Violates separation of concerns
Detection: Look for validation logic throwing exceptions.
Common Locations:
- Input validation
- State machine transitions
- Business rule checks
Severity: MEDIUM
Fix: Use Result<T> pattern for validation:
// BAD
public void TransitionState(State from, State to)
{
if (!IsValidTransition(from, to))
throw new InvalidStateTransitionException(from, to);
// ... transition logic
}
// GOOD
public Result<bool> CanTransitionState(State from, State to)
{
if (!IsValidTransition(from, to))
return Result<bool>.Failure($"Cannot transition from {from} to {to}");
return Result<bool>.Success(true);
}---
Mistake #6: Forgetting to Await Async Calls
Problem: Not awaiting async methods, losing exceptions.
Why It's Bad:
- Exceptions lost on background thread
- Synchronization context issues
- Race conditions
- Unobserved task exceptions
Detection Pattern:
Task\w+\([^;]*\);(?!\s*await)Common Locations:
- Event handlers
- Fire-and-forget operations
- Background initialization
Severity: HIGH
Fix: Always await async calls:
// BAD
public void OnMessageReceived(Message msg)
{
ProcessMessageAsync(msg); // Fire-and-forget
}
// GOOD
public async Task OnMessageReceived(Message msg)
{
await ProcessMessageAsync(msg);
}---
Mistake #7: Ignoring Background Task Exceptions
Problem: Fire-and-forget tasks with no exception handling.
Why It's Bad:
- Silent failures in background work
- No observability
- Corrupted state
- Lost critical operations (audit logs, events)
Detection Pattern:
Task\.Run\(|Task\.Factory\.StartNewCommon Locations:
- Background workers
- Event publishing
- Cache warming
- Cleanup operations
Severity: CRITICAL (if critical operations)
Fix: Add exception boundaries and observability:
// BAD
Task.Run(async () => await PublishEventAsync(evt));
// GOOD
_ = Task.Run(async () =>
{
try
{
await PublishEventAsync(evt);
}
catch (Exception ex)
{
Logger.LogCritical(ex, "Critical event publishing failed");
// Consider alerting, metrics, etc.
}
});---
Mistake #8: Throwing Generic Exceptions
Problem: Using Exception or ApplicationException instead of specific types.
Why It's Bad:
- Cannot distinguish error types
- Forces broad catch blocks
- Breaks selective exception handling
- No semantic meaning
Detection: Manual review for generic exception types.
Common Locations:
- Business logic validation
- Custom error scenarios
- Legacy code
Severity: LOW
Fix: Create domain-specific exception types:
// BAD
throw new Exception("User not found");
// GOOD
public class UserNotFoundException : Exception
{
public UserNotFoundException(string userId)
: base($"User with ID {userId} not found")
{
}
}
throw new UserNotFoundException(userId);---
Mistake #9: Losing Inner Exceptions
Problem: Creating new exceptions without preserving the original.
Why It's Bad:
- Loses root cause information
- Breaks exception analysis
- Debugging becomes impossible
- Obscures real failure
Detection: Review custom exception constructors.
Common Locations:
- Exception translation layers
- Adapter patterns
- Legacy migrations
Severity: MEDIUM
Fix: Always preserve inner exceptions:
// BAD
catch (SqlException ex)
{
throw new DatabaseException("Database error occurred");
}
// GOOD
catch (SqlException ex)
{
throw new DatabaseException("Database error occurred", ex);
}
// Custom exception with innerException support
public class DatabaseException : Exception
{
public DatabaseException(string message, Exception innerException = null)
: base(message, innerException)
{
}
}---
Mistake #10: Missing Global Exception Handling
Problem: No centralized exception-to-HTTP mapping.
Why It's Bad:
- Stack traces exposed to clients (security risk)
- Inconsistent error responses
- Duplicated exception handling in controllers
- No centralized logging/monitoring
Detection:
# Search for AddExceptionHandler in Program.cs
# If not found → CRITICAL violation
grep -n "AddExceptionHandler\|UseExceptionHandler" Program.csCommon in: ASP.NET Core APIs without middleware
Severity: CRITICAL
Fix: Implement IExceptionHandler (see Architecture Patterns section).
---
Detection Patterns
Ripgrep (recommended) Patterns
Use rg -P (PCRE mode) for these patterns:
# Mistake #1: Broad catches
rg -P 'catch\s*\(Exception\b' --glob '*.cs'
# Mistake #2: Empty catches
rg -P 'catch[^{]*\{\s*(//[^\n]*)?\s*\}' --glob '*.cs'
# Mistake #3: throw ex
rg 'throw\s+ex;' --glob '*.cs'
# Mistake #6: Unawaited async
rg -P 'Task\w+\([^;]*\);' --glob '*.cs'
# Mistake #7: Fire-and-forget
rg 'Task\.Run\(|Task\.Factory\.StartNew' --glob '*.cs'
# Mistake #10: Missing global handler
rg 'AddExceptionHandler|UseExceptionHandler' Program.csGrep (POSIX) Alternative
If using standard grep, use POSIX character classes:
# Mistake #1: Broad catches
grep -n 'catch[[:space:]]*(Exception' *.cs
# Mistake #2: Empty catches (simplified)
grep -n 'catch.*{[[:space:]]*}' *.cs
# Mistake #3: throw ex
grep -n 'throw ex;' *.cs---
Fix Templates
Template 1: GlobalExceptionHandler
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception,
"Unhandled exception for {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
var (statusCode, title) = MapException(exception);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode >= 500
? "An error occurred processing your request"
: exception.Message,
Instance = httpContext.Request.Path
}, cancellationToken);
return true; // Exception handled
}
private static (int StatusCode, string Title) MapException(Exception exception)
=> exception switch
{
ArgumentException or ArgumentNullException => (400, "Invalid request"),
UnauthorizedAccessException => (401, "Unauthorized"),
NotFoundException => (404, "Not found"),
ConflictException => (409, "Conflict"),
_ => (500, "Internal server error")
};
}Registration:
// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // Must be before UseRouting---
Template 2: Result<T> Pattern
public readonly struct Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public string ErrorMessage { get; }
private Result(bool isSuccess, T value, string errorMessage)
{
IsSuccess = isSuccess;
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Success(T value) =>
new(true, value, string.Empty);
public static Result<T> Failure(string errorMessage) =>
new(false, default!, errorMessage);
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<string, TResult> onFailure) =>
IsSuccess ? onSuccess(Value) : onFailure(ErrorMessage);
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
IsSuccess
? Result<TNew>.Success(mapper(Value))
: Result<TNew>.Failure(ErrorMessage);
}
// Controller usage
[HttpPost]
public IActionResult ValidateTransition([FromBody] TransitionRequest request)
{
var result = stateMachine.CanTransition(request.From, request.To);
return result.Match(
onSuccess: _ => Ok(),
onFailure: error => BadRequest(error)
);
}---
Template 3: DbContext Exception Extensions
public static class DbContextExtensions
{
public static async Task<Result<int>> SaveChangesWithResultAsync(
this DbContext context,
CancellationToken ct = default)
{
try
{
var changes = await context.SaveChangesAsync(ct);
return Result<int>.Success(changes);
}
catch (DbUpdateConcurrencyException ex)
{
return Result<int>.Failure("The record was modified by another user");
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx)
{
return sqlEx.Number switch
{
2601 or 2627 => Result<int>.Failure("A record with this key already exists"),
547 => Result<int>.Failure("Cannot delete: record is referenced elsewhere"),
_ => Result<int>.Failure("A database error occurred")
};
}
}
}---
Template 4: BackgroundService Exception Boundary
public class EventPublisherService : BackgroundService
{
private readonly ILogger<EventPublisherService> _logger;
private readonly IEventQueue _queue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Event publisher starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var evt = await _queue.DequeueAsync(stoppingToken);
await PublishAsync(evt, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
break;
}
catch (Exception ex)
{
// Log but continue processing
_logger.LogError(ex, "Event publishing failed, will retry");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Event publisher stopped");
}
}---
Architecture Patterns
When to Use GlobalExceptionHandler vs Try/Catch
Use GlobalExceptionHandler for:
- All ASP.NET Core applications
- Consistent error response format
- Security (prevent stack trace leaks)
- Centralized logging
- ProblemDetails RFC 7807 compliance
Use Try/Catch for:
- Resource cleanup (using/try-finally)
- Specific operation recovery
- External service integration with retries
- Transaction boundaries
Never use Try/Catch for:
- Every controller action
- Validation logic (use Result<T>)
- Converting exceptions to HTTP responses (use global handler)
---
Result<T> vs Exception Decision Tree
Is the condition expected in normal operation?
├─ Yes → Use Result<T>
│ Examples: Validation, business rules, state checks
│
└─ No → Use Exception
├─ Is it recoverable?
│ ├─ Yes → Specific exception + handling
│ │ Examples: DbUpdateException, HttpRequestException
│ │
│ └─ No → Let fail + global handler
│ Examples: ArgumentNullException, InvalidOperationException---
Security Considerations
OWASP Compliance
A01:2021 – Broken Access Control:
- Never expose stack traces in production
- Use ProblemDetails with sanitized messages
A03:2021 – Injection:
- Don't include user input directly in exception messages
- Sanitize before logging
A09:2021 – Security Logging and Monitoring Failures:
- Always log exceptions with context
- Include correlation IDs
- Monitor exception rates
Stack Trace Prevention
// WRONG - Exposes stack trace
httpContext.Response.StatusCode = 500;
await httpContext.Response.WriteAsJsonAsync(exception);
// RIGHT - Sanitized response
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 500,
Title = "Internal server error",
Detail = "An error occurred" // Generic message
});Sensitive Data in Exceptions
// WRONG - Leaks sensitive data
throw new Exception($"Failed to authenticate user {username} with password {password}");
// RIGHT - No sensitive data
throw new AuthenticationException("Authentication failed");---
Integration Patterns
ASP.NET Core Minimal APIs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(); // Before MapGet/MapPost
app.MapGet("/users/{id}", async (int id, UserService svc) =>
{
var user = await svc.GetByIdAsync(id); // Throws NotFoundException
return user; // Global handler catches and maps to 404
});EF Core with Result Pattern
public async Task<Result<User>> CreateUserAsync(UserDto dto)
{
var user = new User { Name = dto.Name, Email = dto.Email };
_context.Users.Add(user);
var saveResult = await _context.SaveChangesWithResultAsync();
return saveResult.IsSuccess
? Result<User>.Success(user)
: Result<User>.Failure(saveResult.ErrorMessage);
}Azure SDK Integration
try
{
await blobClient.UploadAsync(stream);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
throw new NotFoundException("Blob container not found", ex);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
throw new ConflictException("Blob already exists", ex);
}
catch (AuthenticationFailedException ex)
{
throw new UnauthorizedAccessException("Azure authentication failed", ex);
}---
Validation Rules
ASP.NET Core Projects
1. Global handler registered: AddExceptionHandler<T>() in Program.cs 2. Middleware active: UseExceptionHandler() in pipeline 3. ProblemDetails enabled: AddProblemDetails() configured 4. No try/catch in controllers: Trust global handler
All Projects
1. No catch (Exception): Except at application boundaries 2. No empty catches: Always log at minimum 3. No throw ex: Use throw; to preserve stack 4. Async awaited: All async calls properly awaited 5. Background tasks monitored: Exception boundaries in BackgroundService
Security
1. Zero stack traces: Never in HTTP responses 2. Sanitized messages: No sensitive data in exceptions 3. Proper HTTP codes: Match exception type to status code 4. Correlation IDs: Track requests through exception logs
---
Severity Classification Reference
CRITICAL (Fix immediately):
- Missing global exception handler (stack trace exposure)
- Background task exceptions swallowed (data loss risk)
- Security vulnerabilities (sensitive data in exceptions)
HIGH (Fix before production):
- Broad catch (Exception) blocks
- Empty catch blocks
- Unawaited async calls
- Lost inner exceptions
MEDIUM (Fix during refactoring):
- Excessive try/catch blocks
- Exceptions for control flow
- throw ex (stack trace reset)
- Wrong HTTP status codes
LOW (Fix when convenient):
- Generic exception types
- Inconsistent exception messages
- Missing XML documentation on custom exceptions
---
References: