The case against annotations

Adam Warski
SoftwareMill Tech Blog
14 min readOct 13, 2017

--

Annotations were introduced to Java in 2004 and have since enabled a lot of progress and vastly improved the way we write software in the Java ecosystem. All the major Java stacks (Spring, JEE) heavily rely on annotations. But it’s 2017; is that it? Or can we do better? Maybe we are stuck in a local optimum, and simply don’t notice the opportunities ahead?

To take a good look around, identify our problems, and be able to take a peek beyond the nearest hill, we’ll proceed in three steps.

The first question that comes to mind is what is it about annotations that makes them so widely used? Entity mapping, defining REST endpoints, validation, JSON mapping are just a few examples of areas where annotations dominate. What are their core characteristics? What are they enabling, that core Java does not?

Secondly, however successful, annotations encourage certain design elements, which might be problematic. They form an embedded mini-language, interpreted at runtime. This, in turn, allows you to extend the base language (Java), which is so useful because of Java’s shortcomings. Such extensions are not type-safe, usually not composable and hard to inspect, reason about and explore.

Thirdly and finally, knowing the crucial features of annotations, and their shortcomings, we’ll be able to explore the land of possible alternatives.

Why annotations are successful

The most important feature of annotations is what they’ve been designed for: the ability to attach additional information to various fragments of the code we write. Turns out that to write good software (for many definitions of that term) we simply need a way to describe our classes, data structures and methods.

This core requirement of attaching meta-data can be satisfied using e.g. xml, but annotations are much better suited for this task. They are well-designed in a number of aspects:

  • straightforward, pleasant syntax
  • easy to introduce to a codebase
  • relatively non-invasive
  • the meta-data is clearly separated from the definition of a class, method or field
  • despite the separation, meta-data is still part of the codebase, “close” to the referenced elements

But attaching meta-data is only one side of the equation; it would be useless without a way to consume that meta-data and act basing on it. Annotations, due to their constrained syntax, can be inspected statically by tools and thanks to the reflection API, the same is possible at run-time.

For example, at application start-up, it’s possible to read the meta-data defined by annotations on any class/method/field, and initialize appropriate business logic statically, without even instantiating the classes involved.

Hence, whatever other approach we might want to take instead of annotations, it needs to enable a concise and elegant way of describing our classes, data structures and methods, so that it’s possible to inspect them programmatically.

What’s wrong with annotations

The design of annotations isn’t without flaws. Or rather, annotations have found applications far beyond the original intentions of the authors. Let’s explore in more detail which aspects of annotations aren’t the best fit for their current use-cases.

Annotations as a language

From a language design perspective, annotations form a mini-language embedded in Java. You can define annotations with parameters, but the parameter values must be constants like Strings, Ints, Class references, arrays of the above, or might be nested annotations. No expressions, no conditionals, no loops; the annotation language shares only basic syntax with Java, otherwise is completely parallel. This language is very limited, but it’s also an asset: it makes annotations static and enables compile-time or run-time inspection.

Going further, the annotation mini-language offers very limited type-safety. Yes, the compiler checks that you got the name of the annotation right, that you are specifying all the right parameters and that the target (class/method/…) is correct. However, that’s it. It’s not possible to specify any contextual requirements; each annotation can go anywhere, for example @Entity with @POST, @JsonSerialize with @Inject etc.

Another problem is that all references in annotations are String-based (stringly-typed). Do you need to reference a field? Use that field’s name as a string. Do you need to reference a named result set mapping? Use a string. Referencing a parameter of a HTTP call? Use a string. Even worse — sometimes you get whole expressions embedded in annotations — for example URL path matchers, or SQL queries. A string-based language inside a mini-language inside Java!

At some point annotations and their parameters are is verified. Most often at run-time. In effect, we get an interpreted, non-typesafe mini-language embedded in Java. It’s worth noting that annotations can also be verified at compile-time — for example through an annotation processor — but that option is rarely used. Probably because of the limited capabilities of the annotation processor API and the need to modify the compilation process.

As an example, consider the following method snippet, which uses JAX-RS annotations:

Whether the path expression is correct, and whether the parameter references the identifier used in the path, is only checked at run-time. It could be potentially checked at compile-time, but that would require special-casing: either dedicated IDE JAX-RS support or amending the build to include a JAX-RS-aware annotation processor (compiler plugin).

As a side-note: one of the reasons why you often need a container to run a Java application (be it a servlet container, JEE container, or Spring) is that something must interpret all of these annotations. Hence, in essence containers are annotations interpreters, providing a run-time for the (mostly dynamic) domain-specific language defined by various annotations. Just as we program the business logic using Java, we program the containers using annotations.

Can we do better? I think so! The answer might seem too simple: just use the base language! But more on that later.

Classpath scanning

A very common practice in annotation-based frameworks is configuration via classpath scanning. It’s enough to put a .jar with a class which has a certain annotation (e.g. @Bean or @Entity) and it will be automatically configured and available in the application. Taking this even further, just add a jar to the classpath and you’ll have a database connection configured for you. Isn’t that convenient?

It might seem so. It’s definitely convenient to rapidly bootstrap a project and makes writing code slightly faster. As long as you have the whole project in your head, that is. But at some point, new people join the project. Or you have to go back to a project from a year ago and fix a bug. Or the project grows big enough that it overflows your brain-cache and you stop grasping all of the code.

What then? Are you able to get a good overview of which services the project uses? Which database it tries to connect to? Do you know what will happen at startup, in what order? (Maybe that’s where the bug that you’ve been hunting down for the past 4 hours is?)

Looking at the project code: do you know, what’s the definite list of database entities used by your application? Can you check it? Maybe there are some entities pulled in through that transitive dependency? What about services? What about database integrations?

What classpath scanning does, is it trades certainty, control and the ability to have a mental model of the entire application for rapid development and fast bootstrap. But as the old saying goes: convenience of the reader of the code should be the priority, not the writer’s.

Explorability

You have some JPA-annotated classes and you’d like to navigate to the code which verifies during startup that the entity definitions match what’s in the database schema. Maybe you think you’ve hit a bug, or maybe you just want to understand how it works to use the library more efficiently. How to approach that problem? Do you look for usages of @Entity?

The problem is that the entity-verification process is invoked somewhere by something that is run at startup because a jar happens to be on the classpath. You never directly invoke it from your code, there’s no way to navigate to that code path. Magic!

And the possibility to explore the code of a framework you are using is one of the best methods to learn it and use properly. Very often with annotations being “magically” handled at startup, we lose the ability to easily navigate to all the complex logic that is running some of the core infrastructure of our application, such as the HTTP layer or database integration.

It is, of course, possible to trace where’s the code that handles annotations. One way is to examine stack traces, when our annotation combo is wrong and we get an exception on startup. Another, to put a breakpoint and fire up the debugger. But both are far from optimal, and constitute the rather unpleasant part of our daily jobs. Stack traces are long and hard follow (or impossible — if there’s a thread boundary), giving little context. Debuggers give more context, but relying on a debugger to understand how a system is working seems wrong (after all, the role of debuggers — as the name suggests — is to help with eliminating bugs).

Testability

I think that one often overlooked aspect of writing code which relies heavily on annotations is testability. Do your tests verify that all the annotations are placed correctly? Do the tests actually make use of the annotations? To verify that, you’ll need to bring up the container and run the tests there — which is often quite slow and volatile due to shared state.

You can have high code coverage, but that only applies to the “regular” Java code. What about annotations themselves? Here, code coverage won’t tell you if that stack of annotations on your class was “invoked” at all, or otherwise “consumed” by the container.

Static vs dynamic

While annotations do a good job X% of the time (where X varies from 90 to 99, depending on project), there’s the (100-X)% of code where you need that extra flexibility and you end up either copy-pasting whole annotation stacks, or hacking around the framework using reflection.

Most of the time, when defining your entities, http endpoints or services you can do that in a static way. But sometimes, you need to do that dynamically; and annotations simply don’t allow such use-cases.

For example, let’s say we use a relational database and want all of our entity identifiers to be generated using a table generator. To achieve that, each id-field must be annotated with a @Id+@GeneratedValue+@TableGenerator combo (parameters omitted for brevity). It would be much easier to simply for-loop over all entities and specify the generators!

But maybe the perceived simplicity and static introspection of annotations is worth the occasional copy-paste?

Composability

Annotations, or rather annotation handlers do not compose. As we noticed earlier, a container is in fact an interpreter for the domain-specific language formed by annotations. Did you ever try running two containers at the same time?

Or, borrowing an example from Jarek Ratajski’s presentation, how would you approach implementing a requirement to retry a database operation 3 times in case of failure? There’s the @Transactional annotation, but how to add the retrying capabilities? We could special-case and create a new interceptor for @RetryTransaction. But what if we want to retry API calls implemented using Feign using the same logic? We would end up writing another special-case. The core of the problem here is that we cannot implement a generic @Retry annotation which could wrap other ones.

Usages of annotations

That’s not to say that annotations are not useful at all. There are some great use-cases. For example, the @Override and @FunctionalInterface annotations in Java or @tailrec in Scala. These are checked at compile-time and provide actionable value to the programmer. They don’t modify how the program behaves at run-time, and they don’t required any kind of container at run-time to fully utilise the annotated class or method.

What the above have in common, is that they act as extra modifiers — augmenting the standard set of private, static etc. Some languages, like Ceylon, take this idea even further — there are no modifiers, only annotations; or Scala, where override is a regular keyword.

If not annotations, then what?

We’ve identified some good and bad sides of annotations, but all of that is at most of academic interest, if there’s no alternative.

Luckily, we are no longer constrained by what the “old” Java has to offer. We can use lambdas; we can use Kotlin, Scala, Ceylon …, all without leaving the JVM ecosystem. So what’s the alternative for annotations? Well, why not simply using the base language to create meta-data objects which describe our classes and methods? Where Java falls short is that it’s not possible to get any meta-information on fields or methods other than through reflection.

Already using Java 8 we can eliminate a lot of annotations. Thanks to lambdas, it’s now possible to embed functions in a syntactically elegant way, thus allowing the design of usable APIs, which create objects describing certain behaviors (with that behavior embedded in the object). Take a look at JOOQ’s transaction management, Ratpack’s HTTP server API or Spring 5 functional web framework.

Using Scala’s implicits and an occasional macro (sometimes annotation-based — but that’s only used at compile-time!), we can create rich APIs capturing a lot of type information and hence providing additional type-safety, fully describing a class, data structure or function. Take a look how to map an entity using Slick or handle JSON using Circe.

Ceylon goes the other way, blending annotations more with the language and providing a type-safe meta-model.

JAX-RS case study

To make the considerations a little less abstract, let’s look at a specific example of how to replace usage of annotations with descriptions reified as first-class values in the base language. Let’s take a very simple JAX-RS example:

This looks quite good at first sight: the meta-data defining the REST endpoint is clearly separated from the business logic. We can test the business logic without worrying about the HTTP layer. Finally, reading the code we can quite easily guess to intent of the author. Even better: we can use a tool like Swagger to automatically generate a nice HTML API browser for us!

But, there are some problems as well; as mentioned above, how can we be sure that we’ve used the right combination of annotations? Maybe something’s missing? We use a statically typed language, but the compiler won’t help us here. We need to run the container so that it verifies that for us. There are also other problems: with traceability of where the endpoint is actually exposed, grasping the complete API of the application and fully testing all of the code.

However, using Lambdas the following API would be possible (the API presented below is imaginary on purpose; for libraries following similar ideas take a look at RatPack or SparkJava):

Notice that:

  • we’ve maintained the separation of business logic (contained in the helloWorld() method) from the description of the endpoint
  • we can test the business logic as before; in addition, we can quite easily write tests which verify that the description of the endpoint is correct as well. No need for reflection and containers
  • if needed, we can programatically generate the endpoint definitions. We have all the power of Java at our disposal. This means loops, conditionals and many other goodies not available in annotations
  • we can still generate HTML API browsers. Just write a simple application which invokes a hypothetical SwaggerGenerator.generate(List<Endpoint> endpoints) method.

Note that when using the “just-use-the-base-language” approach, our meta-data becomes first-class values. While that meta-data can be still explored freely by libraries or frameworks, just as with annotations, it can also be dynamically generated. This gives us an additional axis of freedom.

But what with starting the application? We can no longer expect that things will “just work” and the endpoints will be automatically exposed. Here, we need an extra bit of code:

Isn’t the above boilerplate? Something that the frameworks used to do for us: automatically starting what’s possible by scanning annotations, and what we have to do now by hand? No: that’s the core of our application. The application’s object graph, how it’s created and wired, is central to the problem you are solving. The fact that there’s an http server, and the endpoints which it exposes, probably fulfils a core business requirement. And just looking at the code, it’s crystal clear what’s happening and when.

Eliminating boilerplate is making sure the code needed to achieve the above steps doesn’t obstruct the core business logic. It doesn’t have to disappear altogether; it’s enough that the infrastructure code is minimal, but it’s good that it’s there, as we can see what’s happening.

Spring & Spring Boot

I think that Spring deserves separate attention. After all, being built around annotations, Spring is the most popular framework in Java for business applications, powering a lot of successful systems. It promotes a lot of good practices in engineering: testing, modularity, fast turnaround, separation of concerns, etc. Isn’t that at odds with everything we’ve said about annotations above?

Spring is a local optimum in software engineering: making the most of out of the pre-lambda Java ecosystem. However, we are no longer constrained by using a single language. There’s Java 8+, and there’s a whole host of other languages which run on the JVM and interface with Java. So maybe it’s time to look for a better solution?

Spring is number one in Java despite relying heavily on annotations — which is a vast improvement over “old” EJB-first J2EE or XML-based configuration — and its biggest asset is that it unifies a swath of technologies under a single programming interface. Integration is Spring’s power — not Java, and not annotations.

You might say that if you are proficient enough with Spring/Spring Boot, you’ll eventually learn how to correctly use the annotations, what they mean and how to avoid most common problems. But that’s mainly because you’ve built a mental interpreter of the dynamic language formed by Spring annotations; newcomers won’t be that fast; you’ll increasingly rely on “secret” knowledge, instead of letting compiler do the hard work; finally, you could miss some abstraction opportunities, baring your way in the everlasting quest of writing compact, elegant and simple code.

In fact, when we look at the already mentioned new APIs in Spring 5 (reference documentation), we can see developments following the same ideas as described above: instead of annotations, objects which describe the API. Instead of magical containers, invoking library functions. We might see quite a different Java8 Lambda-based Spring in the future!

Summary

Annotations do a great job in Java, and they caused a revolution in how enterprise applications are written. But annotations are not without problems. Let’s summarise some of the points we’ve raised:

  • Limited explorability of a codebase using containers with annotation-scanning
  • Trading fast initial development for certainty and control over application and service lifecycle
  • String-based, untyped references to class fields, method parameters, etc. and untyped expressions or embedded languages as annotation parameters
  • Easy-to-overlook lack of test coverage of annotations and the logic they bring
  • Static nature, prohibiting dynamic creation of meta-data and possible related abstractions

As a library, framework or application developer, consider treating meta-data as descriptions, represented as first-class values instead of relying on an embedded, limited mini-language of annotations. Maybe it’s possible to solve your problem that way? Maybe Java8 Lambdas can help? Or maybe a better solution might lie in one of the JVM-based languages out there: Scala, Kotlin, Ceylon, or maybe in a new language, which is yet to come. With these tools in our toolbox, we are now able to express a lot more in a way that is, like with annotations, succinct and syntactically elegant.

Credits: Thanks to Jarek Ratajski, Mikołaj Koziarkiewicz and Tomasz Szymański for their comments on the draft.

--

--

Software engineer, Functional Programming and Scala enthusiast, SoftwareMill co-founder