State-of-the-art .NET library to handle errors and validate data.
Create ErrorResource.resx with the next entry:
EmailAlreadyExists = "The email '{email}' is already being used at {company}";
Create your business exception:
public partial class EmailAlreadyExistsException(string email, string company)
: ResxBusinessException(ErrorResource.EmailAlreadyExists)
Throw the exception:
// Add CrossValidation
services.AddCrossValidation();
app.UseCrossValidation();
// Expose endpoint
app.MapPost("/users", () => throw new EmailAlreadyExistsException("alex@gmail.com", "Microsoft"));
Call the endpoint and this is the response:
{
"Errors":
[
{
"Code": "EmailAlreadyExists",
"Message": "The email 'alex@gmail.com' is already being used at Microsoft"
}
]
}
Magic! And perfomant! It doesn't use reflection.
You can send the Accept-Language
HTTP header and the message will be returned in requested language.
- Stop delivering software without a proper error handling mechanism.
- Stop using a different privative solution to validate data in every project of your company.
- Start using modern C# instead of tricks or complex solutions.
- Use typed errors.
- Built-in common error validators.
- Built-in validators for any layer of your project.
- Transport errors from any layer to an input of the frontend.
- Same syntax to validate DTOs or variables.
- Use Minimal APIs with nullable types.
Note Meanwhile you can use different strategies to represent errors, as raw strings or type-safe resX files, we strongly recommend to use typed errors.
- Inline syntax
- Unified syntax
- Typed errors
- Model validation
- Collect several typed errors
- Conditions
- Context unification
- Context switching with Value Objects
- Instantiate Value Objects
- Naming
Raise a generic error
var age = 15;
Validate.Must(age > 17)
Raise a error with an raw error message (not localized)
var age = 15;
Validate.Must(age > 17, $"You're underage having {age}")
Raise a type-safe message (localized or not)
var age = 15;
Validate.Must(age > 17, string.Format(ErrorResource.Underage, age))
Raise a typed error (localized or not)
var age = 15;
Validate.Must(age > 17, new UnderageError(age))
Use the same built-in validators for variables and models
var age = 15;
Validate.That(age).GreaterThan(17);
var age = 15;
Validate.That(age)
.WithMessage($"You're underage having {age}")
.GreaterThan(17);
var age = 15;
Validate.That(age)
.WithMessage(string.Format(ErrorResource.Underage, age)
.GreaterThan(17)
var age = 15;
Validate.That(age)
.WithError(new UnderageError(age)))
.GreaterThan(17)
C# can't treat with errors in a proper way. Developers tend to reuse the same runtime exceptions (ArgumentException, Exception, MyServiceException...) over and over again with hardcoded messages with parameters, or reuse a general exception (usually named BusinessException
, AppException or DomainException).
This general exception BusinessException
can be used in any layer of your application, and its main goal is to express we handled an expected error (the name is too long, the email hasn't an allowed provider, you tried to sign up with a used email, etc.), and therefore the global exception middleware will generate a custom HTTP response for the frontend. If the middleware doesn't detect an exception of type BusinessException
or anyone inheriting from it, it'll consider it an unexpected error (null reference, network error, access to an array item out of bounds) and it'll be logged to be inspected later by developers.
Once we've understood how exceptions are used in enterprise applications, we can start to speak about how to organize exceptions.
Object-oriented developers tend to think "our application of millions of lines of code has exceptions, and we have a folder with thousands of exceptions that we can reuse".
Now we have a considerable design flaw. This is a code smell. Basically, you don't organize exceptions.
So, how can we organize our expected exceptions? Declaring them where they belong. Let's show several examples:
- If you have the Product domain entity and you try to create a product, you can have an exception because you're a seller with a bad reputation.
- If you have the User application service and you try to change your nickname, you can have an exception because the nickname is not available.
We'll continue the example with UserService, and we'll define two exceptions:
public class NotFoundUserException : BusinessException;
public class NotAvailableNicknameException(string nickname) : BusinessException;
Now we have two typed exceptions instead of using a general exception as BusinessException.
So, how do we "attach" a group of exceptions to a class, in this case UserService? We can't do it in a proper way in C#, but we have some approaches.
And regarding to testing, we have to test those group of exceptions in UserServiceTests, not others from the black hole folder called Exceptions.
public class UserService(DatabaseContext context)
{
public class Exception
{
public class NotFoundUserException()
: BusinessException("Couldn't find the user");
public class NotAvailableNicknameException(string nickname)
: BusinessException($"'{nickname}' is not available");
}
public void ChangeNickname(UserDto userDto)
{
var user = context.Users.FirstOrDefault(x => x.Id == userDto.Id);
if (user is null)
{
throw new Exception.NotFoundUserException();
}
var isNicknameAvailable = !context.Users.Any(x => x.Nickname == userDto.Nickname);
Validate.Must(isNicknameAvailable, new Exception.NotAvailableNicknameException(userDto.Nickname))
user.Nickname = userDto.Nickname;
context.Users.Update(user);
context.SaveChanges();
}
So, when you want to handle exceptions in your business logic or test them, you simply reference those exceptions related to your service, instead of referencing an exception in a folder with thousands of them, and instead of having faith that the service will throw that exception (good luck with refactorings).
Sharing exceptions must be an exceptional case, and, as I show in my book, it causes a lot of problems as the codebase grows.
So, the service can be tested this way:
var action = () => userService.ChangeNickname(userDto);
action.Should()
.Throw<UserService.Exception.NotAvailableNicknameException>();
What happens when you create an interface because you rely on mocking? Then you just move the exceptions to the interface:
var action = () => userService.ChangeNickname(userDto);
action.Should()
.Throw<IUserService.Exception.NotAvailableNicknameException>();
This could be strange to see for first time in C# (in part because we have a convention to name interfaces), but it's absolutely common in other languages.
You create it in UserService.cs.
public class UserServiceException
{
public class NotFoundUserException()
: BusinessException("Couldn't find the user");
public class NotAvailableNicknameException(string nickname)
: BusinessException($"'{nickname}' is not available");
}
public class UserService(DatabaseContext context)
{
public void ChangeNickname(UserDto userDto)
{
var user = context.Users.FirstOrDefault(x => x.Id == userDto.Id);
if (user is null)
{
throw new UserServiceException.NotFoundUserException();
}
var isNicknameAvailable = !context.Users.Any(x => x.Nickname == userDto.Nickname);
Validate.Must(isNicknameAvailable, new UserServiceException.NotAvailableNicknameException(userDto.Nickname))
user.Nickname = userDto.Nickname;
context.Users.Update(user);
context.SaveChanges();
}
It can be tested this way:
var action = () => userService.ChangeNickname(userDto);
action.Should()
.Throw<UserServiceException.NotAvailableNicknameException>();
public class UserService(DatabaseContext context)
{
public class NotFoundUserException()
: BusinessException("Couldn't find the user");
public class NotAvailableNicknameException(string nickname)
: BusinessException($"'{nickname}' is not available");
public void ChangeNickname(UserDto userDto)
{
var user = context.Users.FirstOrDefault(x => x.Id == userDto.Id);
if (user is null)
{
throw new NotFoundUserException();
}
var isNicknameAvailable = !context.Users.Any(x => x.Nickname == userDto.Nickname);
Validate.Must(isNicknameAvailable, new NotAvailableNicknameException(userDto.Nickname))
user.Nickname = userDto.Nickname;
context.Users.Update(user);
context.SaveChanges();
}
It can be tested this way:
var action = () => userService.ChangeNickname(userDto);
action.Should()
.Throw<UserService.NotAvailableNicknameException>();
You could use using static
to have a better usage, but my goal is to provide guidelines for a pragmatic and transparent error handling approach.
...
You can create a ValidationException with an error or with a list of errors. Then just collect the errors in a list and throw the exception.
You can add conditional rules.
public class ModelValidator : ModelValidator<Model>
{
public override void CreateValidations()
{
if (Model.CustomerIsPreferred)
{
Field(Model.CustomerDiscount)
.NotNull()
.GreaterThan(0);
Field(Model.CreditCardNumber)
.NotNull();
Field(Model.CustomerDiscount)
.NotNull()
.GreaterThan(0);
Field(Model.CreditCardNumber)
.NotNull();
}
else
{
Field(Model.CustomerDiscount)
.Null();
}
}
}
You're used to validate data in what we call a different context. For example, if you validate the user favorite color is not null in a large validation class (and probably in another file), when you go the application service, you must have faith in a file that will change over time and call unsafe code.
This is a very basic example in another library:
// Can throw NullReferenceException if you remove the previous validator, or ignore the validation if it uses null lifting internally, and the frontend will receive a different error than there's no favorite color
Validate.Field(request.FavoriteColorId) // int?
.NotNull() // int?
.GreaterThan(x => x.Value > 0); // int?
myDomain.AddFavoriteColor(request.FavoriteColorId.Value); // int?
With CrossValidation, you can unify the validation context with different capabilities.
Validate.Field(request.FavoriteColorId) // int?
.NotNull() // int?
.GreaterThan(x => x > 0); // int // If you remove the previous validator, the code doesn't compile
myDomain.AddFavoriteColor(request.FavoriteColorId.Value); // int?
var favoriteColorId = Validate.Field(request.FavoriteColorId)
.NotNull()
.GreaterThan(0)
.Instance();
myDomain.AddFavoriteColor(favoriteColorId);
Another example could be
var color = Validate.Field(request.ColorId)
.NotNull()
.Enum<Color>()
.Instance();
You can't get an autogenerated DTO after validating the original DTO with ModelValidator
because it's not possible to do it in C#.
Validations should be duplicated in the frontend and the backend, so you only should need to validate the data and, not write the error message or return a generic error. You should use That
to return a generic error from the Value Object.
public record UserAge(int Age)
{
public static UserAge Create(int age)
{
Validate.That(age)
.Range(18, 150);
return new(age);
}
}
var age = UserAge.Create(request.Age);
In the real life you can have out of sync the validation of a field in the frontend/s and backend/s, or even between different pages of the same frontend, or simply not have frontend.
What can we do? The domain has no knowledge about the UI context (AKA DTO, AKA the contract between the client and the backend). No worries, you can switch the validation to the UI context!
Inside the Value Object, use Field
to get proper error messages, and instantiate the Value Object with Field
and Instance
.
public record UserAge(int Age)
{
public static UserAge Create(int age)
{
Validate.Field(age)
.Range(18, 150);
return new(age);
}
}
var age = Validate.Field(request.Age)
.Instance(UserAge.Create);
It's more verbose, but with this library you have the option to switch the validation from pure domain code to the UI context.
Without context switching
var email = UserEmail.Create(request.Email);
var emails = request.Emails.Select(UserEmail.Create);
With context switching
var email = Validate.Field(request.Email)
.Instance(UserEmail.Create);
var emails = Validate.Field(request.Emails)
.InstanceMap(UserEmail.Create);
Without context switching
var email = request.Email.Map(UserEmail.Create);
var emails = request.Emails?.Select(UserEmail.Create);
The name "CrossValidation" comes from the ability to validate data in different contexts, and the ability to switch the validation context.