CodeGym /Courses /Spring REST & MVC /Boundary between input and business validation

Boundary between input and business validation

Spring REST & MVC
Level 17, Lesson 3
Available

1. The input/business boundary: the validator is not a detective

As a project grows, a very human desire appears: “What if we just forbid everything with annotations so the controllers stay clean, and the service never sees bad requests?” It sounds beautiful, like “let’s all get along and never fall.” In practice, this often leads to validators that try to read the current system state, and suddenly the validation layer turns into a small... service layer, but worse.

Imagine that we are building Task Tracker API. We already have format rules: title length, tags size, tag uniqueness, the order of dueAfter/dueBefore. All of this can really be checked just by looking at the request. But as soon as you want to check something like “a task cannot be changed if it is ARCHIVED”, you run into a simple fact: the request does not contain the current task status (and that is good — this is server-managed state).

So we end up with two classes of rules:

We can validate the input without knowing the surrounding world — that is input validation.

And we can check whether an operation may be performed with the current resource state and domain rules in mind — that is business validation.

If you mix these classes, you get a validator that reaches into the repository. It starts living its own life, creates strange dependencies, and sometimes even makes “Spring-style” mistakes: circular dependencies, unexpected storage queries, and cheerful N+1 issues... even without a database.

2. Two tests for separating input and business

The most reliable way not to get confused is not to try to memorize definitions, but to ask yourself two very simple questions. I like rules like these because you can apply them even when your brain is tired, and the deadline is not tired (it never gets tired).

The first question is: “Can I make the decision by looking only at this request?” If yes, that is a candidate for input validation. If you need to read the task from the repository, look at the current status, check whether the resource exists, or take transition history into account, that is no longer “request quality”; that is “whether the operation is allowed.”

The second question: “If the same request arrives tomorrow, but the system state changes, will the validation result change?” If the result depends on system state, then it is almost always business validation. For example, “title is too short” will still be “too short” tomorrow. But “an archived task cannot be changed” depends on whether it is archived right now or not.

To make this clearer, it helps to keep a small table. This is not a “memorize these rules” list, but rather a small map of the terrain:

Criterion Input validation Business validation
What the decision is based on Only the input payload (path/query/body) Current system data (resource, state, domain rules)
Where it lives in the project DTO + Bean Validation (@Valid, constraints) Service / domain layer (ordinary if, domain methods, domain exceptions)
Typical HTTP meaning “The request is invalid on its own” → tends toward 400 Bad Request “The request is valid, but the operation conflicts with rules/state” → tends toward 409 Conflict
Can repositories/services be called No (as a basic discipline) Yes, usually there is no other way
Typical examples title is empty, tags duplicate, dueAfter > dueBefore “cannot change ARCHIVED”, “cannot transition TODO → DONE”

Notice: the table says “tends toward a status”, not “we already return everything.” Today it is more important for us to place the rule correctly than to wrap the response nicely (that is a separate discipline).

3. Input validation in the Task Tracker API

Input validation is the “face control at the entrance” of your API. Not in the sense of “we are mean,” but in the sense of “we protect the system from junk.” This is where format, ranges, basic constraints, and consistency of the input message itself are checked. The best part: these checks are usually deterministic and do not require trips to storage.

Let’s start with something simple and familiar: field constraints.

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

public record TaskCreateRequest(
        // Required field + length boundaries: this is a pure check of the request "shape"
        @NotBlank
        @Size(min = 3, max = 120)
        String title,

        // Optional, but we limit the maximum size
        @Size(max = 2000)
        String description,

        // Also "shape": length constraint, without talking to the system
        @Size(max = 80)
        String assigneeName,

        // Uniqueness inside the payload (for example, "bug" and "Bug") — this is input validation
        @UniqueTagsIgnoreCase
        @Size(max = 10)
        List<@NotBlank @Size(max = 30) String> tags
) {}

Here everything is about the “request itself”: lengths, required values, collection format. None of these points require knowing which tasks already exist, what status a specific task has, or what is happening in the repository. If the client sent title = "", that is a bad request in any weather, in any time zone, and even if the server is in a good mood today.

Now for a slightly trickier example: a cross-field rule. Yesterday we already saw that a “date range” cannot be expressed by a single annotation on one field.

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.validation.ValidTaskDueDateRange;

import java.time.LocalDate;

// Validation of a pair of fields at once: such a rule cannot be expressed separately for one field
@ValidTaskDueDateRange
public record TaskSearchCriteria(
        LocalDate dueAfter,
        LocalDate dueBefore
) {}

And the validator (shortly, in essence):

package com.example.tasktracker.domain.validation;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidTaskDueDateRangeValidator implements ConstraintValidator<ValidTaskDueDateRange, TaskSearchCriteria> {

    @Override
    public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
        // If the criteria is not fully set — we do not "break" the request: this is a valid case of search without a range
        if (value == null || value.dueAfter() == null || value.dueBefore() == null) {
            return true;
        }

        // Main rule: the start of the range must not be after the end
        return !value.dueAfter().isAfter(value.dueBefore());
    }
}

This is also input validation. Why? Because the rule is fully computed from the input. We do not need to “fetch the task” — we are checking that the search criteria is logically sound on its own.

Now about 400 Bad Request. Within the semantic model of HTTP, 400 best describes the situation “your request cannot be processed because it is invalid.” As the server, you may be very diligent, but you will not “guess” an empty title — you are not a telepath.

To feel the difference, look at two input examples. Both are “bad,” but in different ways.

Example of “bad shape” (a candidate for 400): duplicate tags inside one request.

{
  "title": "Fix prod",
  "tags": ["bug", "Bug"]
}

We do not have to reach into the repository: within a single payload, it is already clear that this violates the rules.

4. Business validation and the semantics of 409

Business validation starts where “shape validation” ends and “operation feasibility validation” begins. A request can be perfectly formed: correct types, correct strings, perfect dates... but the operation can still be forbidden by domain rules. And that is not a server whim; it is part of the contract.

Key idea: business validation almost always requires the current state of the resource. That means it almost always lives in the service (or near it in domain logic), because the service is what fetches the resource and controls the operation.

The most classic example from our project: an archived task cannot be changed. To check this, we need to load the task and see its status.

A minimal domain exception type (for now without “pretty HTTP,” just the meaning):

package com.example.tasktracker.domain.exception;

// Universal exception "a domain rule was violated"
public class BusinessRuleViolationException extends RuntimeException {

    public BusinessRuleViolationException(String message) {
        // The message here is about the meaning of the rule, not the format of the input JSON
        super(message);
    }
}

And now the rule, already on the service side:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;

public final class TaskRules {

    private TaskRules() {}

    public static void assertEditable(Task task) {
        // This is a business check: we need the current task and its status (server-managed state)
        if (task.status() == TaskStatus.ARCHIVED) {
            throw new BusinessRuleViolationException("Archived task cannot be changed");
        }
    }
}

Why is this not input validation? Because a task update request may not contain status at all. And that is correct: status is part of the current task state, not the request shape.

The second example: status transitions. The new status may be a valid enum value (DONE, ARCHIVED, etc.), so from the point of view of input validation everything is fine: the client sent an existing value. But the operation “set status to ARCHIVED” may be forbidden if the task is not DONE right now.

A short helper for transitions:

package com.example.tasktracker.domain.model;

public final class TaskStatusTransitions {

    private TaskStatusTransitions() {}

    public static boolean canMove(TaskStatus from, TaskStatus to) {
        // Explicitly fix allowed transitions — this is domain logic, not "form validation"
        return switch (from) {
            case TODO -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.BLOCKED;
            case IN_PROGRESS -> to == TaskStatus.BLOCKED || to == TaskStatus.DONE;
            case BLOCKED -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.DONE;
            case DONE -> to == TaskStatus.ARCHIVED;
            case ARCHIVED -> false;
        };
    }
}

And business validation in the service can look like this (without repository details, only the meaning):

import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatusTransitions;

public void assertStatusTransition(Task task, TaskStatus newStatus) {
    // The request may be "valid" by enum, but the operation can still be forbidden by task state
    if (!TaskStatusTransitions.canMove(task.status(), newStatus)) {
        throw new BusinessRuleViolationException("Invalid status transition");
    }
}

This is the classic 409 Conflict in meaning: the request is structurally normal, but it conflicts with the current state of the resource and the transition rules.

If you want one sentence to remember: input validation answers “can we understand the request,” business validation answers “can we perform the operation.”

5. Pipeline: DTO checked — service decides

To keep this from staying theoretical, it helps to see the request route in the application. Spring MVC and Bean Validation already give us a “first cordon” of checks, but the service should be the place where domain decisions are made. The controller in this story is a courier: it receives the package, checks the packaging, and passes it on, but it does not decide the fate of the goods.

Below is the flow diagram (simplified, without internal Spring details):

flowchart TD
    A[HTTP Request] --> B[Binding: path/query/body -> Java types]
    B --> C[Input validation: Bean Validation on DTO]
    C -->|OK| D[Controller]
    D --> E[Service]
    E --> F[Business validation using current state]
    F -->|OK| G[Apply operation, save to repo]
    C -->|Failed| H[Bad request semantics: 400]
    F -->|Violation| I[Conflict semantics: 409]

Now a piece of controller code. Notice: there is no need for “magic” here — @Valid is enough, and input validation will run before entering the service.

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.service.TaskService;
import jakarta.validation.Valid;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @PostMapping
    public void create(
            // @Valid triggers input validation by DTO annotations before the service is called
            @Valid @RequestBody TaskCreateRequest request
    ) {
        // The controller does not decide domain rules — it passes the "packaged" request further
        taskService.create(request);
    }
}

Yes, here the service accepts a request DTO — in a real project you will often map DTOs to an internal model/command through a mapper (we even have an api.mapper package for that). But for understanding the validation boundary this is not critical: what matters is the principle itself, “DTO checked — service decides.”

And here is an example of how the service looks when it “first fetches the state, then decides whether it is allowed”:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.repository.TaskRepository;

public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public Task getTaskOrThrow(String taskId) {
        // This is not input validation: we go to storage and get the current system state
        return taskRepository.findById(taskId)
                .orElseThrow(() -> new TaskNotFoundException(taskId));
    }
}

And using a business rule:

import com.example.tasktracker.domain.model.Task;

public void updateTask(String taskId) {
    // 1) First fetch the resource (current state)
    Task task = getTaskOrThrow(taskId);

    // 2) Then check the domain constraints for the operation
    TaskRules.assertEditable(task);

    // 3) Then you can update and save
}

It looks “too simple,” but that is exactly the strength: business validation does not have to be written as an annotation to be useful. It has to be in the right place, next to the operation.

6. Gray areas: resource, uniqueness, time

In real projects there are areas where it is easy to start arguing (or quietly do the wrong thing). And honestly, arguing is sometimes useful, because it means you have started thinking about the API as a contract, not as a set of methods. Let’s break down a few gray areas so you get a foothold, not a “chat opinion.”

The first case is resource existence. For example, POST /api/v1/tasks/{taskId}/comments: the comment request can be perfect in form, but the task may not exist. This is definitely not input validation, because you cannot determine it from the payload. It is a system state check. Semantically, this is usually closer to 404 Not Found than to 409, because there is no conflict “with state” — the resource simply does not exist. But the important part is different: this is a check in the service, not in a ConstraintValidator.

The second case is uniqueness in the system. For example, “the task title must be unique among all tasks” (we do not have this in our spec, but as an example). This is almost always business validation, because you need to check the current data set “from the outside.” Uniqueness validation inside one list of tags is input validation. Uniqueness across the whole storage is business validation.

This is where people often make an anti-pattern: they write a custom constraint @UniqueTaskTitle that reaches into the repository. Technically Spring may allow this (a validator can be a bean and receive a dependency), but conceptually you are turning the validation layer into a business layer. That makes handling harder, complicates dependencies, and often leads to unexpected effects.

What does the “tempting, but dangerous” example look like:

import com.example.tasktracker.domain.repository.TaskRepository;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ExistingTaskIdValidator implements ConstraintValidator<ExistingTaskId, String> {

    private final TaskRepository taskRepository;

    public ExistingTaskIdValidator(TaskRepository taskRepository) {
        // The validator suddenly depends on storage/repository — this is the "mixing of layers"
        this.taskRepository = taskRepository;
    }

    @Override
    public boolean isValid(String taskId, ConstraintValidatorContext context) {
        // Here validation turns into a system query: the result depends on data in the DB/storage
        return taskId != null && taskRepository.existsById(taskId);
    }
}

Why is this a bad default approach? Because the “validity” of the request suddenly becomes dependent on storage. And validation may run often, at different levels, before you have even decided which operation you are performing. As a result, you can end up with extra storage calls and weird scenarios where “validation passed, but still nothing worked.”

The third case is rules that “depend on time,” for example “dueDate cannot be in the past relative to today.” Formally, you are not reading system state, but you are using “current time,” which means the validation result can change. Most of the time this is still input validation (because it does not require a resource), but here it is important to do the check carefully: use Clock, think about timezone, and remember that “yesterday/today” is a slippery thing. If the rule is truly domain-based (“you cannot set dueDate earlier than the task’s createdAt”), then it depends on the state of a specific task and moves into business validation.

7. Typical mistakes at the boundary

Mistake #1: trying to forbid with annotations what is not in the request.
The most common trap is the desire to write @NotArchivedTask on an update DTO. But the DTO does not know whether the current task is archived, because that state is stored on the server. As a result, the validator will either start reaching into the repository (and you will mix layers) or will be pointless and always “valid.”

Mistake #2: turning ConstraintValidator into a mini-service.
As soon as a validator gets a repository, logging, configs, and starts “checking everything,” you lose the simple model: “DTO → annotation → pure check.” Validation becomes not a predictable input filter, but a business operation disguised as an annotation. It is hard to maintain, and debugging is even more fun (especially when the fun is in production).

Mistake #3: marking business restrictions as 400 Bad Request.
If the request is correct, but the operation is forbidden by domain rules, 400 turns the situation into “the client sent junk.” This breaks client logic: the UI usually reacts differently to “fix the form fields” and to “the operation is forbidden because the resource is in this state.” Semantically, such cases tend toward 409.

Mistake #4: duplicating the same rule in both DTO and service without reason.
Sometimes there is a temptation to “play it safe” and check tags both in @UniqueTagsIgnoreCase and again in the service. In the end you have two sources of truth: they can diverge (for example, one trims spaces, the other does not). It is better to have one “main place” for the rule and understand what type it belongs to.

Mistake #5: confusing “validation” with “data normalization.”
Validation answers “can/can’t.” Normalization answers “how to bring it to canonical form.” Sometimes a validator really needs to do light normalization (for example, trim() for comparing tags), but if you start changing the meaning of the data inside the validator, you complicate the contract. Normalization is more appropriate in the mapper/service (where you explicitly control the transformation), not as a hidden side effect of checking.

1
Task
Spring REST & MVC, level 17, lesson 3
Locked
Title update with separation of input and business validation
Title update with separation of input and business validation
1
Task
Spring REST & MVC, level 17, lesson 3
Locked
Task creation with a uniqueness check for title in the repository
Task creation with a uniqueness check for title in the repository
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION