The tools of validation: Bean validation and custom constraints
In the first post of this series, we covered the cost of scattered validation logic. In the second post, we defined the three layers of validation: Boundary, domain, and persistence.
Now in this third post, we’ll dive into the tools that make each layer work: Jakarta Bean Validation, Spring’s validation support, and custom constraints.
Jakarta bean validation: the foundation #
Jakarta Bean Validation (JSR 380) provides a standardized way to validate objects using annotations. It’s the backbone of validation in Spring applications.
Below is a table of the constraints used in this post. The full list can be found here:
| Annotation | Purpose | Example |
|---|---|---|
@NotNull |
Field must not be null | @NotNull Integer age |
@NotBlank |
String must not be null, empty, or whitespace | @NotBlank String name |
@Size |
String/Collection/Map size constraints | @Size(min = 3, max = 100) String name |
@Min / @Max |
Numeric range constraints | @Min(1) @Max(100) Integer age |
@Email |
Email format validation | @Email String email |
@Pattern |
Regex pattern matching | @Pattern(regexp = "^[A-Z]{2}[0-9]{6}$") String code |
The @Valid annotation
#
The @Valid annotation triggers cascading validation on nested objects:
public record CreateOrderRequest(
@NotBlank String customerId,
@Valid // Triggers validation on nested object, meaning it will run validation on every LineItemRequest object
List<LineItemRequest> items
) {
}
public record LineItemRequest(
@NotBlank String productId,
@Min(1) Integer quantity
) {
}
Method validation with @Validated
#
While @Valid is for object graph validation, Spring’s @Validated annotation enables method-level validation:
@RestController
@RequestMapping("/orders")
@Validated // Enables method validation
public class OrderController {
@GetMapping("/{objectId}")
public OrderResponse getByObjectId(
@PathVariable @NotBlank @Size(min = 3, max = 40) String objectId
) {
// objectId is validated before the method executes
return orderService.getByObjectId(objectId);
}
}
Key distinction:
@Validis fromjakarta.validationand works on object graphs@Validatedis from Spring and enables method parameter validation
In this case the @Validated annotation will enable the Jakarta bean validation. Without it, those annotations
(@NotBlank, @Size) would be completely ignored.
Custom constraints: When the built-ins aren’t enough #
Sometimes the built-in constraints aren’t enough. This is where custom constraints shine.
Custom constraint example: A Swedish personal number validator #
In Sweden, we have something called a personal identity number. The example below covers how to write both a custom annotation you can use and a validator to actually validate the input.
First, create the custom annotation:
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalNumberValidator.class)
public @interface SwedishPersonalNumber {
String message() default "Invalid Swedish personal number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Then create the validator:
// The implementation below is naive and ignores quite a few rules that would be required for a full-scale production-ready implementation.
public class SwedishPersonalNumberValidator implements ConstraintValidator<SwedishPersonalNumber, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
// Let @NotBlank handle required fields
return true;
}
String normalized = value
.replace("-", "")
.replace("+", "");
if (normalized.length() == 12) {
normalized = normalized.substring(2);
}
if (!normalized.matches("\\d{10}")) {
return false;
}
if (!isValidDate(normalized.substring(0, 6))) {
return false;
}
return isValidChecksum(normalized.substring(0, 9), normalized.substring(9));
}
private static boolean isValidDate(String date) {
try {
LocalDate.parse(date, DateTimeFormatter.ofPattern("yyMMdd"));
return true;
} catch (DateTimeParseException _) {
return false;
}
}
private static boolean isValidChecksum(String pin, String checksum) {
int sum = 0;
for (int i = 0; i < pin.length(); i++) {
int digit = Character.getNumericValue(pin.charAt(i));
int temp = digit * (i % 2 == 0 ? 2 : 1);
if (temp > 9) {
temp -= 9;
}
sum += temp;
}
return (10 - (sum % 10)) % 10 == Integer.parseInt(checksum);
}
}
Now you can use it in your DTO:
public record CreateCustomerRequest(
@NotBlank String name,
@NotBlank
@SwedishPersonalNumber
String personalNumber,
@Email
String email
) {
}
Custom constraint example: Configuration validation #
Sometimes you need to validate configuration properties. Here’s how you validate a message queue configuration:
@Validated
@QueueConfig
@ConfigurationProperties(prefix = "app.mq")
public record MqConfig(
boolean enabled,
String manager,
String channel,
@NotBlank String name,
@NotBlank String host,
@Min(1025) @Max(65535) Integer port,
@NotBlank String user,
@NotBlank String password,
@Min(1) @Max(900) Integer batchSize,
@NotBlank String encoding
) {
}
The custom annotation:
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = QueueConfigValidator.class)
public @interface QueueConfig {
String message() default "Queue configuration is invalid";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
And the validator:
public class QueueConfigValidator implements ConstraintValidator<QueueConfig, MqConfig> {
@Override
public boolean isValid(MqConfig config, ConstraintValidatorContext context) {
if (!config.enabled()) {
return true; // We do not need a valid configuration unless queues are enabled
}
boolean isValid = true;
if (config.manager() == null || config.manager().isEmpty()) {
isValid = false;
context
.disableDefaultConstraintViolation()
.buildConstraintViolationWithTemplate("Manager is required when queue is enabled")
.addPropertyNode("manager")
.addConstraintViolation();
}
if (config.channel() == null || config.channel().isEmpty()) {
isValid = false;
context
.disableDefaultConstraintViolation()
.buildConstraintViolationWithTemplate("Channel is required when queue is enabled")
.addPropertyNode("channel")
.addConstraintViolation();
}
return isValid;
}
}
If you run the application with a configuration that has app.mq.enabled set to true, all other required values set
to something sensible, but leave the fields app.mq.name and app.mq.manager empty, the following output would be
produced during startup:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target org.example.java.configurations.MqConfig failed:
Reason: Queue configuration is invalid
Property: app.mq.name
Value: ""
Reason: must not be blank
Property: app.mq.manager
Value: ""
Reason: Manager is required when queue is enabled
Action:
Update your application's configuration
Domain layer validation: Business invariants #
While Bean Validation is great for checking data formats (boundary validation), the domain layer needs validation of business rules and invariants. This is where you ensure your core business objects stay valid.
In the domain layer, we move away from @NotNull and @Size. Here we resort to good old if statements — and this is
finally where the weight problem from the first two posts in this series gets a permanent home. Recall the DbHandler
defaulting null or zero weight to 1, and the mapper trap that tried to do the same thing in the wrong place. Here’s
what that rule looks like once it lives where it belongs:
public record Weight(BigDecimal value) {
private static final BigDecimal MINIMUM = BigDecimal.ONE;
public Weight {
if (value == null || value.signum() == 0) {
value = MINIMUM; // business default: absent/zero weight means "not yet measured"
} else if (value.signum() < 0) {
throw new IllegalArgumentException("Weight cannot be negative");
}
}
}
public record Shipment(String id, Weight weight) {
public Shipment {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Shipment ID is required");
}
if (weight == null) {
throw new IllegalArgumentException("Weight is required");
}
}
}
Why validate in the domain?
- Invariant Enforcement: Domain validation ensures that no object in your system can ever exist in an invalid state.
- Business Logic: It captures rules that might change frequently without touching your DTOs or controllers.
- Testability: Pure Java validation is easy to unit test without Spring dependencies.
- Not every invariant is a rejection:
Weightshows a business default (null/zero → minimum) living safely in the domain, rather than scattered across aDbHandleror a mapper.Shipment, by contrast, rejects invalid input outright — both are domain rules, they just enforce differently.
Persistence layer validation: Database constraints #
The persistence layer is the final gatekeeper. Even with robust application-level validation, you must protect your database integrity.
JPA/Hibernate annotations provide a direct mapping to database constraints:
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotNull // Bean Validation
@Column(nullable = false, unique = true) // DB Constraint
private String email;
@Column(nullable = false) // DB Constraint
private LocalDate birthDate;
// Getters...
}
Key Differences Between Layers
| Layer | Primary Concern | Typical Tools | Error Handling |
|---|---|---|---|
| Boundary | Request/Response Format | @Valid, @NotBlank |
HTTP 400 Bad Request |
| Domain | Business Invariants | Java Logic, Records | Application Exceptions |
| Persistence | Data Integrity | JPA, DB Constraints | Transaction Rollback |
Why duplicate validation? Validation is redundant by design. If your application validation fails, the user gets a clear error immediately. If your database validation fails (e.g., concurrent updates bypassing application logic), the transaction rolls back safely.
Key takeaways #
- Jakarta bean validation: the foundation of DTO validation, with built-in constraints such as
@NotBlankand@Size. @Validtriggers cascading validation on nested objects, while@Validatedenables method-level validation.- Custom constraints are essential for domain-specific validation like Swedish personal numbers or cross-field validation.
- Domain layer validation uses plain constructor checks rather than annotations, keeping invariants enforceable and testable without a framework.
What’s Next #
In the next article, we’ll cover the other half of the toolbox: testing strategies for each layer, structured API error responses, and how to organize validation code in a real project. Together, these close the loop between “how do I write a constraint” and “how do I know it actually works.”