Annotations allow you to add metadata to your code in a clean and readable way, enabling tools and frameworks to automatically handle repetitive tasks like configuration, dependency injection, and component scanning. Understanding how annotations work under the hood will give you the foundation needed to confidently utilize powerful feature offered by Java Frameworks.
You have already used annotations such as @Test and @BeforeEach in Unit Testing with JUnit. We used these annotations to mark our tests which then JUnit picked up and ran them. In this chapter, you’ll take a closer look at what annotations are, how tools process them, and why Spring Boot relies on them so heavily.
Annotations in Java are like markers or metadata. They are extra information that you attach to code. You can add annotations to classes, methods, fields, constructors, parameters, and other code elements.
On their own, annotations do not execute business logic. Instead, a tool reads them and reacts to them. That tool might be the Java compiler, a test runner such as JUnit, a library such as Jackson, or a framework such as Spring Boot.
Here are some common ways annotations are used to:
@FunctionalInterface tells the compiler to enforce that the interface has exactly one abstract method.@Test and @BeforeEach tell JUnit which methods should be run as tests or setup code.@Getter from Lombok can generate getter methods for fields.<aside> 💡
Do you notice the pattern? Many annotations deal with functionality needed in several places across an application. These concerns are often separate from the core business logic. Java developers call these cross-cutting concerns.
</aside>
<aside> ⚠️
An annotation is not magic by itself. The important question is always: which tool reads this annotation, and what does that tool do with it?
</aside>
Java comes with several built-in annotations. Three of the most common you’ll encounter are @Override, @Deprecated, and @SuppressWarnings.
@OverrideYou saw @Override when learning about inheritance in Inheritance. You use this annotation when a subclass method is meant to override a method from a parent class or interface.
@Override adds a compiler check. It tells Java: “This method must match a method from a superclass or interface.”
Without @Override, the code below still compiles, but it does not override add(String). It accidentally defines a new method named addd (With an extra letter ‘d’ at the end)
public class ArrayListStrings extends ArrayList<String> {
// Should've been add. Compiler won't catch anymore since this is like defining a new method.
public boolean addd(String item) {
return false;
}
}
<aside> ⌨️
Hands on: Copy the code above into IntelliJ and add @Override above addd(). Ignore the red underline for a moment, then try compiling or running the code. What compiler error do you get?
</aside>
<aside> ⚠️
A common misconception is that @Override is what makes a method override its parent. It is not. Overriding happens when a subclass defines a method with the same signature as a method from its superclass or interface.
</aside>
@Override is a safety check. Your code can work without it, but you lose protection against typos and signature mismatches.
@Deprecated@Deprecated marks a method, class, constructor, or field as still available but no longer recommended. It signals to other developers: “This still works, but there is a better alternative. Avoid using this deprecated feature in new code.”
In Java’s source code, the reason for deprecation, along with the alternative, is mentioned in the JavaDoc comment for that method, class, or field.
<aside> 💡
Javadoc comments are official API comments that explain how to use code. They start with /**. Tools can turn them into complete API documentation for a library.
In IntelliJ, you can write Javadoc comments for one of your classes or methods by typing /** above them and pressing Enter :
</aside>
Take a look at Java’s older way of initializing an Integer from a String using the Integer(String) constructor. In modern Java, this constructor is deprecated and the Javadoc recommends alternatives such as Integer.valueOf(String) or Integer.parseInt(String).

JavaDoc comment above Integer(String) constructor providing multiple alternatives for the deprecation
<aside> ❗
Deprecated does not mean broken. The @Deprecated annotation is a warning: this code still works today, but it may be removed in a future version. Prefer the recommended alternative.
</aside>
@SuppressWarnings@SuppressWarnings tells the Java compiler to ignore specific warnings for a specific piece of code.
For example, here’s how you can suppress a deprecation warning. Notice that we’ve also added a clear comment explaining why the warning is being suppressed? Always document the reason behind any suppression. Tt’s not just helpful for others, but also for your future self when you revisit the code months later:
// Short one-sentence comment on why warning is being suppressed, for example:
// Legacy code from XYZ API still uses this
@SuppressWarnings("deprecation")
Integer id = new Integer("512");
<aside> 💡
Another important reason to document a suppressed warning is to be able to quickly tell when circumstances have changed and the suppression is no longer necessary.
</aside>
Use @SuppressWarnings sparingly. In most cases, it is better to fix the underlying issue. Warnings often point to possible bugs, readability problems, or places where you can improve type safety.
A well-known example appears inside Java’s own collection classes. ArrayList stores elements internally in an array of Objects and, because of Java generics and type erasure, some internal casts cannot be checked fully by the compiler. The JDK uses @SuppressWarnings("unchecked") in carefully controlled places where the library code takes over the type-safety guarantee from the compiler.
<aside> ❗
The case with ArrayList does not mean every unchecked cast is safe. The JDK can use this technique because its collection classes are heavily tested and designed around strict internal rules.
</aside>
Not all annotations work the same way behind the scenes. One important distinction is where the annotation information is available.
| Retention | What it means | Common examples |
|---|---|---|
| Source only | The compiler can use the annotation, but it is not stored in the compiled .class file. |
@Override, @SuppressWarnings |
| Class file | The annotation is stored in the .class file, but it is not usually available through reflection at runtime. |
Some build-tool or bytecode-tool annotations |
| Runtime | The annotation is stored in the .class file and can be read while the program is running. |
JUnit, Spring, and Jackson annotations |
Runtime annotations are often read through reflection*.
<aside> 💡
Reflection is Java’s ability to inspect classes, methods, fields, constructors, and annotations while a program is running. Frameworks use reflection heavily so they can discover your code and connect it to framework behavior.
If you’re curious about the concept, look up Reflective Programming. Wikipedia.
</aside>
There is also a related idea called annotation processing. Annotation processors run during compilation and can check code or generate extra code. Lombok is a common example: it reads annotations such as @Getter during compilation and generates code before your application runs.
💬 Out of the following annotations, which ones are used by the compiler/build process, and which ones are used by tools at runtime?
@Override, @SuppressWarnings, @FunctionalInterface, @Test, @BeforeEach
Lombok is a build-time library that uses annotation processing to reduce boilerplate. It can generate getters, setters, constructors, equals(), hashCode(), toString(), and more.
<aside> ⚠️
Adding Lombok requires a little extra setup. We’ll use it when working with Spring Boot. If you can’t wait, in the Extra Resources section of this chapter, see Lombok addition
</aside>
Many compile-time annotations you have seen so far are compiler checks. Lombok shows a different kind of compile-time annotation: one that generates code.
Let’s use these Lombok annotations to improve the ErrorMessage enum we made in Enums. Remember that one?
public enum ErrorMessage {
ACCOUNT_NOT_FOUND("Account could not be found in our system"),
USER_NOT_AUTHORIZED("Your credentials are not sufficient, please try login in again.");
private String message;
ErrorMessage(String message) {
this.message = message;
}
// Getter public method to safely retrieve the private field message
public String getMessage() {
return message;
}
}
With Lombok, the getter and constructor can be generated from annotations. Significantly reducing the boilerplate:
@Getter // Generate a getter method for all non-static fields
@RequiredArgsConstructor // Generate a constructor for all final fields
public enum ImprovedErrorMessage {
ACCOUNT_NOT_FOUND("Account could not be found in our system"),
USER_NOT_AUTHORIZED("Your credentials are not sufficient, please try login in again.");
private final String message;
}
In the ImprovedErrorMessage.class file, which IntelliJ decompiles automatically when you open it in the editor, you can see the generated code:
After compilation, you’ll be able to find the generated .class file in the target/ directory. The generated code will look roughly like this:
public enum ImprovedErrorMessage {
ACCOUNT_NOT_FOUND("Account could not be found in our system"),
USER_NOT_AUTHORIZED("Your credentials are not sufficient, please try login in again.");
private final String message;
@Generated
public String getMessage() {
return this.message;
}
@Generated
private ImprovedErrorMessage(final String message) {
this.message = message;
}
}
After compilation, IntelliJ IDEA can show you the generated .class file. The generated code will look roughly like this:
public enum ImprovedErrorMessage {
ACCOUNT_NOT_FOUND("Account could not be found in our system"),
USER_NOT_AUTHORIZED("Your credentials are not sufficient. Please try logging in again.");
private final String message;
public String getMessage() {
return this.message;
}
private ImprovedErrorMessage(final String message) {
this.message = message;
}
}
The important point: Lombok does not run in your application at runtime to create the getter. It generates the missing code during compilation. It’s like an extra compiler over your Java compiler javac , if you’d like to think of it that way of course.
Some annotations accept parameters that change or refine their behavior. A common example is @SuppressWarnings("unchecked").
Here, "unchecked" tells the compiler which warning to suppress.
For some annotations, parameters are essential. Without parameters, Java would need many separate annotations for small variations of the same behavior. Can you imagine the number of annotations there would have to be for suppressing all Java warnings?
Annotation parameters are usually called annotation elements. When you use them, they look similar to named arguments*:
<aside> 💡
</aside>
// Positional Arguments: Method declaration and usage
int add(int a, int b) { return a + b }
add(3, 5) // 3 maps to "a" and 5 maps to "b" based on order
// Named annotation elements: simplified annotation declaration and usage
@interface ApiRoute {
String path();
String method() default "GET";
}
@ApiRoute(path = "/categories", method = "GET")
class CategoryRouteExample {}
// Can swap the parameter positions and it would work the same
@ApiRoute(method = "GET", path = "/categories")
class CategoryRouteExample {}
But wait, we did not write a parameter name in @SuppressWarnings("unchecked"), right?
That is a Java shortcut. When you pass one unnamed value to an annotation, the compiler looks for an annotation element named exactly value and maps the argument to it automatically.
So @SuppressWarnings("unchecked") is short for @SuppressWarnings(value = "unchecked")
@SuppressWarnings(value = "unchecked")
// Is shortened to:
@SuppressWarnings("unchecked")
Annotation authors often name the most common element value so the annotation is easier to write.
If an annotation accepts multiple values for the same element, you may see direct curly braces:
@SuppressWarnings({"unchecked", "deprecation"})