Telerik blogs
ASP.NET Core

Exceptions are a common approach to dealing with unexpected situations. But are they truly necessary? Let’s see some best practices for using them in ASP.NET Core.

Exceptions have been part of the .NET ecosystem since the earliest versions of the .NET Framework. Today, with the platform’s continuous evolution, their use is simpler and more integrated than ever. The problem, however, arises when they are used inappropriately or imprecisely.

In this post, we will explore in which situations the use of exceptions is truly recommended and when more appropriate alternatives should be considered. Furthermore, we will see in practice some of the most modern features available in ASP.NET Core for implementing exception handling aligned with best practices.

💡 What Are Exceptions in the Context of ASP.NET Core?

In ASP.NET Core, exceptions are objects that represent unexpected events that occur during program execution and interrupt its normal flow. Common examples of flow interruption include null reference errors, invalid user input, and database connection problems.

In practice, when an exception is thrown, the .NET runtime starts looking for somewhere that knows how to handle it, typically a try/catch statement, as shown in the example below:

try
{
    var result = 10 / 0;
}
catch (Exception ex)
{
    throw new Exception("Error: " + ex.Message);
}

If this exception is not handled, it returns to the beginning of the execution and eventually reaches the ASP.NET Core pipeline, resulting in an HTTP error, usually a 500 - Internal Server Error.

🚫 When Should Exceptions Be Avoided?

One of the most common mistakes in backend applications is using exceptions for expected situations. If code already expects something to happen, then it’s not exceptional, it’s predictable.

The principle of exceptions is to reserve exceptions for unpredictable situations.

A common example of a situation where exceptions should be avoided is for input data validation. Data validation should never use exceptions as a rule. Note the example below:

if (string.IsNullOrEmpty(user.BankAccountNumber))
{
    throw new Exception("BankAccountNumber is required");
}

The problem here is that the system expects the client to send a user with invalid data. In other words, this is part of the normal flow, not an exception. In this case, the correct approach would be to treat this as data validation and simply return a 400 Bad Request:

if (string.IsNullOrEmpty(user.BankAccountNumber))
{
    return Results.BadRequest("BankAccountNumber is required");
}

💁 When Does Using Exceptions Make Sense?

If exceptions shouldn’t be used in the normal flow, then when do they make sense? In backend applications like ASP.NET Core, exceptions should be used when something truly unexpected happens and the application cannot continue safely or should not continue at all.

Below, we’ll look at practical scenarios where exceptions actually make sense.

💥 Infrastructure Failures

This is a very common scenario. If the database fails here, it’s not possible to proceed normally, meaning it’s an exception:

try
{
    var user = await dbContext.Users.FindAsync(id);
    return Results.Ok(user);
}
catch (Exception ex)
{
    return Results.InternalServerError($"Error retrieving user. Error details: {ex.Message}");
}

⚠️ Impossible or Inconsistent States

Despite frontend and backend validations, inconsistent states can reach the application core. If this happens, something is certainly wrong, and this corrupted state cannot be saved to the database or continue its path. In this case, an exception must be generated:

if (BankAccountNumber.Length < 8)
{
    throw new InvalidOperationException("The bank account number must have at least 8 digits at this point");
}

If the system logic guarantees that BankAccountNumber will always have at least 8 characters at this point, then this indicates a bug, data inconsistency or even a flow error, and the exception is the last resort to prevent an inconsistent state at this stage of the process.

🚨 Dependency on External Services

If the external service is mandatory for the flow and the absence of data completely prevents correct processing, then you are dealing with a real error and the exception represents a technical problem.

In this case, it is correct to throw an exception because the process cannot continue with inconsistent data. Furthermore, it is important to clarify that the error occurred in the integration between the services, as demonstrated in the example below:

   var customer = await externalService.GetCustomerAsync(customerId);

    if (customer == null)
    {
        throw new InvalidOperationException("Unable to retrieve customer data from the external service: CustomerService");
    }

Result Pattern as an Alternative

If exceptions shouldn’t be used for normal flow, the question arises: what would be a good alternative to represent expected errors?

One of the most widely used alternatives today is the Result Pattern. The Result Pattern is a design pattern used to manage execution flow and errors, returning a structured object instead of throwing exceptions for expected failures.

Instead of throwing exceptions, the method returns an object indicating whether it succeeded or failed and why.

Implementing the Result Pattern

To use the Result Pattern, we will create a simple application and then create the classes and methods. The complete source code with examples is available in this GitHub repository: PracticingExceptionHandling source code.

To create the base, you can use the command below in your terminal:

dotnet new web -o PracticingExceptionHandling

Open the application and create the following class within it:

namespace PracticingExceptionHandling;

public class Result
{
    public bool IsSuccess { get; }
    public string Error { get; }

    protected Result(bool isSuccess, string error)
    {
        IsSuccess = isSuccess;
        Error = error;
    }

    public static Result Ok() => new Result(true, null);
    public static Result Fail(string error) => new Result(false, error);
}

public class Result<T> : Result
{
    public T Value { get; }

    private Result(T value) : base(true, null)
    {
        Value = value;
    }

    private Result(string error) : base(false, error)
    {
    }

    public static Result<T> Ok(T value) => new Result<T>(value);
    public static new Result<T> Fail(string error) => new Result<T>(error);
}

Note that two Result classes are implemented, both aiming to explicitly represent the success or failure of an operation as well as the error details. The base class defines the common structure for any result. It has the IsSuccess property, which indicates whether the operation was successful, and the Error property, which contains the error message in case of failure.

The generic Result<T> class extends this idea to operations that return a value. When the operation is successful, in addition to IsSuccess being true, the result also carries a Value of type T. For this, there are two distinct private constructors: one for success, which receives the value, and another for failure, which receives only the error message. This avoids the creation of invalid states within the class itself.

The static methods Ok and Fail in the generic version follow the same pattern as the base class, but now allow data to be returned along with the result. This is useful in scenarios such as searches, validations or external integrations, where you want to return a value on success or a clear explanation on error, without throwing exceptions for expected situations.

An important point is that the consumer of the result must always check IsSuccess before accessing Value. This is a desirable behavior, as it forces the API user to explicitly handle failures, preventing silent errors or unexpected flows.

Now create a new class called UserService and add the following code to it:

namespace PracticingExceptionHandling;

public class UserService
{
    public Result<User> CreateUser(User newUser)
    {
        if (string.IsNullOrWhiteSpace(newUser.Name))
            return Result<User>.Fail("Name is required");

        var user = new User()
        {
            Id = newUser.Id,
            Name = newUser.Name,
            BankAccountNumber = newUser.BankAccountNumber,
            Status = 1
        };

        return Result<User>.Ok(user);
    }
}

Here we have the CreateUser method which validates whether the Name property is null or contains only spaces. If either of these conditions is true, the method immediately returns a failure result Result<User>.Fail, preventing the creation of an invalid user and explicitly stating the reason for the error.

If the validation passes, a new User object is instantiated with the data provided in the request, also applying a default value for Status. Finally, the method returns a success result Result<User>.Ok, encapsulating the created object.

The next step is to call the CreateUser method, so in the Program class, add the following endpoint:

app.MapPost("/users/create", (User newUser, UserService userService) =>
{
    var result = userService.CreateUser(newUser);

    if (!result.IsSuccess)
        return Results.BadRequest(new { error = result.Error });

    return Results.Ok(result.Value);
});

In this endpoint, we define a POST route /users/create that receives a User object and a UserService instance. Upon receiving the request, the endpoint calls the CreateUser method, which returns a Result<User>. This return encapsulates both success and potential failure.

Next, the endpoint checks the IsSuccess property. If the result indicates failure, it returns a 400 Bad Request, including the error message (result.Error) in the response body, clearly explaining the reason for the rejection to the client. If the operation is successful, the endpoint returns a 200 OK with the created object (result.Value).

In this way, we use the Result Pattern as a clear contract between the layers, which improves the readability and predictability of the API’s behavior, reserving exceptions only for unpredictable scenarios.

✨ Expressive Exceptions

A common mistake when using exceptions is creating weak or generic exceptions. We’ve already seen that exceptions should be reserved for unpredictable errors; in this case, when an error occurs, it’s important to obtain as much information as possible about why that exception happened. Consider the example below:

throw new Exception("User not found");

The problem here is that the system knows something went wrong but doesn’t know exactly what went wrong (that is, the reason for the error).

To solve this, we can create a custom exception. Custom exceptions are classes that represent domain or application specific errors. Consider the following example:

public class UserNotFoundException : Exception
{
    public UserNotFoundException(string id)
        : base($"User with id {id} was not found")
    {
    }
}

Now the error has an identity. It provides user details and makes it explicit that this is a domain exception and is important to the application, much more explicit than a generic exception.

Now see how it looks in the call:

❌ Generic

throw new Exception("User not found");

✅ Specific (Custom Exception)

throw new UserNotFoundException(id);

With custom exceptions, we obtain better tracking (logs), the possibility of specific handling and more expressive code.

💡 Creating Exceptions with Helper Methods

When using custom exceptions, it’s common to have duplicate exceptions, for example, when the same exception is thrown in multiple parts of the class. Note the example below:

public User(string name)
{
    if (string.IsNullOrWhiteSpace(name))
        throw new InvalidUserException("Name cannot be empty");

    Name = name;
}

public void UpdateName(string name)
{
    if (string.IsNullOrWhiteSpace(name))
        throw new InvalidUserException("Name cannot be empty");

    Name = name;
}

In both cases, the same exception is being thrown, using the same details. To reuse exceptions, we can create helper methods and even generic methods depending on the context in which they are used. In the case above, we can create a method that expects the name of a property and returns a custom exception and use it everywhere it is useful:

   public User(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw NewInvalidPropertyException("Name");

        Name = name;
    }

    public void UpdateName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw NewInvalidPropertyException("Name");

        Name = name;
    }

    private static InvalidUserException NewInvalidPropertyException(string property)
    {
        return new InvalidUserException($"{property} cannot be empty");
    }

🌱 Conclusion

Exceptions are an excellent feature of ASP.NET Core and are especially useful in unpredictable scenarios or as a last line of defense to prevent invalid states from progressing through the flow or persisting in the database. The problem arises when they are used to handle expected errors or in a generic way, without providing sufficient details.

In this post, we explore best practices in the use of exceptions and examine the Result Pattern as a more suitable alternative for predictable scenarios.

I hope this post helps you make better decisions when using exceptions, when to avoid them and how to apply them effectively in your applications.


assis-zang-bio
About the Author

Assis Zang

Assis Zang is a software developer from Brazil, developing in the .NET platform since 2017. In his free time, he enjoys playing video games and reading good books. You can follow him at: LinkedIn and Github.

Related Posts

Comments

Comments are disabled in preview mode.