Spring Boot — Power of Value Objects
Semyon Kirekov
Posted on December 4, 2022
In this post, I’m telling you:
- What are value objects and why are they so crucial?
- How you can apply those patterns in your Spring Boot controllers to make code safer and easier to maintain.
I took code examples from this repository. You can clone it to see the entire project in action.
Domain
The domain is rather simple. We have just a single entity. Anyway, you'll see that even one class may produce ambiguity and data corruption.
I’m using Hibernate as a persistence framework. Therefore, the domain entity
User
is also a Hibernate entity. But the ideas I’m proposing to you remain the same even if you don’t use the Hibernate at all.
At first, let’s start with the database schema. There is one table. So, it won’t be difficult.
Supposing we want to store the user’s phone number. Here is the SQL table definition:
CREATE TABLE users
(
id UUID PRIMARY KEY,
phone_number VARCHAR(200) NOT NULL UNIQUE
);
The types are straight-forward. Now let's define the corresponding Hibernate entity. Look at the code example below.
@Entity
@Table(name = "users")
@Getter
public class User {
@Id
private UUID id;
@Column(name = "phone_number")
private String phoneNumber;
}
Looks good, doesn’t it? Phone number is String
type. The fields map to the database columns directly. What can go wrong? As you’ll see soon, lots of things.
Phone number integrity issues
Is every possible String
value a valid phone number? Of course, not. And that’s the problem. A user can put 0
, a negative value, or even some-unknown-value-string
as a phone number.
Imagine that during the registration process, some user set their phone number to -78005553535
. Obviously, there is a typo and there should be the +
sign instead of the -
one. Anyway, the user hasn’t noticed the mistake and applied the settings. Later, another user wants to find the previous folk and send an invitation to the group. He or she knows only the phone number. And suddenly the +78005553535
search returns no result. Though the query input is absolutely correct. Now imagine that your application serves thousands of people. Even if 1% percent make a mistake in their phone number, the fixing values in the database will be tedious.
How you can overcome the issue? The answer is value object. The idea is simple:
- Value object has to be immutable.
- Value object should be comparable (i.e. implements
equals/hashCode
). - Value object guarantees that it always holds the correct value.
Look at the the first attempt of PhoneNumber
declaration below.
@Value
public class PhoneNumber {
String value;
public PhoneNumber(String value) {
this.value = value;
}
}
The
@Value
Lombok annotation generatesequals
,hashCode
,toString
methods, getters and defines all fields asprivate final
.
The PhoneNumber
class solves the first and the second requirement that we’ve defined. However, you can still construct the class with invalid phone number (e.g. 0
, -123
, abc
). Meaning we should proceed with the validation process inside the constructor. Look at the fixed code snippet below.
@Value
public class PhoneNumber {
private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();
String value;
public PhoneNumber(String value) {
this.value = value;
validatePhoneNumber(value);
}
private static void validatePhoneNumber(String value) {
try {
if (Long.parseLong(value) <= 0) {
throw new PhoneNumberParsingException("The phone number must be positive: " + value);
}
PHONE_NUMBER_UTIL.parse(String.valueOf(value), "RU");
} catch (NumberParseException | NumberFormatException e) {
throw new PhoneNumberParsingException("The phone number isn't valid: " + value, e);
}
}
}
I'm using Google Libphonenumber library to validate the input.
Seems like we solved the problem. We always validate the value during the object construction. However, a slight detail remains untouched. And it’s called data normalization.
If user A
sets their phone number to 88005553535
, the user B
won’t find him or her typing +78005553535
value in the search bar. Though people in Russia treat these phone numbers as equal.
It's a valid scenario for local calls only. Anyway, some users can make assumptions based on that.
As a matter of fact, we should always transform valid input values that are equal from the business perspective to the same output result to eliminate possible ambiguities. Look at the final PhoneNumber
class declaration below.
@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);
// E164 format returns phone number with + character
return formattedPhoneNumber.substring(1);
} catch (NumberParseException | NumberFormatException e) {
throw new PhoneNumberParsingException("The phone number isn't valid: " + value, e);
}
}
}
Let's also write some unit tests to verify the behavior.
class PhoneNumberTest {
@ParameterizedTest
@CsvSource({
"78005553535,78005553535",
"88005553535,78005553535",
})
void shouldParsePhoneNumbersSuccessfully(String input, String expectedOutput) {
final var phoneNumber = assertDoesNotThrow(
() -> new PhoneNumber(input)
);
assertEquals(expectedOutput, phoneNumber.getValue());
}
@ParameterizedTest
@ValueSource(strings = {
"0", "-1", "-56"
})
void shouldThrowExceptionIfPhoneNumberIsNotValid(String input) {
assertThrows(
PhoneNumberParsingException.class,
() -> new PhoneNumber(input)
);
}
}
And here is the execution result.
The last step is putting the value object to the User
Hibernate entity. In this situation, the AttributeConverter comes in handy. Look at the code block below.
@Converter
public class PhoneNumberConverter implements AttributeConverter<PhoneNumber, String> {
@Override
public String convertToDatabaseColumn(PhoneNumber attribute) {
return attribute.getValue();
}
@Override
public PhoneNumber convertToEntityAttribute(String dbData) {
return new PhoneNumber(dbData);
}
}
@Entity
@Table(name = "users")
@Getter
public class User {
@Id
private UUID id;
@Column(name = "phone_number")
@Convert(converter = PhoneNumberConverter.class)
@NotNull
private PhoneNumber phoneNumber;
}
What are the benefits of value objects in comparison to raw types using? Here they are:
- If you receive the
PhoneNumber
instance, then you definitely know it is valid and you don’t need to repeat validations. - Fail-fast pattern. If the phone number is invalid, you quickly get an exception.
- The code is more secure. If you don’t use raw types as business values at all, you guarantee that all input values have passed the defined checks.
- If you’re a Hibernate user, then JPQL queries will return the
PhoneNumber
value object but not just context-lessString
attribute. - You encapsulate all the checks in one class. If you have to adjust them according to the new business requirements, you should only do it in one place.
Typed entities' IDs
You will probably have more than a single entity in your project. And there is a high chance that most of them will share the same type of the ID (in this case, the UUID
type).
What's the problem with that? Supposing you have a service that assigns the User
to some UserGroup
. And it accepts two IDs as input parameters. Look at the code example below.
public void assignUserToGroup(UUID userId, UUID userGroupId) { ... }
I bet you’ve seen dozens of similar snippets. Anyway, let’s assume that somebody has written this line of code.
assignUserToGroup(userGroup.getId(), user.getId());
Can you spot a bug in here? We accidentally swapped the IDs. If you made such a mistake, you’d be lucky if you get foreign key violation on statement execution. But if you don’t have foreign keys on the table or the assignment has proceeded successfully and the business operation result is incorrect, then you’re in a big trouble.
I'm putting a slight change to the assignUserToGroup
method declaration. Look at the fixed option below.
public void assignUserToGroup(User.ID userId, UserGroup.ID userGroupId) { ... }
Now the swapping IDs bug is impossible. Because it would lead to compile time error. What’s better is that you can easily implement the approach to the Hibernate entity.
@Entity
@Table(name = "users")
@Getter
public class User {
@EmbeddedId
private User.ID id;
@Column(name = "phone_number")
@Convert(converter = PhoneNumberConverter.class)
@NotNull
private PhoneNumber phoneNumber;
@Data
@Setter(PRIVATE)
@Embeddable
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
public static class ID implements Serializable {
@Column(updatable = false)
@NotNull
private UUID id;
}
}
All the findBy
Spring Data queries, all the custom JPQL statements work with User.ID
but not the raw UUID
type. Besides, it also helps with method overloading. If you’re a raw IDs user and you need the same method accepting distinct entities’ IDs, then you have to name them differently. But with typed IDs, it’s not the case. Look at the example below.
public class RoleOperations {
// doesn't compile
public boolean hasAnyRole(UUID userId, Role... role) {...}
public boolean hasAnyRole(UUID userGroupId, Role... role) {...}
}
public class RoleOperations {
// compiles successfully
public boolean hasAnyRole(User.ID userId, Role... role) {...}
public boolean hasAnyRole(UserGroup.ID userGroupId, Role... role) {...}
}
Unfortunately, you cannot smoothly wrap the sequence-based IDs with value objects. There are solutions, but they are rather cumbersome. I left the proposal to the Hibernate types project of adding support to this feature. You’ve already seen the benefits of user-defined types. So, you can rate up my issue to make it more popular. However, you can still generate number IDs on the client side. For example, the TSID library does the job.
REST endpoints parameters
I’ve pointed out that using value objects in all application levels reduces code complexity and makes it safer. However, when it comes to REST endpoints, things aren’t that simple. Assuming we need two operations:
- Creating the new
User
with the givenPhoneNumber
. - Searching the existing
User
by the providedPhoneNumber
.
Look at the possible implementation below.
@RestController
class UserController {
@PostMapping("/api/user")
void createUser(@RequestParam String phoneNumber) {
...
}
@GetMapping("/api/user")
UserResponse getUserByPhoneNumber(@RequestParam String phoneNumber) {
...
}
record UserResponse(UUID id, String phoneNumber) {
}
}
As you can see, we came back again to the raw types usage (i.e. UUID
as the id and String
as the phone number). If we simply replace the @RequestParam
type to PhoneNumber
, we’ll get an exception in runtime. The UserResponse
serialization won’t probably produce any errors, but the client will receive the data in the unexpected format. Because Jackson (the default serialization library in Spring Boot) don’t know how to handle custom types.
Thankfully, there is a solution. Firstly, let's define a SerdeProvider
interface.
public interface SerdeProvider<T> {
JsonDeserializer<T> getJsonDeserializer();
JsonSerializer<T> getJsonSerializer();
Formatter<T> getTypedFieldFormatter();
Class<T> getType();
}
Then we need two implementations registered as Spring beans. The PhoneNumberSerdeProvider
and the UserIdSerdeProvider
. Look at the declaration below.
@Component
class PhoneNumberSerdeProvider implements SerdeProvider<PhoneNumber> {
@Override
public JsonDeserializer<PhoneNumber> getJsonDeserializer() {
return new JsonDeserializer<>() {
@Override
public PhoneNumber deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
final var value = p.getValueAsString();
if (value == null) {
return null;
}
return new PhoneNumber(value);
}
};
}
@Override
public JsonSerializer<PhoneNumber> getJsonSerializer() {
return new JsonSerializer<>() {
@Override
public void serialize(PhoneNumber value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
} else {
gen.writeString(value.getValue());
}
}
};
}
@Override
public Formatter<PhoneNumber> getTypedFieldFormatter() {
return new Formatter<>() {
@Override
public PhoneNumber parse(String text, Locale locale) {
return new PhoneNumber(text);
}
@Override
public String print(PhoneNumber object, Locale locale) {
return object.getValue();
}
};
}
@Override
public Class<PhoneNumber> getType() {
return PhoneNumber.class;
}
}
The
UserIdSerdeProvider
implementation is similar. You can find the source code in the repository.
And now we just need to register those custom providers to the ObjectMapper
instance. Look at the Spring @Configuration
below.
@Slf4j
@Configuration
@RequiredArgsConstructor
@SuppressWarnings({"unchecked", "rawtypes"})
class WebMvcConfig implements WebMvcConfigurer {
private final List<SerdeProvider<?>> serdeProviders;
@Override
public void addFormatters(FormatterRegistry registry) {
for (SerdeProvider<?> provider : serdeProviders) {
log.info("Add custom formatter for field type '{}'", provider.getType());
registry.addFormatterForFieldType(provider.getType(), provider.getTypedFieldFormatter());
}
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule())
.registerModule(customSerDeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
public com.fasterxml.jackson.databind.Module customSerDeModule() {
final var module = new SimpleModule("Custom SerDe module");
for (SerdeProvider provider : serdeProviders) {
log.info("Add custom serde for type '{}'", provider.getType());
module.addSerializer(provider.getType(), provider.getJsonSerializer());
module.addDeserializer(provider.getType(), provider.getJsonDeserializer());
}
return module;
}
}
Afterwards, we can refactor the initial REST controller. Look at the final version below.
@RestController
class UserController {
@PostMapping("/api/user")
void createUser(@RequestParam PhoneNumber phoneNumber) {
...
}
@GetMapping("/api/user")
UserResponse getUserByPhoneNumber(@RequestParam PhoneNumber phoneNumber) {
...
}
record UserResponse(User.ID id, PhoneNumber phoneNumber) {
}
}
As a result, we don’t have to deal with raw types in our application anymore. The framework converts the input values to the corresponding value objects and vice versa. Therefore, Spring automatically validates the value objects during their creation. So, if the input is invalid, you get the exception as early as possible without touching business logic at all. Amazing!
Conclusion
Value objects are extremely powerful. On the one hand, it makes codes easier to maintain and helps to read it almost like a plain English text. Also, it also makes it safer because you always validate the input on a value object instantiation. I can also recommend you to read this brilliant book about value objects and domain primitives. I got an inspiration for the article by reading this piece.
If you have any questions or suggestions, leave your comments down below. Thanks for reading!
Resources
Posted on December 4, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.