Annotations everywhere
Lately my team have been working with some Java projects that have been handed of to us. The majority of them are relatively small and simple, others not so much, but all use Spring, SpringBoot and a couple of other relatively common dependencies that usually come with it like JPA or spring-security.
We were struggling more than expected. Besides having to understand each application’s business model and what is it really doing, we also had to deal with an avalanche of annotations that were leaking framework’s logic everywhere.
When we started reading applications' code and trying to understand what was going on, we felt we were significantly slowed down by how hard it was to follow the flow of control. At the beginning, when we were asked to solve some problem, we had a difficult time trying to debug the application. Of course, with time we got better at figuring out what was happening, but we are always finding new annotations and details that evaded our initial understanding. Sometimes it becomes frustrating to deal with this magic behavior.
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
Do you know what does your application do on startup? Probably you are not sure, and it won’t be easy to figure out just by looking at your code base.
Do you know what instances are actually created at runtime? We can’t easily know which classes are instantiated on our application. There is no single point of entry, at least one that we can easily find and read. The important components are scattered all over the code and if we are not familiarized with it we’ll probably have an hard time trying to find them and understand if they are being used the way we expect. We end up forced to start the debugger and follow endless stack traces to understand where is the piece of code we want to check next.
Annotations like @service
or @component
automatically instantiate a class and without we being aware of it, there it is, consuming resources or causing some kind of unexpected behavior. When we import some dependency that uses for example a @Bean
or @Entity
annotations, classpath scanning will automatically configure the instance and make it globally available for use. Even the apparently harmless @Autowire
which tries to ease dependency injection, can be easily misused.
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@IdClass(UserPreferenceComposedKey.class)
@Table(name = "UserPreference")
public class UserPreferenceEntity {
@Id
private String userGuid;
@Id
@ManyToOne
private MonitorEntity monitor;
@Column(columnDefinition = "boolean default false")
private boolean favorite;
@CreationTimestamp
@Column(nullable = false)
private LocalDateTime createDate;
@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updateDate;
public UserPreferenceEntity(String userGuid, MonitorEntity monitor, boolean favorite) {
this.userGuid = userGuid;
this.monitor = monitor;
this.favorite = favorite;
}
public UserPreferenceEntity(LocalDateTime createDate, String userGuid, MonitorEntity monitor, boolean favorite) {
this(userGuid, monitor, favorite);
this.createDate = createDate;
}
}
We trust that everything is going to work as expected, that nothing bad will ever happen. People end up just believing that the magic will do what they expect it to do, but when something goes wrong it is not easy to find the root of the problem. We don’t really know how things are connected with each other and at some point we’ll hit a dead end while trying to figure it out. We split the object responsibility, one part keeping the data while the other knowing how to deal with this data and transfer it to the database. This logic is hidden and running behind the scenes. This is logic that we don’t see and don’t control. Moreover, it is not trivial to find and navigate to those magical code paths.
During our explorations we also found several annotations that were there for no reason at all, probably after someone copy pasted some code from another class or a StackOverflow snippet which brought other annotation attached. It was working, so it just stayed there.
How are we going to know that the annotations we need are there and were not deleted (by mistake) in our last commit? or that we are not breaking functionality after adding a new one? We can do it but we’ll need to bring up the container and run the tests there — which is quite slow and volatile.
How do we know we are really testing all our annotations' behavior? We don’t. Code coverage won’t tell us if that stack of annotations on our class was “invoked” or used by the container and we don’t actually know what code is being run.
This does not mean there is no use case for annotations. Actually, we use some, but we try to limit them to the ones that are purely descriptive and that don’t change program behavior at runtime, for example @Override
, @Test
, @FunctionalInterface
. In general I think they should be used with some caution and criteria.
If we are dealing with a really small project or proof of concept, I would argue that it is not so bad. As long as we can easily keep the all application behavior in our head and it can be easily picked up by someone else later, the development is faster and we can remove a lot of boilerplate code. However, as the project grows, becomes more complex or needs some customization, annotations can become really harmful, significantly decreasing code readability and resulting in an increased ramp up time for new team members and also the “mean time to resolve”.
In the end I believe it boils down to a tradeoff between fast initial progress and long term maintainability.