DEV Community

Semyon Kirekov
Semyon Kirekov

Posted on

Spring Boot — Power of Value Objects

In this post, I’m telling you:

  1. What are value objects and why are they so crucial?
  2. 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
);


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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:

  1. Value object has to be immutable.
  2. Value object should be comparable (i.e. implements equals/hashCode).
  3. 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;
    }
}


Enter fullscreen mode Exit fullscreen mode

The @Value Lombok annotation generates equals, hashCode, toString methods, getters and defines all fields as private 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);
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}


Enter fullscreen mode Exit fullscreen mode

And here is the execution result.

Tests 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;
}


Enter fullscreen mode Exit fullscreen mode

What are the benefits of value objects in comparison to raw types using? Here they are:

  1. If you receive the PhoneNumber instance, then you definitely know it is valid and you don’t need to repeat validations.
  2. Fail-fast pattern. If the phone number is invalid, you quickly get an exception.
  3. 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.
  4. If you’re a Hibernate user, then JPQL queries will return the PhoneNumber value object but not just context-less String attribute.
  5. 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) { ... }


Enter fullscreen mode Exit fullscreen mode

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());


Enter fullscreen mode Exit fullscreen mode

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) { ... }


Enter fullscreen mode Exit fullscreen mode

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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) {...}
}


Enter fullscreen mode Exit fullscreen mode

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:

  1. Creating the new User with the given PhoneNumber.
  2. Searching the existing User by the provided PhoneNumber.

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) {
    }
}


Enter fullscreen mode Exit fullscreen mode

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();
}


Enter fullscreen mode Exit fullscreen mode

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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) {
    }
}


Enter fullscreen mode Exit fullscreen mode

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

  1. The repository with code examples
  2. Value object definition
  3. @Value Lombok annotation
  4. Google Libphonenumber library
  5. JPA AttributeConverter
  6. JPQL
  7. Database foreign key definition
  8. My proposal to implement typed sequence IDs in Hibernate types project
  9. Jackson library
  10. Secure by Design book
  11. TSID library

Top comments (2)

Collapse
 
devdufutur profile image
Rudy Nappée

In DDD or hexagonal architecture, your domain shouldn't be tainted with JPA annotation nor any technical code this is infrastructure code.

Otherwise, phone number should be stored as string, not longs, "0123456789" is not equal to "123456789".

Collapse
 
kirekov profile image
Semyon Kirekov

@devdufutur

Otherwise, phone number should be stored as string, not longs, "0123456789" is not equal to "123456789".

Thank you very much for pointing this out. That's a valid case. I updated the article to store the phone_number value as a varchar column.

In DDD or hexagonal architecture, your domain shouldn't be tainted with JPA annotation nor any technical code this is infrastructure code.

Agreed. The trully DDD approach dictates that domain objects should be decoupled from the persistence implementation. However, my article is not about pure DDD usage but about value objects as a concept. There are plenty of programmers that use Hibernate and don't apply DDD patterns at all. Even so, implementing value objects will be helpful to decrease code complexity and increase the security bar.