Java Annotations
Java Annotations
An annotation is metadata attached to a class, method, field, parameter, or package - extra information that the compiler, the JVM, or a framework can read and act on, without that information being part of the code's runtime logic. @Override tells the compiler to verify that a method actually overrides something. @Deprecated tells every caller that this API is on its way out. @Autowired tells Spring to inject a dependency. None of these change what the annotated code does at the machine level - they change how tools and frameworks treat that code.
What Are Annotations?
An annotation is declared with @interface and applied with an @ prefix. It can carry named elements (which look like methods in the declaration, values in the usage), or it can be a simple marker with no elements at all.
DECLARING AN ANNOTATION:
public @interface MyAnnotation {
String value() default "default-value";
int priority() default 1;
}
APPLYING AN ANNOTATION:
@MyAnnotation(value = "prod", priority = 3)
public class PaymentService { ... }
@MyAnnotation("prod") <- shorthand when only 'value' is set
public class OrderService { ... }
@MyAnnotation <- shorthand when only defaults are used
public class UserService { ... }
WHERE ANNOTATIONS CAN BE PLACED:
Classes, interfaces, enums, records
Methods and constructors
Fields and local variables
Parameters
Packages (in package-info.java)
Type uses (Java 8+ - in generics, casts, implements clauses)
Basic Overview - What Every Developer Needs to Know
BUILT-IN ANNOTATIONS - used every day without thinking about them
@Override - tells the compiler this method MUST override a
superclass method or interface method.
If nothing matches, COMPILE ERROR.
@Deprecated - marks a method, class, or field as outdated.
Callers see a compile-time warning.
@SuppressWarnings - tells the compiler to silence a specific
warning category for this element.
@FunctionalInterface - declares an interface has exactly one
abstract method, making it a lambda target.
If more than one abstract method exists, COMPILE ERROR.
META-ANNOTATIONS - annotations that annotate other annotations
@Retention - controls how long the annotation lives:
SOURCE (discarded after compile),
CLASS (in .class file but not at runtime),
RUNTIME (readable at runtime via reflection)
@Target - restricts WHERE the annotation can be placed:
TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR, ...
@Documented - includes the annotation in Javadoc output
@Inherited - a subclass automatically inherits this annotation
from its parent class (applies to class annotations only)
@Repeatable - allows the same annotation to be applied MORE
than once to the same element
CUSTOM ANNOTATIONS - the ones you and your framework write
Fresher view : you can define your OWN annotation to mark things
and then write code (a processor or reflection-based
validator) that reads those marks and acts on them
Deeper view : the annotation itself is just metadata storage -
the PROCESSOR (compile-time annotation processor
or runtime reflection code) is what gives it meaning.
The annotation declares WHAT; the processor decides WHAT TO DO
RETENTION POLICY - the single most important concept after the basics
Fresher view : RetentionPolicy.RUNTIME means you can read the
annotation using reflection while the program runs.
Most custom annotations for frameworks need this.
Deeper view : RetentionPolicy.SOURCE means the annotation is
useful ONLY to the compiler and is gone afterward
(Lombok annotations work this way - they generate
code at compile time and leave no annotation in
the .class file). RetentionPolicy.CLASS (the default)
is in the .class file but not accessible at runtime -
rarely the right choice unless you are building
a bytecode manipulation tool.
A fresher needs @Override, @Deprecated, @SuppressWarnings, and the concept that annotations are metadata the framework reads - this covers probably 80% of daily annotation usage. The @Retention/@Target pair and custom annotation writing are where the topic moves from "I use these" to "I understand how these work" - and that shift is exactly what interviewers at product companies test.
Why Annotations Matter
Before annotations existed - before Java 5 - the same kind of metadata lived in XML configuration files. Every Spring bean, every JPA entity mapping, every web servlet had a corresponding XML descriptor file. The code and its configuration were separate artifacts that had to be kept in sync manually, and any mismatch was a runtime error discovered during startup rather than a compile-time signal.
Annotations moved that configuration into the source code itself, adjacent to the thing being configured. @Entity, @Column(name="user_id"), @GetMapping("/users") express in the declaration exactly how that class or method participates in the larger system - and tools can verify them, process them, and generate warnings or errors about them before the application ever runs.
Three concrete things annotations enable:
Compiler verification. @Override is the clearest example: the compiler checks that the annotated method actually matches a method in a supertype. Without it, a typo in the method name silently creates a new method instead of overriding, and the bug only surfaces at runtime when the expected behavior does not occur.
Framework configuration. Spring, JPA, Jakarta EE, and most major frameworks read annotations at runtime via reflection to determine how to configure, inject, proxy, and manage components. @Autowired on a constructor field tells Spring what to inject; @Transactional on a service method tells Spring to wrap it in a transaction proxy.
Code generation. Compile-time annotation processors (the javax.annotation.processing API) can generate source files, validate constraints, or produce documentation at build time, before the annotated classes are compiled into bytecode. Lombok's @Getter, @Setter, and @Builder work this way - the annotation processor generates the getter, setter, and builder methods during compilation, and the resulting class file contains them as if they had been written by hand.
Built-In Annotations
Compiler Annotations
The three annotations used most often to communicate with the Java compiler directly are @Override, @Deprecated, and @SuppressWarnings.
1// File: BuiltInAnnotationsDemo.java
2
3public class BuiltInAnnotationsDemo {
4
5 interface Greeter {
6 String greet(String name);
7 }
8
9 static class EnglishGreeter implements Greeter {
10
11 // @Override tells the compiler to verify this overrides Greeter.greet()
12 // If the method name is misspelled or the parameter type is wrong,
13 // the compiler reports an error here instead of silently creating
14 // a second, unrelated method
15 @Override
16 public String greet(String name) {
17 return "Hello, " + name + "!";
18 }
19
20 // @Deprecated signals to every caller that this method should no
21 // longer be used - IDEs strike through the name, and calling it
22 // produces a compile-time warning
23 @Deprecated(since = "2.0", forRemoval = true)
24 public String helloOldStyle(String name) {
25 return "Hello " + name;
26 }
27
28 // @SuppressWarnings silences one specific warning category.
29 // "deprecation" tells the compiler not to warn that helloOldStyle
30 // is deprecated for THIS call site only - not globally
31 @SuppressWarnings("deprecation")
32 public void demonstrateDeprecated() {
33 System.out.println(helloOldStyle("Ananya")); // no warning here
34 }
35 }
36
37 @FunctionalInterface
38 interface PriceFormatter {
39 String format(double amount); // exactly one abstract method - lambda target
40 // adding a second abstract method here would be a COMPILE ERROR
41 }
42
43 public static void main(String[] args) {
44 Greeter greeter = new EnglishGreeter();
45 System.out.println(greeter.greet("Rahul"));
46
47 PriceFormatter formatter = amount -> "Rs. " + String.format("%.2f", amount);
48 System.out.println(formatter.format(1499.0));
49 }
50}Output:
Hello, Rahul!
Rs. 1499.00
Meta-Annotations
Meta-annotations control how a custom annotation behaves - where it can be placed and how long it survives.
1// File: MetaAnnotationsDemo.java
2
3import java.lang.annotation.ElementType;
4import java.lang.annotation.Retention;
5import java.lang.annotation.RetentionPolicy;
6import java.lang.annotation.Target;
7import java.lang.reflect.Field;
8
9public class MetaAnnotationsDemo {
10
11 // @Target restricts this annotation to FIELD declarations only.
12 // Placing it on a method or class would be a COMPILE ERROR.
13 // @Retention(RUNTIME) makes it readable at runtime via reflection -
14 // essential for any annotation a framework needs to process.
15 @Target(ElementType.FIELD)
16 @Retention(RetentionPolicy.RUNTIME)
17 @interface MaxLength {
18 int value();
19 String message() default "Value exceeds maximum length";
20 }
21
22 static class UserProfile {
23 @MaxLength(50)
24 private String displayName;
25
26 @MaxLength(value = 100, message = "Bio must be 100 characters or fewer")
27 private String bio;
28
29 UserProfile(String displayName, String bio) {
30 this.displayName = displayName;
31 this.bio = bio;
32 }
33 }
34
35 // A simple runtime validator that reads @MaxLength via reflection
36 static void validate(Object obj) throws IllegalAccessException {
37 for (Field field : obj.getClass().getDeclaredFields()) {
38 MaxLength maxLength = field.getAnnotation(MaxLength.class);
39 if (maxLength != null) {
40 field.setAccessible(true);
41 String value = (String) field.get(obj);
42 if (value != null && value.length() > maxLength.value()) {
43 System.out.println("INVALID - " + field.getName() + ": " + maxLength.message());
44 } else {
45 System.out.println("VALID - " + field.getName() + " (" + (value == null ? 0 : value.length()) + " chars)");
46 }
47 }
48 }
49 }
50
51 public static void main(String[] args) throws IllegalAccessException {
52 UserProfile valid = new UserProfile("Ananya Sharma", "Java developer at a fintech startup");
53 System.out.println("=== Validating a well-formed profile ===");
54 validate(valid);
55
56 System.out.println();
57
58 String longBio = "This biography is intentionally written to be longer than one hundred characters so that the MaxLength validation will trigger and report an error for this field.";
59 UserProfile invalid = new UserProfile("Rahul", longBio);
60 System.out.println("=== Validating a profile with an oversized bio ===");
61 validate(invalid);
62 }
63}Output:
=== Validating a well-formed profile ===
VALID - displayName (13 chars)
VALID - bio (36 chars)
=== Validating a profile with an oversized bio ===
VALID - displayName (5 chars)
INVALID - bio: Bio must be 100 characters or fewer
How Annotations Work Internally
Annotation Types Are Interfaces
Under the hood, every annotation declaration is compiled into an interface that extends java.lang.annotation.Annotation. Each element declared in the annotation body becomes an abstract method on that interface, and the values you supply when applying the annotation become what those methods return when called at runtime.
SOURCE:
public @interface MaxLength {
int value();
String message() default "Value exceeds maximum length";
}
COMPILED TO (conceptually):
public interface MaxLength extends java.lang.annotation.Annotation {
int value();
String message(); // default handled by the JVM proxy
}
HOW RUNTIME ANNOTATION READING WORKS:
field.getAnnotation(MaxLength.class)
|
v
JVM checks: does this field's metadata (in the .class file's
RuntimeVisibleAnnotations attribute) include MaxLength?
|
YES: JVM creates a DYNAMIC PROXY implementing MaxLength.
Calling proxy.value() returns 50 (the stored value).
Calling proxy.message() returns the default or stored value.
|
RETURNS: the MaxLength proxy object the validator code calls
RETENTION POLICY DETERMINES WHERE THE METADATA IS STORED:
RetentionPolicy.SOURCE -> discarded after javac runs, never in .class
RetentionPolicy.CLASS -> in the .class file's attributes, but NOT
in RuntimeVisibleAnnotations - reflection
cannot read it at runtime
RetentionPolicy.RUNTIME -> in RuntimeVisibleAnnotations - reflection
CAN read it while the program runs
Annotation Elements and Defaults
Annotation elements can have types from a restricted set: primitives, String, Class, enums, other annotations, and arrays of any of the above. A default value makes an element optional at the point of use. The special element name value allows the shorthand @MyAnnotation("x") syntax - but only when value is the only element being set.
1// File: AnnotationElementsDemo.java
2
3import java.lang.annotation.ElementType;
4import java.lang.annotation.Retention;
5import java.lang.annotation.RetentionPolicy;
6import java.lang.annotation.Target;
7import java.lang.reflect.Method;
8
9public class AnnotationElementsDemo {
10
11 enum Severity { LOW, MEDIUM, HIGH, CRITICAL }
12
13 @Target(ElementType.METHOD)
14 @Retention(RetentionPolicy.RUNTIME)
15 @interface AuditLog {
16 String action(); // required - no default
17 Severity severity() default Severity.LOW; // optional - has default
18 String[] tags() default {}; // optional array - empty by default
19 }
20
21 static class PaymentService {
22
23 @AuditLog(action = "INITIATE_PAYMENT", severity = Severity.HIGH, tags = {"payment", "critical"})
24 public void initiatePayment(String orderId) {
25 System.out.println("Initiating payment for: " + orderId);
26 }
27
28 @AuditLog(action = "CHECK_BALANCE") // severity defaults to LOW, tags defaults to empty
29 public double checkBalance(String accountId) {
30 System.out.println("Checking balance for: " + accountId);
31 return 5000.0;
32 }
33 }
34
35 public static void main(String[] args) throws Exception {
36 System.out.println("=== Reading @AuditLog annotations at runtime ===");
37 for (Method method : PaymentService.class.getDeclaredMethods()) {
38 AuditLog audit = method.getAnnotation(AuditLog.class);
39 if (audit != null) {
40 System.out.printf("Method %-20s action=%-20s severity=%-10s tags=%s%n",
41 method.getName(), audit.action(), audit.severity(),
42 java.util.Arrays.toString(audit.tags()));
43 }
44 }
45
46 System.out.println();
47
48 PaymentService service = new PaymentService();
49 service.initiatePayment("ORD-1001");
50 service.checkBalance("ACC-7042");
51 }
52}Output:
=== Reading @AuditLog annotations at runtime ===
Method initiatePayment action=INITIATE_PAYMENT severity=HIGH tags=[payment, critical]
Method checkBalance action=CHECK_BALANCE severity=LOW tags=[]
Initiating payment for: ORD-1001
Checking balance for: ACC-7042
Real-World Example - Razorpay Request Validation Framework
A payment API at a company like Razorpay needs to validate incoming request parameters before processing them - minimum and maximum values, required fields, and string pattern constraints. Writing the validation logic inline inside every service method clutters the business code and makes the rules invisible at first glance. Annotations let the constraints live on the fields themselves - readable at a glance, processed once by a shared validator.
1// File: NotNull.java
2
3import java.lang.annotation.ElementType;
4import java.lang.annotation.Retention;
5import java.lang.annotation.RetentionPolicy;
6import java.lang.annotation.Target;
7
8@Target(ElementType.FIELD)
9@Retention(RetentionPolicy.RUNTIME)
10public @interface NotNull {
11 String message() default "Field must not be null";
12}1// File: Range.java
2
3import java.lang.annotation.ElementType;
4import java.lang.annotation.Retention;
5import java.lang.annotation.RetentionPolicy;
6import java.lang.annotation.Target;
7
8@Target(ElementType.FIELD)
9@Retention(RetentionPolicy.RUNTIME)
10public @interface Range {
11 double min() default 0.0;
12 double max() default Double.MAX_VALUE;
13 String message() default "Value is out of the allowed range";
14}1// File: Pattern.java
2
3import java.lang.annotation.ElementType;
4import java.lang.annotation.Retention;
5import java.lang.annotation.RetentionPolicy;
6import java.lang.annotation.Target;
7
8@Target(ElementType.FIELD)
9@Retention(RetentionPolicy.RUNTIME)
10public @interface Pattern {
11 String regex();
12 String message() default "Value does not match required pattern";
13}1// File: PaymentRequest.java
2
3public class PaymentRequest {
4
5 @NotNull(message = "Order ID is required")
6 private String orderId;
7
8 @NotNull
9 @Range(min = 1.0, max = 500000.0, message = "Amount must be between Rs.1 and Rs.5,00,000")
10 private Double amount;
11
12 @NotNull
13 @Pattern(regex = "^[A-Z]{3}$", message = "Currency must be a 3-letter ISO code like INR or USD")
14 private String currency;
15
16 public PaymentRequest(String orderId, Double amount, String currency) {
17 this.orderId = orderId;
18 this.amount = amount;
19 this.currency = currency;
20 }
21}1// File: RequestValidator.java
2
3import java.lang.reflect.Field;
4import java.util.ArrayList;
5import java.util.List;
6
7public class RequestValidator {
8
9 public List<String> validate(Object request) throws IllegalAccessException {
10 List<String> errors = new ArrayList<>();
11
12 for (Field field : request.getClass().getDeclaredFields()) {
13 field.setAccessible(true);
14 Object value = field.get(request);
15
16 // Process @NotNull
17 if (field.isAnnotationPresent(NotNull.class)) {
18 NotNull notNull = field.getAnnotation(NotNull.class);
19 if (value == null) {
20 errors.add(field.getName() + ": " + notNull.message());
21 continue; // no point checking further constraints on a null value
22 }
23 }
24
25 if (value == null) continue;
26
27 // Process @Range - applies only to numeric fields
28 if (field.isAnnotationPresent(Range.class) && value instanceof Number number) {
29 Range range = field.getAnnotation(Range.class);
30 double numericValue = number.doubleValue();
31 if (numericValue < range.min() || numericValue > range.max()) {
32 errors.add(field.getName() + ": " + range.message());
33 }
34 }
35
36 // Process @Pattern - applies only to String fields
37 if (field.isAnnotationPresent(Pattern.class) && value instanceof String stringValue) {
38 Pattern pattern = field.getAnnotation(Pattern.class);
39 if (!stringValue.matches(pattern.regex())) {
40 errors.add(field.getName() + ": " + pattern.message());
41 }
42 }
43 }
44
45 return errors;
46 }
47}1// File: PaymentApiDemo.java
2
3import java.util.List;
4
5public class PaymentApiDemo {
6
7 public static void main(String[] args) throws IllegalAccessException {
8 RequestValidator validator = new RequestValidator();
9
10 System.out.println("=== Valid payment request ===");
11 PaymentRequest validRequest = new PaymentRequest("ORD-7001", 4999.0, "INR");
12 List<String> errors = validator.validate(validRequest);
13 System.out.println(errors.isEmpty() ? "Request is valid - proceeding to payment gateway" : errors);
14
15 System.out.println();
16
17 System.out.println("=== Missing order ID ===");
18 PaymentRequest missingOrderId = new PaymentRequest(null, 4999.0, "INR");
19 validator.validate(missingOrderId).forEach(e -> System.out.println(" ERROR: " + e));
20
21 System.out.println();
22
23 System.out.println("=== Amount out of range ===");
24 PaymentRequest badAmount = new PaymentRequest("ORD-7002", 750000.0, "INR");
25 validator.validate(badAmount).forEach(e -> System.out.println(" ERROR: " + e));
26
27 System.out.println();
28
29 System.out.println("=== Invalid currency code ===");
30 PaymentRequest badCurrency = new PaymentRequest("ORD-7003", 1500.0, "indian-rupee");
31 validator.validate(badCurrency).forEach(e -> System.out.println(" ERROR: " + e));
32
33 System.out.println();
34
35 System.out.println("=== Multiple violations ===");
36 PaymentRequest multipleErrors = new PaymentRequest(null, -50.0, "xx");
37 validator.validate(multipleErrors).forEach(e -> System.out.println(" ERROR: " + e));
38 }
39}Output:
=== Valid payment request ===
Request is valid - proceeding to payment gateway
=== Missing order ID ===
ERROR: orderId: Order ID is required
=== Amount out of range ===
ERROR: amount: Amount must be between Rs.1 and Rs.5,00,000
=== Invalid currency code ===
ERROR: currency: Currency must be a 3-letter ISO code like INR or USD
=== Multiple violations ===
ERROR: orderId: Order ID is required
ERROR: amount: Amount must be between Rs.1 and Rs.5,00,000
ERROR: currency: Currency must be a 3-letter ISO code like INR or USD
The constraint rules for every field in PaymentRequest are readable at a glance, exactly where the field is declared. RequestValidator processes every annotation generically, with no knowledge of specific field names - adding a new field to PaymentRequest with @NotNull and @Range is automatically validated the next time validate() runs, with no changes to RequestValidator at all. This is the pattern behind Jakarta Bean Validation (@NotNull, @Min, @Max, @javax.validation.constraints.Pattern), which works on exactly these principles at production scale.
Best Practices
Always specify both @Retention and @Target on every custom annotation. The default retention is CLASS - not RUNTIME - which means reflection cannot read the annotation at runtime, a surprise that produces silent failures where the annotation appears to be ignored. The default target is all elements, which allows the annotation to be placed in locations where it makes no sense. Both defaults are almost never what a custom annotation author actually wants.
Name annotation elements with the intended usage in mind, and use value for the single most important element. @AuditLog(action = "PAYMENT") is readable; @AuditLog("PAYMENT") is only readable when action is named value. Reserve the value shorthand for annotations where one element is overwhelmingly the primary one - @SuppressWarnings("unchecked"), @RequestMapping("/orders").
Keep annotation elements to a minimum, and provide sensible defaults for everything optional. An annotation with six required elements where only two are commonly set is hard to use and hard to read at call sites. Defaults make annotations approachable; required elements should be only those without which the annotation genuinely cannot do its job.
Do not use annotations to carry business logic. An annotation is metadata - what the annotated thing is or how it should be treated. The annotation processor or the framework is where the actual behavior lives. An annotation element whose value encodes a conditional branch inside the processor is a sign the annotation is trying to be a DSL, which usually means the design needs rethinking.
For custom validators, prefer Jakarta Bean Validation over hand-rolled reflection. The pattern in this article's example - @NotNull, @Range, a reflective validator - is exactly how Jakarta Bean Validation (javax.validation/jakarta.validation) works, and that library is already present in every Spring Boot project. Using standard annotations gives you IDE support, integration with Spring's @Valid, and a tested implementation for free.
Common Mistakes
Mistake 1 - Missing RetentionPolicy.RUNTIME on an Annotation Read at Runtime
1import java.lang.annotation.Target;
2import java.lang.annotation.ElementType;
3import java.lang.reflect.Method;
4
5// WRONG - no @Retention, so the default is RetentionPolicy.CLASS.
6// The annotation is in the .class file but NOT in
7// RuntimeVisibleAnnotations - reflection returns null at runtime.
8@Target(ElementType.METHOD)
9@interface LogCall {}
10
11class ServiceBroken {
12 @LogCall
13 void processOrder() {}
14}
15
16class BrokenReader {
17 static void demo() throws Exception {
18 Method method = ServiceBroken.class.getDeclaredMethod("processOrder");
19 LogCall annotation = method.getAnnotation(LogCall.class);
20 System.out.println(annotation); // prints: null - annotation not visible
21 }
22}
23
24// CORRECT - add @Retention(RetentionPolicy.RUNTIME)
25import java.lang.annotation.Retention;
26import java.lang.annotation.RetentionPolicy;
27
28@Target(ElementType.METHOD)
29@Retention(RetentionPolicy.RUNTIME)
30@interface LogCallFixed {}Mistake 2 - Applying an Annotation to a Target It Was Not Designed For
1import java.lang.annotation.ElementType;
2import java.lang.annotation.Retention;
3import java.lang.annotation.RetentionPolicy;
4import java.lang.annotation.Target;
5
6// @Target(ElementType.FIELD) means this annotation is for FIELDS only
7@Target(ElementType.FIELD)
8@Retention(RetentionPolicy.RUNTIME)
9@interface FieldOnly {
10 String value();
11}
12
13// WRONG - placing a field-targeted annotation on a METHOD
14// This is a COMPILE ERROR: annotation type not applicable to this kind of declaration
15class MisappliedAnnotation {
16 @FieldOnly("productName") // COMPILE ERROR
17 public String getProductName() {
18 return "Laptop";
19 }
20}
21
22// CORRECT - use the annotation only on field declarations
23class CorrectUsage {
24 @FieldOnly("productName")
25 private String productName;
26}Mistake 3 - Using an Illegal Type as an Annotation Element
1import java.lang.annotation.Retention;
2import java.lang.annotation.RetentionPolicy;
3import java.util.List;
4
5// WRONG - annotation elements can only be: primitives, String, Class,
6// enum types, other annotation types, or arrays of the above.
7// List, Map, Date, and any other class type are NOT allowed.
8@Retention(RetentionPolicy.RUNTIME)
9@interface InvalidAnnotation {
10 List<String> tags(); // COMPILE ERROR - List is not a valid annotation element type
11 java.util.Date expires(); // COMPILE ERROR - Date is not a valid annotation element type
12}
13
14// CORRECT - use arrays and strings instead of collections
15@Retention(RetentionPolicy.RUNTIME)
16@interface ValidAnnotation {
17 String[] tags() default {}; // array of String - valid
18 String expires() default ""; // String - valid, caller supplies as "2026-12-31"
19}Mistake 4 - Expecting @Inherited to Work on Interface Annotations
1import java.lang.annotation.*;
2
3// @Inherited makes this annotation propagate from a SUPERCLASS
4// to its SUBCLASSES when read via reflection
5@Inherited
6@Retention(RetentionPolicy.RUNTIME)
7@Target(ElementType.TYPE)
8@interface ServiceTier {
9 String value();
10}
11
12// WRONG ASSUMPTION - placing @ServiceTier on an INTERFACE and
13// expecting implementing CLASSES to inherit it. @Inherited only
14// works for CLASS-to-SUBCLASS inheritance, not for
15// INTERFACE-to-IMPLEMENTING-CLASS.
16@ServiceTier("payment")
17interface PaymentService {}
18
19class PaymentServiceImpl implements PaymentService {} // PaymentServiceImpl.class.getAnnotation(ServiceTier.class) returns null
20
21// CORRECT - @Inherited works for direct class inheritance only
22@ServiceTier("payment")
23class BasePaymentService {}
24
25class ConcretePaymentService extends BasePaymentService {}
26// ConcretePaymentService.class.getAnnotation(ServiceTier.class) returns the annotationInterview Questions
Q1. What is an annotation in Java, and how is it different from a comment?
An annotation is structured metadata attached to a program element - a class, method, field, or parameter - that the compiler, the JVM, or a framework can read and act on programmatically. A comment is unstructured text that compilers and tools completely ignore. The key difference is that annotations have a defined type (@interface), can carry typed data (elements with specific types and defaults), survive into the compiled .class file (depending on retention policy), and can be read at runtime via reflection or processed at compile time by annotation processors. Comments cannot be read by any tool at runtime.
Q2. What is the difference between RetentionPolicy.SOURCE, CLASS, and RUNTIME?
RetentionPolicy.SOURCE means the annotation is discarded after compilation - it exists only in the source file and is useful only to tools that process source code before the compiler runs (like Lombok's annotation processors, which generate code and then the annotation is no longer needed). RetentionPolicy.CLASS (the default) stores the annotation in the compiled .class file but not in the RuntimeVisibleAnnotations attribute, so reflection cannot read it at runtime - this is used by bytecode analysis and instrumentation tools. RetentionPolicy.RUNTIME stores the annotation in RuntimeVisibleAnnotations, making it accessible via Class.getAnnotation(), Method.getAnnotation(), and related reflection methods while the application runs. Most custom annotations for framework-style processing need RUNTIME.
Q3. What are meta-annotations, and what does each one do?
Meta-annotations are annotations on other annotation declarations - they configure how the annotation behaves. @Retention specifies how long the annotation survives (SOURCE, CLASS, or RUNTIME). @Target restricts which kinds of elements the annotation can be placed on (TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR, etc.). @Documented includes the annotation in Javadoc output for annotated elements. @Inherited makes a class-level annotation automatically visible on subclasses when read via reflection (this applies only to class annotations, not to interface annotations or any other kind). @Repeatable allows the same annotation to be applied multiple times to the same element, by specifying a container annotation that holds an array of the repeated annotation.
Q4. How are annotations read at runtime, and what is the role of reflection?
Annotations with RetentionPolicy.RUNTIME are stored in the .class file's RuntimeVisibleAnnotations attribute. At runtime, the reflection API exposes them through methods like Class.getAnnotation(AnnotationType.class), Method.getAnnotation(AnnotationType.class), and Field.getAnnotation(AnnotationType.class). When called, the JVM creates a dynamic proxy implementing the annotation's interface, with each element's method returning the value stored in the bytecode attribute. The caller calls annotation.value() or annotation.message() on this proxy to retrieve the element values - which is exactly what frameworks like Spring and Hibernate do to read their configuration annotations when the application starts.
Q5. What types are valid for annotation elements, and why is this restriction in place?
Annotation elements must be of one of these types: any primitive (int, long, double, etc.), String, Class (or a parameterized form of Class), any enum type, any annotation type, or a one-dimensional array of any of the above. No other types - no List, no Map, no arbitrary class instances - are allowed. The restriction exists because annotation values must be constants: they must be known and representable at compile time and storable in the class file's constant pool. List and Map instances are heap objects that cannot be represented as compile-time constants; arrays of primitives and strings can be.
Q6. What is the difference between compile-time annotation processing and runtime annotation reading?
Compile-time annotation processing uses the javax.annotation.processing.Processor API - annotation processors run during compilation, can read annotations on source files before they become class files, and can generate new source files, validate constraints, or report custom compiler errors and warnings. The generated files are then compiled as part of the same build. Lombok works this way: @Getter causes the processor to generate getter method source code during compilation. Runtime annotation reading uses the reflection API to access annotations from compiled class files while the application runs. Spring's component scanning works this way: it reads @Component, @Service, @Autowired, and similar annotations from already-compiled classes at application startup to configure the dependency injection container.
FAQs
Can an annotation have another annotation as an element?
Yes - annotation types are a valid element type. This is how composed or nested annotation designs work in frameworks. For example, a @SpringBootTest annotation might include a @BootstrapWith(SpringBootTestContextBootstrapper.class) element, where @BootstrapWith is itself an annotation. When reading the outer annotation at runtime, the inner annotation is also accessible as a proxy object with its own elements.
Why does @Override exist if the compiler would catch the wrong signature anyway?
Without @Override, a method that SHOULD override a superclass method but has a typo in the name or a slightly different parameter type simply becomes a new, unrelated method - no error, no warning, and the expected behavior silently stops working. @Override tells the compiler the developer's INTENT is to override, so if the method does not match any method in the supertype, the compiler reports an error at the annotation. It turns a subtle runtime bug into a compile-time error.
Can you annotate a lambda expression in Java?
Not directly in the way you annotate a named method. A lambda can be annotated if the functional interface it implements carries type annotations, or if the variable it is assigned to is annotated. The lambda expression itself cannot have @Override or @SuppressWarnings placed on it directly - those apply to named method declarations. However, parameters of a lambda can carry type annotations in some contexts, depending on how the functional interface is declared and which Java version is in use.
What happens if you define an annotation without @Target?
Without @Target, the annotation can be placed on any declaration - classes, methods, fields, parameters, constructors, packages, local variables - but NOT on type uses (which requires ElementType.TYPE_USE explicitly). This is almost never the right choice for a custom annotation, because an annotation meaningful only on fields being silently accepted on a class declaration misleads readers and produces silent, hard-to-debug processing errors when the processor finds it in an unexpected location.
Is it possible to make an annotation required, so the compiler enforces it must be present?
No - the Java language has no mechanism to require that a particular annotation must be present on a declaration. Annotations are always optional from the compiler's perspective. Enforcement can be achieved through compile-time annotation processors (a processor that scans all classes in a package and reports an error if a class implementing some interface lacks a specific annotation), through checkstyle or similar static analysis tools configured to enforce the presence, or through runtime checks at startup that reflect over all relevant classes and fail fast if the annotation is absent.
Are annotations inherited by subclasses?
Only class-level annotations with @Inherited are automatically inherited by subclasses when read via getAnnotation(). Without @Inherited, SubClass.class.getAnnotation(SomeAnnotation.class) returns null even if ParentClass carries the annotation. Method annotations are never inherited - if you want a subclass's overriding method to carry the same annotation as the parent, it must be re-applied explicitly. Interface annotations are also never inherited by implementing classes, even if the annotation is marked @Inherited.
Summary
Annotations are metadata - typed, structured, and readable by tools - attached to the program elements they describe. The built-in ones (@Override, @Deprecated, @FunctionalInterface) do specific, well-understood things at compile time. The meta-annotations (@Retention, @Target) control how every custom annotation behaves, and getting them wrong - especially defaulting to CLASS retention instead of RUNTIME - is the single most common source of "my annotation is being ignored" bugs.
Custom annotations follow the same four-step pattern every time: declare the annotation with @interface, set @Retention(RetentionPolicy.RUNTIME) and a specific @Target, annotate the fields or methods with the relevant data, and write the processor (a reflective validator, a framework startup routine, or a compile-time processor) that reads those annotations and acts on them. The Razorpay example above is that pattern at its most direct - constraints declared once on fields, processed generically by a validator that knows nothing about specific field names.
The annotation-to-processor gap is what interviewers at product companies probe: anyone can name @Override. Understanding that the annotation is inert without a processor, that retention controls whether reflection can reach the annotation, and that compile-time processors work before the code becomes a class file - that is what separates surface-level familiarity from genuine architectural understanding of how annotation-driven frameworks are built.
What to Read Next
Learn the annotations Java already provides, like @Override.