Dealing with Value Objects in REST API with Jackson
Dealing with serialization and deserialization of Value Objects with Jackson seems to be straightforward, right? It’s very common to expose REST API in our applications and JSON format is one of the most frequently used ones. How do we usually do it?
Data Transfer Objects
One of the most popular approaches is to introduce a set of Data Transfer Objects, which define external messages format. Let’s have a look at an example.
public class Project {
private final ProjectId id;
private final String name;
public Project(ProjectId id, String name) {
this.id = id;
this.name = name;
}
public ProjectId getId() {
return id;
}
public String getName() {
return name;
}
}
public class ProjectId {
private final String id;
public ProjectId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
public class ProjectDTO {
private String id;
private String name;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
This example is very simple, but even now we have to transform ProjectId
to String during serialization and String
to ProjectId
during deserialization. What will happen when Value Object is more complex and contains 3-4 fields? and one of the fields is another Value Object?
public class Address {
private final String streetName;
private final Integer houseNumber;
private final ZipCode zipCode;
private final String city;
public Address(String streetName, Integer houseNumber, ZipCode zipCode, String city) {
this.streetName = streetName;
this.houseNumber = houseNumber;
this.zipCode = zipCode;
this.city = city;
}
...
}
public class ZipCode {
private final String code;
public ZipCode(String code) {
this.code = code;
}
...
}
In case of Address
, dealing with serialization and deserialization Address
to/from AddressDto
is not a very pleasant task to do. It is not just a matter of adding AddressDto
in each resource containing Address
, but also transforming the values.
public class CustomerDto {
private String name;
private AddressDto address;
// other fields omitted for brevity
}
public class AddressDto {
private String streetName;
private Integer houseNumber;
private String zipCode;
private String city;
public Address toAddress() {
ZipCode zipCodeValue = new ZipCode(zipCode);
return new Address(streetName, houseNumber, zipCodeValue, city);
}
public AddressDto(Address address) {
this.streetName = address.getStreetName();
this.houseNumber = address.getHouseNumber();
this.zipCode = address.getZipCode().getCode();
this.city = address.getCity();
}
}
Each time you want to compose Address
in your Data Transfer Objects (such as CustomerDto
in the example above) you have to prepare a data structure (AddressDto
in this case) and the transformation. Of course, it can be reused in a single module, but not in case of a multi-module complex application — usually the DTO/converter code is duplicated.
There are tools to make the mapping and transformation easier, but anyway:
- You have to do it (in the code, tool configuration, etc.)
- You have to test it, because it is very probable that you will forget to pass some value.
Why can not we use the Value Object directly and serialize/deserialize its instances?
Please take a look at the full example of Address
value object again. What will happen when you try to deserialize the object from JSON string?
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `Address` (no Creators, like default construct, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{"streetName":"5th Avenue","houseNumber":12,"zipCode":{"code":"34564"},"city":"New York"}"; line: 1, column: 2]
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
Jackson annotations
You can easily fix it by adding some Jackson annotations which allow defining a way how the object should be constructed:
public class Address {
private final String streetName;
private final Integer houseNumber;
private final ZipCode zipCode;
private final String city;
@JsonCreator
public Address(@JsonProperty(value = "streetName", required = true) String streetName,
@JsonProperty(value = "houseNumber", required = true) Integer houseNumber,
@JsonProperty(value = "zipCode", required = true) ZipCode zipCode,
@JsonProperty(value = "city", required = true) String city) {
this.streetName = streetName;
this.houseNumber = houseNumber;
this.zipCode = zipCode;
this.city = city;
}
...
}
Now Address
can be deserialized from JSON and everything works fine but…
We have just added some additional dependency to Jackson, which sometimes may not be an acceptable approach. In case of a project where Address
is used internally in a single module, it works perfectly fine and we can live with this Jackson dependency.
It becomes more of a challenge if we expose a set of interfaces and value objects to the external world (e.g. as a library). We should avoid forcing users to include Jackson in their project just because it was convenient for our internal use.
Is there any other way to deal with simple Value Objects?
Custom serializer / deserializer
I think we should deal with this situation in exactly the same way as we deal with Java 8 date/time classes. We can prepare set of custom serializers and deserializers.
At first glance, it looks like a non-trivial task to deal with low-level Jackson API. Actually, it is much simpler than you might think. The main benefit of this approach is that you code and test it once and then you can reuse it whenever you want.
public class AddressDeserializer extends StdDeserializer<Address> {
@Override
public Address deserialize(JsonParser jp, DeserializationContext ctx) throws IOException, JsonProcessingException {
JsonNode rootNode = jp.getCodec().readTree(jp);
String streetName = getNode(rootNode, "streetName")
.filter(node -> node.isTextual())
.map(node -> node.textValue())
.orElseThrow(() -> new JsonMappingException(jp, "Missing textual 'streetName' property"));
Integer houseNumber = getNode(rootNode, "houseNumber")
.filter(node -> node.isInt())
.map(node -> node.intValue())
.orElseThrow(() -> new JsonMappingException(jp, "Missing int 'houseNumber' property"));
String city = getNode(rootNode, "city")
.filter(node -> node.isTextual())
.map(node -> node.textValue())
.orElseThrow(() -> new JsonMappingException(jp, "Missing textual 'city' property"));
JsonNode zipCodeNode = getNode(rootNode, "zipCode")
.orElseThrow(() -> new JsonMappingException(jp, "Missing 'zipCode' property"));
ZipCode zipCode = zipCodeNode.traverse(jp.getCodec()).readValueAs(ZipCode.class);
return new Address(streetName, houseNumber, zipCode, city);
}
private Optional<JsonNode> getNode(JsonNode node, String name) {
return Optional.ofNullable(node.get(name));
}
}
For simple Value Objects which are just wrappers for string values, we can prepare a common serializer and deserializer:
public class ValueObjectDeserializer<T> extends StdDeserializer<T> {
private final Function<String, T> constructor;
public ValueObjectDeserializer(Class vc, Function<String, T> constructor) {
this.constructor = constructor;
}
@Override
public T deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException, JsonProcessingException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
String value = node.asText();
return constructor.apply(value);
}
}
public class ValueObjectSerializer<T> extends StdSerializer<T> {
private final Function<T, String> valueExtractor;
public ValueObjectSerializer(Class vc, Function<T, String> valueExtractor) {
this.valueExtractor = valueExtractor;
}
@Override
public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(valueExtractor.apply(value));
}
}
The only thing we have to do is to register it properly in a custom Jackson module:
public class CustomModule extends Module {
// non-relevant methods omitted for brevity
@Override
public void setupModule(SetupContext context) {
context.addSerializers(createSerializers());
context.addDeserializers(createDeserializers());
}
private Serializers createSerializers() {
SimpleSerializers serializers = new SimpleSerializers();
serializers.addSerializer(zipCodeSerializer());
return serializers ;
}
private Deserializers createDeserializers() {
SimpleDeserializers deserializers = new SimpleDeserializers();
deserializers.addDeserializer(ZipCode.class, zipCodeDeserializer());
return deserializers ;
}
private ValueObjectDeserializer zipCodeDeserializer() {
return new ValueObjectDeserializer<>(ZipCode.class, value -> new ZipCode(value));
}
private ValueObjectSerializer zipCodeSerializer() {
return new ValueObjectSerializer<>(ZipCode.class, value -> value.getCode());
}
}
Comparison of approaches
Thus far, we considered three approaches to serialization of value objects:
- Mapping them to and from DTOs
- Annotating value objects directly
- Writing custom serializers and deserializers and delivering them in a Jackson module
I summarized pros and cons of different approaches in the table below.
Pros | Cons | |
DTOs |
|
|
Jackson annotations directly on Value Objects |
|
|
Custom serializer / deserializer |
|
|
Recommended scenarios
While a custom Jackson module is a great solution for some cases, there are cases where the traditional DTO-based model works perfectly fine. Based on the pros and cons listed above, I think that we can define the target scenario for each approach:
Recommended usage scenario | |
---|---|
DTOs | Having Jackson annotations on value objects is not an option. Value Object is used in a single module – it’s easy to reuse data structures and transformations |
Jackson annotations directly on Value Objects | Value Objects are used in multiple modules internally and Jackson dependency is not a problem (e.g. when target module uses Jackson anyway) |
Custom serializer / deserializer | Having Jackson annotations on value objects is not an option. Value Object is reused in many modules or external applications. It is worth to spend a bit more effort to prepare a serializer / deserializer once and reuse it |
Summary
As we can see, a silver bullet solution does not exist, even for a problem as simple as value object serialization. There are consequences of each design decision we make. They are very important especially when there are external consumers of our classes. Adding an annotation on our class might seem like a good idea, but it can also mean that somebody has to include a Jackson jar in their project. It is always a worthwhile experience to assess our decisions by placing ourselves in our users’ shoes. I encourage you to do so on daily basis!
Thank you very much for this article