Today, I will write about a Map anti-pattern that I have sometimes observed.
I have come across code as follows from time to time:
1 2 3 4 5 |
Map<String, Object> json = new HashMap<>(); //bad don't do this!!! // or Map<String, Object> parameters = new HashMap<>(); //again don't do this |
I suggested to the developer who wrote similar code to use a class instead of a Map. I explained that a Map should be used for mapping problems, such as associating data with key value pairs.
The developer was upset, and argued back that using a class instead of a map is over-engineering. I interpreted this as some sort of logical fallacy “Ad Hominem” attack against classes. I exclaimed “Using a map is under-engineering! At the very least, we can introduce an interface that functions a marker class, and let Jackson perform the transformations?” This developer would not budge, and stated that she did not want to write a class for everything that will become serialized as JSON. I stepped back, gave her the benefit of the doubt, looked further at the code, noted the pervasiveness of this anti-pattern, and now I am writing this.
Deeper analysis of the code affirmed my conviction that classes should not be substituted with maps. Now I will attempt to objectively make my case.
My most compelling reason is that it is not cohesive. When maps are created, fields are assigned at different places within the code, and ultimately are consumed at different sections of the code. This production and consumption of the data loses its proximity from the location of its original declaration. It would often happen in a different method, a different class, or even a different library. This forces the consumer to search through multiple modules, methods, or classes in order to discover which data resides within the map. This a time-consuming and error prone process that takes much more time than just creating a plain ordinary Java object. Then after time is spent on this lookup, the following problems still exist:
- A map promotes magic “Strings” for the keys unless a class is written just to contain the keys. If there is no class written for the keys, then either a) somebody must read the source code to determine which key value pairs and types are in the map b) the application must be debugged to determine which key value pairs and types are in the map or c) information can pass through the organization through tribal knowledge.
- There can be a typo in the name of the field, which is not a compilation, but instead a runtime error or bug.
- Refactoring has to change String names and types, and there is less tooling available for that.
- Maps cannot have any business methods.
- Maps cannot support primitives.
- Composition with other classes requires more and more hash maps.
- Maps do not work well with other frameworks that use annotations such as the Validations framework (JSR 349), JPA, Spring, Juice, etc.
- Maps performs more slowly than a class, and also requires more memory.
- Extracting values from a map requires a cast, which pollutes the readability of the code.
- The lack of strong typing of a value in the Map makes it error prone.
Conclusion
If you need serialize data then introduce a class that represents that data and then use a serialization framework that can perform the serialization for you.
1 2 |
ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(anyObjectWithGetters); |
If you need to read JSON into a general purpose data-structure (situations where you don’t know what class you are parsing, then use Jackson’s JsonNode class.
If you need to pass data to other classes or methods, then again, introduce a class. You’ll find the code much more cohesive, readable and safe.
If you are tired of writing complete classes then use Lombok. With Lombok you can write POJOs as simply as this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import java.time.Instant; @Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor public class Profile { private String firstName; private String lastName; private Instant dob; } |
Or use the new JDK 14 record type.
1 |
public record Person (String firstName, String lastName, Instant dob) {} |
It is not over-engineering to introduce a class.