Input Validation

Any data crossing a trust boundary — user input, a file, a network call, another team's API — must be validated before your code relies on it being well-formed.

Input validation is the practice of checking that data coming from outside your code's direct control — command-line arguments, HTTP request bodies, file contents, database rows, another service's response — actually satisfies the assumptions the rest of your code makes about it, before that data is used. Skipping validation doesn't make the invalid-data case go away; it just moves the failure somewhere later, harder to diagnose, and possibly less safe.

What Counts as External Data

"External" means anything your method did not itself construct with values it controls: parameters on a public method, command-line arguments, environment variables, file and network I/O, deserialized objects, results from a database query, and even arguments from other teams' internal libraries when you cannot fully trust their contracts. Data produced entirely inside a tightly-scoped private method, by contrast, often does not need re-validation — the validation boundary should sit where trust changes, not on every internal call.

A useful discipline: validate at the boundary once, then treat the data as trusted for the rest of its life inside that boundary. Re-validating everywhere is noisy and can mask where the real trust boundary is.

Null Checks

Java does not statically prevent null from being passed where an object reference is expected (outside of narrow cases using newer nullability tooling), so a method that cannot sensibly operate on null should check for it explicitly and fail with a clear exception, rather than let a NullPointerException occur deep inside unrelated logic with a confusing stack trace.

Objects.requireNonNull throws immediately with a clear, custom message pinpointing exactly which argument was null, rather than letting a later name.trim() throw an NPE with no context about which value was the problem.

import java.util.Objects;

public class NullChecks {
    static void processName(String name) {
        Objects.requireNonNull(name, "name must not be null");
        System.out.println("Processing: " + name.trim());
    }

    public static void main(String[] args) {
        processName("Ada");
        try {
            processName(null);
        } catch (NullPointerException e) {
            System.out.println("Rejected: " + e.getMessage());
        }
    }
}

Range and Boundary Validation

Numeric and size-bounded inputs (an age, a page size, an array index, a percentage) should be checked against their valid range before use. Off-by-one errors at range boundaries are extremely common, so tests should specifically exercise the boundary values themselves (the minimum, the maximum, one below, one above), not just "typical" values in the middle.

The check happens once, at the entry point, and the exception message states both the rule and the offending value -- useful for whoever reads the log or stack trace later.

public class RangeValidation {
    static int clampPercentage(int value) {
        if (value < 0 || value > 100) {
            throw new IllegalArgumentException(
                "percentage must be within [0, 100], was: " + value);
        }
        return value;
    }

    public static void main(String[] args) {
        System.out.println(clampPercentage(75));
        try {
            clampPercentage(150);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

Format Validation

String inputs that must match a shape (an email address, a date, a numeric string, an identifier) should be checked against that shape before being parsed or stored. String.matches(...) with a regular expression handles simple format checks; for structured formats with well-defined semantics (dates, URIs, UUIDs), prefer the JDK's own parser (LocalDate.parse, URI, UUID.fromString) over a hand-rolled regex, since the JDK type encodes the real grammar and rejects things a casual regex would miss or wrongly accept.

A precompiled static Pattern avoids recompiling the regex on every call. LocalDate.parse does real calendar validation (rejecting 2024-02-30, for instance) that a naive digit-matching regex would not catch.

import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.regex.Pattern;

public class FormatValidation {
    private static final Pattern SIMPLE_ID = Pattern.compile("^[A-Z]{2}\\d{4}$");

    static boolean isValidId(String id) {
        return id != null && SIMPLE_ID.matcher(id).matches();
    }

    static LocalDate parseDateOrThrow(String text) {
        try {
            return LocalDate.parse(text); // ISO-8601 by default: yyyy-MM-dd
        } catch (DateTimeException e) {
            throw new IllegalArgumentException("Invalid date: " + text, e);
        }
    }

    public static void main(String[] args) {
        System.out.println(isValidId("AB1234"));   // true
        System.out.println(isValidId("abc"));        // false
        System.out.println(parseDateOrThrow("2024-03-15"));
    }
}

Fail-Fast Principle

Fail-fast means detecting an invalid state as close as possible to where it was introduced, rather than letting it propagate silently until it causes a confusing failure far away. A method that receives bad input and immediately throws a clear, specific exception is far easier to debug than one that accepts the bad value, stores it, and fails three method calls later with a NullPointerException or ClassCastException that gives no hint about the original cause.

Validating constructor arguments is a particularly important case of fail-fast: an object that skips validation in its constructor can exist in an invalid state for its entire lifetime, silently corrupting every piece of logic that later trusts its fields.

Validating in the constructor guarantees every Account instance that exists is valid for its entire lifetime -- no code anywhere else in the program needs to re-check ownerName or balance.

public class Account {
    private final String ownerName;
    private final double balance;

    Account(String ownerName, double balance) {
        if (ownerName == null || ownerName.isBlank()) {
            throw new IllegalArgumentException("ownerName must not be blank");
        }
        if (balance < 0) {
            throw new IllegalArgumentException("balance cannot be negative: " + balance);
        }
        this.ownerName = ownerName;
        this.balance = balance;
    }
}

Exception Strategy: What to Throw

Java's convention is IllegalArgumentException for a bad argument value, NullPointerException (typically via Objects.requireNonNull) for an unexpectedly null reference, and IllegalStateException when the object itself is not currently in a state that permits the call, independent of the arguments given. Using these consistently lets callers write targeted catch clauses and lets readers infer the failure category from the exception type alone, without reading the message.

Whether a validation failure should be a checked or unchecked exception is a design decision: unchecked (as above) fits programming errors -- bad input the caller should have prevented -- while a checked exception fits recoverable, expected failure conditions the caller is meant to actively handle (a malformed user-supplied file, for example).

IllegalArgumentException : the argument value itself is invalid
NullPointerException      : a required reference argument was null
IllegalStateException     : the object/receiver isn't in a valid state for this call
Custom checked exception  : caller is expected to handle recoverable failure explicitly

Validating Collections and Composite Input

Validation isn't limited to single scalar values. A List parameter might need to reject null elements, enforce a non-empty size, or check every element individually; a composite input (a request object with several fields) benefits from validating the whole thing together so a single exception can report every problem, rather than the caller fixing one field at a time across repeated failed calls.

import java.util.List;

public class CollectionValidation {
    static void validateScores(List<Integer> scores) {
        if (scores == null || scores.isEmpty()) {
            throw new IllegalArgumentException("scores must be non-null and non-empty");
        }
        for (int s : scores) {
            if (s < 0 || s > 100) {
                throw new IllegalArgumentException("score out of range [0,100]: " + s);
            }
        }
    }
}

Practical Usage: Where to Validate

Public API boundaries (constructors, public methods, REST controllers, CLI argument parsing) are where validation earns its keep, because that's where you cross from "caller you don't control" into "code that assumes correctness." Private helper methods called only from already-validated code generally don't need to repeat the same checks -- doing so adds noise without meaningfully increasing safety, since the caller already guaranteed the precondition.

For layered applications, validating once at the outermost boundary (e.g. a web controller) and trusting the data through internal service and repository layers is a common, sensible design -- provided that boundary is genuinely never bypassed.

Edge Cases

Empty string versus blank string versus null are three distinct conditions that are easy to conflate; decide explicitly which are acceptable (isEmpty() checks length only, isBlank() also treats whitespace-only strings as empty).

Numeric parsing (Integer.parseInt, Double.parseDouble) throws NumberFormatException (a subtype of IllegalArgumentException) on malformed input -- catching and re-wrapping it with a domain-specific message is often clearer for callers than letting the raw exception surface.

Floating-point ranges need special care around NaN and infinities: NaN < 0 and NaN > 100 are both false, so a naive range check can silently let NaN through unless it is checked for explicitly (Double.isNaN(value)).

Best Practices

Validate as early as possible (fail-fast), state the rule and the offending value in the exception message, use Objects.requireNonNull for null checks so intent is obvious at a glance, prefer JDK parsers over hand-rolled regexes for well-known formats, and keep validation logic testable and separate from business logic where a component grows complex enough to warrant it (a dedicated Validator class or a validation framework like Bean Validation for larger applications).

Quick Reference

Objects.requireNonNull(x, "msg")     : throws NPE with message if x is null
if (x < min || x > max) throw ...    : range check
Pattern.matches / String.matches     : simple format check
LocalDate.parse / UUID.fromString    : structured format check via JDK parser
throw new IllegalArgumentException   : bad argument value
throw new IllegalStateException      : receiver not in a valid state
Validate in constructors             : guarantees objects are always valid

Key Takeaways

Validate data at the trust boundary -- where external, uncontrolled data enters your code -- not on every internal call afterward.

Use Objects.requireNonNull for null checks, explicit range checks for numeric bounds, and JDK parsers (LocalDate, UUID, URI) over hand-rolled regexes for structured formats.

Fail-fast: reject invalid input as close to its origin as possible, especially in constructors, so invalid objects can never exist.

Use IllegalArgumentException for bad values, NullPointerException for missing required references, and IllegalStateException for a receiver in the wrong state.

Watch for edge cases: blank vs empty vs null strings, NaN slipping through naive numeric range checks, and off-by-one errors at boundaries.

void setAge(int age) { this.age = age; // no check at all } void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("age out of range: " + age); } this.age = age; }

Skipping validation lets an invalid age (negative, or absurdly large) sit silently in the object, corrupting any logic that later trusts it.

String name = request.getParameter("name"); greet(name.toUpperCase()); // may throw NPE deep inside

String name = request.getParameter("name"); Objects.requireNonNull(name, "name parameter is required"); greet(name.toUpperCase());

Without an explicit null check, a missing request parameter causes an NPE inside unrelated code (toUpperCase), losing the context that the real problem was a missing parameter.

if (value.matches("\\d{4}-\\d{2}-\\d{2}")) { /* treat as valid date */ } try { LocalDate.parse(value); } catch (DateTimeException e) { throw new IllegalArgumentException("Invalid date: " + value, e); }

The regex accepts syntactically date-shaped but calendar-invalid strings like "2024-02-30"; LocalDate.parse validates real calendar rules.

if (percentage < 0 || percentage > 100) {... } // percentage is a double that could be NaN if (Double.isNaN(percentage) || percentage < 0 || percentage > 100) {... }

NaN compares false to every ordering comparison (NaN < 0 and NaN > 100 are both false), so a naive range check silently lets NaN pass unless checked for explicitly.

Why should input validation generally happen at the trust boundary rather than being repeated on every internal method call?

Because that's where uncontrolled, potentially-invalid external data first enters the code; validating once there and then trusting the data internally avoids both redundant checks that add noise and the risk of validation logic drifting out of sync across many call sites.

What's the difference between IllegalArgumentException, NullPointerException, and IllegalStateException, and when should each be used?

IllegalArgumentException signals a bad value for an argument; NullPointerException (typically via Objects.requireNonNull) signals a required reference argument was null; IllegalStateException signals the receiving object itself isn't in a state that allows the call, independent of the arguments passed.

Why is fail-fast validation in a constructor especially important compared to validating in an ordinary setter method later?

A constructor is the only chance to prevent an invalid object from ever existing at all; if validation is skipped there, the object can be created in a bad state and passed around, with every piece of code that touches it having to (incorrectly) assume it's valid.

Why might LocalDate.parse be preferred over a regular expression for validating a date string?

A regex checking digit shapes only confirms the string looks date-shaped (e.g. matches \\d{4}-\\d{2}-\\d{2}), but LocalDate.parse enforces actual calendar semantics, rejecting invalid dates like February 30th that a naive regex would incorrectly accept.

What subtle bug can occur when range-checking a double value without accounting for NaN?

NaN returns false for every ordering comparison, so both value < min and value > max evaluate to false for NaN, meaning a naive range check silently treats NaN as valid unless Double.isNaN(value) is checked explicitly.

Give an example of when you would choose a checked exception over an unchecked one for a validation failure.

When the failure represents an expected, recoverable condition the caller should actively handle -- e.g. a user-uploaded file that fails format validation -- a checked exception forces the caller to consider and handle that case, unlike an unchecked exception which signals more of a programming-error condition.

Why is Objects.requireNonNull preferred over an ad-hoc 'if (x == null) throw new NullPointerException()' block?

It is more concise, conventional, and lets you attach a specific, informative message identifying exactly which argument was null in one line, making the resulting stack trace immediately actionable.

Write a static method validateEmail(String email) that throws IllegalArgumentException with a clear message if the email is null, blank, or doesn't contain an '@' followed by at least one '.', and otherwise returns the trimmed email.

public class EmailValidation { static String validateEmail(String email) { if (email == null || email.isBlank()) { throw new IllegalArgumentException("email must not be blank"); } String trimmed = email.trim(); int at = trimmed.indexOf('@'); if (at < 1 || trimmed.indexOf('.', at) < 0) { throw new IllegalArgumentException("invalid email format: " + trimmed); } return trimmed; } public static void main(String[] args) { System.out.println(validateEmail(" [email protected] ")); try { validateEmail("not-an-email"); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); } } }

The method checks for null/blank first, then trims, then confirms an '@' exists (not at position 0) and a '.' appears after it -- throwing IllegalArgumentException with a message stating the actual offending value in each failure case.

This code compiles and runs fine, printing a total. What's the input-validation problem with it, and how would you fix it?

There's no validation that each score is within a sensible range (e.g. 0-100); the -5 silently corrupts the total instead of being rejected. Fix: validate each element before summing, e.g. 'if (s < 0 || s > 100) throw new IllegalArgumentException("invalid score: " + s);' inside the loop, so bad data is caught immediately rather than silently propagating into the result.

Write a constructor for a Temperature class that stores a Celsius value as a double, rejecting values below absolute zero (-273.15) and rejecting NaN.

public class Temperature { private final double celsius; Temperature(double celsius) { if (Double.isNaN(celsius)) { throw new IllegalArgumentException("celsius cannot be NaN"); } if (celsius < -273.15) { throw new IllegalArgumentException( "celsius cannot be below absolute zero: " + celsius); } this.celsius = celsius; } double getCelsius() { return celsius; } }

The constructor checks Double.isNaN explicitly (since NaN comparisons are always false and would otherwise slip past a naive '< -273.15' check) before validating the physical lower bound, guaranteeing every constructed Temperature is physically valid.

List at least two input-validation problems with this withdraw method and describe how to fix them.

Problems: (1) no check that amount is positive -- a negative amount would increase the balance instead of decreasing it; (2) no check that balance stays non-negative (or within an allowed overdraft) after the withdrawal; (3) NaN could silently corrupt balance. Fix: 'if (Double.isNaN(amount) || amount <= 0) throw new IllegalArgumentException("amount must be positive: " + amount); if (amount > balance) throw new IllegalStateException("insufficient funds"); balance -= amount;'

int[] scores = {85, 92, -5, 78};
int total = 0;
for (int s : scores) {
    total += s;
}
System.out.println(total);
public void withdraw(double amount) {
    balance -= amount;
}