Spring boot : A domain driven data validation

Moroccan software developer, Java/Spring. Love to learn, eager to write.
Introduction
In modern software development, data validation is important for keeping data correct and stopping incorrect input from entering a system. Spring Boot offers different ways to do data validation. However, with Java 14's new records feature, we can use a more design-focused way to handle data validation.
This article will explore how Java records can simplify and streamline the data validation process in Spring Boot. We'll adopt a design-driven mindset where each field that requires validation becomes a separate record. Through this modular approach, data becomes cleaner, validation becomes more maintainable, and the intent behind each DTO (Data Transfer Object) becomes more expressive.
Why Use Records for DTOs in Spring Boot?
1. Immutability by Default
Records in Java are immutable, which means their fields can't be changed once they are set. This makes them perfect for DTOs, as they usually move data between different parts of an application without needing changes. Also, I believe that user data entering the application should be kept as is and never altered!
2. Concise Syntax
With records, the boilerplate code for getters, constructors, equals, hashCode, and toString methods is eliminated. This helps reduce the size of DTO classes and makes them more readable and maintainable.
3. Encapsulation with Compact Constructors
The compact constructor in records allows developers to embed validation logic directly within the record's creation process. This ensures that invalid data is immediately rejected, serving as the first line of defense against faulty input. Since records lack a default constructor, the canonical constructor becomes the sole entry point for creating instances, making it the ideal place to enforce these checks.
4. Aligning with Domain-Driven Design (DDD)
Using records for individual fields that require validation promotes a design-driven approach where each field becomes a self-contained component. This aligns with domain-driven design (DDD) principles by enforcing clear, domain-relevant rules directly where the data is defined.
Creating Records as DTOs in Spring Boot
As mentioned before, we'll treat each field/group of fields that needs validation as its own record. This record will include not just the value but also the rules and limits for that value. We will also make use of the record’s compact constructor to add the validation logic.
Below is a step-by-step example to illustrate how this can be implemented.
Step 1: Defining a Record for Field Validation
Suppose we have a User DTO that contains fields such as username, age, and email. Each of these fields will become a separate record, containing its value and validation logic.
Let’s start by defining a Username record:
public record Username(@JsonProperty("value") String value) {
public Username {
if(value.isBlank()){
throw new IllegalArgumentException("Username should not be empty");
}
}
}
Here’s what’s happening:
- Compact Constructor: Adds additional validation to enforce that the username should not be empty nor null.
Similarly, let’s define an Age record:
javaCopy codeimport jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
public record Age(@JsonProperty("value") int value) {
public Age {
if (value < 18) {
throw new IllegalArgumentException("Age must be greater than 18.");
}
}
}
In this example:
- Compact Constructor: Throws an exception if the age is outside the specified range.
Let’s add an Email record with a regex-based custom validation inside the compact constructor:
public record Email(@JsonProperty("value") String value) {
public Email {
if (!value.matches("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$")) {
throw new IllegalArgumentException("Invalid email format.");
}
}
}
This Email record ensures that the value conforms to a basic email pattern.
Step 2: Combining Fields into a DTO Record
Now, let’s create a User record that uses these field-specific records:
public record User(Username username, Age age, Email email) {}
This User record serves as the DTO for the entire user object, made up of individual, validated records. The benefit is that each field is validated when created, ensuring only valid data is included in the User record.
Step 3: Using the DTO in Controller Classes
Now that we have a fully validated User DTO, let’s see how it can be used in a Spring Boot controller:
@RestController
public class UserController {
@PostMapping("/users")
public String createUser(@RequestBody User user) {
// User is guaranteed to be valid at this point
return "User created successfully: " + user.username().value();
}
}
Here:
@RequestBody: Automatically maps the incoming JSON payload to theUserrecord, triggering all field validations.
A more business-oriented validation
Let’s make our own Spring validator
Let's say now that the user has another business constraint: the username must be unique. It's clear that we cannot validate this at the record level since the record is a simple class and has no access to the user repository (no, we are not going to get the bean statically from the Spring context and plug it to the record class 🙂).
Below is a step-by-step example to illustrate how this can be implemented.
Step 1: Let’s create the “UniqueUsername” annotation
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {
String message() default "Username already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Step 2: Let’s create the UniqueUsernameValidator class
Now let’s implement our validator. For this, we are going to be leveraging the Spring validation module (make sure you add it to your dependency manager). To do so, we are going to implement the ConstraintValidator interface.
NB: Each field present in this annotationwill be further discussed and explained in future articles
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
return !userRepository.existsByUsername(username);
}
}
Step 3: Update the existing code
Now let’s make use of our newly created validator.
public record Username(@UniqueUsername @JsonProperty("value") String value) {
public Username {
if(value.isBlank()){
throw new IllegalArgumentException("Username should not be empty");
}
}
}
public record User(@Valid Username username, Age age, Email email) {}
@RestController
public class UserController {
@PostMapping("/users")
public String createUser(@Valid @RequestBody User user) {
// User will be valid at this point
return "User created successfully: " + user.username().value();
}
}
We use @Valid to enable Spring validation on the object. This annotation also applies to nested objects if their fields use Spring validation, as seen with Username.
In summary, our objects can now validate their own state, either independently or by using Spring validation.
Benefits of Design-Driven Validation with Records
1. Modular Design
Each field is represented as a distinct record, encapsulating its value and validation logic. This modular design makes it easier to reuse validation logic across different DTOs.
2. Early Error Detection
Validation occurs immediately when the records are constructed, ensuring that invalid data is caught as early as possible.
3. Cleaner Code
Using records reduces the boilerplate code in your DTOs, resulting in cleaner, more maintainable code. The use of compact constructors further reduces clutter by consolidating validation logic at the point of data creation.
4. Better Alignment with Business Logic
By encapsulating validation logic within records, the intent behind each validation rule becomes clearer. This aligns with the principles of domain-driven design, where the code reflects the business logic and domain rules.
Drawbacks
1. Potential Performance Overhead
Creating a separate record for every individual field introduces more object allocations and might impact performance.
2. Reduced Readability with Deep Nesting
If each field becomes its own record, the overall DTO can become deeply nested and harder to read. (A better way would be to use a static constructor/ builder 😋)
User user = new User(
new Username("alice"),
new Age(25),
new Email("alice@example.com")
);
3. Less intuitive json structure
Since each field/group of fields that need validation are record we drift away from the typical json structure to a more nested one.
The usual JSON we send to create a user is something like this :
{
"username" : "falcon",
"age" : 25,
"email" : "falcon@gmail.com"
}
With this new approach, we will need to make some adjustments to the JSON.
{
"Username": {
"value": "falcon"
},
"Age": {
"value": 25
},
"email": {
"value": "falcon@gmail.com"
},
}
Conclusion
Using records as DTOs in Spring Boot provides a design-focused method for data validation, where each field/ group of fields is a self-contained unit with its own validation rules. This not only makes validation simpler but also improves code readability and maintainability. By using the immutability and compact constructors of records, developers can ensure data integrity right from the start.
As software development evolves, using modern language features like records helps us write clearer, more maintainable, and error-resistant code. In a Spring Boot application, using records as DTOs for validation can result in cleaner and more reliable designs that meet both technical and business needs.





