Summarize with AI:
Learn about error-prone domain validations in ASP.NET Core and how to correctly model them.
In ASP.NET Core applications, developers often underestimate the importance of validating objects and classes. An if statement in the controller, a FluentValidation in the request or some DataAnnotations in the model, and that’s it. The problem is that, if care isn’t taken when implementing domain validations, responsibility ends up leaking, which can compromise the entire system’s evolution.
In this article, we will analyze common examples of domain validation that are highly prone to errors and how these validations should be correctly modeled according to the principles of Domain-Driven Design (DDD).
Although it speeds up development, implementing validations in API controllers is discouraged, as it makes the controller highly coupled to business rules. Furthermore, the rules created there are impossible to reuse. Finally, validations in controllers allow other parts of the code to execute the same actions, bypassing the rules.
Consider the following example:
[Route("api/[controller]")]
[ApiController]
public class OrderController : ControllerBase
{
private readonly OrderRepository _orderRepository;
public OrderController(OrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
[HttpPost]
public IActionResult Create(CreateOrderDto dto)
{
if (dto.Total <= 0)
return BadRequest("Total must be greater than zero");
if (dto.Items == null || !dto.Items.Any())
return BadRequest("Order must have at least one item");
var order = new Order()
{
Total = dto.Total,
Items = dto.Items
};
_orderRepository.Add(order);
return Ok();
}
}
The code above is a common example of validations in the Controller, and it’s a bad example because it has all the flaws mentioned earlier. Note that the _orderRepository.Add(order); method is called at the end, meaning there are loopholes here.
Imagine that the parameter CreateOrderDto dto has a total greater than zero, and the Items list has one item, but the rest of the properties are null. Even so, the Add method will be called and will create problematic entities or generate a bug.
Although common, the use of validations in the Service class should also be avoided because they place business rules in the wrong place. Considering the previous example, they only changed location but still remain a problem.
When a business rule is coupled to a Service, the domain model becomes passive, and entities can be created or modified in invalid states because there is nothing to prevent them from doing so. The result is a fragile system, as object validation ceases to be a priority and becomes solely dependent on the execution flow.
Another problem is rule duplication. In large systems, the same rule is often needed in more than one use case. When it is in the Service, it ends up being copied to other services, handlers or jobs, which increases the risk of inconsistency and hinders business evolution.
Finally, the Service Layer is responsible for orchestrating use cases, coordinating repositories, transactions and external calls. When it validates domain rules, it mixes responsibilities and becomes a central point of complexity, bloated with if statements and exceptions that don’t belong to it.
The example below shows what a Service class looks like with business rules defined in it:
public class OrderService
{
private readonly OrderRepository _orderRepository;
public OrderService(OrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public void Create(CreateOrderDto dto)
{
if (dto.Total <= 0)
throw new Exception("Total must be greater than zero");
if (!dto.Items.Any())
throw new Exception("Order must have at least one item");
var order = new Order(dto.Total, dto.Items);
_orderRepository.Add(order);
}
}
Note that the logic of if and else statements remains the same, it has only changed location, allowing objects to change state and create corrupted states.
Another common form of validation is through the Data Annotations Model Binder, which is implemented using attributes placed above the properties of an entity class.
The problem is that, as they make the domain dependent on a framework, the validation only works in binding (MVC), and they may not work in non-web scenarios such as workers, messaging and tests.
public class Order
{
[Required]
[Range(1, double.MaxValue)]
public decimal Total { get; set; }
public List<OrderItem> Items { get; set; }
}
Note that we are requiring the Total property to have a minimum value of 1. But even so, it is not yet a secure validation because it is still possible to create an entity with an invalid state.
Domain-Driven Design emphasizes the importance of keeping validations within the domain. The main reason is that this way, the domain becomes self-protecting.
Entities and aggregates cease to be passive structures (as seen in previous examples) and ensure that their rules are always respected, regardless of where or how they are used.
Another positive aspect of this approach is the model’s coherence. When business rules are in the domain, they are closer to the concept they represent, making the code more expressive and easier to understand. Reading an entity or a behavioral method reveals the rules governing that concept. In other words, you understand the intention behind that behavior.
Finally, implementing validations in the domain makes system evolution safer. New use cases can be added without fear of breaking existing rules because the domain itself acts as a safety barrier. This reduces maintenance costs and makes the system more resilient to business growth and changes.
Next, we’ll look at an example of a domain guided by DDD principles and analyze each point. You can access the source code in this GitHub repository: Domain Validations code.
Order class with Domain Validations:
public class Order
{
private readonly List<OrderItem> _items = new();
public Guid Id { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public decimal Total { get; private set; }
public Order(IEnumerable<OrderItem> items)
{
if (items == null || !items.Any())
throw new DomainException("Order must have at least one item.");
Id = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
foreach (var item in items)
AddItem(item);
ValidateItemsQuantity();
}
public void AddItem(OrderItem item)
{
if (item == null)
throw new DomainException("Order item cannot be null.");
_items.Add(item);
RecalculateTotal();
}
private void RecalculateTotal()
{
Total = _items.Sum(i => i.Subtotal);
if (Total <= 0)
throw new DomainException("Order total must be greater than zero.");
}
private void ValidateItemsQuantity()
{
if (!_items.Any())
throw new DomainException("Order cannot exist without items.");
}
}
Item class with Domain Validations:
public class OrderItem
{
public Guid ProductId { get; private set; }
public decimal Price { get; private set; }
public int Quantity { get; private set; }
public decimal Subtotal => Price * Quantity;
protected OrderItem() { } // EF
public OrderItem(Guid productId, decimal price, int quantity)
{
if (productId == Guid.Empty)
throw new DomainException("ProductId is required.");
if (price <= 0)
throw new DomainException("Price must be greater than zero.");
if (quantity <= 0)
throw new DomainException("Quantity must be greater than zero.");
ProductId = productId;
Price = price;
Quantity = quantity;
}
}
The first aspect we can notice in this new version of the Order class is that the Items property is private, which means it is inaccessible outside the class: private readonly List<OrderItem> _items = new();.
We also have a public list of items: public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();, but note that it is defined as read-only. This means that it can be accessed by external sources, but only for reading the data and never for modification.
Another factor protecting the class properties is that, despite being public, they have private setters: public Guid Id { get; private set; }, preventing external sources from modifying their states.
Protecting the constructor method means allowing only valid states of an entity to be created. In our example, we are defining rules within the constructor:
public Order(IEnumerable<OrderItem> items)
{
if (items == null || !items.Any())
throw new DomainException("Order must have at least one item.");
Id = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
foreach (var item in items)
AddItem(item);
ValidateItemsQuantity();
}
public void AddItem(OrderItem item)
{
if (item == null)
throw new DomainException("Order item cannot be null.");
_items.Add(item);
RecalculateTotal();
}
private void RecalculateTotal()
{
Total = _items.Sum(i => i.Subtotal);
if (Total <= 0)
throw new DomainException("Order total must be greater than zero.");
}
private void ValidateItemsQuantity()
{
if (!_items.Any())
throw new DomainException("Order cannot exist without items.");
}
Note that the item quantity validation is performed within the constructor. That is, when creating a new object state, values are also set for the Id and CreatedAt properties. Finally, the ValidateItemsQuantity method verifies if the private property _items has actually been loaded with items, adding an extra layer of validation. In this way, the database will only receive valid states, a corrupted or incomplete entity will never be inserted, enabling data consistency.
Domain exceptions are errors thrown when a business rule (invariant) is violated. Unlike technical failures such as database outages or timeouts, domain exceptions represent violations of the system’s business rules, such as an order without items, for example.
Domain exceptions are important because they help prevent an entity or aggregate from existing in an invalid state. If a rule is broken, the domain needs to react immediately, and the exception is a suitable mechanism to keep inconsistent data from reaching the database or being manipulated in any way.
In the previous example, we validated whether the value was less than zero and whether the list of items was empty. If either condition is met, a DomainException will be thrown, which is a custom exception. The code below shows how the exception is implemented:
namespace DomainValidation;
[Serializable]
internal class DomainException : Exception
{
public DomainException()
{
}
public DomainException(string? message) : base(message)
{
}
public DomainException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
FluentValidation is a widely used .NET library for validations, and even in scenarios where we use the domain validation approach, it certainly remains useful.
The main functionality of FluentValidation is to validate input data, not business rules. Therefore, by using it in the application, we can obtain an extra layer of validation, preventing corrupted data from reaching the domain.
The code below demonstrates how FluentValidation can be used to validate input data:
using FluentValidation;
public class CreateOrderRequest
{
public List<CreateOrderItemRequest> Items { get; set; }
}
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.Items)
.NotNull()
.WithMessage("Items cannot be null.")
.Must(x => x.Any())
.WithMessage("Order must contain at least one item.");
RuleForEach(x => x.Items)
.SetValidator(new CreateOrderItemRequestValidator());
}
}
public class CreateOrderItemRequest
{
public Guid ProductId { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
}
public class CreateOrderItemRequestValidator : AbstractValidator<CreateOrderItemRequest>
{
public CreateOrderItemRequestValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty()
.WithMessage("ProductId is required.");
RuleFor(x => x.Price)
.GreaterThan(0)
.WithMessage("Price must be greater than zero.");
RuleFor(x => x.Quantity)
.GreaterThan(0)
.WithMessage("Quantity must be greater than zero.");
}
}
Validations in the Controller and Service classes make sense when they are not business rules, but rather validations such as security, flow or format.
It is common to perform authentication/authorization validations in the Controller, because this is the responsibility of the edge (API), and if a user or system is not properly authenticated/authorized, it should not have access to the domain or the rest of the system; it should be blocked at the entrance.
The Service class, on the other hand, can have validations used to manage operational flows, for example, checking if the customer exists before creating an order, checking if the product exists, checking if there is already an open order for the customer.
In this way, we do not validate the internal state of the entity. Instead, we validate the interactions between aggregates or external systems.
Implementing domain validations in an application means explicitly stating the reason for that domain’s existence and the rules that determine its behavior. Furthermore, domain validations keep an invalid object from reaching the database, preventing future bugs and the resulting damage.
Throughout this post, we’ve seen examples of incorrectly implemented validations, scattered across controllers or services, resulting in fragile and difficult-to-maintain code. In contrast, we implemented validations directly on the entities, applying DDD principles.
I hope this post has helped you see the domain as the heart of the application and the importance of keeping it consistent, protected and expressive.
More on DDD: Getting Started with Domain-Driven Design in ASP.NET Core