ASP.NET Core Minimal APIs are a new (as of the end of 2024) way to build web APIs in ASP.NET Core. Minimal APIs are lightweight and easy to use, if you keep the handlers minimal.
In most API endpoints there is a need to tackle errors, which can introduce complications to the code. An API endpoint can return an error in many ways, such as internal server errors, bad requests, or not found errors.
In this article, I will show you the usual way of tackling errors in ASP.NET Core, and how it can be improved using the Result pattern. Traditionally, you bubble up errors in ASP.NET Core is by throwing exceptions. While this is a workable way of dealing with errors, it has its own problems.
To show the pattern, I will build an endpoint that returns data. Then, iterate through the code and arrive at a more expressive way to build this API endpoint. With each iteration, you should start to see how the Result pattern can be used with ease.
The Result pattern is a more structured way of handling errors, and it can be used to make your APIs more robust and easier to maintain. At the end of this take, you will see a minimal API endpoint that utilizes less code, is easier to maintain, and remains minimal.
Feel free to follow along with the code examples in this take. You can find the complete code in this GitHub repository.
The app is a simple weather forecast API that returns a list of random data. The app is built using the .NET 8.0 SDK and the Entity Framework Core in-memory database provider. The app is structured in a way that demonstrates the problem with exceptions and how this design pattern can be used to tackle error logic.
Introduction
Be sure to have the latest version of the .NET SDK installed on your machine. You can download it from the .NET website. I will be using .NET 8.0. You can check your version by running the following command in your terminal:
1 |
dotnet --version |
To fire up a new minimal API project, run the following commands:
1 2 3 4 5 6 7 8 |
mkdir result-pattern-minimal-apis cd result-pattern-minimal-apis mkdir ResultPattern cd ResultPattern dotnet new web --no-https cd .. dotnet new sln dotnet sln add ResultPattern/ResultPattern.csproj |
This should be enough to get you started with a new minimal API project.
Next, add the necessary dependencies to the project. You will need the Entity Framework Core in-memory database provider. Run the following command to add the necessary dependencies:
1 2 |
cd ResultPattern dotnet add package Microsoft.EntityFrameworkCore.InMemory |
The Entity Framework Database Context
The Data-Transfer-Objects (DTOs) are the first thing to create. These define the structure of the data that will be passed around in the application.
With any endpoint, you typically start with data then map the DTOs into the data model that will become the actual payload response. I chose Entity Framework because it runs in memory.
To set up for the endpoint we will be creating, we will create the following set of code. Start with creating a new file named WeatherDto.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
namespace ResultPattern; public record WeatherDto( string CityCode, int Hour, int Temperature, string Condition, int Humidity) { public WeatherDto() : this("", 0, 0, "", 0) {} } public record CityDto( string Code, string Name) { public CityDto() : this("", "") {} } |
Because I am dealing with Entity Framework Core, the DTOs must have a parameter-less constructor. This is because Entity Framework Core needs to create instances of these classes to map the database tables.
Then, create a new file named WeatherForecastContext.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
using Microsoft.EntityFrameworkCore; namespace ResultPattern; public sealed class WeatherDbContext : DbContext { public DbSet<WeatherDto> WeatherDb { get; set; } public DbSet<CityDto> CityDb { get; set; } public WeatherDbContext(DbContextOptions<WeatherDbContext> options) : base(options) { Database.EnsureCreated(); SeedDatabase(); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase("WeatherDatabase"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<CityDto>().HasKey(c => c.Code); modelBuilder.Entity<WeatherDto>().HasKey(w => new {w.CityCode, w.Hour}); } private void SeedDatabase() { // Seed only once if (CityDb.Any()) { return; } List<CityDto> cities = [ new("NY", "New York"), new("LA", "Los Angeles"), new("CH", "Chicago"), new("HO", "Houston"), new("PH", "Phoenix"), new("SA", "San Antonio"), new("SD", "San Diego"), new("DA", "Dallas") ]; foreach (var city in cities) { CityDb.Add(city); } var conditions = new List<string> { "Sunny", "Rainy", "Cloudy", "Snowy", "Windy" }; var unpredictableWeather = new Random(); foreach (var city in cities) { for (var i = 0; i < 24; i++) { WeatherDb.Add(new WeatherDto( city.Code, i, unpredictableWeather.Next(0, 100), conditions[unpredictableWeather.Next(0, 4)], unpredictableWeather.Next(0, 100)) ); } } SaveChanges(); } } |
This automatically seeds the database with some random weather forecasts for the cities in the CityDb
table. There is a relationship between the city and the hourly weather forecast, which is one-to-many. Because this class gets scoped per request, I added a check to ensure that the database is only seeded once.
Next, wire the database context into the application. Open the Program.cs
file in the ResultPattern
directory and add the following code:
1 |
builder.Services.AddDbContext<WeatherDbContext>(); |
Lastly, be sure to create models that API endpoints can return. This decouples your raw tables in the database from the domain model a client can consume.
Create a new file named WeatherModel.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 |
namespace ResultPattern; public record WeatherModel( CityModel City, List<HourlyModel> Weather); public record CityModel( string Code, string Name); public record HourlyModel( int Hour, int Temperature, string Condition, int Humidity); |
The Problem with Exceptions
Exceptions can be used to control the flow of errors in the application. This allows you to jump out of the logic and catch the error elsewhere.
To illustrate the issue, create a new file named CrummyWeatherForecastService.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
namespace ResultPattern; public class CrummyWeatherService(WeatherDbContext dbContext, ILogger<WeatherService> logger) { private readonly WeatherDbContext _dbContext = dbContext; private readonly ILogger<WeatherService> _logger = logger; public WeatherModel GetWeather(string cityCode) { if (cityCode.Length != 2 || cityCode.Any(char.IsDigit)) { throw new InvalidException("City code must be two non-digit characters"); } try { // Simulate an internal server error if (cityCode == "HO") { throw new InternalServerErrorException("An error occurred"); } var city = _dbContext.CityDb.FirstOrDefault(c => c.Code == cityCode); if (city is null) { throw new NotFoundException($"City with code {cityCode} not found"); } var weather = _dbContext.WeatherDb.Where(w => w.CityCode == city.Code).ToList(); return new WeatherModel( new CityModel( city.Code, city.Name), weather.Select(w => new HourlyModel( w.Hour, w.Temperature, w.Condition, w.Humidity)).ToList() ); } catch (InternalServerErrorException ex) { _logger.LogError(ex, "An error occurred"); throw new InternalServerErrorException(ex.Message); } } } public class InvalidException(string _message) : Exception(_message); public class NotFoundException(string _message) : Exception(_message); public class InternalServerErrorException(string _message) : Exception(_message); |
I picked a random city like Houston to simulate an internal server error. The city code must be two characters and non-numeric. If the city code is not found, a not found exception is thrown. If the city code is not valid, an invalid exception is thrown.
Time to build the actual endpoint via Minimal APIs. If you scaffolded the project you should find everything you need in a single file like the Program.cs.
Then, in the Program.cs
file, add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
using ResultPattern; builder.Services.AddScoped<CrummyWeatherService>(); app.MapGet("/crummyWeather/cities/{cityCode}", (CrummyWeatherService service, string cityCode) => { try { return Results.Ok(service.GetWeather(cityCode)); } catch (InvalidException ex) { return Results.BadRequest(ex.Message); } catch (NotFoundException ex) { return Results.NotFound(ex.Message); } catch (InternalServerErrorException) { return Results.StatusCode(500); } catch (Exception ex) { return Results.Problem(ex.Message); } }); |
This calls the weather service, wraps the call around a try then attempts to catch every conceivable error that might occur in the service layer.
I am coining this try/catch hell, not only because we use exceptions to handle validation logic, but also because we must catch different types of exceptions to return the appropriate status code. The code is hard to read, because the logic jumps around and is not in a single place. Exceptions bubble up the call stack, which can make it hard to understand where the error originated.
Throwing exceptions works much like a goto
statement, where the code jumps arbitrarily to a different part of the program. What’s cumbersome is how the catch must match the type of exception thrown. Exception types all inherit from the parent Exception class, and you can infinitely chain multiple errors into a parent/child hierarchy. This can lead to errors that collide with each other and end up in the wrong catch block.
To further illustrate, imagine what would happen if the service class did this instead:
1 2 3 4 5 6 7 8 9 |
try { // Throws an exception for internal server error, and not found error } catch (Exception ex) { _logger.LogError(ex, "An error occurred"); throw new InternalServerErrorException(ex.Message); } |
Did you catch the issue? The catch block will catch all exceptions, including the not found exception. This is because the not found exception is a child of the parent exception class. This can lead to unexpected behaviour in the code because you can have deep inheritance chains when you build custom errors. At least the humble goto
statement is explicit about where it jumps to. Here, a deep chain of exceptions, and a deep call stack, can make the code unpredictably hard to follow.
Throwing exceptions to handle control flow logic also has performance implications. Exceptions are expensive to create and throw, and they can slow down the application. This is because the runtime has to unwind the stack to find the appropriate catch block which can be anywhere.
Because exceptions can slow down the application, an attacker can exploit this weakness and use it as a denial-of-service attack. An attacker can flood the application with unsuccessful requests and make it unresponsive.
The Result Pattern
A better approach is to return a result object that encapsulates the success or failure of the operation. This contains either a value or an error message. A flag can be used to determine the flow of the API response.
This is the Result pattern.The pattern mostly allows you to wrap your validation results in a single place. This way it is perfectly clear what the endpoint should expect to return and handle the response. The result object now becomes a reliable source of truth for the entire application.
Create a new file named Result.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
using System.Diagnostics.CodeAnalysis; namespace ResultPattern; public class Result<T> { public T? Value { get; } public ErrorResponse? Error { get; } [MemberNotNullWhen(true, nameof(Value))] [MemberNotNullWhen(false, nameof(Error))] public bool IsSuccess { get; } private Result(T value) { Value = value; IsSuccess = true; } private Result(ErrorResponse error) { Error = error; IsSuccess = false; } private static Result<T> Ok(T value) => new(value); private static Result<T> Fail(ErrorType type, string error) => new(new ErrorResponse(error, type)); public static Result<T> NotFound(string error) => Fail(ErrorType.NotFound, error); public static Result<T> Invalid(string error) => Fail(ErrorType.Invalid, error); public static Result<T> InternalServerError(string error) => Fail(ErrorType.InternalServerError, error); public static implicit operator Result<T>(T value) => Ok(value); } public record ErrorResponse(string Error, ErrorType ErrorType); public enum ErrorType { NotFound, Invalid, Unauthorized, Forbidden, Conflict, InternalServerError } |
There are many libraries that implement this class, but you can create your own with minimal code. The Result
class is a generic class that can hold any type of value. It has a Value
property that holds the value if the operation is successful, and an Error
property that holds the error response if the operation fails. The IsSuccess
property is a flag that determines the control flow of the API response.
The Weather Forecast Service with the Result pattern
With the Result object the control flow logic can simply return this same result value. Keep in mind the purpose of the pattern is to encapsulate all return values in a single place.
Now, implement a new weather service that uses the Result pattern without throwing exceptions to tackle errors. Create a new file named WeatherService.cs
in the ResultPattern
directory and add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
namespace ResultPattern; public class WeatherService(WeatherDbContext dbContext, ILogger<WeatherService> logger) { private readonly WeatherDbContext _dbContext = dbContext; private readonly ILogger<WeatherService> _logger = logger; public Result<WeatherModel> GetWeather(string cityCode) { if (cityCode.Length != 2 || cityCode.Any(char.IsDigit)) { return Result<WeatherModel>.Invalid("City code must be three non-digit characters"); } try { if (cityCode == "HO") { throw new Exception("An error occurred"); } var city = _dbContext.CityDb.FirstOrDefault(c => c.Code == cityCode); if (city is null) { return Result<WeatherModel>.NotFound($"City with code {cityCode} not found"); } var weather = _dbContext.WeatherDb.Where(w => w.CityCode == city.Code).ToList(); // Implicit conversion from WeatherModel to Result<WeatherModel> return new WeatherModel( new CityModel( city.Code, city.Name), weather.Select(w => new HourlyModel( w.Hour, w.Temperature, w.Condition, w.Humidity)).ToList() ); } catch (Exception ex) { _logger.LogError(ex, "An error occurred"); return Result<WeatherModel>.InternalServerError(ex.Message); } } } |
There is an implicit conversion from the WeatherModel
to the Result
class. This allows you to return a WeatherModel
from the method, and the compiler will automatically convert it to a Result
object. For error handling, the method returns a Result
object with the appropriate error type. I added static methods like NotFound
, Invalid
, and InternalServerError
to create a Result
object with the appropriate error.
Any unhandled exceptions that occur are caught and logged, and an internal server error response is returned. This encapsulates the truly exceptional error handling logic in a single place, making the code easier to understand. Because I no longer rely on exceptions to handle control flow logic, the code can catch all exceptions and return an error response, which is more predictable.
Time to build our endpoint with our newfound result value. Instead of wrapping everything around a try and catching arbitrary errors, the code should be much easier to follow via control blocks.
Then, in the Program.cs
file, add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
builder.Services.AddScoped<WeatherService>(); app.MapGet("/fairWeather/cities/{cityCode}", (WeatherService service, string cityCode) => { var result = service.GetWeather(cityCode); if (result.IsSuccess) { return Results.Ok(result.Value); } if (result.Error.ErrorType == ErrorType.NotFound) { return Results.NotFound(result.Error.Error) } if (result.Error.ErrorType == ErrorType.Invalid) { return Results.BadRequest(result.Error.Error); } if (result.Error.ErrorType == ErrorType.InternalServerError) { return Results.StatusCode(500); } return Results.Problem(result.Error.Error); }); |
The code remains somewhat the same, but the error handling is based on conditionals rather than throwing exceptions. These are value based, which makes the code easier to digest. The ErrorType
enum can be extended to include more error types, such as unauthorized, forbidden, and conflict errors. This explicitly defines the type of error that occurred, without jumping around in the code.
A Bit of Functional Programming
Functional programming attempts to make the code much clearer and predictable. This Result class gives you the ability to make the code more expressive via simple ideas that you can steal from the functional paradigm.
The code has been much improved, but it can be made even better with a bit of functional concepts. Luckily, since C# 9.0, the language has been getting more features added. Features like pattern matching, and the switch
expression to make the code more expressive.
A good place to start is in the Result class itself because this encapsulates the API response. The Result class is not quite a monad, because it does not have a Bind
or SelectMany
method for proper functional composition. But it can steal some functional concepts such as the `Match` method to make it more expressive.
Add the following code to the Result
class:
1 2 3 4 |
public TResult Match<TResult>( Func<T, TResult> ok, Func<ErrorResponse, TResult> error) => IsSuccess ? ok(Value) : error(Error); |
This may not look like much, but it generically matches the success or error state of the Result
object. Then calls the appropriate function based on the state. The delegate functions are passed in as arguments, which allows the minimal API endpoint to be more expressive.
Then, in the Program.cs
file, add the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
app.MapGet("/weather/cities/{cityCode}", (WeatherService service, string cityCode) => service.GetWeather(cityCode).Match( ok: Results.Ok, // Switch expression error: error => error.ErrorType switch { // Pattern matching ErrorType.NotFound => Results.NotFound(error.Error), ErrorType.Invalid => Results.BadRequest(error.Error), ErrorType.InternalServerError => Results.StatusCode(500), _ => Results.Problem(error.Error) } ) ); |
There you have it. The code is more expressive and remains minimal. In minimal APIs, the handlers should be lean, sexy, and expressive, while all the complexity is put elsewhere.
The Result pattern works great with minimal APIs because it pushes all the complexity to the result class itself. In programming, you don’t really eliminate complexity, you just move it around. Luckily, once you establish this pattern, you can reuse this same technique in other endpoints without having to repeat the try/catch hell I showed you above.
A Quick Word on Exception Handling
There is an idiom that says when you have a hammer everything looks like a nail. Tackling errors with exceptions is a hammer, and it can be tempting to use it for everything. But exceptions should be used for truly exceptional cases, not for control flow logic. The runtime takes a performance hit when exceptions are thrown, and they can slow down the application.
Exceptions also behave like a goto
statement on steroids, which can make the code unpredictable and hard to follow.
Conclusion
The Result pattern is a more structured way of handling errors in ASP.NET Core Minimal APIs. It can be used to make your APIs more robust and easier to maintain. The Result pattern encapsulates the success or failure of an operation in a single object, which makes the code easier to read and maintain.
Load comments