Validating Input
Check every field at the API boundary, report all problems in one 400 response, and turn raw strings into typed values that cannot be invalid.
Map<String, String> errors = new LinkedHashMap<>();
if (name.isBlank()) errors.put("name", "is required");
if (!errors.isEmpty()) return badRequest(errors); // 400, every error
Signup valid = new Signup(email, name, age); // typed from hereEverything in a request is untrusted: the path, the query string, headers and body. Validation at the boundary decides, before any business logic runs, whether the request is something your service can process. Skipping it does not save work; it moves the failure into a NumberFormatException that becomes a 500, a database constraint error, or bad data that someone finds months later.
What to check, in order
- Size: refuse oversized bodies before reading them (413). Reading an unbounded body into memory is an easy denial of service.
- Format: wrong
Content-Typeis 415; JSON that does not parse is 400. - Fields: required, type, range, length, pattern. Collect every error, not just the first, so a form can highlight all of them at once.
- Rules that need data: duplicate email, unknown product id. These are usually 409 or 422 and live in the service, not the parser.
400 or 422
Both are in use. 400 Bad Request is the general answer and what most Java frameworks return for validation failures. 422 Unprocessable Content means the syntax was fine but the content breaks a rule. Pick one convention for the whole API and keep it.
Parse, do not just check
Turn the raw input into a typed record whose constructor refuses invalid values, then pass only that record inward. Code past the boundary can rely on age being an int in range instead of re-checking a String. Normalise as you parse: strip whitespace, lower-case emails, so " [email protected]" and "[email protected]" do not become two accounts.
Common mistakes
- Validating in the browser only. Anyone can send a request with
curl. - Unbounded strings: always set a maximum length, matching the database column.
- Trusting an id in the body that disagrees with the id in the path.
- Echoing raw input into HTML or logs without encoding it.
- Over-strict email regexes that reject real addresses. Check the shape loosely and confirm ownership by sending an email.
Spring and Jakarta EE do this with Jakarta Bean Validation annotations such as @NotBlank, @Email and @Size on record components; the checks below are what those annotations run for you.
Example
When to use it
- Return every invalid field of a sign-up form in one 400 response so the front end can mark them all.
- Reject a 200 MB JSON upload with 413 before it is read into a service with a 512 MB heap.
- Stop limit=abc in a list endpoint from becoming a NumberFormatException and a 500.
More examples
Reading a numeric query parameter safely
Returning a result instead of throwing lets the handler gather several parameter errors into one response. The last case overflows int, which parseInt reports as a NumberFormatException just like text.
sealed interface Parsed permits Ok, Bad {}
record Ok(int value) implements Parsed {}
record Bad(String message) implements Parsed {}
Parsed intParam(String raw, String name, int fallback, int min, int max) {
if (raw == null || raw.isBlank()) return new Ok(fallback);
try {
int value = Integer.parseInt(raw.strip());
if (value < min || value > max) return new Bad(name + " must be between " + min + " and " + max);
return new Ok(value);
} catch (NumberFormatException e) {
return new Bad(name + " must be a whole number");
}
}
void main() {
for (String raw : Arrays.asList(null, "25", " 7 ", "0", "1000", "ten", "99999999999")) {
String shown = raw == null ? "(absent)" : "\"" + raw + "\"";
IO.println("limit=" + shown + " -> " + intParam(raw, "limit", 20, 1, 100));
}
}
// Output:
// limit=(absent) -> Ok[value=20]
// limit="25" -> Ok[value=25]
// limit=" 7 " -> Ok[value=7]
// limit="0" -> Bad[message=limit must be between 1 and 100]
// limit="1000" -> Bad[message=limit must be between 1 and 100]
// limit="ten" -> Bad[message=limit must be a whole number]
// limit="99999999999" -> Bad[message=limit must be a whole number]Capping the request body size
readAllBytes on a request stream reads whatever the client sends, so one large upload can exhaust the heap. Checking the Content-Length header first is a quick early exit, but the client can lie, so the capped read is the real protection.
class PayloadTooLargeException extends RuntimeException {
PayloadTooLargeException(int max) { super("body larger than " + max + " bytes"); }
}
// Read at most max + 1 bytes: one extra byte is enough to know the body is too big.
byte[] readLimited(InputStream in, int max) throws IOException {
byte[] bytes = in.readNBytes(max + 1);
if (bytes.length > max) throw new PayloadTooLargeException(max);
return bytes;
}
void main() throws IOException {
int max = 1024;
List<String> bodies = List.of("{\"name\":\"Ada\"}", "x".repeat(50_000));
for (String body : bodies) {
InputStream in = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
try {
IO.println("200 read " + readLimited(in, max).length + " bytes");
} catch (PayloadTooLargeException e) {
IO.println("413 " + e.getMessage());
}
}
}
// Output:
// 200 read 14 bytes
// 413 body larger than 1024 bytesA value type that cannot hold an invalid email
Once a method takes an Email instead of a String, it cannot be called with unchecked input, and the normalisation happens in exactly one place. Most services treat addresses case-insensitively as done here, even though the part before the @ is technically allowed to be case-sensitive.
record Email(String value) {
private static final Pattern SHAPE = Pattern.compile("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
Email {
if (value == null || value.isBlank()) throw new IllegalArgumentException("email is required");
value = value.strip().toLowerCase(Locale.ROOT);
if (value.length() > 254) throw new IllegalArgumentException("email is too long");
if (!SHAPE.matcher(value).matches()) throw new IllegalArgumentException("not an email address: " + value);
}
}
void main() {
for (String raw : List.of(" [email protected] ", "ada@localhost", "", "two@@example.com")) {
try {
IO.println("ok " + new Email(raw));
} catch (IllegalArgumentException e) {
IO.println("400 " + e.getMessage());
}
}
}
// Output:
// ok Email[[email protected]]
// 400 not an email address: ada@localhost
// 400 email is required
// 400 not an email address: two@@example.com
Discussion