Regarding the development of API solutions, the accessibility of information to end users is a central element in our set of considerations.
Whether from a monolith or a microservice, we must significantly expand our scope and implement usage-oriented thinking in various aspects.
Of all these aspects, there is no doubt that the field of error handling is one of the most significant. Here, we will see how we will optimally handle Exception type errors in this project.

When will we have to deal with exception errors?

As developers, we all dread the day we will have to deal with this error. After all, it's never good news. Exception errors will occur when server communication crashes, our database is unavailable, and many other serious communication failures prevent the product from being used. 
However, this is an error that the service consumer has very little to do with. Therefore, the wisest way to mediate such an error to the consumer is to use the same objects intelligently and ensure they will be presented in a different, more accessible implementation.

A few points to consider

To ensure a high user experience and proper code flow, it is cardinal that we make sure that no exception is thrown in cases of managed errors. A REST-based API will usually retrieve a limited number of error types:

  • 200 – the operation was performed successfully
  • 400 – Validation errors
  • 404 – resource not found
  • 500 – unmanaged system errors

The implementation can be done using—net7, minimal API, and the fluentValidation and LanguageExt libraries.

Our ultimate goal is to produce an implementation that will convert the error into one the end user can understand and manage.

Exception errors - a case study

Let's say we are developing a product that provides weather forecast data. In Visual Studio, there are ready-made templates for such a solution.
First, we'll create a record which will hold our API query parameters:

internal record WeatherForecastQuery(DateTime fromDate, int ? daysCount) {
  public class Validator: AbstractValidator<WeatherForecastQuery> {
    public Validator() {
      RuleFor(x => x.fromDate)
        .InclusiveBetween(DateTime.Now, DateTime.Now.AddDays(7))
        .WithMessage("Invalid date range; start date can be up to seven days from now.");

      RuleFor(x => x.daysCount)
        .NotNull()
        .WithMessage("Invalid items count, cannot be null");

      RuleFor(x => x.daysCount)
        .inclusiveBetween(1, 5)
        .WithMessage("Invalid items count, count must be between one to five.");
    }
  }
}

We have created a record intended as a search object sent by the end user to get the forecast.
This can also be done as a Class.
For simplicity of illustration, we chose to use the reserved word record here. This object has two main characteristics:

• A starting date from which the range of days in which the forecast is requested begins

• Number of days from the starting date that will delimit the range of days

After adding the FluentValidation library, you should define Validation for the record we created. The advantage of this approach is that the Validation will remain with the relevant class and not outside of it.

Now that we have finished characterizing the object, we will define an Interface to use in Dependency injection:

internal interface IWeatherForecastService {
  Task <IEnumerable<WeatherForecast>> Get(WeatherForecastQuery query);
}

After that, we will implement the service:

internal class WeatherForecastService: IWeatherForecastService {

    internal String[] summaries = new [] {

      "Freezing",
      "Bracing",
      "Chilly",
      "Cool",
      "Mild",
      "Warm",
      "Balmy",
      "Hot",
      "Sweltering",
      "Scorching"

    };

    public async Task <IEnumerable<WeatherForecast>> Get(WeatherForecastQuery weatherForecastQuery) {
      return Enumerable.Range(1, weatherForecastQuery.daysCount.Value).Select(index => new WeatherForecast(
        weatherForecastQuery.fromDate.AddDays(index),
        Random.Shared.Next(-20, 55),
        summaries[Random.Shared.Next(summaries.Length)]
      ));
    }

At this stage, no validation is performed, and the rules we defined are not reflected.

Now, we need to integrate the validations component:

private readonly IValidator <WeatherForecastQuery> weatherForecastQueryValidator;
public WeatherForecastService(IValidator <WeatherForecastQuery> weatherForecastQueryValidator) {
    this.weatherForecastQueryValidator = weatherForecastQueryValidator;
}
public async Task <IEnumerable<WeatherForecast>> Get(WeatherForecastQuery weatherForecastQuery) {
    var validationResult = await weatherForecastQueryValidator.ValidateAsync(weatherForecastQuery);
    if (validationResult.IsValid == false) {
        throw new...
    }
    return Enumerable.Range(1, weatherForecastQuery.daysCount.Value).Select(index => new WeatherForecast(weatherForecastQuery.fromDate.AddDays(index),
        Random.Shared.Next(-20, 55),
        summaries[Random.Shared.Next(summaries.Length)]
    ));
}

In the case mentioned above, we have no choice but to throw an exception.
However, as stated, our goal is to avoid such a situation.
For us to ensure this, we must take an additional step.
This is where another library comes into play: Language extension. This library offers us several advantages, but today we will focus on one specific class that will allow us to realize the error in a more accessible way - Result.

Therefore, now we will change the code according to the class:

internal interface IWeatherForecastService {
  Task <Result<IEnumerable<WeatherForecast>>> Get(WeatherForecastQuery query);
}

public async Task <IEnumerable<WeatherForecast>> Get(WeatherForecastQuery weatherForecastQuery) {
  ///Input validation check
  var validationResult = await weatherForecastQueryValidator.ValidateAsync(weatherForecastQuery);

  if (validationResult.IsValid == false) {
    return new Result<IEnumerable<WeatherForecast>>(new ValidationException(validationResult.Errors));
  }

  try {
    var weatherForecast = Enumerable.Range(1, weatherForecastQuery.daysCount.Value).Select(index => new WeatherForecast(

      weatherForecastQuery.fromDate.AddDays(index),
      Random.Shared.Next(-20, 55),
      summaries[Random.Shared.Next(summaries.Length)]
    ));

    // success
    return new Result <IEnumerable<WeatherForecast>>(weatherForecast);
  } 
  catch (Exception ex) //unmanaged/unhandled Exception
  {
    return new Result<IEnumerable<WeatherForecast>>(ex);
  }
}

 

After this change, the service we built returns the same object for all services. So now, we have to take care of things on the client side. To do this, we will write the controller:

app.MapGet("/weatherforecast", async (IWeatherForecastService weatherForecastService, [FromRoute] WeatherForecastQuery weatherForecastQuery) => {
    return await weatherForecastService.Get(weatherForecastQuery);
  })
  .Produces <IEnumerable<WeatherForecast>>()
  .WithName("GetWeatherForecast");

 

To return HTML Status codes as usual, a helper function (implemented as an extension method) is added:

public static IResult Result<TResult> (this Result <TResult> result) {
  return result.Match < IResult > (obj => {
    return Results.Ok(obj);
  }, exception => {

    if (exception is ValidationException validationException) {
      return Results.BadRequest(validationException);
    }

    return Results.Problem(new Microsoft.AspNetCore.Mvc.ProblemDetails {
      Title = exception.Message,
        Status = (int) HttpStatusCode.InternalServerError,
        Detail = exception.StackTrace,
        Instance = exception.Source
    });
  });
}

In a function like the one before us, which returns a model of the WeatherForecast type, it is recommended to change the signature and return WeatherForecastViewModel. This object will return a slimmer version than the entire model, ensuring a more efficient Flow. Automapper, in this context, can also do an excellent job here. We will talk about all the advantages we can do with this tool in our next article.

Decor image
Decor image
Decor image