Exception Filters in .NET Framework: Goodbye to Try-Catch Hell
How many times have you seen .NET Framework controllers full of identical try-catch blocks? If you’re working with Web API .NET Framework 4.8 and find yourself with repeated try-catch everywhere, this article will show you how to centralize error handling to improve maintainability and consistency.
The Problem: Try-Catch Everywhere
Let’s start with an example of a .NET Framework controller that, at first glance, seems well structured:
[Authorize]
[RoutePrefix("api/user")]
public class UserController : BaseController
{
private readonly ILogger _logger;
private readonly IUserService _userService;
[HttpGet]
[Route("getUser")]
public IHttpActionResult GetUser()
{
try
{
var userData = _userService.GetUser();
return Ok(userData);
}
catch (Exception ex)
{
_logger.LogError($"Error in GetUser: {ex}");
return InternalServerError();
}
}
[HttpGet]
[Route("getAssets")]
public IHttpActionResult GetAssets()
{
try
{
var assets = _userService.GetAssets();
return Ok(assets);
}
catch (Exception ex)
{
_logger.LogError($"Error in getAssets: {ex}");
return BadRequest();
}
}
// Another 20 methods with the same pattern...
}
At first glance this controller seems well structured: it correctly delegates business logic to services (_userService.GetUser()
, _userService.GetAssets()
), maintains clear responsibilities and focuses on handling HTTP requests.
But there’s a hidden problem: every method is forced to duplicate the same error handling logic, polluting the code and creating inconsistencies.
The Problems
- Duplication: The same error handling code repeated everywhere
- Inconsistency: Some methods return
BadRequest()
, othersInternalServerError()
without logic - Maintainability: Changing the logic requires modifications in dozens of places
The Solution: Exception Filters
What Are Exception Filters
Exception Filters in .NET Framework are components that are part of the Web API pipeline. They are special attributes that intercept unhandled exceptions during action execution before they are returned to the client. Exception Filters are executed only when an exception occurs and are strategically positioned to “catch” all exceptions before they become generic errors.
Exception Filters can be applied at two levels:
- Globally: Registered in
WebApiConfig.cs
and applied automatically to all controllers and actions - Controller/Action level: Applied using the
[GlobalExceptionFilter]
attribute on specific controllers or methods
Basic Implementation
public class GlobalExceptionFilterAttribute : ExceptionFilterAttribute
{
private readonly ILogger _logger;
public GlobalExceptionFilterAttribute()
{
_logger = DependencyResolver.Current.GetService<ILogger>();
}
public override void OnException(HttpActionExecutedContext actionExecutedContext)
{
var exception = actionExecutedContext.Exception;
LogException(exception, actionExecutedContext);
var actionResult = CreateActionResult(exception);
actionExecutedContext.Response = actionResult.ExecuteAsync(CancellationToken.None).Result;
}
private void LogException(Exception exception, HttpActionExecutedContext context)
{
var controllerName = context.ActionContext.ControllerContext.Controller.GetType().Name;
var actionName = context.ActionContext.ActionDescriptor.ActionName;
_logger.LogError(exception,
"Unhandled exception in {Controller}.{Action}",
controllerName, actionName);
}
private IHttpActionResult CreateActionResult(Exception exception)
{
switch (exception)
{
case NotFoundException notFoundEx:
return CreateErrorResponse(HttpStatusCode.NotFound, "NotFoundException", notFoundEx.Message);
case ValidationException validationEx:
return CreateErrorResponse(HttpStatusCode.BadRequest, "ValidationException", validationEx.Message);
case UnauthorizedException unauthorizedEx:
return CreateErrorResponse(HttpStatusCode.Unauthorized, "UnauthorizedException", "Unauthorized access");
default:
return CreateErrorResponse(HttpStatusCode.InternalServerError, "InternalServerError", "An internal server error occurred");
}
}
private IHttpActionResult CreateErrorResponse(HttpStatusCode statusCode, string error, string message)
{
var errorResponse = new
{
error = error,
status = (int)statusCode,
message = message,
timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
};
var jsonContent = Newtonsoft.Json.JsonConvert.SerializeObject(errorResponse);
return new ResponseMessageResult(
new HttpResponseMessage(statusCode)
{
Content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json")
});
}
}
Custom Exceptions
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class ValidationException : Exception
{
public ValidationException(string message) : base(message) { }
}
public class UnauthorizedException : Exception
{
public UnauthorizedException(string message = "Access denied") : base(message) { }
}
Filter Registration
In WebApiConfig.cs
:
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Global registration of the filter
config.Filters.Add(new GlobalExceptionFilterAttribute());
// Other configurations...
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
}
Alternative Approach: Instead of global registration, you could apply the filter at controller level using [GlobalExceptionFilter]
decorator on specific controllers.
However, the global approach ensures complete coverage and eliminates the possibility of forgetting to apply the filter to new controllers.
Project Structure with Exception Filters
Taking up the example we’ve seen with the controller that correctly delegates business logic to services, integrating Exception Filters requires adding a few specific components to the existing structure. Here’s how the project evolves:
📁 ExceptionFiltersDemo/
├── 📁 Controllers/
│ └── 📄 UserController.cs # Existing controllers (refactored)
├── 📁 Services/
│ ├── 📄 IUserService.cs # Existing service layer
│ └── 📄 UserService.cs # (modified for typed exceptions)
├── 📁 Repository/
│ ├── 📄 IUserRepository.cs # Existing repository
│ └── 📄 UserRepository.cs # (no modifications needed)
├── 📁 Models/
│ ├── 📄 UserViewModel.cs # Existing DTOs
│ └── 📄 CreateUserRequest.cs # (no modifications needed)
├── 📁 Filters/
│ └── 📄 GlobalExceptionFilterAttribute.cs # Our Exception Filter
├── 📁 Exceptions/
│ ├── 📄 NotFoundException.cs # Custom exceptions
│ ├── 📄 ValidationException.cs
│ └── 📄 UnauthorizedException.cs
├── 📁 App_Start/
│ └── 📄 WebApiConfig.cs # (modified to register the filter)
├── 📄 Global.asax.cs # Existing configuration
├── 📄 Web.config # Existing configuration
The main changes involve:
- Adding
/Filters/
folder for the GlobalExceptionFilterAttribute - Adding
/Exceptions/
folder for typed exceptions - Modifying
WebApiConfig.cs
to register the filter globally - Refactoring controllers by removing try-catch blocks
- Updating services to throw typed exceptions instead of handling them
Controller Refactoring
Before: Try-Catch Hell
[Authorize]
[RoutePrefix("api/user")]
public class UserController : BaseController
{
private readonly ILogger _logger;
private readonly IUserService _userService;
[HttpGet]
[Route("getUser")]
public IHttpActionResult GetUser()
{
try
{
var userData = _userService.GetUser();
return Ok(userData);
}
catch (Exception ex)
{
_logger.LogError($"Error in GetUser: {ex}");
return InternalServerError();
}
}
}
After: Clean Controller
[Authorize]
// [GlobalExceptionFilter] attribute not needed thanks to global registration
[RoutePrefix("api/user")]
public class UserController : BaseController
{
private readonly IUserService _userService;
[HttpGet]
[Route("getUser")]
public IHttpActionResult GetUser()
{
// Clean code focused on business logic
var userData = _userService.GetUser();
return Ok(userData);
}
}
Service Layer with Typed Exceptions
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
public UserViewModel GetUser()
{
var userId = GetUserId();
var user = _userRepository.GetById(userId);
if (user == null)
{
throw new NotFoundException($"User {userId} not found");
}
if (!user.IsActive)
{
throw new ValidationException("User account is not active");
}
return new UserViewModel
{
Id = user.Id,
Name = user.Name,
Email = user.Email
};
}
}
When to Use Exception Filters
Exception Filters are ideal for .NET Framework 4.8 applications with Web API, especially when you want to centralize common error handling and keep controllers clean and focused. They are particularly useful in team development contexts where a shared standard for error handling is needed.
Conclusions
Exception Filters in .NET Framework represent an elegant solution to eliminate Try-Catch Hell from controllers while maintaining a clear separation of responsibilities. They allow you to write cleaner code where controllers focus exclusively on their main responsibility: orchestrating service calls and handling HTTP requests.
Centralizing error handling ensures consistency throughout the application and greatly simplifies code maintenance. When error handling logic needs to be modified, you only need to intervene in one place instead of dozens of different controllers. By implementing this solution, you’ll transform controllers fragmented by try-catch into clean and professional code.
Happy coding with .NET Framework!
Alberto