← Back to list

Build Robust APIs with Declarative Validation — From Built-in Constraints to Custom Validators

Input validation is one of the most fundamental responsibilities of any backend service, yet it remains one of the most commonly mishandled…

Mridul Choudhary · 2026-05-13 13:10 · 2 claps · 10.5 min read
#spring-boot #bean-validation #rest-api #java
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Build Robust APIs with Declarative Validation — From Built-in Constraints to Custom Validators

Input validation is one of the most fundamental responsibilities of any backend service, yet it remains one of the most commonly mishandled aspects of API development.

Invalid data that slips past an API boundary can corrupt databases, trigger obscure runtime exceptions deep in business logic and open the door to injection attacks.

Bean Validation standardized through JSR 380 and now part of the Jakarta Validation specification provides a declarative, annotation-driven approach to enforcing data integrity directly on your domain objects.

In this guide, we will explore Bean Validation comprehensively using a mocked payment processing API built with Spring Boot.

You will learn:

  • How does built-in constraint annotations work?
  • How to create powerful custom validators?
  • How to validate relationships between multiple fields using cross-field validators?
  • How to produce structured, client-friendly error responses that conform to RFC 7807 Problem Details?

By the end, you will have the knowledge to build a validation layer that is expressive, maintainable and secure one that serves as your API’s first line of defense.

The mocked payment API handles card payments, bank transfers via IBAN, and refund processing, making it an ideal vehicle for demonstrating every major Bean Validation capability.

Bean Validation Fundamentals

How It Works in Spring Boot:

Spring Boot integrates Bean Validation seamlessly through the spring-boot-starter-validation dependency, which bundles Hibernate Validator (the reference implementation of the Jakarta Validation specification).

When you annotate a controller method parameter with @Valid, Spring's argument resolver intercepts the incoming request body and passes it through the validation engine before your handler method executes.

The validation lifecycle proceeds as follows:

  • Spring deserializes the JSON request body into a Java object.
  • The Bean Validation engine scans the object for constraint annotations, each annotation’s associated validator executes its isValid() method, and any violations are collected into a set.
  • If violations exist, Spring throws a MethodArgumentNotValidException rather than invoking your controller method.
  • This fail-fast approach ensures that invalid data never reaches your service layer.

The key components in this architecture are:

  • Constraint annotations (metadata declaring rules)
  • Constraint validators (implementations that enforce those rules)
  • The Validator engine (orchestrator that discovers and invokes validators)
  • The exception handler (translator that converts violations into HTTP error responses)

Figure 1: Bean Validation request flow showing Client → Controller → ValidationEngine → Service/ExceptionHandler paths

Figure 1: Bean Validation request flow showing Client → Controller → ValidationEngine → Service/ExceptionHandler paths

This separation of concerns is what makes Bean Validation so powerful: your controller stays lean, your validation rules live on the data class itself, and error translation happens in a single centralized handler.

Built-in Validators Deep Dive

Jakarta Validation provides a rich catalog of constraint annotations organized into several categories. Understanding what is available out of the box prevents you from writing unnecessary custom validators.

Nullability Constraints: @NotNull ensures a field is present, while @NotBlank (for strings) additionally rejects empty strings and strings containing only whitespace. Use @NotBlank for string fields and @NotNull for objects and numbers.

Numeric Constraints: @Positive, @PositiveOrZero, @Negative, @NegativeOrZero enforce sign rules. @Min and @Max set numeric bounds. @Digits(integer, fraction) controls precision—essential for monetary values where you must limit decimal places.

String Constraints: @Size(min, max) restricts length. @Pattern(regexp) provides full regular expression matching for format enforcement. @Email validates email format.

Range Constraints: @Min and @Max work on numeric types, while @DecimalMin and @DecimalMax support string representations of numbers.

Here is theProcessPaymentRequest DTO demonstrating multiple built-in validators working together:

public class ProcessPaymentRequest {

    @NotNull(message = "Amount is required")
    @Positive(message = "Amount must be positive")
    @Digits(integer = 10, fraction = 2, message = "Amount must have max 2 decimal places")
    private BigDecimal amount;

    @NotBlank(message = "Currency is required")
    @ValidCurrency
    private String currency;

    @NotBlank(message = "Description is required")
    @Size(max = 255, message = "Description must not exceed 255 characters")
    private String description;

    @NotNull(message = "Payment method is required")
    @Valid
    private PaymentMethodDto paymentMethod;

    @NotNull(message = "Billing address is required")
    @Valid
    private BillingAddressDto billingAddress;
}

Notice how each field stacks multiple annotations to express compound rules. The amount field, for instance must be non-null, positive and have at most two decimal places.

All expressed declaratively without a single line of imperative code. Custom messages provide clarity for API consumers, making error responses immediately actionable.

Creating Custom Validators

Built-in annotations cover common scenarios, but real-world APIs inevitably need domain-specific validation. Bean Validation’s extensibility model lets you create custom constraints that integrate seamlessly with the framework.

Anatomy of a Custom Validator:

Every custom validator consists of two components:

  • An annotation that declares the constraint’s metadata.
  • And a validator class that implements the checking logic.

Figure 2: Architecture showing the relationship between annotation, validator class, and Bean Validation Engine

Figure 2: Architecture showing the relationship between annotation, validator class, and Bean Validation Engine

Building @ValidIBAN: A Step-by-Step Tutorial:

Let us build a validator that verifies International Bank Account Numbers using the ISO 13616 MOD-97 algorithm. This is a non-trivial validation that demonstrates the full power of custom constraints.

Step 1: Define the annotation.

@Documented
@Constraint(validatedBy = IBANValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidIBAN {
    String message() default "Invalid IBAN format";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Three elements are mandatory for every constraint annotation: message() provides the default violation message groups() enables conditional validation (discussed later) payload() allows attaching metadata such as severity levels.

The @Constraint meta-annotation links this annotation to its validator implementation and @Target controls where the annotation may be placed.

Step 2: Implement the validator.

public class IBANValidator implements ConstraintValidator<ValidIBAN, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // Let @NotNull handle nullability
        }

        String iban = value.replace(" ", "").toUpperCase();

        if (iban.length() < 15 || iban.length() > 34) {
            return false;
        }

        if (!iban.matches("^[A-Z]{2}[0-9]{2}[A-Z0-9]+$")) {
            return false;
        }

        return performMod97Check(iban);
    }

    private boolean performMod97Check(String iban) {
        String rearranged = iban.substring(4) + iban.substring(0, 4);

        StringBuilder numericString = new StringBuilder();
        for (char c : rearranged.toCharArray()) {
            if (Character.isLetter(c)) {
                numericString.append(c - 'A' + 10);
            } else {
                numericString.append(c);
            }
        }

        BigInteger ibanNumber = new BigInteger(numericString.toString());
        return ibanNumber.mod(BigInteger.valueOf(97)).intValue() == 1;
    }
}

Key design decisions in this implementation:

  • Returning true for null values follows the Bean Validation convention that nullability should be controlled separately by @NotNull
  • Normalizing input (trimming spaces, uppercasing) makes the validator tolerant of formatting variations.
  • And the multi-stage approach (length check, format regex, then MOD-97) provides early exits for obviously invalid values before reaching the expensive BigInteger computation.

The payment API also includes custom validators for:

  • @ValidCurrency (ISO 4217 code verification against a known set)
  • @ValidCardNumber (Luhn algorithm)
  • @ValidBIC (SWIFT code format)
  • @ValidAmount (precision rules) — each following this same two-component pattern.

Nested Object Validation:

Real-world DTOs are rarely flat. Payment requests contain payment methods, which themselves contain card details or bank account information. Bean Validation’s @Valid annotation cascades validation into nested objects.

In classProcessPaymentRequest, the @Valid annotation on paymentMethod ensures that not only is the field non-null, but the PaymentMethodDto object itself is also validated:

@NotNull(message = "Payment method is required")
@Valid
private PaymentMethodDto paymentMethod;

@NotNull(message = "Billing address is required")
@Valid
private BillingAddressDto billingAddress;

The PaymentMethodDto carries its own validation annotations, including the custom validators:

public class PaymentMethodDto {

    @NotNull(message = "Payment method type is required")
    private PaymentMethodType type;

    @ValidCardNumber
    private String cardNumber;

    @Min(value = 1, message = "Expiry month must be between 1 and 12")
    @Max(value = 12, message = "Expiry month must be between 1 and 12")
    private Integer expiryMonth;

    @ValidIBAN
    private String iban;

    @ValidBIC
    private String bic;
}

When a nested field fails validation, the error path includes the full property path (e.g., paymentMethod.cardNumber), giving API consumers precise information about where the problem occurred.

Without @Valid, the nested object would be treated as an opaque reference and its internal constraints would never fire.

Cross-Field Validation:

Some business rules cannot be expressed on a single field. “Refund amount must not exceed the original transaction amount” requires comparing two fields against each other.

Bean Validation supports this through class-level constraint annotations.

Figure 3: Cross-field validation showing how a class-level validator accesses multiple fields

Figure 3: Cross-field validation showing how a class-level validator accesses multiple fields

Approach 1: Class-Level Validator (Recommended):

The class-level validator pattern places the constraint annotation on the class declaration itself, giving the validator access to the entire object:

@Documented
@Constraint(validatedBy = RefundValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidRefund {
    String message() default "Refund amount cannot exceed original transaction amount";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Note @Target({ElementType.TYPE})— this annotation applies to classes, not fields. The validator receives the entire RefundRequest object:

public class RefundValidator implements ConstraintValidator<ValidRefund, RefundRequest> {

    @Override
    public boolean isValid(RefundRequest request, ConstraintValidatorContext context) {
        if (request == null) {
            return true;
        }

        if (request.getRefundAmount() == null || request.getOriginalAmount() == null) {
            return true; // Let field-level @NotNull handle these
        }

        return request.getRefundAmount().compareTo(request.getOriginalAmount()) <= 0;
    }
}

The DTO then wears the annotation at the class level:

@ValidRefund
public class RefundRequest {
    @NotNull(message = "Refund amount is required")
    @Positive(message = "Refund amount must be positive")
    private BigDecimal refundAmount;

    @NotNull(message = "Original amount is required")
    @Positive(message = "Original amount must be positive")
    private BigDecimal originalAmount;
    // ...
}

Approach 2: @AssertTrue Helper Method:

For simpler cross-field checks, you can use an @AssertTrue — annotated method directly on the DTO

@AssertTrue(message = "Refund amount cannot exceed original amount")
private boolean isRefundAmountValid() {
    if (refundAmount == null || originalAmount == null) return true;
    return refundAmount.compareTo(originalAmount) <= 0;
}

The class-level validator approach is preferred for complex logic, reusability across DTOs and cleaner separation of concerns. The @AssertTrue approach works well for one-off checks that are simple enough to inline.

Validation Groups

Sometimes different API operations require different validation rules for the same DTO. Validation groups let you activate constraints selectively based on context.

Define marker interfaces representing each group:

public interface OnCreate {}
public interface OnUpdate {}

Then assign constraints to specific groups:

@NotNull(groups = OnCreate.class, message = "Amount required for new payments")
private BigDecimal amount;

@Null(groups = OnCreate.class, message = "Transaction ID must not be provided for new payments")
@NotNull(groups = OnUpdate.class, message = "Transaction ID required for updates")
private String transactionId;

In your controller, use @Validated (Spring's extension of @Valid) to specify which group to activate:

@PostMapping
public ResponseEntity<?> create(@Validated(OnCreate.class) @RequestBody PaymentRequest req) { ... }

@PutMapping("/{id}")
public ResponseEntity<?> update(@Validated(OnUpdate.class) @RequestBody PaymentRequest req) { ... }

Groups provide conditional validation without duplicating DTOs, though for significantly different operations, separate request classes often remain clearer.

Method-Level Validation

Bean Validation extends beyond request bodies to path variables, query parameters and even method return values. Spring activates this through the @Validated annotation at the class level.

@RestController
@RequestMapping("/api/payments")
@Validated
public class PaymentController {

    @GetMapping("/{transactionId}")
    public ResponseEntity<PaymentResponse> getPayment(
            @PathVariable
            @NotBlank(message = "Transaction ID is required")
            @Pattern(regexp = "^txn_[a-zA-Z0-9]+$", message = "Invalid transaction ID format")
            String transactionId) {
        PaymentResponse response = paymentService.getPaymentById(transactionId);
        return ResponseEntity.ok(response);
    }
}

The @Validated annotation on the class enables method-level validation via a Spring AOP proxy. Without it, @NotBlank and @Pattern on path variables would be silently ignored.

When method-level validation fails, Spring throws a ConstraintViolationException (not MethodArgumentNotValidException), which is why your exception handler must handle both exception types.

Exception Handling and Error Responses

A validation layer is only as useful as the error responses it produces. Two distinct exception types arise from Bean Validation in Spring:

  • MethodArgumentNotValidException: thrown when @Valid fails on a @RequestBody parameter.
  • ConstraintViolationException: thrown when method-level validation (path variables, query parameters) fails.

Figure 4: Exception handling flow showing how different validation failures are processed

Figure 4: Exception handling flow showing how different validation failures are processed

The GlobalExceptionHandler uses Spring's ProblemDetail class (RFC 7807 implementation) to produce structured error responses:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Validation failed"
        );

        Map<String, List<String>> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            String fieldName = error.getField();
            String errorMessage = error.getDefaultMessage();
            errors.computeIfAbsent(fieldName, k -> new ArrayList<>()).add(errorMessage);
        }

        problemDetail.setProperty("errors", errors);
        problemDetail.setProperty("timestamp", Instant.now());
        return problemDetail;
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Constraint violation"
        );

        Map<String, String> errors = new HashMap<>();
        for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
            errors.put(violation.getPropertyPath().toString(), violation.getMessage());
        }

        problemDetail.setProperty("errors", errors);
        problemDetail.setProperty("timestamp", Instant.now());
        return problemDetail;
    }
}

This produces a response like:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Validation failed",
  "errors": {
    "amount": ["Amount must be positive"],
    "currency": ["Invalid ISO 4217 currency code"]
  },
  "time{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Validation failed",
  "errors": {
    "amount": ["Amount must be positive"],
    "currency": ["Invalid ISO 4217 currency code"]
  },
  "timestamp": "2026-05-12T10:30:00Z"
}

Grouping errors by field name and supporting multiple messages per field means clients can display all relevant feedback simultaneously rather than forcing users to fix errors one at a time.

Best Practices and Pitfalls

Security Considerations

Validate enum values explicitly. If your API accepts a string that maps to an enum, validate it against the known set rather than relying on Jackson deserialization errors. Deserialization exceptions leak internal class names and produce inconsistent error formats.

Avoid exposing validation internals. Custom error messages should describe the constraint violation without revealing implementation details like table names, internal field names or algorithm specifics.

Performance Tips

Order validations from cheapest to most expensive. The validation engine evaluates constraints in declaration order. Place @NotNull and simple format checks before expensive validators that perform computations or network calls.

Avoid regex catastrophic backtracking. Complex regular expressions in @Pattern can be exploited for *ReDoS* attacks. Keep patterns simple and test them against pathological inputs.

Return true for null in custom validators. This is not just convention, it allows @NotNull to be composed independently. If your validator rejects null, it becomes impossible to make the field optional in other contexts.

Common Mistakes

Forgetting @Valid on nested objects. Without it, the nested object's constraints are invisible to the validation engine. This is the single most common Bean Validation bug.

Using @Validated instead of @Valid on method parameters. While Spring's @Validated enables group support, it does not trigger cascading validation on @RequestBody. For request body validation, use @Valid.

Missing @Validated on the controller class. Method-level validation (path variables, query params) requires the class-level @Validated annotation to activate Spring's validation proxy.

Production Checklist

  • ✅ All request DTOs have validation annotations
  • ✅ Custom validators return true for null values
  • GlobalExceptionHandler handles both MethodArgumentNotValidException and ConstraintViolationException
  • ✅ Error responses use RFC 7807 format with grouped field errors
  • ✅ Validation unit tests cover boundary cases
  • ✅ Integration tests verify the full request-to-error-response pipeline

Conclusion and Key Takeaways

Bean Validation in Spring Boot transforms input validation from scattered if-statements into a declarative, composable and testable layer. | Through this payment API, we have seen how built-in annotations handle common constraints, how custom validators encapsulate domain-specific rules like IBAN verification, how class-level annotations enable cross-field validation for business rules like refund limits and how a centralized exception handler produces consistent RFC 7807 error responses.

The key takeaways are:

  1. Declarative over imperative: Express rules as annotations on the data class itself, keeping controllers and services focused on business logic.
  2. Compose constraints: Stack annotations, use @Valid for cascading and create custom validators for domain rules that built-in annotations cannot express.
  3. Fail fast and fail clearly: Validate at the API boundary, group errors by field and provide actionable messages.
  4. Test at both levels: Unit test validators in isolation for logic correctness and integration test with MockMvc for wiring correctness.
  5. Defense in depth: Bean Validation is your first line of defense, but it complements rather than replaces database constraints, business rule checks, and security controls deeper in the stack.

👏 Found This Helpful? If this guide helped you understand Bean Validation better, give it a clap and share it with your team. Follow me for more deep dives into Spring Boot and API development.


메타데이터
post_id
ab7d446c156b
slug
build-robust-apis-with-declarative-validation-from-built-in-constraints-to-custom-validators-ab7d446c156b
url
https://medium.com/@myc5/build-robust-apis-with-declarative-validation-from-built-in-constraints-to-custom-validators-ab7d446c156b
canonical_url
https://medium.com/@myc5/build-robust-apis-with-declarative-validation-from-built-in-constraints-to-custom-validators-ab7d446c156b
author_url
https://medium.com/@myc5
status
ok
fetched_at
2026-07-10 16:32:07