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.

SyntaxMap<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 here

Everything 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

  1. Size: refuse oversized bodies before reading them (413). Reading an unbounded body into memory is an easy denial of service.
  2. Format: wrong Content-Type is 415; JSON that does not parse is 400.
  3. Fields: required, type, range, length, pattern. Collect every error, not just the first, so a form can highlight all of them at once.
  4. 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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.

Example · java
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 bytes

A 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.

Example · java
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

  • Be the first to comment on this lesson.