Use exceptions everywhere — for validation, for business rules, or leave them only for exceptional situations. If you want to know which approach is better and why, then welcome to the hood.

Exceptions are basically a way to tell us that the runtime couldn’t process a piece of code. For investigation purposes, all exceptions contain the following properties:

  • Message — a message that describes the current exception
  • Source — the name of the part of the application that caused the exception
  • StackTrace — a representation of the call stack
  • HelpLink — a link that provides help related to this exception
  • Data — a collection of user-defined information about the exception

These properties come from System.Exception. There are other properties as well, but the ones listed above are the most important.

As I mentioned earlier, exceptions are meant for exceptional situations. However, a developer can often predict where an exception might occur. Therefore, in .NET there’s a statement that allows catching exceptions to prevent the entire application from shutting down.

For example, imagine you need to read a file from the file system — you might write something like this:

public async Task ReadFileWithoutCatch()
{
var text = await File.ReadAllTextAsync(_fileName);
}

And it works fine until the file doesn’t exist or another process is accessing it at the same time.

As a developer, you can probably guess that this situation might happen, so you use a try-catch statement.

The code above would then look like this:

public async Task ReadFileWithCatch()
{
try
{
var text = await File.ReadAllTextAsync(_fileName);
}
catch (Exception exception)
{
Console.WriteLine(exception);
}
}

Now that we understand what an exception truly is, let’s talk about how we can use it — and whether we should — in an ASP.NET Core application.

Exceptions are meant to signal that something went seriously wrong.
However, some developers use exceptions almost like a goto statement, for example, to handle validation errors or business rule violations.

Even FluentValidation has an option to validate and throw if validation fails. And honestly, I’ve used this approach myself. It can be quite convenient — you don’t have to think about invalid input or unexpected situations; you just throw an exception and move on.

However, exceptions are also expensive — they come with a performance cost when handling requests.

I know Microsoft has significantly improved exception handling performance over time, but it’s still relatively costly.

To prove this, let’s create one simple application which will have two endpoints for register users: one that uses exceptions and another that doesn’t — and then run a performance comparison test.

I’ll start by create a User class:

public class User
{
public Guid Id { get; set; }

public string? Email { get; set; }
}

And RegisterUserRequest :

public record RegisterUserRequest(Guid UserId, string Email);

Then let’s continue by creating a UserRegistration; it’s like a handler with two methods:

public sealed class UserRegistration
{
private readonly List<User> users = [];

public async Task<User> RegisterUserExceptionFlowAsync(
RegisterUserRequest registerUserRequest)
{
if (registerUserRequest.UserId == Guid.Empty)
{
throw new ValidationException("UserId can not be empty.");
}

if (string.IsNullOrWhiteSpace(registerUserRequest.Email))
{
throw new ValidationException("Email can not be empty or null.");
}

var user = new User
{
Id = registerUserRequest.UserId,
Email = registerUserRequest.Email,
};

users.Add(user);

await Task.Delay(1000);

return user;
}

public async Task<OneOf<User, RegisterUserError>> RegisterUserAsync(
RegisterUserRequest registerUserRequest)
{
if (registerUserRequest.UserId == Guid.Empty)
{
return new RegisterUserError("UserId can not be empty.");
}

if (string.IsNullOrWhiteSpace(registerUserRequest.Email))
{
return new RegisterUserError("Email can not be empty or null.");
}

var user = new User
{
Id = registerUserRequest.UserId,
Email = registerUserRequest.Email,
};

users.Add(user);

await Task.Delay(1000);

return user;
}
}

ValidationException is my custom exception:

public sealed class ValidationException(string message) : Exception(message);

RegisterUserError is a simple class with a single property:

public sealed record RegisterUserError(string Message);

And OneOf is a great NuGet package that can be added using the following command:

dotnet add package OneOf

And here’s the code from the Program class:

using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using RndExceptions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped(typeof(UserRegistration));

var app = builder.Build();

// this use in case of exception
app.UseExceptionHandler(configure =>
{
configure.Run(async httpContext =>
{
httpContext.Response.ContentType = "application/problem+json";
httpContext.Response.StatusCode = 400;

var exceptionHandlerFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;

var problemDetails = new ProblemDetails
{
Status = (int) HttpStatusCode.BadRequest
};

if (exception is not null)
{
problemDetails.Title = exception.Message;
}

await JsonSerializer.SerializeAsync(
httpContext.Response.BodyWriter.AsStream(),
problemDetails);
});
});

app.MapPut("v1/users", async (
[FromBody] RegisterUserRequest request,
[FromServices] UserRegistration useCase) =>
{
var result = await useCase.RegisterUserExceptionFlowAsync(request);

return Results.Ok(result);
});

app.MapPut("v2/users", async (
[FromBody] RegisterUserRequest request,
[FromServices] UserRegistration useCase) =>
{
var result = await useCase.RegisterUserAsync(request);

return result.Match(Results.Ok, Results.BadRequest);
});

app.Run();

When I started the performance test using the K6 testing tool, I got some amazing results:

As you can see, using exceptions for validation is not a good idea — I’d even say it’s a bad one.

I believe that exceptions should be reserved only for exceptional situations.
Of course, you should handle them using a global exception-handling middleware, but avoid using exceptions as part of your business logic.

🤝 Let’s connect on LinkedIn!
If you enjoyed this post or want to chat more about it, feel free to connect with me on LinkedIn. I’d love to hear your thoughts and keep in touch!

Exceptions are a powerful tool in .NET, but they’re often misunderstood and overused. In this article, we’ll look at what exceptions really are and why using them for validation or business logic can hurt your application’s performance.

Now from you guys, what do you think about it and how do you use exceptions?

If you have any questions, or if you’d like to share your thoughts or experiences, feel free to leave a comment down below!

As always, here’s the link to the repository for you to explore.