What Are Meta-Annotations in Java?
Expand your programming knowledge by learning more about an advanced Java annotation — meta-annotations.
Join the DZone community and get the full member experience.
Join For FreeAnnotations are an indispensable part of Java applications and have become nearly ubiquitous thanks to their use in frameworks such as Spring and JUnit. While the annotations do not add any functionality in and of themselves, they allow for an application to process them and make decisions based on the presence of certain annotations.
A basic understanding of annotations is essential to becoming a seasoned Java developer, but more and more, more advanced annotation techniques are required. In this article, we will delve into one of these advanced techniques: Meta-annotations. As well as describing the basic concepts surrounding meta-annotations, we will also look at pragmatic ways to process meta-annotations and examples of how these annotations are used in a few of the most popular frameworks.
For more information on annotations, see the other articles in this series:
Annotations & Meta-Annotations
An annotation is a special type of interface in Java that can be used to describe Java constructs, such as classes, methods, and fields. According to the Java Language Specification (JLS):
An annotation is a marker which associates information with a program construct, but has no effect at run time.
In most cases, an annotation provides supplementary information about a class, method, or field. In the case of Spring, a class can be annotated with @Service
to inform the Spring Dependency Injection (DI) framework that it can be used as a candidate for DI. Likewise, a field can be annotated with @Autowired
to inform the Spring DI framework that the field should be populated using DI. For example, the following is a canonical use of Spring annotations:
@Service
public class FooService {
@Autowired
private Bar bar;
public void doSomething() {
// ...use bar field...
}
}
It is important to note that annotations do not inherently perform any action. As stated in the official definition, an annotation has no effect at run time. In order for an annotation to affect the behavior of the system, it must be processed, as we will see later.
Additionally, the JLS specifies nine distinct contexts in which an annotation may appear:
- Module declarations
- Package declarations
- Type declarations: class, interface, enum, and annotation type declarations
- Method declarations (including elements of annotation types)
- Constructor declarations
- Type parameter declarations of generic classes, interfaces, methods, and constructors
- Field declarations (including enum constants)
- Formal and exception parameter declarations
- Local variable declarations (including loop variables of
for
statements and resource variables oftry
-with-resources statements)
Context (3) is of particular importance, as it allows for annotations to be applied to other annotation type declarations. Following this capability, we can create meta-annotations, which are simply annotations applied to other annotations. While this may at first appear to be a strange concept, it is very important in practice. Not only does this allow us to describe annotations using other annotations, but it also allows us to compose annotations.
Java includes some important meta-annotations directly within the language specification:
@Target
: Describes the targets to which an annotation can be applied; this directly corresponds to the nine contexts above@Retention
: Describes how long the annotation should be retained by the compiler@Inherited
: Denotes that an annotation should be inherited by a subtype if applied to a supertype@Deprecated
: Denotes that an annotation (or any other type) should no longer be used@Repeatable
: Denotes that an annotation can be applied multiple times in the same context; i.e. a class can have the same annotation applied to it two or more times
As we saw in Creating Annotations in Java, an annotation is usually annotated with @Target
and @Retention
, allowing the Java compiler to know to what constructs the annotation can be applied and how long to retain the annotation, respectively (if no target or retention policy is specified, the annotation defaults to any declaration except a type parameter declaration and class retention, respectively). For example:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Foo {}
While the meta-annotations provided by Java are useful, there are many use cases that require us to create custom meta-annotations and process these meta-annotations.
Creating Custom Meta-Annotations
Creating a meta-annotation is as simple as creating a new annotation that has a target that includes RetentionPolicy.TYPE
orRetentionPolicy.ANNOTATION_TYPE
. While this may seem abstract, the use cases this applies to are very practical. For example, suppose that we are creating a test framework that executes three different types of tests: (1) unit tests, (2) integration test, and (3) acceptance tests.
Being that some tests take longer to run, we should allow users to mark slow tests and possibly execute these tests later in the testing pipeline or execute these tests in parallel with the faster running tests. In order to mark a test by its type and denote if a test is a slow test, we can create two annotations: @Test
and @SlowTest
, along with an enumeration of the test types, TestType
.
public enum TestType {
UNIT, INTEGRATION, ACCEPTANCE;
}
@Retention(RUNTIME)
@Target(TYPE)
public @interface Test {
public TestType value();
}
@Retention(RUNTIME)
@Target(TYPE)
public @interface SlowTest {}
Using this scheme, to create an integration test, we can simply annotate a new test class with the @Test
annotation, supplying the appropriate TestType
value:
@Test(TestType.INTEGRATION)
public class SomeIntegrationTest {
// ...some test cases...
}
If a single test case should be considered a slow test class, we can also apply the @SlowTest
annotation:
@SlowTest
@Test(TestType.INTEGRATION)
public class SomeIntegrationTest {
// ...some test cases...
}
By their nature, integration tests are usually considered slow tests when compared to much faster unit tests. If this decision is made, then all integration tests would require both the @Test(TestType.INTEGRATION)
and @SlowTest
annotations to be present on an integration test class. This presents two major issues: (1) it is tedious to manually apply the @SlowTest
annotation anywhere the @Test(TestType.INTEGRATION)
annotation is present and (2) the decision that all integration tests are slow tests can be violated if a developer forgets to include the @SlowTest
annotation when applying the @Test(TestType.INTEGRATION)
annotation to a test class.
In order to mitigate these issues, we can create a new, composed annotation:
A composed annotation,
A
, is a single annotation that combines one or more other annotations,B
,C
,...
, by applyingB
,C
,...
as meta-annotations toA
I.e. if
B
,C
are a meta-annotations applied toA
,A
is composed annotation ofB
andC
and can be treated as representing the same characteristics ofB
andC
Our composed annotation, @IntegrationTest
, acts as a single annotation representing both an integration test and a slow test by using @Test(TestType.INTEGRATION)
and @SlowTest
as meta-annotations:
@SlowTest
@Test(TestType.INTEGRATION)
@Retention(RUNTIME)
@Target(TYPE)
public @interface IntegrationTest {}
This composed annotation, when applied to a type, now represents both a slow test (i.e. @SlowTest
) and an integration test (i.e. @Test(TestType.INTEGRATION
). The @IntegrationTest
annotation can then be applied to an integration test class:
@IntegrationTest
public class SomeIntegrationTest {
// ...some test cases...
}
While we could have simply implemented our test system to process all test classes marked with @Test(TestType.INTEGRATION)
to run as slow tests, this is cumbersome, as it requires that we make special allowances for integration tests. For example, we would have to have some conditional logic to check if a test is an integration test and automatically run it as a slow test. Not only does this create inconsistencies in our system, but it also creates duplication, as we would be forced to apply the same logic to any test that has the @SlowTest
annotation (i.e. a unit test that just so happens to run slowly).
By combining annotations into a composed annotation, we have maintained a consistent interface: slow test cases are denoted solely by the @SlowTest
annotation rather than some special-case logic for a specific type of test.
Processing Meta-Annotations
As seen in the JLS definition of an annotation, an annotation has no effect at run time. This means that applying an annotation to a class does not change the behavior of the class. Instead, we must process the annotation in order for any logic to be executed. To process annotations, we use reflection to inspect an annotated class and extract the annotations applied to that class. In the case of our test system, we need to create an annotation processor that can handle the @IntegrationTest
, @Test
, and @SlowTest
annotations.
Although we could create a special-purpose annotation processor, we will create a more generalized annotation processor, which allows for generic handlers to be registered and register handlers specific to our test system. This will allow us to use this processor for other use cases as well. First, we must define the interface for the handler:
public interface AnnotationHandler<T extends Annotation> {
@SuppressWarnings("unchecked")
public default void handle(Annotation annotation) {
doHandle((T) annotation);
}
public void doHandle(T annotation);
}
This interface contains a single abstract method, doHandle
, that handles an annotation of the generic type T
. The handle
method is simply a helper method that coerces the supplied Annotation
object (java.lang.annotation.Annotation
) to the expected generic type T
. This method assumes that the Annotation
object passed to the method is actually of type T
. This is an assumption we will cover when we create the annotation processor (i.e. a handler for annotations of type A
should only be called with an argument of type A
).
This our handler interface defined, we can now create the processor:
public class AnnotationProcessor {
private Map<Class<?>, AnnotationHandler<?>> handlers = new HashMap<>();
public <T extends Annotation> void registerHandler(Class<T> type, AnnotationHandler<T> handler) {
handlers.put(type, handler);
}
public void process(Class<?> root) throws Exception {
for (Annotation annotation: root.getAnnotations()) {
handle(annotation);
for (Annotation subAnnotation: annotation.annotationType().getAnnotations()) {
handle(subAnnotation);
}
}
}
private void handle(Annotation annotation) {
AnnotationHandler<?> handler = handlers.get(annotation.annotationType());
if (handler != null) {
handler.handle(annotation);
}
}
}
The registerHandler
method simply allows for handlers to be registered, where the first parameter denotes the type of the annotation (as a Class
object) and the second parameter is a handler that will be used to handle annotations of that type. Upon registration, the handler is put into a Map
object, where the type of the annotation represents the key of the Map
and the handler represents the value.
The process method accepts Class
objects and iterates through the annotations associated with the class. For each annotation found, we then iterate through the annotations applied to that annotation. This is the core of the meta-annotation handling logic: for each annotation applied to the class being processed, its meta-annotation are also discovered and handled. Lastly, the handle method simply attempts to find a handler to the Annotation
object supplied, and if one exists, the handler is called.
This processor can be seen in action in the following application:
public class Application {
public static void main(String[] args) throws Exception {
AnnotationProcessor processor = new AnnotationProcessor();
processor.registerHandler(IntegrationTest.class, a -> System.out.println("Found a class annotated with @IntegrationTest"));
processor.registerHandler(SlowTest.class, a -> System.out.println("Found a class annotated with @SlowTest"));
processor.registerHandler(Test.class, a -> System.out.println("Found a class annotated with @Test(" + a.value() + ")"));
processor.process(SomeIntegrationTest.class);
}
}
In this application, we create an AnnotationProcessor
object and register three handlers: (1) for the @IntegrationTest
annotation, (2) for the @SlowTest
annotation, and (3) for the @Test
annotation. For the purpose of demonstration, we simply print that we found each of the annotations, but in a real-world scenario, we would perform some more complex logic when each annotation is encountered. Lastly, we process the SomeIntegrationTest
class we previously created. Running this application produces the following output:
Found a class annotated with @IntegrationTest
Found a class annotated with @SlowTest
Found a class annotated with @Test(INTEGRATION)
It is important to note that the SomeIntegrationTest
class is only annotated with the @IntegrationTest
annotation, not the @Test
or @SlowTest
annotations. Since we are processing the meta-annotations of any annotation applied to the SomeIntegrationTest
class (using the nested loops in our processor), the processor was able to discover, indirectly, that the SomeIntegrationTest
was both an integration test and a slow test.
While processing meta-annotations appears to be a simple task, there are some important notes to consider. Foremost among these is the fact that annotations may be forward-referenced. In practice, this means that an annotation may be meta-annotated with itself. For example, if we look at the definition of the @Retention
annotation (from the Java Development Kit, JDK, 11 source code), we see that @Retention
is meta-annotated with @Retention
:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
RetentionPolicy value();
}
In our AnnotationProcessor
implementation, we only processed meta-annotations at one level deep but we could have simply recursed through each of the annotations, processing the annotations applied to each annotation until there were no more annotations to process (i.e. process meta-annotations of meta-annotations, and so on). However, doing so would cause a StackOverflowError
, since the @Retention
annotation on @IntegrationTest
is annotated with itself (i.e. @Retention
). This would cause a recursive annotation processor to recursive ad infinitum (or, in the case of Java, until the stack is exhausted).
In order to avoid this issue, we would have to add cycle-detection logic, limiting the depth of recursion, or omit the processing of the meta-annotations included with Java, such as @Retention
(which would still not solve the problem if a custom, non-Java-included annotation forward-referenced itself). Regardless of the solution chosen, it is important to pay special attention to the quirks of Java annotations and not only understand the various ways in which annotations can be used in Java (i.e. forward-referencing), but also understand their practical consequences (i.e. forward-referencing causing infinite recursion). For the reader interested in developing a more complete AnnotationProcessor
implementation, Section 9.6 of the JLS is a good place to start.
Meta-Annotations in Practice
The benefits of meta-annotations, and the composed annotations created from them, far outweigh the simplicity of their definitions. Many popular frameworks, including JUnit and Spring, both allow many of their annotations to be used as meta-annotations, allowing users to combine multiple annotations into a single, easy-to-use annotation.
JUnit
As of JUnit 5, JUnit annotations can be used as meta-annotations. This allows users to combine multiple annotations into a single, composed annotation, reducing duplication and saving a great deal of manual effort. In particular, the @Tag
annotation, while useful for categorizing tests into different groups, can be cumbersome.
The @Tag
annotation allows a user to group test cases into a named category and execute tests of a specific group together. In order to provide the greatest flexibility, the @Tag
annotation allows for a String
-based named to be supplied. For example, if two test cases are annotated with @Tag("fast")
, both test classes are now considered to be part of the fast
group. This allows only the fast
tests to be executed or excluded from the execution of other tests.
While this is a very pragmatic tool, it can cause serious duplication: Since the tag name is String
-based, each test is required to have exactly the same name. If at some point in the future the name of the group is changed to another name, this name must be updated everywhere the old name is present. A constant can be created, but this still does not alleviate another problem. Suppose at the beginning of development, unit tests are considered fast by default. To follow this convention, all unit tests must be annotated with @Test("fast")
. If in the future, unit tests are no longer considered fast
tests, the fast
tag must be individually removed from all unit tests.
In essence, we would be tieing the fastness of the test to the particular test rather than the fact that it is a unit test. If unit tests are no longer considered fast
, then we would be forced to update all unit test classes because we have tied the fastness of the test to the particular test classes, rather than the fact they are unit tests.
Fortunately, JUnit 5 allows for the @Tag
annotation to be used as a meta-annotation, which means we can create a composed annotation that includes @Tag
. For example, if we wanted to create a @UnitTest
annotation that is considered fast
, we could create the following:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface UnitTest {}
Likewise, if we wanted to abstract the @Tag("fast")
into its own annotation, we could create a similarly composed annotation:
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Tag("fast")
public @interface Fast {}
We can then update the @UnitTest
annotation to use the @Fast
annotation:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Fast
public @interface UnitTest {}
While our example is simple, it very practical on many large projects and represents only the tip of the iceberg on how to use JUnit 5 meta-annotations. For me information, see the official JUnit 5 Writing Test Meta-Annotations documentation.
Spring
Since annotations-based configuration was added to Spring, the number of Spring annotations has become nearly endless, due in large part to the vast scope of Spring. While there are countless annotations for various situations, for most tasks, only a small subset of annotations will be consistently used. Whatsmore, these annotations will likely be consistently used in combination with one another.
For example, many of controllers developed in Spring will be Representational State Transfer (REST) controllers and will likely have the same characteristics, such as being a controller (having the @Controller
annotation) and having its returned object serialized to Javascript Object Notation (JSON) (having the @ResponseBody
annotation). Looking at the source code for the @RestController
annotation, we can see that it is simply a composed annotation containing the @Controller
and @ResponseBody
annotations:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}
By composing these separate annotations into a single annotation, we can now annotate REST controllers with a single @RestController
annotation. Likewise, the @TransactionalService
annotation is a composed annotation consisting of the @Transactional
and @Service
annotations.
Spring also allows for custom composed annotations to be created and be processed by the Spring framework. While the above examples are in common usage, they are only a small fraction of the composed annotations provided by Spring and only scratch the surface of the capability provided by meta-annotations in Spring. For more information, see Using Meta-annotations and Composed Annotations and Spring Annotation Programming Model.
Conclusion
Annotations are an essential part of Java programming, and while a basic understanding of annotations suffices for small projects, a more advanced understanding of annotations is required when working on practical projects, especially those that use the most popular Java frameworks. Among these concepts, meta-annotations (and the compose annotations that they enable) are foremost among the concepts that should be understood by all advanced Java developers. While creating custom meta-annotations and composed annotations may not be an everyday task for all developers, when necessary, they can save a large amount of effort and understanding the concepts behind these annotations can go a long way in understanding the most popular Java frameworks.
Opinions expressed by DZone contributors are their own.
Comments