Where does validation belong?

ยท 2 min read

Request validation, business rules and data constraints are different kinds of checks. Putting each one in the right layer avoids both duplication and gaps.

Ask a team where validation goes and you'll get several answers: in the controller, in a validator class, in the domain model, in the database. They're all partly right, because "validation" covers several different kinds of checks.

1. Is the request well formed? At the edge

Required fields, string lengths, formats and ranges. These checks are about the shape of the input, and they belong where the input arrives: the API endpoint or message handler.

public record PlaceOrderRequest(
    [property: Required] Guid? CustomerId,
    [property: Required, MinLength(1)] List<LineItemRequest> Items,
    [property: EmailAddress] string? ConfirmationEmail);

Data annotations, FluentValidation, or the built-in validation for minimal APIs added in .NET 10 (builder.Services.AddValidation()) all work here. The point is to reject bad requests with a clear 400 before any real work starts.

2. Does it obey the business rules? In the domain

"An order can't be shipped before it's paid." "A discount can't exceed 50%." "A cancelled subscription can't be renewed." These are invariants: they must hold however the change is made, whether from the API, a background job, a message or an admin tool.

So they belong in the domain model, enforced by the methods that change state:

public void ApplyDiscount(decimal percent)
{
    if (percent is < 0 or > 50)
        throw new DomainException("Discounts must be between 0% and 50%.");
    if (Status != OrderStatus.Draft)
        throw new DomainException("Discounts can only be applied to draft orders.");

    DiscountPercent = percent;
}

If these rules live only in a request validator, the first background job that changes orders directly skips them.

3. Does it fit the current data? In the application layer

"The customer must exist." "The SKU must be in stock." "The email must not already be registered." These need a database lookup, so they belong in the handler or application service that coordinates the use case. They usually return a not-found or conflict result rather than a validation error.

4. The last line of defense: the database

Uniqueness checked in code has a race: two requests can both check, both find nothing, and both insert. A unique index in the database is what actually guarantees it. Use constraints, foreign keys and NOT NULL columns as a backstop for the rules that matter most, and translate their violations into proper responses.

Avoiding duplication

The same rule at several layers isn't always duplication. "Quantity must be at least 1" at the edge gives the user a friendly message, and the domain enforcing it again keeps the model safe from every other caller. What you want to avoid is business rules that exist only at the edge.

Takeaway

Validate request shape at the edge, enforce business invariants in the domain model, check rules that need current data in the application layer, and back critical guarantees with database constraints.