Java Annotations Explained — How They Work, Why They Exist, and When to Write Your Own
Every time you type @Override or @Autowired, you're using one of Java's most powerful but least-understood features. Annotations are everywhere in modern Java — Spring Boot, JUnit, Hibernate, Jackson, Lombok — and yet most developers treat them like magic spells: copy them from Stack Overflow, hope they work, and move on. That's a problem, because when something goes wrong with annotation processing, you have no idea where to even start debugging.
What Annotations Actually Are — Metadata, Not Magic
An annotation in Java is a special kind of interface, declared with @interface. It attaches structured metadata to a program element — a class, method, field, parameter, or even another annotation. Crucially, annotations don't execute anything on their own. They're passive labels. The work happens in whatever reads them: the compiler, a runtime framework, or an annotation processor running at build time.
This distinction matters enormously. When you write @Override, the compiler reads that label and checks your method signature against the parent class. When you write @Autowired, Spring's runtime reflection reads it and injects a dependency. The annotation itself is inert — it's just a structured marker that carries information.
Java's annotation system has three layers you need to understand: built-in annotations (the ones Java provides out of the box), meta-annotations (annotations that annotate annotations — yes, really), and custom annotations that you write yourself. Most developers only know the first layer, which is why the other two feel mysterious. Let's demystify all three.
import java.lang.annotation.*; import java.lang.reflect.Method; // Step 1: Declare a custom annotation using @interface // @Retention tells Java HOW LONG to keep this annotation around // RetentionPolicy.RUNTIME means it survives compilation and is readable at runtime @Retention(RetentionPolicy.RUNTIME) // @Target restricts WHERE this annotation can be placed // ElementType.METHOD means only on methods, not classes or fields @Target(ElementType.METHOD) public @interface AnnotationBasics { // This is the annotation declaration — we'll use it below @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface LogExecutionTime { // Annotation elements look like method declarations // Default values make elements optional String label() default "unnamed-operation"; boolean enabled() default true; } // A simple service class whose methods we want to annotate class ReportService { // Annotating this method with our custom annotation // We're providing a value for 'label'; 'enabled' uses its default @LogExecutionTime(label = "generate-monthly-report") public void generateMonthlyReport() throws InterruptedException { System.out.println("Generating report..."); Thread.sleep(100); // Simulate work taking time } // This method is annotated but disabled — the processor should skip it @LogExecutionTime(label = "send-email", enabled = false) public void sendEmailNotification() { System.out.println("Sending email..."); } // This method has NO annotation — processor ignores it entirely public void helperMethod() { System.out.println("Helper running..."); } } // Simulated annotation processor using reflection // In real frameworks like Spring AOP, this is done automatically static void runWithAnnotationProcessing(ReportService service) throws Exception { // Get all declared methods on the class Method[] methods = ReportService.class.getDeclaredMethods(); for (Method method : methods) { // Check if THIS method carries our annotation if (method.isAnnotationPresent(LogExecutionTime.class)) { LogExecutionTime annotation = method.getAnnotation(LogExecutionTime.class); // Read the 'enabled' element from the annotation if (!annotation.enabled()) { System.out.println("[SKIPPED] " + annotation.label() + " is disabled"); continue; // Skip this method entirely } // Start timing BEFORE the method runs long startTime = System.currentTimeMillis(); method.invoke(service); // Actually call the method via reflection long elapsed = System.currentTimeMillis() - startTime; // Log the result using the label from the annotation System.out.println("[TIMED] '" + annotation.label() + "' took " + elapsed + "ms"); } else { // No annotation present — run it normally without timing method.invoke(service); } } } static void main(String[] args) throws Exception { ReportService service = new ReportService(); runWithAnnotationProcessing(service); } }
[SKIPPED] send-email is disabled
Helper running...
Generating report...
[TIMED] 'generate-monthly-report' took 101ms
Meta-Annotations — The Annotations That Control Annotations
Meta-annotations are Java's way of configuring how your custom annotations behave. There are five built-in meta-annotations, but two of them — @Retention and @Target — are the ones you'll use on virtually every custom annotation you write. Getting them wrong is the #1 cause of 'my annotation does nothing' bugs.
@Retention controls the annotation's lifecycle. RetentionPolicy.SOURCE means the annotation is erased after the compiler reads it (used by @Override and @SuppressWarnings — the compiler acts on them, then discards them). RetentionPolicy.CLASS means it's stored in the .class file but not loaded into the JVM at runtime. RetentionPolicy.RUNTIME means it's available through reflection while the program runs — this is what you need for frameworks and your own runtime processing.
@Target controls which program elements can be annotated. If you declare @Target(ElementType.METHOD) and someone tries to put your annotation on a class, the compiler throws an error immediately. This is a free safety net — always use it.
The other three meta-annotations — @Documented, @Inherited, and @Repeatable — are situation-specific. @Inherited lets subclasses inherit annotations from their parent class (only works on class-level annotations). @Repeatable allows the same annotation to appear multiple times on the same element. @Documented includes your annotation in Javadoc output.
import java.lang.annotation.*; public class MetaAnnotationShowcase { // ── EXAMPLE 1: @Repeatable ────────────────────────────────────────────── // To make an annotation repeatable, you need a 'container' annotation // The container holds an array of the repeatable annotation @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface SecurityRoles { // This is the CONTAINER annotation RequiresRole[] value(); // Must have a value() returning an array } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Repeatable(SecurityRoles.class) // Links to its container @interface RequiresRole { String value(); } // ── EXAMPLE 2: @Inherited ─────────────────────────────────────────────── @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) // TYPE means class/interface/enum @Inherited // Subclasses will inherit this annotation @interface ServiceLayer { String team() default "platform"; } // ── Demo classes using these annotations ──────────────────────────────── @ServiceLayer(team = "payments") // Placed on parent class static class PaymentService { // Using @RequiresRole TWICE on the same method — only possible with @Repeatable @RequiresRole("ADMIN") @RequiresRole("FINANCE_MANAGER") public void approveRefund() { System.out.println("Refund approved."); } } // Subclass does NOT redeclare @ServiceLayer — it inherits it via @Inherited static class InternationalPaymentService extends PaymentService { public void processForexTransaction() { System.out.println("Forex transaction processed."); } } public static void main(String[] args) throws Exception { // ── Check @Inherited behavior ──────────────────────────────────────── // Even though InternationalPaymentService never declared @ServiceLayer, // it inherits it from PaymentService because of @Inherited boolean hasServiceLayer = InternationalPaymentService.class .isAnnotationPresent(ServiceLayer.class); ServiceLayer serviceAnnotation = InternationalPaymentService.class .getAnnotation(ServiceLayer.class); System.out.println("InternationalPaymentService has @ServiceLayer: " + hasServiceLayer); System.out.println("Inherited team value: " + serviceAnnotation.team()); // ── Check @Repeatable behavior ─────────────────────────────────────── var approveMethod = PaymentService.class.getMethod("approveRefund"); // getAnnotationsByType handles the unwrapping of the container automatically RequiresRole[] roles = approveMethod.getAnnotationsByType(RequiresRole.class); System.out.println("\napproveRefund requires " + roles.length + " role(s):"); for (RequiresRole role : roles) { System.out.println(" -> " + role.value()); } } }
Inherited team value: payments
approveRefund requires 2 role(s):
-> ADMIN
-> FINANCE_MANAGER
Retention Policies in Practice — The Bug That Silently Kills Your Annotation
The single most common annotation bug in real codebases is forgetting to set the right @Retention policy. Here's what makes it brutal: there's no compile error. Your code compiles perfectly. Your annotation appears to be there. But at runtime, the annotation is completely invisible to reflection — and your framework or processor silently does nothing.
The default retention policy when you omit @Retention is RetentionPolicy.CLASS. This sounds reasonable — it IS stored in the .class file. But CLASS retention means the JVM strips the annotation when it loads the class into memory. So reflection calls like isAnnotationPresent() always return false, even though javap (the bytecode disassembler) can see the annotation in the .class file. This discrepancy is deeply confusing the first time you encounter it.
RetentionPolicy.SOURCE is intentional and useful for annotations consumed by tools during compilation: @Override, @SuppressWarnings, and Lombok's @Data all use SOURCE. They're acted upon and then discarded — there's no point keeping them around. The rule of thumb is: if you need to read your annotation with reflection at runtime, you MUST use RetentionPolicy.RUNTIME. If you're writing an annotation processor that runs at compile time (via javax.annotation.processing), SOURCE or CLASS is appropriate.
import java.lang.annotation.*; public class RetentionPolicyDemo { // SOURCE: Compiler reads this, then throws it away. // Useful for IDE hints, compile-time checks, Lombok-style processors. @Retention(RetentionPolicy.SOURCE) @Target(ElementType.METHOD) @interface TodoReminder { String ticket(); // e.g. "JIRA-1234" } // CLASS: Stored in .class file, but stripped on JVM load. // Default if you forget @Retention — this is the trap. @Retention(RetentionPolicy.CLASS) @Target(ElementType.METHOD) @interface InternalOnly { // Bytecode tools like ASM can read this, but runtime reflection can't } // RUNTIME: Survives into the running JVM. Readable via reflection. // This is what Spring, Hibernate, JUnit etc. use. @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Auditable { String action(); } static class UserService { @TodoReminder(ticket = "JIRA-999") // SOURCE — erased after compilation @InternalOnly // CLASS — in .class file, not in JVM memory @Auditable(action = "DELETE_USER") // RUNTIME — fully visible at runtime public void deleteUser(String userId) { System.out.println("Deleting user: " + userId); } } public static void main(String[] args) throws Exception { var deleteMethod = UserService.class.getMethod("deleteUser", String.class); // Check which annotations are actually visible at runtime via reflection System.out.println("--- Runtime Reflection Results ---"); // @TodoReminder: SOURCE — completely gone, annotation class doesn't even exist at runtime // (We can't even reference it here without a compile trick, so we show the count) Annotation[] allVisible = deleteMethod.getAnnotations(); System.out.println("Total annotations visible at runtime: " + allVisible.length); // Only 1 annotation survives — @Auditable with RUNTIME retention // @InternalOnly: CLASS — NOT visible. isAnnotationPresent returns false. boolean internalVisible = deleteMethod.isAnnotationPresent(InternalOnly.class); System.out.println("@InternalOnly visible at runtime: " + internalVisible); // @Auditable: RUNTIME — fully visible. We can read its elements. boolean auditableVisible = deleteMethod.isAnnotationPresent(Auditable.class); System.out.println("@Auditable visible at runtime: " + auditableVisible); if (auditableVisible) { Auditable auditInfo = deleteMethod.getAnnotation(Auditable.class); System.out.println("Audit action recorded: '" + auditInfo.action() + "'"); } // Simulate an audit processor acting on the annotation UserService service = new UserService(); System.out.println("\n--- Processing deleteUser Call ---"); if (auditableVisible) { Auditable audit = deleteMethod.getAnnotation(Auditable.class); System.out.println("[AUDIT LOG] Action '" + audit.action() + "' initiated"); } deleteMethod.invoke(service, "user-42"); System.out.println("[AUDIT LOG] Action completed successfully"); } }
Total annotations visible at runtime: 1
@InternalOnly visible at runtime: false
@Auditable visible at runtime: true
Audit action recorded: 'DELETE_USER'
--- Processing deleteUser Call ---
[AUDIT LOG] Action 'DELETE_USER' initiated
Deleting user: user-42
[AUDIT LOG] Action completed successfully
Writing a Real Custom Annotation — A Validation Framework From Scratch
The best way to cement your understanding is to build something you'd actually use: a lightweight field-validation annotation, similar to what Bean Validation (@NotNull, @Size) does under the hood. This pattern appears in almost every production codebase, and building it yourself demystifies how frameworks like Hibernate Validator work.
We'll create a @ValidRange annotation that checks numeric fields fall within a specified min/max range. We'll also create a small validator engine that scans an object's fields, finds annotated ones, reads the annotation elements, and runs the validation logic. This is exactly the pattern Spring, Hibernate, and Jackson use — they just do it at a much larger scale.
The key insight here is that your annotation is the contract (what constraints should apply), and your processor is the enforcer (what happens when those constraints are checked). Keeping these separate is what makes the design extensible — you can add new annotation types without touching the processor logic, and you can change validation behavior without touching the annotated classes.
import java.lang.annotation.*; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; public class CustomValidationFramework { // ── Annotation 1: Validate that a number is within a range ─────────────── @Retention(RetentionPolicy.RUNTIME) // Must be RUNTIME so we can read it via reflection @Target(ElementType.FIELD) // Only valid on fields, not methods or classes @interface ValidRange { int min() default 0; int max() default Integer.MAX_VALUE; String message() default "Value is out of the allowed range"; // Custom error message } // ── Annotation 2: Mark a String field as required (non-null, non-empty) ── @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @interface Required { String message() default "Field is required and cannot be blank"; } // ── A real domain object using our annotations ─────────────────────────── static class ProductListing { @Required(message = "Product name must be provided") private String productName; @ValidRange(min = 1, max = 10000, message = "Price must be between $1 and $10,000") private int priceInCents; @ValidRange(min = 0, max = 500, message = "Stock cannot exceed warehouse capacity of 500") private int stockQuantity; // A field with NO annotation — the validator should leave it alone private String internalSku; public ProductListing(String productName, int priceInCents, int stockQuantity, String internalSku) { this.productName = productName; this.priceInCents = priceInCents; this.stockQuantity = stockQuantity; this.internalSku = internalSku; } } // ── The Validator Engine ───────────────────────────────────────────────── static class ObjectValidator { // Returns a list of validation failure messages. // An empty list means the object is valid. public static List<String> validate(Object target) throws IllegalAccessException { List<String> violations = new ArrayList<>(); Class<?> clazz = target.getClass(); // Walk through every declared field in the class for (Field field : clazz.getDeclaredFields()) { field.setAccessible(true); // Allow reading private fields Object fieldValue = field.get(target); // ── Check @Required ────────────────────────────────────────── if (field.isAnnotationPresent(Required.class)) { Required requiredAnnotation = field.getAnnotation(Required.class); // A field fails @Required if it's null OR an empty/blank string if (fieldValue == null || (fieldValue instanceof String str && str.isBlank())) { violations.add("[" + field.getName() + "] " + requiredAnnotation.message()); } } // ── Check @ValidRange ──────────────────────────────────────── if (field.isAnnotationPresent(ValidRange.class)) { ValidRange rangeAnnotation = field.getAnnotation(ValidRange.class); // Only validate numeric types — skip gracefully for others if (fieldValue instanceof Integer intValue) { if (intValue < rangeAnnotation.min() || intValue > rangeAnnotation.max()) { // Include the actual value in the error so developers know what was rejected violations.add("[" + field.getName() + "] " + rangeAnnotation.message() + " (actual: " + intValue + ")"); } } } } return violations; } } public static void main(String[] args) throws Exception { System.out.println("=== Test 1: Valid product listing ==="); ProductListing validProduct = new ProductListing("Wireless Keyboard", 4999, 50, "SKU-001"); List<String> violations = ObjectValidator.validate(validProduct); if (violations.isEmpty()) { System.out.println("Product is valid. Ready to publish."); } System.out.println("\n=== Test 2: Invalid product listing ==="); // Blank name, price too low (0), stock way over limit ProductListing invalidProduct = new ProductListing("", 0, 750, "SKU-002"); List<String> errors = ObjectValidator.validate(invalidProduct); if (!errors.isEmpty()) { System.out.println("Validation failed with " + errors.size() + " error(s):"); errors.forEach(error -> System.out.println(" ✗ " + error)); } } }
Product is valid. Ready to publish.
=== Test 2: Invalid product listing ===
Validation failed with 3 error(s):
✗ [productName] Product name must be provided
✗ [priceInCents] Price must be between $1 and $10,000 (actual: 0)
✗ [stockQuantity] Stock cannot exceed warehouse capacity of 500 (actual: 750)
| Aspect | RetentionPolicy.SOURCE | RetentionPolicy.CLASS | RetentionPolicy.RUNTIME |
|---|---|---|---|
| Survives compilation? | No — erased by compiler | Yes — stored in .class file | Yes — stored in .class file |
| Visible to JVM at runtime? | No | No — stripped on class load | Yes — fully accessible |
| Readable via reflection? | No | No | Yes |
| Typical use case | @Override, @SuppressWarnings, Lombok | Bytecode tools like ASM, ProGuard | Spring, Hibernate, JUnit, Jackson |
| Performance overhead | None | None at runtime | Tiny — reflection cost only when read |
| Default when omitted? | No | YES — this is the default trap | No — must be explicit |
🎯 Key Takeaways
- Annotations are inert metadata — they do nothing by themselves. The behavior comes from whatever reads them: the compiler, a framework, or your own reflection code. Forgetting this is the root of most annotation confusion.
- RetentionPolicy.RUNTIME is mandatory for any annotation you need to read at runtime via reflection. The default (CLASS retention) silently makes your annotation invisible to reflection — no error, just no behavior.
- @Target is your free compile-time guard — always use it to restrict which element types your annotation can be applied to. It prevents misuse and makes your intent explicit.
- The pattern behind every major Java framework annotation (Spring @Autowired, Hibernate @Column, JUnit @Test) is the same: annotation-as-contract + processor-as-enforcer. Build this pattern once yourself and every framework becomes readable.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting @Retention(RetentionPolicy.RUNTIME) on a custom annotation — Symptom: isAnnotationPresent() always returns false at runtime, your processor silently does nothing, no errors thrown — Fix: Always explicitly add @Retention(RetentionPolicy.RUNTIME) to any annotation you intend to read via reflection. Never rely on the default (CLASS retention).
- ✕Mistake 2: Omitting @Target and accidentally annotating the wrong element type — Symptom: No compile error, but the annotation sits on a class when you wrote logic to scan methods, so the processor never finds it — Fix: Always declare @Target with the narrowest possible ElementType. If your annotation is method-only, declare @Target(ElementType.METHOD). You get a free compile-time error if someone misuses it.
- ✕Mistake 3: Using getAnnotation() on a @Repeatable annotation and getting null when multiple are present — Symptom: You annotate a method with @RequiresRole twice, call getAnnotation(RequiresRole.class), and get null — Fix: When an annotation is repeated, Java wraps them in the container annotation. Use getAnnotationsByType(RequiresRole.class) instead — it handles the unwrapping automatically and returns an array of all instances.
Interview Questions on This Topic
- QWhat is the difference between RetentionPolicy.CLASS and RetentionPolicy.RUNTIME, and why does it matter in practice? (Expect a follow-up: 'What is the default retention policy if you omit @Retention?')
- QHow would you design a custom annotation-based caching system? Walk me through the annotation declaration, the processor, and how you'd hook it into method calls.
- QIf an annotation is present on a parent class, will a subclass's getAnnotation() call see it? What controls this, and what are its limitations?
Frequently Asked Questions
What is the difference between @Retention SOURCE, CLASS, and RUNTIME in Java?
SOURCE annotations are erased after the compiler reads them (used for compile-time checks like @Override). CLASS annotations are stored in the .class file but stripped when the JVM loads the class — they're invisible to reflection. RUNTIME annotations survive all the way into the running JVM and are fully readable via reflection. If you're writing an annotation for use with Spring, Hibernate, or your own runtime processor, you need RUNTIME.
Can Java annotations have methods?
Annotation elements look like method declarations but they aren't really methods in the traditional sense — they define the attributes (data) the annotation carries. Each element can have a default value. The only types allowed are primitives, String, Class, enums, other annotations, and arrays of these types. You can't use arbitrary objects like List or Map as annotation element types.
What is the difference between an annotation and an interface in Java?
An annotation is declared with @interface and is a special kind of interface that extends java.lang.annotation.Annotation implicitly. Unlike regular interfaces, annotation types can only have elements of restricted types (primitives, String, Class, enums, annotations, arrays thereof) and cannot have method bodies, constructors, or generic parameters. The key functional difference is that annotations are designed to carry metadata that tools and frameworks read, not to define behavior that classes implement.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.