← Back to the section

Every entry point into an application receives data from the outside — and that data can be invalid. The question is not whether to check the data, but in which layer and for what purpose.

Two Levels: Boundary and Domain

Two places matter for validation:

  • Boundary (HTTP controller, Kafka consumer, gRPC handler) — the first point where data appears in the system.
  • Domain (service, aggregate, domain object) — the place where data acquires business meaning.

Each level has its own job. Confusion arises when the same checks get smeared across every layer indiscriminately.

What to Check at the Boundary

At the boundary the data is still "raw" — strings, numbers, flags from an HTTP body or a queue. This is where structural checks belong:

  • required fields: @NotNull, @NotBlank;
  • sizes and ranges: @Size, @Min, @Max;
  • format: @Email, @Pattern.
public record CreateOrderRequest(
    @NotBlank String customerId,
    @NotEmpty List<@NotNull Long> productIds,
    @Min(1) int quantity
) {}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
        @Valid @RequestBody CreateOrderRequest request) {
    ...
}

@Valid makes Spring check the DTO fields before the controller method is invoked. On failure — a MethodArgumentNotValidException, which @RestControllerAdvice turns into a 422 response.

The principle: format, requiredness, range — at the boundary.

What to Check in the Domain

Some rules cannot be checked against a single field. They require knowledge of context: the state of an aggregate, data from the database, business policies.

Examples of domain validation:

  • an order cannot be cancelled if it has already been shipped;
  • a discount cannot exceed the cart's value;
  • a customer cannot buy more than one unit of an item at the promotional price.

Such rules live in the domain, next to the state they protect:

public class Order {
    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new DomainException("Cannot cancel a shipped order");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

The principle: business invariants — in the domain.

Why You Shouldn't Duplicate Checks

The temptation is understandable: duplicate @NotNull in the service "just in case". But this leads to:

  • blurred responsibility — it's unclear who owns the check;
  • rule divergence — sooner or later the checks start contradicting each other;
  • code duplication — the same condition in three places.

A short formula: the boundary checks the shape of the data, the domain checks the meaning of the data.

Fail Fast: an Error at the Entrance, Not Deep Down

Structural errors are best caught as early as possible — before hitting the database, before calling external services, before opening a transaction. This is called fail fast.

If @Valid sits on the controller, an invalid request is rejected immediately — without any wasted work. If you skip the check at the boundary, it will surface later: a NullPointerException in the repository, an external API error, or corrupted data in the database — and debugging that is far harder.

In Short

  • The boundary (controller) checks format, requiredness, and ranges via @Valid and jakarta.validation annotations.
  • The domain holds business invariants — rules that depend on state and context.
  • Duplicating the same checks across every layer is harmful: the logic diverges and the code bloats.
  • Fail fast: cut off structural errors at the entrance, not deep in the stack.
  • Domain exceptions and HTTP errors are different things; the mapping happens in @RestControllerAdvice.
  • Bean Validation: @NotNull, @Size, @Valid — the standard annotations and how to apply them
  • Custom Messages and Annotations — your own rules and localized error texts
  • REST API Errors — how 422 and other error codes are returned to the client