In the world of software development, there are many scenarios where it is necessary to model and work with data that does not have its own identity and is not an entity in the strict sense. This data is information that characterizes an aspect of an object or event, and can be used in different contexts.
Value Object is one of the key design patterns that allows you to model and work with this kind of data. Value Objects are objects whose identity is determined by their values, not by an identifier. They are immutable and do not have their own life cycle, unlike entities that have a unique identifier and can change over time.
In the context of developing applications built on Spring and Hibernate, using Value Objects has many advantages. They make the code more understandable, make maintenance easier, and help improve performance. For example, Value Objects can be used to represent date and time, addresses, geographic coordinates, currency, and other attributes that can be generalized as data that does not have its own identity.
The purpose of this article is to consider the application of the Value Object pattern in the context of applications built using Spring and Hibernate. We'll start by explaining how to create Value Objects in Java, and then look at how they can be integrated into Hibernate for storage in a database. We will also look at methods for transferring and using Value Objects in Spring applications and provide examples and tips on how to use them effectively.
Let's start by learning how to model Value Objects in Java and why it's important for your application.
Value Object is an immutable class that wraps a simple value (int, string, bool...) or other Value Objects, while validating input values.
You cannot create an instance of Value Object with an invalid input value.
A classic example of a Value Object is a phone number.
var phoneNumberRaw = "+78005553535";
var phoneNumber = new PhoneNumber("+78005553535");
In the first case, we store the phone number as a String. In the second case, we have a PhoneNumber class, which has some kind of validation.
@Value
public class PhoneNumber {
private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();
String value;
public PhoneNumber(String value) {
this.value = validateAndNormalizePhoneNumber(value);
}
private static String validateAndNormalizePhoneNumber(String value) {
try {
if (Long.parseLong(value) <= 0) {
throw new PhoneNumberParsingException("The phone number cannot be negative: " + value);
}
final var phoneNumber = PHONE_NUMBER_UTIL.parse(value, "RU");
final String formattedPhoneNumber = PHONE_NUMBER_UTIL.format(phoneNumber, E164);
// return the phone in the format 78005553535
return formattedPhoneNumber.substring(1);
} catch (NumberParseException | NumberFormatException e) {
throw new PhoneNumberParsingException("The phone number isn't valid: " + value, e);
}
}
}
Normalization of the value of the Value Object
Not all Value Objects normalize values, in our example, all three phone numbers are the same phone number in terms of value:
var set = new HashSet<PhoneNumber>();
set.add(new PhoneNumber("+78005553535"));
set.add(new PhoneNumber("78005553535"));
set.add(new PhoneNumber("88005553535"));
assertEquals(1, set.size());
That is, when saving a Value Object, its value is normalized and subsequently compared with other Value Object objects by value.
public static User newUser(String phoneNumber,
String passportNumber) { … }
In this example, you can pass obviously incorrect data to the user creation function and subsequently get a lot of problems. If Value Object is used as parameters, then guaranteed validated data is transmitted.
public static User newUser(PhoneNumber phoneNumber,
PassportNumber passportNumber) { … }
@Entity
public class User {
@Id
private UUID id;
@Column(name = "phone_number")
@Convert(converter = PhoneNumberConverter.class)
private PhoneNumber phoneNumber;
}
@Converter
public class PhoneNumberConverter implements AttributeConverter<PhoneNumber, String>{
public String convertToDatabaseColumn(PhoneNumber attribute) {
return attribute.getValue();
}
public PhoneNumber convertToEntityAttribute(String dbData) {
return new PhoneNumber(dbData);
}
}
For example, we validate the order name according to the following rules:
The Entity code will look like this:
@Value
public class OrderName {
String value;
public OrderName(String value) {
this.value = validateOrderName(value);
}
private static String validateOrderName(String value) {
…
}
}
@Entity
public class Order {
…
@Convert(converter = OrderNameConverter.class)
private OrderName name;
}
Repository Code:
public interface OrderRepository extends JpaRepository<Order, UUID>{
List<Order> findByNameLike(String name);
}
As a result, we get an error:
Unexpected exception thrown: org.springframework.dao.InvalidDataAccessApiUsageException: Argument [name%] of type [java.lang.String] did not match parameter type [com.example.domain.OrderName (n/a)]
The problem is that for Order.name you can't search by %. The problem is solved using native query:
@Query(
value = "SELECT * FROM order WHERE name LIKE :name",
nativeQuery = true
)
List<Order> findByNameLike(String name);
The problem can be solved by updating the database of old data, but
The solution is apply Value Object only when inserting/changing data.
The advantages of this approach:
New validation/normalization rules will not break compatibility with existing data
The rules are still encapsulated in one place, they are easy to change
If we pass a Value Object somewhere, we are sure of the validity of the values based on the compiled requirements
Like-the request is working as expected
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByNameLike(String name);
}