Wim Deblauwe

Value Objects with Spring Boot REST API

February 26, 2020 · 4 min read spring-boot

In Using primary key objects with Spring Data and Hibernate, I explained how to use Value Objects for interaction with the database. This time, I will focus on how to do something similar at "the other end" of the application, in the REST API.

Path variables

To get started, generate a Spring Boot project at https://start.spring.io with the "Web" dependency. The version of Spring Boot I used is 2.2.4.

Most REST API’s will define their controller similar to this:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("{id}")
    public UserInfo getUserInfo(@PathVariable("id") long userId) { (1)
        ...
    }
}
1 The path variable is typed to long

Notice how the path variable is typed to long. What we want to do is use a Value Object, for example UserId:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("{id}")
    public UserInfo getUserInfo(@PathVariable("id") UserId userId) {
        ...
    }
}

The UserId class:

public class UserId {
    private long id;

    public UserId(long id) {
        this.id = id;
    }

    public long getId() {
        return id;
    }

    @Override
    public String toString() {
        return new StringJoiner(", ", UserId.class.getSimpleName() + "[", "]")
                .add(String.format("id=%s", id))
                .toString();
    }
}

However, this will not work out-of-the-box. Spring has no way of knowing how to convert the String from the URL to a UserId instance.

Just try this test:

@WebMvcTest
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void testGetUserInfo() throws Exception {
        mockMvc.perform(get("/api/users/{id}", 1L))
               .andExpect(status().isOk());
    }
}

If you run it, it fails with:

Failed to convert value of type 'java.lang.String' to required type 'com.wimdeblauwe.examples.valueobjectswithrestapi.user.UserId'

To fix this, we can define a Converter instance that will tell Spring how to convert from String to UserId:

import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;

@Component (1)
public class StringToUserIdConverter implements Converter<String, UserId> { (2)
    @Override
    public UserId convert(@NonNull String s) {
        return new UserId(Long.parseLong(s)); (3)
    }
}
1 Annotate with @Component so a singleton instance is added to the Spring context
2 Use generics to indicate what is converted
3 Implement the conversion logic

Run the test again, it should be ok now.

Request bodies

Another place where we can use Value Objects is in request bodies. Assume we have a TodoController with maps a POST request:

@RestController
@RequestMapping("/api/todos")
public class TodoController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void addTodo(@RequestBody CreateTodoParameters parameters) {
        ...
    }
}

The body is expected to look like this:

{
  "userId": 2,
  "description": "Test Description"
}

The matching class for this is:

public class CreateTodoParameters {
    private final UserId userId;
    private final String description;

    @JsonCreator
    public CreateTodoParameters(@JsonProperty("userId") UserId userId,
                                @JsonProperty("description") String description) {
        this.userId = userId;
        this.description = description;
    }

    public UserId getUserId() {
        return userId;
    }

    public String getDescription() {
        return description;
    }

    @Override
    public String toString() {
        return new StringJoiner(", ", CreateTodoParameters.class.getSimpleName() + "[", "]")
                .add(String.format("userId=%s", userId))
                .add(String.format("description='%s'", description))
                .toString();
    }
}

As this class is immutable, we use @JsonCreator and @JsonProperty annotations to ensure the JSON that will be POST’ed can be deserialized.

To ensure serialization and deserialization is ok, we write this test:

import com.wimdeblauwe.examples.valueobjectswithrestapi.user.UserId;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.json.JsonContent;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;

@JsonTest
class CreateTodoParametersTest {

    @Autowired
    private JacksonTester<CreateTodoParameters> tester;

    @Test
    void testSerialization() throws IOException {
        CreateTodoParameters parameters = new CreateTodoParameters(new UserId(3L),
                                                                   "Test Description");
        JsonContent<CreateTodoParameters> content = tester.write(parameters);
        assertThat(content).hasJsonPathNumberValue("userId", 3L);
        assertThat(content).hasJsonPathStringValue("description", "Test Description");
    }

    @Test
    void testDeserialization() throws IOException {
        CreateTodoParameters parameters = tester.parseObject("{\n" +
                                                                     "  \"userId\": 2,\n" +
                                                                     "  \"description\": \"Test Description\"\n" +
                                                                     "}");
        assertThat(parameters).isNotNull();
        assertThat(parameters.getUserId()).isNotNull().extracting(UserId::getId).isEqualTo(2L);
        assertThat(parameters.getDescription()).isEqualTo("Test Description");
    }
}

If we run this, the serialization test fails because we have not stated anything special for Jackson. By default, Jackson will create a nested id property for UserId:

{
  "userId": {
    "id": 2
  },
  "description": "Test Description"
}

To avoid this, annotated the getId() method in UserId with @JsonValue:

public class UserId {

    ...

    @JsonValue
    public long getId() {
        return id;
    }
}

It might come as a surprise, but the deserialization test succeeds immediately.

Jackson will notice that it needs a UserId to instantiate the CreateTodoParameters object, but all it has in the JSON is a number. If we look at the UserId code, we see there is a constructor that takes a long. So Jackson will use that constructor to create the UserId instance, and use that in turn to create the CreateTodoParameters object.

We can finally test everything together in a @WebMvcTest that tests the controller:

@WebMvcTest
class TodoControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void testAddTodo() throws Exception {
        String content = objectMapper.writeValueAsString(new CreateTodoParameters(new UserId(1L), "Item 1"));
        mockMvc.perform(post("/api/todos")
                                .content(content)
                                .contentType(MediaType.APPLICATION_JSON))
               .andExpect(status().isCreated());
    }
}

Conclusion

With a minimal effort, we can use Value Objects in our REST API’s to ensure a maximum expressiveness of our code.

Source code is available on GitHub.