Java Tutorial
🔍

Java Custom Annotations

Java Custom Annotations

A custom annotation is one you define yourself using @interface - it looks like a regular annotation at the call site but is declared by your codebase rather than by the Java platform. Frameworks like Spring, JPA, and Lombok are built almost entirely on top of custom annotations that their teams wrote exactly this way. The mechanism is not hidden or special - any Java developer can write an annotation, attach it to classes and methods, and write code that reads those annotations at runtime via reflection or processes them at compile time. Understanding how to build one end-to-end is what separates "I use framework annotations" from "I understand how framework annotations work."

What Is a Custom Annotation?

A custom annotation is declared with the @interface keyword. Its body contains elements - which look like methods but declare the metadata fields the annotation carries - each with an optional default value.

DECLARING A CUSTOM ANNOTATION:

  @Retention(RetentionPolicy.RUNTIME)   <- meta-annotation: how long it survives
  @Target(ElementType.METHOD)            <- meta-annotation: where it can be placed
  public @interface AuditLog {
      String action();                    <- required element (no default)
      String module() default "GENERAL"; <- optional element (has a default)
      boolean sensitive() default false;  <- optional element
  }

APPLYING THE ANNOTATION:

  @AuditLog(action = "TRANSFER_FUNDS", module = "PAYMENTS", sensitive = true)
  public void transferFunds(String fromAccount, String toAccount, double amount) { ... }

  @AuditLog(action = "VIEW_STATEMENT")  // module and sensitive use their defaults
  public List<Transaction> getStatement(String accountId) { ... }

THE RELATIONSHIP BETWEEN DECLARATION AND USAGE:
  - Elements in @interface become the named attributes at the call site
  - Elements with no default are REQUIRED at every call site
  - The element named 'value' has a shorthand: @AuditLog("TRANSFER_FUNDS")
    is valid if 'value' is the only element being set
  - The annotation itself does NOTHING without a PROCESSOR that reads it
    and acts on it - the annotation is metadata; the processor is behavior

Basic Overview - The Four Things You Need to Know

1. THE DECLARATION - @interface with elements
   Fresher view  : declare the annotation the same way you declare a
                   class - one file, a name, and a body containing the
                   data you want to attach (called elements)
   Deeper view   : @interface compiles to an interface extending
                   java.lang.annotation.Annotation. Each element
                   becomes an abstract method on that interface. When
                   applied, the values are stored in the .class file's
                   annotation attributes. At runtime, the JVM creates
                   a dynamic proxy implementing the interface - calling
                   annotation.action() calls the proxy, which returns
                   the stored value

2. THE META-ANNOTATIONS - @Retention and @Target
   Fresher view  : @Retention(RUNTIME) means you can READ the
                   annotation at runtime. Without it you cannot.
                   @Target(METHOD) means the annotation can only go
                   on methods. Without it, it can go anywhere.
   Deeper view   : the default retention is CLASS - .class file but
                   not runtime-readable. Forgetting @Retention(RUNTIME)
                   is the single most common bug when building a custom
                   annotation, because everything compiles and applies
                   correctly but reflection returns null silently

3. THE ELEMENT RULES - valid types for annotation elements
   Fresher view  : you can use numbers, booleans, Strings, Class
                   types, enum values, other annotations, and arrays
                   of any of those. You CANNOT use List, Map, Date,
                   or any arbitrary class.
   Deeper view   : annotation element values must be compile-time
                   constants - known at compile time and expressible
                   in the .class file's constant pool. Heap objects
                   (List, Map, etc.) cannot be constants, which is
                   why they are excluded

4. THE PROCESSOR - what gives the annotation meaning
   Fresher view  : an annotation by itself does nothing. You write
                   SEPARATE code that reads the annotation using
                   reflection (at runtime) or annotation processing
                   (at compile time) and decides what to do
   Deeper view   : runtime reflection via getAnnotation() is for
                   frameworks that act at application startup or
                   request time - Spring, validators, AOP interceptors.
                   Compile-time annotation processing (APT) via
                   javax.annotation.processing.Processor is for code
                   generation tools - Lombok, Dagger, MapStruct

A fresher needs the first two boxes to start: declare the annotation with the right meta-annotations, apply it, read it with reflection. The element-type restriction and the processor-vs-annotation distinction are where this topic earns its place as a genuine interview topic - they reveal whether someone has actually built a custom annotation or just read about one.

Why Custom Annotations Matter

The alternative to annotations for the same purposes is configuration that lives somewhere else - XML files, constructor arguments, method calls in main(), or comments that only humans read. Annotations move configuration and metadata into the source code, adjacent to the thing they describe, where the compiler can verify they are syntactically correct and where any developer opening the file can see them immediately.

Three concrete problems custom annotations solve in production codebases:

Declarative cross-cutting concerns. Logging, security checks, transaction management, and caching all need to happen around many different methods without duplicating the same wrapper code in each one. @AuditLog, @RequiresPermission, @Transactional, and @Cacheable let a developer declare "this method needs this behavior" at the method, and a processor (typically an AOP interceptor) applies the behavior at runtime - without the method's own code knowing about it.

Validation constraints as field metadata. @NotNull, @Range(min=1, max=500000), @Pattern(regex="...") placed on DTO fields describe what valid values look like, right where the field is declared. A generic validator reads all the fields at runtime via reflection and applies the rules without knowing any specific field name - adding a new field with annotations automatically validates it.

Compile-time code generation. Lombok's @Getter, @Setter, @Builder, and @AllArgsConstructor demonstrate the other major application: annotation processors that run during compilation and generate boilerplate Java source code before the class is compiled. The annotation is the signal; the processor is the code generator.

Creating and Using a Custom Annotation

Step 1 - Declare the Annotation

Every element in an annotation body is declared as a no-argument method signature. The allowed return types are: byte, short, int, long, float, double, boolean, char, String, Class (or Class<?> with wildcards), any enum type, any annotation type, and one-dimensional arrays of any of the above. default makes an element optional.

1// File: AuditLog.java 2 3import java.lang.annotation.ElementType; 4import java.lang.annotation.Retention; 5import java.lang.annotation.RetentionPolicy; 6import java.lang.annotation.Target; 7 8// @Retention(RUNTIME) - this annotation must be visible via reflection 9// at runtime because the audit interceptor reads it when a method is called 10@Retention(RetentionPolicy.RUNTIME) 11 12// @Target - restricts to METHOD declarations only. 13// Placing @AuditLog on a class or field would be a COMPILE ERROR. 14@Target(ElementType.METHOD) 15public @interface AuditLog { 16 17 // Required element - every use of @AuditLog must supply an action 18 String action(); 19 20 // Optional elements with defaults - callers can omit these 21 String module() default "GENERAL"; 22 boolean sensitive() default false; 23 24 // Array element with default - callers supply "tags = {"payment", "critical"}" 25 String[] tags() default {}; 26}

Step 2 - Apply the Annotation

1// File: PaymentService.java 2 3public class PaymentService { 4 5 // All three optional elements left at defaults 6 @AuditLog(action = "VIEW_BALANCE") 7 public double getBalance(String accountId) { 8 System.out.println("Fetching balance for: " + accountId); 9 return 15000.0; 10 } 11 12 // All elements explicitly set 13 @AuditLog( 14 action = "TRANSFER_FUNDS", 15 module = "PAYMENTS", 16 sensitive = true, 17 tags = {"fund-transfer", "high-value"} 18 ) 19 public boolean transferFunds(String fromAccount, String toAccount, double amount) { 20 System.out.println("Transferring Rs." + amount + " from " + fromAccount + " to " + toAccount); 21 return amount <= 100000.0; 22 } 23 24 // No @AuditLog - not every method needs auditing 25 public String getAccountType(String accountId) { 26 return "SAVINGS"; 27 } 28}

Step 3 - Read the Annotation via Reflection

1// File: AuditLogReader.java 2 3import java.lang.reflect.Method; 4import java.util.Arrays; 5 6public class AuditLogReader { 7 8 public static void main(String[] args) throws Exception { 9 System.out.println("=== Scanning PaymentService for @AuditLog annotations ==="); 10 System.out.println(); 11 12 for (Method method : PaymentService.class.getDeclaredMethods()) { 13 14 // getAnnotation() returns null if the annotation is absent 15 // or if its retention is not RUNTIME 16 AuditLog auditLog = method.getAnnotation(AuditLog.class); 17 18 if (auditLog != null) { 19 System.out.println("Method : " + method.getName()); 20 System.out.println("Action : " + auditLog.action()); 21 System.out.println("Module : " + auditLog.module()); 22 System.out.println("Sensitive: " + auditLog.sensitive()); 23 System.out.println("Tags : " + Arrays.toString(auditLog.tags())); 24 System.out.println(); 25 } 26 } 27 28 System.out.println("=== getAccountType has no @AuditLog ==="); 29 Method noAnnotation = PaymentService.class.getDeclaredMethod("getAccountType", String.class); 30 System.out.println("getAnnotation returned: " + noAnnotation.getAnnotation(AuditLog.class)); 31 } 32}
Output:
=== Scanning PaymentService for @AuditLog annotations ===

Method   : getBalance
Action   : VIEW_BALANCE
Module   : GENERAL
Sensitive: false
Tags     : []

Method   : transferFunds
Action   : TRANSFER_FUNDS
Module   : PAYMENTS
Sensitive: true
Tags     : [fund-transfer, high-value]

=== getAccountType has no @AuditLog ===
getAnnotation returned: null

Building a Complete Custom Annotation System

A single annotation reads cleanly but rarely shows what custom annotations are for in production. The real pattern is: a set of annotations declaring constraints or behavior, a processor that reads them all generically, and the annotated classes themselves knowing nothing about the processor - the same pattern that drives Jakarta Bean Validation and Spring's component model.

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@Retention(RetentionPolicy.RUNTIME) 9@Target(ElementType.FIELD) 10public @interface NotNull { 11 String message() default "Field must not be null"; 12}
1// File: MinLength.java 2 3import java.lang.annotation.ElementType; 4import java.lang.annotation.Retention; 5import java.lang.annotation.RetentionPolicy; 6import java.lang.annotation.Target; 7 8@Retention(RetentionPolicy.RUNTIME) 9@Target(ElementType.FIELD) 10public @interface MinLength { 11 int value(); 12 String message() default "Value is shorter than the minimum allowed length"; 13}
1// File: MaxLength.java 2 3import java.lang.annotation.ElementType; 4import java.lang.annotation.Retention; 5import java.lang.annotation.RetentionPolicy; 6import java.lang.annotation.Target; 7 8@Retention(RetentionPolicy.RUNTIME) 9@Target(ElementType.FIELD) 10public @interface MaxLength { 11 int value(); 12 String message() default "Value exceeds the maximum allowed length"; 13}
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@Retention(RetentionPolicy.RUNTIME) 9@Target(ElementType.FIELD) 10public @interface Range { 11 double min() default 0.0; 12 double max() default Double.MAX_VALUE; 13 String message() default "Value is outside the allowed range"; 14}
1// File: AnnotationValidator.java 2 3import java.lang.reflect.Field; 4import java.util.ArrayList; 5import java.util.List; 6 7public class AnnotationValidator { 8 9 public List<String> validate(Object obj) throws IllegalAccessException { 10 List<String> errors = new ArrayList<>(); 11 12 for (Field field : obj.getClass().getDeclaredFields()) { 13 field.setAccessible(true); 14 Object value = field.get(obj); 15 16 // Process @NotNull first - if null, skip further checks on this field 17 if (field.isAnnotationPresent(NotNull.class)) { 18 NotNull constraint = field.getAnnotation(NotNull.class); 19 if (value == null) { 20 errors.add(field.getName() + ": " + constraint.message()); 21 continue; 22 } 23 } 24 25 if (value == null) continue; 26 27 // @MinLength - only for String fields 28 if (field.isAnnotationPresent(MinLength.class) && value instanceof String stringValue) { 29 MinLength constraint = field.getAnnotation(MinLength.class); 30 if (stringValue.length() < constraint.value()) { 31 errors.add(field.getName() + ": " + constraint.message() 32 + " (min=" + constraint.value() + ", actual=" + stringValue.length() + ")"); 33 } 34 } 35 36 // @MaxLength - only for String fields 37 if (field.isAnnotationPresent(MaxLength.class) && value instanceof String stringValue) { 38 MaxLength constraint = field.getAnnotation(MaxLength.class); 39 if (stringValue.length() > constraint.value()) { 40 errors.add(field.getName() + ": " + constraint.message() 41 + " (max=" + constraint.value() + ", actual=" + stringValue.length() + ")"); 42 } 43 } 44 45 // @Range - only for numeric fields 46 if (field.isAnnotationPresent(Range.class) && value instanceof Number number) { 47 Range constraint = field.getAnnotation(Range.class); 48 double numericValue = number.doubleValue(); 49 if (numericValue < constraint.min() || numericValue > constraint.max()) { 50 errors.add(field.getName() + ": " + constraint.message() 51 + " (min=" + constraint.min() + ", max=" + constraint.max() 52 + ", actual=" + numericValue + ")"); 53 } 54 } 55 } 56 57 return errors; 58 } 59}
1// File: AnnotationValidatorDemo.java 2 3import java.util.List; 4 5public class AnnotationValidatorDemo { 6 7 static class UserRegistrationRequest { 8 9 @NotNull(message = "Username is required") 10 @MinLength(value = 3, message = "Username must be at least 3 characters") 11 @MaxLength(value = 30, message = "Username must not exceed 30 characters") 12 private String username; 13 14 @NotNull(message = "Email is required") 15 @MinLength(value = 6, message = "Email is too short to be valid") 16 private String email; 17 18 @NotNull 19 @Range(min = 18, max = 120, message = "Age must be between 18 and 120") 20 private Integer age; 21 22 UserRegistrationRequest(String username, String email, Integer age) { 23 this.username = username; 24 this.email = email; 25 this.age = age; 26 } 27 } 28 29 public static void main(String[] args) throws Exception { 30 AnnotationValidator validator = new AnnotationValidator(); 31 32 System.out.println("=== Valid request ==="); 33 var validReq = new UserRegistrationRequest("ananya_dev", "ananya@example.com", 26); 34 List<String> errors = validator.validate(validReq); 35 System.out.println(errors.isEmpty() ? "All validations passed" : errors); 36 37 System.out.println(); 38 39 System.out.println("=== Username too short ==="); 40 var shortName = new UserRegistrationRequest("an", "an@ex.com", 22); 41 validator.validate(shortName).forEach(e -> System.out.println(" FAIL: " + e)); 42 43 System.out.println(); 44 45 System.out.println("=== Null required field ==="); 46 var nullEmail = new UserRegistrationRequest("rahul_singh", null, 30); 47 validator.validate(nullEmail).forEach(e -> System.out.println(" FAIL: " + e)); 48 49 System.out.println(); 50 51 System.out.println("=== Age out of range ==="); 52 var badAge = new UserRegistrationRequest("priya_dev", "priya@dev.com", 15); 53 validator.validate(badAge).forEach(e -> System.out.println(" FAIL: " + e)); 54 55 System.out.println(); 56 57 System.out.println("=== Multiple violations ==="); 58 var multipleViolations = new UserRegistrationRequest(null, "x", 200); 59 validator.validate(multipleViolations).forEach(e -> System.out.println(" FAIL: " + e)); 60 } 61}
Output:
=== Valid request ===
All validations passed

=== Username too short ===
  FAIL: username: Username must be at least 3 characters (min=3, actual=2)

=== Null required field ===
  FAIL: email: Email is required

=== Age out of range ===
  FAIL: age: Age must be between 18 and 120 (min=18.0, max=120.0, actual=15.0)

=== Multiple violations ===
  FAIL: username: Username is required
  FAIL: email: Email is shorter than the minimum allowed length (min=6, actual=1)
  FAIL: age: Age must be between 18 and 120 (min=18.0, max=120.0, actual=200.0)

Real-World Example - PhonePe Role-Based Access Control

A financial platform needs method-level security: some methods are accessible only to users with specific roles, and calling a method without the required role should fail before any business logic runs. An @RequiresRole custom annotation placed on each method declares the requirement; a runtime interceptor reads the annotation and enforces it against the currently authenticated user - the service methods themselves never check roles directly.

1// File: RequiresRole.java 2 3import java.lang.annotation.ElementType; 4import java.lang.annotation.Retention; 5import java.lang.annotation.RetentionPolicy; 6import java.lang.annotation.Target; 7 8@Retention(RetentionPolicy.RUNTIME) 9@Target(ElementType.METHOD) 10public @interface RequiresRole { 11 String[] value(); 12 String errorMessage() default "Access denied: insufficient role"; 13}
1// File: UserRole.java 2 3public enum UserRole { 4 CUSTOMER, MERCHANT, ANALYST, ADMIN 5}
1// File: AuthenticatedUser.java 2 3public class AuthenticatedUser { 4 private final String userId; 5 private final UserRole role; 6 7 public AuthenticatedUser(String userId, UserRole role) { 8 this.userId = userId; 9 this.role = role; 10 } 11 12 public String getUserId() { return userId; } 13 public UserRole getRole() { return role; } 14}
1// File: WalletService.java 2 3public class WalletService { 4 5 @RequiresRole("CUSTOMER") 6 public double getWalletBalance(String userId) { 7 System.out.println(" [WalletService] Fetching wallet balance for: " + userId); 8 return 2350.0; 9 } 10 11 @RequiresRole({"MERCHANT", "ADMIN"}) 12 public void processMerchantSettlement(String merchantId, double amount) { 13 System.out.println(" [WalletService] Settling Rs." + amount + " for merchant: " + merchantId); 14 } 15 16 @RequiresRole( 17 value = {"ANALYST", "ADMIN"}, 18 errorMessage = "Financial reports require ANALYST or ADMIN role" 19 ) 20 public String generateFinancialReport(String period) { 21 return " [WalletService] Report for period: " + period; 22 } 23 24 // No @RequiresRole - public endpoint, no access control needed 25 public String getServiceStatus() { 26 return "WalletService: OPERATIONAL"; 27 } 28}
1// File: RoleInterceptor.java 2 3import java.lang.reflect.Method; 4import java.util.Arrays; 5 6public class RoleInterceptor { 7 8 private final AuthenticatedUser currentUser; 9 10 public RoleInterceptor(AuthenticatedUser currentUser) { 11 this.currentUser = currentUser; 12 } 13 14 public void checkAccess(Method method) { 15 RequiresRole annotation = method.getAnnotation(RequiresRole.class); 16 17 if (annotation == null) { 18 System.out.println(" [Interceptor] No role restriction on " + method.getName() + " - proceeding"); 19 return; 20 } 21 22 String[] requiredRoles = annotation.value(); 23 boolean hasRequiredRole = Arrays.stream(requiredRoles) 24 .anyMatch(role -> role.equals(currentUser.getRole().name())); 25 26 if (!hasRequiredRole) { 27 throw new SecurityException( 28 "[Interceptor] " + annotation.errorMessage() 29 + " | User: " + currentUser.getUserId() 30 + " [" + currentUser.getRole() + "]" 31 + " | Required: " + Arrays.toString(requiredRoles) 32 ); 33 } 34 35 System.out.println(" [Interceptor] Access granted - " + currentUser.getUserId() 36 + " [" + currentUser.getRole() + "] -> " + method.getName()); 37 } 38}
1// File: RoleAccessControlDemo.java 2 3import java.lang.reflect.Method; 4 5public class RoleAccessControlDemo { 6 7 static void invoke(RoleInterceptor interceptor, WalletService service, 8 String methodName, Class<?>[] paramTypes, Object... args) { 9 try { 10 Method method = WalletService.class.getDeclaredMethod(methodName, paramTypes); 11 interceptor.checkAccess(method); 12 Object result = method.invoke(service, args); 13 if (result != null) System.out.println(" Result: " + result); 14 } catch (SecurityException securityException) { 15 System.out.println(" BLOCKED: " + securityException.getMessage()); 16 } catch (Exception e) { 17 System.out.println(" ERROR: " + e.getCause().getMessage()); 18 } 19 } 20 21 public static void main(String[] args) throws Exception { 22 WalletService service = new WalletService(); 23 24 System.out.println("=== Customer accessing their own wallet balance ==="); 25 AuthenticatedUser customer = new AuthenticatedUser("USR-001", UserRole.CUSTOMER); 26 RoleInterceptor customerInterceptor = new RoleInterceptor(customer); 27 invoke(customerInterceptor, service, "getWalletBalance", 28 new Class[]{String.class}, "USR-001"); 29 30 System.out.println(); 31 32 System.out.println("=== Customer attempting merchant settlement ==="); 33 invoke(customerInterceptor, service, "processMerchantSettlement", 34 new Class[]{String.class, double.class}, "MER-501", 5000.0); 35 36 System.out.println(); 37 38 System.out.println("=== Analyst accessing financial report ==="); 39 AuthenticatedUser analyst = new AuthenticatedUser("ANA-007", UserRole.ANALYST); 40 RoleInterceptor analystInterceptor = new RoleInterceptor(analyst); 41 invoke(analystInterceptor, service, "generateFinancialReport", 42 new Class[]{String.class}, "Q1-2026"); 43 44 System.out.println(); 45 46 System.out.println("=== Admin accessing everything ==="); 47 AuthenticatedUser admin = new AuthenticatedUser("ADM-001", UserRole.ADMIN); 48 RoleInterceptor adminInterceptor = new RoleInterceptor(admin); 49 invoke(adminInterceptor, service, "processMerchantSettlement", 50 new Class[]{String.class, double.class}, "MER-502", 12000.0); 51 52 System.out.println(); 53 54 System.out.println("=== Service status - no role restriction ==="); 55 invoke(adminInterceptor, service, "getServiceStatus", new Class[]{}); 56 } 57}
Output:
=== Customer accessing their own wallet balance ===
  [Interceptor] Access granted - USR-001 [CUSTOMER] -> getWalletBalance
  [WalletService] Fetching wallet balance for: USR-001
  Result: 2350.0

=== Customer attempting merchant settlement ===
  BLOCKED: [Interceptor] Access denied: insufficient role | User: USR-001 [CUSTOMER] | Required: [MERCHANT, ADMIN]

=== Analyst accessing financial report ===
  [Interceptor] Access granted - ANA-007 [ANALYST] -> generateFinancialReport
  Result:   [WalletService] Report for period: Q1-2026

=== Admin accessing everything ===
  [Interceptor] Access granted - ADM-001 [ADMIN] -> processMerchantSettlement
  [WalletService] Settling Rs.12000.0 for merchant: MER-502

=== Service status - no role restriction ===
  [Interceptor] No role restriction on getServiceStatus - proceeding
  Result: WalletService: OPERATIONAL

WalletService contains zero security code - no role checks, no user context, no if branches for permission. The entire access control logic lives in RoleInterceptor, which reads @RequiresRole via reflection and enforces it. Adding a new method to WalletService with @RequiresRole("ADMIN") is automatically protected the moment the annotation is present - no change to RoleInterceptor needed. This is exactly the pattern Spring Security's method-level security (@PreAuthorize) is built on.

Custom Annotation - Design Checklist

DecisionQuestionDefault Recommendation
@RetentionDoes the annotation need to be readable at runtime via reflection?Yes for most custom annotations - use RUNTIME. Use SOURCE only for code generation tools.
@TargetWhich program element types should carry this annotation?Restrict to exactly what makes sense - do not leave it unrestricted
Required vs optional elementsDoes every usage always need this element?Make it required (no default) if omitting it would make the annotation meaningless; optional otherwise
Element typeWhat data type does this element need?Prefer String, int, boolean, Class, enum. Arrays need String[] not List<String>.
Processor locationRuntime reflection or compile-time APT?Runtime reflection for validators and interceptors; compile-time APT for code generation
DocumentationDoes it carry a @Documented annotation?Add it if the annotation is part of a public API where Javadoc consumers need to see it

Best Practices

Always declare @Retention(RetentionPolicy.RUNTIME) explicitly unless there is a specific reason not to. The default retention is CLASS - the annotation ends up in the .class file but is not accessible via reflection at runtime. Forgetting this is the single most common bug in custom annotation development: the annotation applies at the call site, the processor's getAnnotation() call returns null, and there is no compile-time signal that anything is wrong.

Always declare @Target explicitly, restricted to the elements the annotation is designed for. Without @Target, the annotation can be placed anywhere - on a class, a method, a field, a parameter - even where it makes no sense and where the processor will never look for it. A narrowly scoped target makes misuse a compile error rather than a silent no-op.

Name the most important element value when there is one dominant piece of data, and give everything else a default. This enables the shorthand @AuditLog("TRANSFER_FUNDS") when only the action needs to be specified. Every optional element with a good default reduces the annotation's cognitive overhead at each call site.

Keep annotation elements as data, not behavior. An element that encodes a conditional branch inside the processor - @AuditLog(mode = "ASYNC_IF_HIGH_VALUE") where the processor branches on the string value - is usually a sign the design needs a separate annotation or a different approach. Annotations describe what; processors decide what to do.

Write the processor first as a plain class, then add annotation support. A validator that works by calling explicit methods is easier to test, easier to iterate, and easier to understand than one that only works through reflection. Adding annotation-reading on top of a working processor is straightforward; debugging a processor that was built annotation-first is not.

Common Mistakes

Mistake 1 - Missing @Retention(RetentionPolicy.RUNTIME)

1import java.lang.annotation.Target; 2import java.lang.annotation.ElementType; 3import java.lang.reflect.Method; 4 5// WRONG - no @Retention, so the default applies: RetentionPolicy.CLASS 6// The annotation IS in the .class file, but NOT in RuntimeVisibleAnnotations. 7// Reflection at runtime sees nothing - getAnnotation() returns null silently. 8@Target(ElementType.METHOD) 9@interface AuditLogBroken { 10 String action(); 11} 12 13class ServiceBroken { 14 @AuditLogBroken(action = "TRANSFER") 15 public void transfer() {} 16} 17 18class ProcessorBroken { 19 static void demo() throws Exception { 20 Method method = ServiceBroken.class.getDeclaredMethod("transfer"); 21 AuditLogBroken annotation = method.getAnnotation(AuditLogBroken.class); 22 System.out.println(annotation); // prints: null 23 // Processor silently does nothing - no error, no warning 24 } 25} 26 27// CORRECT - explicitly declare RUNTIME retention 28import java.lang.annotation.Retention; 29import java.lang.annotation.RetentionPolicy; 30 31@Target(ElementType.METHOD) 32@Retention(RetentionPolicy.RUNTIME) 33@interface AuditLogFixed { 34 String action(); 35}

Mistake 2 - Using an Illegal Type as an Annotation Element

1import java.lang.annotation.Retention; 2import java.lang.annotation.RetentionPolicy; 3import java.util.List; 4import java.util.Map; 5 6// WRONG - List, Map, Date, and other arbitrary class instances are 7// NOT valid annotation element types. Annotation elements must be 8// compile-time constants: primitives, String, Class, enum, other 9// annotation types, or arrays of those. COMPILE ERROR on both elements. 10@Retention(RetentionPolicy.RUNTIME) 11@interface InvalidConfig { 12 List<String> roles(); // COMPILE ERROR 13 Map<String, String> params(); // COMPILE ERROR 14 java.util.Date expiresAt(); // COMPILE ERROR 15} 16 17// CORRECT - use arrays instead of collections, String for serialized values 18@Retention(RetentionPolicy.RUNTIME) 19@interface ValidConfig { 20 String[] roles() default {}; 21 String[] paramKeys() default {}; 22 String[] paramValues() default {}; 23 String expiresAt() default ""; // caller supplies "2026-12-31" 24}

Mistake 3 - Defining an Annotation Without @Target (Allowing It Everywhere)

1import java.lang.annotation.Retention; 2import java.lang.annotation.RetentionPolicy; 3 4// WRONG - no @Target means this annotation can be placed on classes, 5// methods, fields, parameters, constructors, local variables, packages 6// - anywhere. If the processor only looks at METHOD-level annotations, 7// placing this on a FIELD is a silent no-op that looks correct to the 8// developer but is never processed. There is no compile-time signal. 9@Retention(RetentionPolicy.RUNTIME) 10@interface NoTargetAnnotation { 11 String value(); 12} 13 14// CORRECT - restrict to exactly the element types the processor 15// actually handles. Misuse is now a COMPILE ERROR, not a silent no-op. 16import java.lang.annotation.ElementType; 17import java.lang.annotation.Target; 18 19@Retention(RetentionPolicy.RUNTIME) 20@Target(ElementType.METHOD) 21@interface CorrectTargetAnnotation { 22 String value(); 23}

Mistake 4 - Reading Annotations From a Supertype's Methods on a Subclass Instance

1import java.lang.annotation.*; 2import java.lang.reflect.Method; 3 4@Retention(RetentionPolicy.RUNTIME) 5@Target(ElementType.METHOD) 6@interface Loggable { String value(); } 7 8class BaseService { 9 @Loggable("BASE_OPERATION") 10 public void operate() {} 11} 12 13class ConcreteService extends BaseService { 14 @Override 15 public void operate() { 16 System.out.println("Concrete implementation"); 17 } 18} 19 20class MisreadProcessor { 21 static void demo() throws Exception { 22 // WRONG ASSUMPTION - the developer expects to read @Loggable 23 // via ConcreteService's 'operate' method, but ConcreteService.operate() 24 // OVERRIDES the method without repeating @Loggable. The annotation 25 // is on BASE's method, not on CONCRETE's method. Method annotations 26 // are NOT inherited through overrides. 27 Method concreteMethod = ConcreteService.class.getDeclaredMethod("operate"); 28 Loggable annotation = concreteMethod.getAnnotation(Loggable.class); 29 System.out.println(annotation); // null - not on the overriding method 30 31 // CORRECT - also check the superclass's method declaration 32 Method baseMethod = BaseService.class.getDeclaredMethod("operate"); 33 Loggable baseAnnotation = baseMethod.getAnnotation(Loggable.class); 34 System.out.println(baseAnnotation.value()); // "BASE_OPERATION" 35 } 36}

Interview Questions

Q1. How do you declare a custom annotation in Java, and what meta-annotations are essential?

A custom annotation is declared with the @interface keyword. Its body lists elements as method signatures with optional default values. The two essential meta-annotations are @Retention and @Target. @Retention(RetentionPolicy.RUNTIME) makes the annotation accessible via reflection at runtime - omitting it defaults to CLASS, which is in the .class file but invisible to reflection. @Target restricts where the annotation can be placed; without it, the annotation is valid everywhere, which makes misuse impossible to catch at compile time. In production code, both should always be explicit.

Q2. What types are valid for annotation elements, and why?

Valid types are: the eight primitive types, String, Class (with optional wildcard parameterization), any enum type, any annotation type, and one-dimensional arrays of any of the above. No other types - List, Map, Date, arbitrary class instances - are permitted. The restriction exists because annotation element values must be compile-time constants expressible in the class file's constant pool. List and Map are heap objects created at runtime, not constants, and cannot be stored in a class file attribute. Arrays of primitives and strings, by contrast, can be represented as literal values in the constant pool.

Q3. What is the difference between RetentionPolicy.SOURCE, CLASS, and RUNTIME for a custom annotation?

SOURCE means the annotation is discarded after compilation - useful only to compile-time tools that process source files (like Lombok's annotation processor). CLASS (the default) means the annotation is stored in the compiled .class file's bytecode attributes, but the Reflection API cannot read it at runtime. RUNTIME stores it in the RuntimeVisibleAnnotations bytecode attribute, making it accessible via getAnnotation() at runtime. Most custom annotations that frameworks process at application startup or request time need RUNTIME. The mistake of relying on the CLASS default produces code that compiles correctly, applies correctly, but is silently ignored by any reflection-based processor.

Q4. How does a runtime annotation processor typically work?

A runtime processor uses the java.lang.reflect package to inspect annotated program elements. The pattern: get the Class object for the target type, iterate over its getDeclaredMethods() or getDeclaredFields(), call getAnnotation(AnnotationType.class) on each one (which returns null if absent), and read the annotation's element values via the proxy object's methods. The processor has no knowledge of specific field or method names - it discovers them dynamically at runtime. This makes the processor completely generic: adding a new annotated field or method to the target class automatically participates in the processor's behavior without any change to the processor itself.

Q5. Why are method annotations not inherited by overriding methods in subclasses?

Java's specification defines that method annotations do not transfer to overriding methods. When a subclass overrides a method that has an annotation, the overriding method's own annotation set is inspected independently - it does not inherit the parent method's annotations unless the same annotations are re-applied explicitly. The @Inherited meta-annotation only applies to class-level (type) annotations, not to method annotations. For reflection-based processors that need to handle inheritance hierarchies, the standard approach is to walk the class hierarchy explicitly - check the method on the concrete class, then on its superclass, then on interfaces it implements - rather than assuming the annotation will propagate automatically.

Q6. What is the difference between a runtime annotation processor and a compile-time annotation processor?

A runtime annotation processor reads annotations from compiled class files at application runtime via the reflection API. It processes annotations after the code has been compiled and the application has started. Spring's component scanning and dependency injection use this approach - they read @Component, @Autowired, and similar annotations at startup to configure beans. A compile-time annotation processor implements javax.annotation.processing.Processor, runs during the javac compilation phase, can generate new source files or report compiler errors before any class is compiled to bytecode. Lombok uses this approach - @Getter causes a processor to generate getter method source code before the containing class is compiled into a .class file. The resulting .class file contains the generated getter as if it had been hand-written.

FAQs

Can a custom annotation have no elements at all?

Yes - an annotation with no elements is called a marker annotation. @Override and @Deprecated with no since or forRemoval in older code are examples. A marker annotation is applied as @MyMarker (no parentheses) and serves as a flag - the processor checks whether the annotation is present at all, and the presence itself is the signal. Serializable and Cloneable are marker interfaces; @Override is a marker annotation.

Can the same custom annotation be applied more than once to the same element?

Not by default. An annotation can only appear once on any given element unless it is declared with @Repeatable. @Repeatable(ContainerAnnotation.class) on the annotation declaration, combined with a container annotation that holds an array of the repeatable annotation, allows @AuditLog("X") @AuditLog("Y") on the same method. The compiler automatically wraps multiple uses in the container annotation when it sees them.

Can a custom annotation be placed on another annotation?

Yes - annotations can annotate other annotations. This is exactly what meta-annotations do: @Retention, @Target, @Documented, @Inherited, and @Repeatable are all annotations on annotation declarations. Custom meta-annotations are also possible and used by frameworks - Spring's @Service, @Controller, and @Repository are all themselves annotated with @Component, composing annotations to create specialized variants.

Does an annotation on a class automatically apply to all its methods?

No - an annotation on a class applies to the class declaration. Reading method.getAnnotation(SomeAnnotation.class) on a method of that class returns null unless the annotation is also placed on the method itself. A processor that wants "apply this class-level annotation to all methods" must explicitly check both method.getAnnotation() and method.getDeclaringClass().getAnnotation() and combine the results according to its own rules.

Can a custom annotation extend another annotation?

No. Annotation types implicitly extend java.lang.annotation.Annotation and cannot extend any other annotation or class. There is no annotation inheritance in the Java type system. Composition is achieved through @Repeatable, through having one annotation carry another as an element, or through processor code that checks for multiple annotations on the same element and combines their effects.

What happens if you place a custom annotation on a private field and try to read it with reflection?

The annotation is readable, but accessing the field's value requires field.setAccessible(true) because the field is private. Without calling setAccessible(true), field.get(object) throws IllegalAccessException. getAnnotation() itself does not require setAccessible - it just reads metadata. The setAccessible(true) call is required only when reading the field's runtime value. In Java 9+ with modules, setAccessible may also require the containing package to be open to the reflecting module, which affects how reflection-based frameworks configure their module permissions.

Summary

A custom annotation is three things working together: a declaration that defines what data the annotation carries, meta-annotations that control where it can go and how long it survives, and a processor that reads it and decides what happens next. The annotation is inert without the processor; the processor is blind without the annotation. Neither the annotated code nor the processor needs to know about the other beyond the annotation type they share - and that decoupling is precisely what makes annotation-driven frameworks possible.

The rules to commit to memory: @Retention(RetentionPolicy.RUNTIME) is almost always required for custom annotations and almost always forgotten at least once. @Target should always be narrowed to exactly the element types the processor handles. Element types are restricted to compile-time constants - no List, no Map, no arbitrary objects. Annotation processors discover annotated elements via reflection at runtime, generically, without knowing specific names - which means adding a new annotated element anywhere in the codebase automatically participates in the processor's behavior with no change to the processor itself.

The @RequiresRole example above is the pattern behind Spring Security's method-level security, just as the validation example is the pattern behind Jakarta Bean Validation. Both are approachable for any Java developer once the three components - annotation, meta-annotations, processor - are clearly understood.

What to Read Next