1. Problem: “let the service figure it out”
When you first start writing backend code, it is very easy to fall into the trap of “well, that is obvious, I will just quickly check it with ifs”. And yes, in a small method that even looks cute: three checks, two throws, and you are a hero. The problem is that an API is the public front door to your house, not a private draft. And if there are no clear rules at the entrance, people will start bringing in everything: empty strings, huge texts, null where you expected a value, and “tags” in the form of a single space (yes, that happens too).
Imagine our Task Tracker API. The client calls POST /api/v1/tasks and sends JSON. If you do not validate input at the boundary, “bad” data will creep into the service, then into the repository, and then somewhere inside you will get an exception that looks nothing like a normal contract response. That does not make the client any happier: they do not understand what to fix, and you do not understand why task creation keeps “failing again, but only on Fridays” (because on Fridays someone likes to enter three spaces into title — people are mysterious).
Validation at the API boundary solves exactly this problem: it turns a “soft request from the developer” into an explicit input rule, and one that works the same way for all endpoint’s, not just where you were too lazy to write yet another if. The most important idea here is simple: the service layer should deal with the meaning of the operation (“create a task”), not with sanitising the input (“make sure the title is not empty and is not 50,000 characters long”).
2. The API boundary in Spring MVC
The phrase “API boundary” sounds a bit like “state border”, and Spring Boot sometimes behaves like a customs officer with very energetic enthusiasm. But in practice it is simpler: the API boundary is the place where the outside world (a client, browser, mobile app, neighbouring service) first touches your code in the form of your types. In our project, that is primarily controllers and their arguments: @RequestBody TaskCreateRequest, @RequestParam ..., @PathVariable ....
To be completely honest, some things happen before the controller too: Spring accepts the HTTP request, selects the handler method, reads the request body, and tries to turn JSON into a DTO. And as soon as the DTO has been created (that is, the JSON was valid), the ideal moment for validation arrives: we have an object, it has fields, and we can check the “allowed / not allowed” rules before business logic starts doing anything.
It helps to keep this in mind as a small diagram. Not because you need to memorise Spring internals, but because you want to understand “what happens when” — and stop treating the framework like a magical forest.
flowchart TD
A[HTTP request] --> B["HttpMessageConverter: JSON -> DTO"]
B --> C[Input validation: constraints]
C --> D[Controller method]
D --> E[Service layer]
Notice an important detail. If the JSON is broken, then the “JSON -> DTO” arrow will not work, and validation will never happen. That is normal: you cannot validate an object you failed to create. So “broken JSON” and “DTO does not satisfy the rules” are two different classes of problems, and it is useful to distinguish them now, even without diving into the error format.
3. Manual checks and a “contract that drifts apart”
Manual checks are not “evil”. Evil is when they become your main way of describing the contract, and then spread across the project in a thin layer. First you check title in create, then you forget to check it in update, then you add a description length check, but only in one place, and then you change the limit from 2000 characters to 3000 — and guess how many places need to be fixed? Right: as many as you no longer remember.
Look at a typical “manual” implementation. It looks reasonable until you imagine that there will be dozens of such methods.
public void create(TaskCreateRequest request) {
// Mandatory field check: you cannot create a task without a title
if (request.title() == null || request.title().isBlank()) {
// A downside of this approach: everyone writes the error format however they want
throw new IllegalArgumentException("title is required");
}
// Length limit check: otherwise a "wall of text" may end up in the database/log/UI
if (request.description() != null && request.description().length() > 2000) {
throw new IllegalArgumentException("description is too long");
}
}
At this stage, you may think the problem is only “extra code”. But the real problem is different: the rule for “what counts as a valid request” is now hidden inside the method. The client does not know it, the controller does not know it, tests discover it by accident, and another developer will learn about it only after the third attempt to send a request and get yet another IllegalArgumentException.
Another unpleasant thing is that manual checks almost inevitably create different behaviours for different endpoint’s. For example, one method will throw IllegalArgumentException with the text "title is required", another will say "Bad title", and a third will throw NullPointerException because someone forgot to check for null and called trim(). In the end, the client gets not a contract, but a guessing game.
And finally, manual ifs are very poor at maintaining layer discipline. You can easily start doing “a little bit of validation” in the controller, “a little bit” in the service, and “a couple more checks” in the repository. The code will still work, but reading and maintaining it will hurt: input rules will spread through the project like a cat over a keyboard — cute on the surface, but the result is questionable.
4. DTO and constraints: an explicit input contract
When we say “validation at the boundary”, it does not mean “let us add another layer with lots of magic”. On the contrary, the idea is very down to earth: if a rule applies to the field of an input DTO, then it should be expressed next to that field. Then the DTO stops being just a “transport container” and becomes a small specification: what is required, what length limits exist, which formats are allowed.
In Task Tracker API we have business constraints for a task: the title is required and has length 3..120, the description may be absent, but if it is present, it must be no longer than 2000. And that is the perfect candidate for declarative validation. Because the rule does not depend on application state, does not require knowledge of the database, and does not require complex logic. It is a sanitary rule: like “no one may bring a chainsaw into the office”. Not because we are against chainsaws, but because they are usually inconvenient in the office.
For this fragment, we only need two fields — title and description. We will intentionally leave out the other fields of the project request DTO here, so that we do not lose the core principle: how a constraint lives next to a field.
An example of a DTO that carries its own rules:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Required field: you cannot pass null / an empty string / only spaces
@NotBlank
// Length contract: from 3 to 120 characters
@Size(min = 3, max = 120)
String title,
// The field may be absent, but if it is present we limit the maximum length
@Size(max = 2000)
String description
) {}
Notice the magic of “honest engineering”: nothing is hidden, everything is readable. You open the file and you see the contract. No need to search for which service and which method decided today that title must be at least 3 characters long.
This also helps maintain consistency between create and update. If a field has the same meaning in create and update, then the basic constraints should usually match as well. And if tomorrow you decide that the title can be up to 140 characters long (because the task manager suddenly became a poetry platform), you change the rule in one place — in the DTO. Not in ten services and not in five controllers.
5. A thin controller: a “checkpoint”
There is a fear that almost always appears in beginners: “If we validate at the boundary, will the controller become smart and fat?” No. On the contrary, proper validation makes the controller even thinner. It stops being the place where you manually check every field, and becomes what it is supposed to be: an entry point that accepts the contract, enables validation, and passes things along.
In Spring MVC, this looks like a very small but very important action: you tell the framework “this DTO argument must be validated”. That is all. The controller does not become a judge; it remains a turnstile.
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PutMapping("/{taskId}")
public ResponseEntity<TaskDetailsResponse> update(
// Take the identifier from the URL path parameter
@PathVariable String taskId,
// Ask Spring to validate the DTO against its constraints before business logic starts
@Valid @RequestBody TaskPutRequest request
) {
// If the DTO is invalid, execution usually will not even reach this line
var updated = taskService.update(taskId, request);
return ResponseEntity.ok(taskMapper.toDetailsResponse(updated));
}
}
Yes, @Valid appears here, and it looks like “just another annotation”. But the meaning is more important than the syntax: you enabled system-level input checking. The controller no longer has to write if (title == null || title.isBlank()) and no longer has to decide which exception to throw. It simply states: “This object must satisfy the rules described on it”. And where are the rules? On the DTO. Which means the rules are not scattered across the code, but live in one predictable place.
And one more thing: such a controller is easier to read. You see the method signature and almost immediately understand the contract: taskId comes from the path, the body comes from JSON, and the body must be valid. That is no longer a “random function”, but a declaration of API behaviour.
6. The service layer: less noise, more meaning
In our course, the service layer is where the application action lives: create a task, update a task, change the status, add a comment. If the service is constantly forced to do manual string checking, it quickly turns into long “plumbing” code where business logic drowns in checks, trim(), isBlank(), and attempts to explain to the client what exactly is wrong.
When you move basic validation rules to the API boundary and describe them in the request DTO, the service starts working with cleaner input. It does not become “absolutely trusting”, but basic sanitation moves out of it. And the code gets closer to the meaning, not to fighting chaos.
public Task update(String taskId, TaskPutRequest request) {
// Here we assume that the basic sanitation check has already been done at the API boundary
Task task = repository.getById(taskId);
// The service handles the semantic update of the entity, not repeated string validation
task.setTitle(request.title());
task.setDescription(request.description());
task.setAssigneeName(request.assigneeName());
task.setPriority(request.priority());
// Save the final state (and domain checks may still be possible at this level)
return repository.save(task);
}
It is important to read this example correctly. It does not say: “there will never be checks in the service”. It says: “the service should not contain repetitive sanitation checks that are logically cheaper to handle at the boundary”. The service can now focus on the meaning of the update, on domain rules, on storage, and on operation consistency. Not on the fact that someone sent title as spaces again.
7. Systematic negative scenarios
When input is validated systematically, not only does the code change, but so does the API’s behaviour as a product. Predictability appears: if the client sends an invalid request, it does not “sometimes crash”, does not “sometimes create strange data”, and does not “sometimes return some weird exception”. The request is stopped at the boundary. And that matters, because the boundary is the place where we, as developers, can still honestly say: “You sent the wrong thing”.
Validation at the boundary also makes negative scenarios reproducible. If the rule is expressed in the DTO and enabled on input, you will not forget to apply it to another endpoint “by accident”. The error stops being a lottery. For an API, that is a huge difference: client code starts trusting the fact that if a request is accepted and passes the basic rules, then we can start talking about real application problems, not about “technical garbage on the input”.
And one more thing: this approach helps you as a team. DTOs with constraints are a document that is readable directly in the code. Even if you do not look at any documentation, even if the Postman collection is lost, even if the README is written “however it turns out”, the input rules still remain explicit. And that makes the project more mature without any heavy infrastructure.
8. Typical mistakes when validating at the API boundary
Mistake #1: checking the same thing in two places “just in case”.
A very human reaction: you added constraints to the DTO, but left the old ifs in the service or controller because “you never know”. In the end you get duplication that will almost inevitably drift apart: one place has a 120 limit, another has 100, and a third one forgot to update it altogether. It is better to choose one main boundary for basic sanitation and keep it there consistently.
Mistake #2: hiding input rules inside the service because “controllers should be dumb”.
Controllers should indeed be thin, but thin does not mean “irresponsible”. A thin controller is one that accepts the request, triggers the boundary infrastructure mechanisms (including validation), and delegates the meaningful work to the service. If you hide input rules in the service, you mix responsibilities: the service starts defending itself against garbage instead of doing the action.
Mistake #3: treating DTO as “just a box for data” and not as a contract.
In a REST API, a request DTO is part of the public agreement. If you do not define rules for title, description, assigneeName, then the agreement becomes implicit. Clients start guessing what is allowed and what is not, and often guess toward chaos. A DTO with constraints makes the rules visible and protects the API from accidental flexibility.
Mistake #4: throwing IllegalArgumentException as a universal answer to everything.
When checks are spread across the project, a developer often chooses the quickest exit: “if it is bad, throw IllegalArgumentException”. It works in the short term, and in the long term it leads to different methods throwing different exceptions, with different texts, and the API becomes unpredictable. Declarative validation disciplines this layer precisely: the check becomes systematic, not “everyone writes however they want”.
Mistake #5: forgetting to enable cascading validation for nested objects.
If a request DTO contains nested objects or lists (for example, List<SubtaskCreateRequest>), constraints on nested elements will not work by themselves. You need to explicitly mark the field with @Valid, otherwise you will be sure that “validation is enabled”, while in reality part of the input will slip through to the service layer without any trouble.
GO TO FULL VERSION