The previous lesson closed with a concrete shortcoming: generics speak to the compiler and only to the compiler, and there is information about code that does not fit into a type system. "This field is exported to the CSV in the third column with the header ISBN". "This operation has to be recorded in the audit". "This method has been deprecated since version 3 and will disappear in version 4". "This class is a service that must be instantiated at start-up".
None of that is a type. It is information about the code, and Java's mechanism for expressing it is called an annotation.
Annotations are probably the language feature whose real importance is most underestimated while learning it. Seen close up they look like decoration: @Override on top of a method, @Deprecated on top of another. Seen from a distance, they are the pillar on which the whole of modern enterprise Java is built. When in module 11 you write @Entity on a class and Hibernate creates a table, @Autowired on a field and Spring injects a dependency, or @Test on a method and JUnit runs it, you will be using exactly the mechanism you learn here.
And there is one idea worth fixing from the very first line, because it is the source of almost all the confusion: an annotation does nothing on its own. @Test runs nothing. @Entity creates no table. They are inert labels. What happens is that somebody reads them —the compiler, a build tool, or a framework at run time— and acts accordingly. This lesson teaches you to write the labels; the next one, to write the reader.
By the end, BiblioTech will have @CsvField and @Auditable, defined and placed. And they will be unused, waiting for 10-03's engine.
Contents
- What an annotation is: metadata about the code
- Usage syntax: marker, single value and several elements
- The JDK's standard annotations
@Overrideand the bug it prevents@Deprecated, withsinceandforRemoval@SuppressWarnings@SafeVarargs@FunctionalInterface- Creating your own annotations:
@interface - Elements: allowed types,
defaultand thevalueelement - Meta-annotations:
@Retention - Meta-annotations:
@Target @Documentedand@Inherited@Repeatableand its container annotation- Type annotations (Java 8)
- How they are read (I): annotation processors at compile time
- How they are read (II): reflection at run time
- What real frameworks use them for
- Configuration next to the code versus XML
- When NOT to use annotations
- BiblioTech:
@CsvField - BiblioTech:
@Auditable - Common Mistakes and Tips
- Exercises
- What an annotation is: metadata about the code
An annotation is a label attached to a program element —a class, a method, a field, a parameter, a local variable, a package— that carries information about that element without being part of its logic.
The useful analogy is the labels on a physical archive. A file in a folder has its content (the documents) and it has stickers on the flap: "urgent", "reviewed by Marta Ruiz", "destroy in 2030". The stickers do not change the file's content. But when somebody goes through the cabinet looking for what to destroy, they read them and act.
Formally:
- An annotation does not change the behaviour of the code it annotates. Putting
@Auditableonlend()does not cause anything to be audited. The method keeps doing exactly the same thing. - An annotation is information that somebody consumes. That "somebody" can be the compiler (
@Override), a tool that runs during compilation (an annotation processor), or code that runs alongside your program (a framework, via reflection). - An annotation is compiled together with the code and, depending on its
@Retention, it can survive all the way to run time.
graph TD
A["Source code<br/>with annotations"] --> B["javac compiler"]
B -->|"RETENTION SOURCE<br/>they are discarded"| C["Only warnings and errors<br/>@Override, @SuppressWarnings"]
B -->|"RETENTION CLASS<br/>they stay in the .class"| D["Bytecode tools<br/>static analysers"]
B -->|"RETENTION RUNTIME<br/>they arrive alive"| E["JVM at run time"]
E --> F["Reflection: Spring, Hibernate, JUnit<br/>and 10-03's engine"]
B -.->|"annotation processor"| G["Generates new source code<br/>example: Lombok"]
G --> B
That diagram is, deep down, the whole lesson. The rest is detail.
- Usage syntax: marker, single value and several elements
An annotation is written with @ followed by its name, in front of the element it annotates. There are three forms depending on how much data it carries:
// 1. MARKER ANNOTATION: no elements. The parentheses are omitted.
@Override
public String toString() { ... }
// 2. SINGLE VALUE: if the element is called 'value', the name is omitted.
@SuppressWarnings("unchecked")
List<Book> books = (List<Book>) raw;
// equivalent in every way to:
@SuppressWarnings(value = "unchecked")
// 3. SEVERAL ELEMENTS: name = value pairs, separated by commas.
@Deprecated(since = "3.2", forRemoval = true)
public void lendBook(String isbn) { ... }And when an element is an array, braces are used (and can be omitted if there is a single value):
@SuppressWarnings({"unchecked", "rawtypes"}) // several values
@SuppressWarnings("unchecked") // just one: braces optionalSeveral annotations can be stacked on the same element:
@Auditable(level = "HIGH")
@Override
@SuppressWarnings("unchecked")
public Result<Loan> lend(String isbn, String employee) { ... }Style convention: annotations on classes, methods and fields go on their own line, above the element. Those on parameters and local variables go on the same line.
- The JDK's standard annotations
The JDK ships a handful of annotations that the compiler understands in a special way. There are not many, and it is worth knowing them all because you will use them daily.
| Annotation | Where it goes | What it really does | Retention |
|---|---|---|---|
@Override |
Methods | The compiler verifies that something really is overridden; if not, error | SOURCE |
@Deprecated |
Almost any element | The compiler warns when it is used; it appears in the Javadoc | RUNTIME |
@SuppressWarnings |
Almost any element | Silences specific compiler warnings | SOURCE |
@SafeVarargs |
static, final methods or constructors with generic varargs |
Suppresses the heap pollution warning | RUNTIME |
@FunctionalInterface |
Interfaces | The compiler verifies that it has exactly one abstract method | RUNTIME |
Look at the "what it really does" column, because it distinguishes three very different behaviours: verify (compilation fails if it is not satisfied), warn (compiles with a warning) and silence (removes a warning).
There are a few more, of specialised use, that are only worth naming: @Native (constants referenced from native code) and the meta-annotations from section 11 onwards.
@Override and the bug it prevents
@Override and the bug it preventsPicking up 03-05. @Override is Java's most used annotation and the most useful one, and its value lies in a particularly cruel kind of bug: the method you think overrides and that overrides nothing.
public class Material {
private final String isbn;
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof Material)) return false;
return isbn.equals(((Material) other).isbn);
}
}Now look at this mistake, which is the classic of classics:
public class Material {
// NO @Override: it looks like it overrides equals... but it does NOT.
public boolean equals(Material other) { // Material, not Object!
return this.isbn.equals(other.isbn);
}
}This compiles perfectly. It does not override Object.equals(Object): it overloads it. And the effect is devastating:
Material a = new Book("Effective Java", "978-0000000001");
Material b = new Book("Effective Java", "978-0000000001");
System.out.println(a.equals(b)); // true: it calls your equals(Material)
List<Material> list = new ArrayList<>();
list.add(a);
System.out.println(list.contains(b)); // false! contains() calls equals(Object)
Set<Material> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set.size()); // 2! duplicates in a Setcontains(), HashSet, HashMap, remove(), indexOf(): the whole collections framework calls equals(Object), which is still Object's (reference identity). You get duplicates in a Set, searches that find nothing and hours of debugging.
With @Override, the compiler stops you right there:
Rule with no exceptions: put @Override on absolutely every method that overrides something. It costs nothing and it catches signature errors, name errors (toSting() instead of toString()) and arity errors. Since Java 6 it also works for interface methods, so it goes on implementations of Comparator.compare, Runnable.run or your own interfaces too.
Its retention is SOURCE: it does its job in the compiler and disappears. No trace of it is left in the .class.
@Deprecated, with since and forRemoval
@Deprecated, with since and forRemoval@Deprecated marks an element as obsolete: it still works, but you should not use it in new code.
package com.nexussoftware.bibliotech.service;
public class LoanManager {
/**
* Lends a material identified by its ISBN.
*
* @deprecated Since 3.2 use {@link #lend(String, String)}, which returns
* a {@code Result<Loan>} instead of throwing exceptions
* for expected cases. It will be removed in version 4.0.
*/
@Deprecated(since = "3.2", forRemoval = true)
public void lendBook(String isbn) throws BiblioTechException {
lend(isbn, "unknown");
}
public Result<Loan> lend(String isbn, String employee) { ... }
}When you use it:
The two elements, added in Java 9, matter more than they seem:
| Element | Type | Meaning |
|---|---|---|
since |
String |
Version in which it was deprecated. It helps to gauge how long it has been that way |
forRemoval |
boolean |
true = it is going to be removed. It changes the warning to the removal category, which is noisier |
The difference between forRemoval = false (the default) and true is real: the first means "there is something better", the second "this is going away, migrate now". IDEs display them differently and -Xlint:removal lets you treat them as errors.
Two rules of use:
- Always accompany
@Deprecatedwith@deprecatedin the Javadoc, saying what to use instead. A deprecation with no alternative is cruelty: you report the problem and not the solution. Notice that they are two different things: the annotation (for the compiler) and the Javadoc tag (for the human). - Do not deprecate without a plan. Marking and never removing turns the warnings into noise that everybody ignores.
Its retention is RUNTIME, and that allows analysis tools to inspect a compiled .jar and detect uses of deprecated API.
@SuppressWarnings
@SuppressWarningsYou already used it in 10-01. It silences specific compiler warnings, identified by a string.
The most useful identifiers:
| Value | Silences |
|---|---|
"unchecked" |
Operations without generic checking (10-01's casts) |
"rawtypes" |
Use of raw types |
"deprecation" |
Use of deprecated API |
"removal" |
Use of API marked forRemoval = true |
"serial" |
Serialisable class with no serialVersionUID (07-05) |
"this-escape" |
this escaping in the constructor (Java 21) |
"all" |
All of them. Practically never the right thing |
The identifiers are not standardised beyond a few: each compiler and IDE recognises its own, and an unknown one is silently ignored.
The three rules (the same ones from 10-01, because they matter):
- Minimum scope. On the local variable, not on the method; on the method, not on the class.
- A comment justifying why the operation is safe despite the warning.
- Never
"all".
// BAD: switches everything off in the whole class forever
@SuppressWarnings("all")
public class CatalogImporter { ... }
// GOOD: minimum scope and justification
public List<Book> readBooks(Path file) throws IOException {
// SAFE: the file is only written by CsvCatalogExporter, which
// always serialises Book. Any other content fails earlier,
// in the header validation.
@SuppressWarnings("unchecked")
List<Book> books = (List<Book>) readEntities(file);
return books;
}
@SafeVarargs
@SafeVarargsGeneric varargs produce an inconvenient warning. Remember from 10-01 that generic arrays cannot be created, but T... elements is exactly a T[]. The compiler allows it by creating an array of the erased class, and warns about possible heap pollution:
public static <T> List<T> listOf(T... elements) {
return new ArrayList<>(Arrays.asList(elements));
}warning: [unchecked] Possible heap pollution from parameterized vararg type T
public static <T> List<T> listOf(T... elements) {
^The warning is legitimate, because this method is dangerous:
// DANGEROUS: it exposes the varargs array
@SafeVarargs
static <T> T[] dangerous(T... elements) {
return elements; // returns the array created by the compiler
}
public static void main(String[] args) {
String[] texts = dangerous("a", "b"); // ClassCastException here
}@SafeVarargs is your promise to the compiler that the method neither stores nor exposes the varargs array, it only reads from it. If the promise is true, the warning disappears:
@SafeVarargs
public static <T> List<T> listOf(T... elements) {
return new ArrayList<>(Arrays.asList(elements)); // it only READS from the array
}Restriction: it can only be put on methods nobody can override —static, final, private (Java 9+)— and on constructors. It is logical: you cannot promise anything about an implementation that does not exist yet.
@FunctionalInterface
@FunctionalInterfacePicking up 04-06. It marks an interface as functional, and the compiler verifies that it has exactly one abstract method:
package com.nexussoftware.bibliotech.service;
/**
* Calculates the fine of an overdue loan.
* Being functional, it can be implemented with a lambda.
*/
@FunctionalInterface
public interface FinePolicy {
double calculate(int daysLate);
// default methods do NOT count as abstract: the interface is still functional
default FinePolicy cappedAt(double maximum) {
return days -> Math.min(calculate(days), maximum);
}
// static methods do not count either
static FinePolicy fixed(double perDay) {
return days -> days * perDay;
}
}FinePolicy standard = days -> days * 0.25;
FinePolicy capped = standard.cappedAt(10.0);
System.out.printf("30 days late: %.2f EUR%n", standard.calculate(30)); // 7.50 EUR
System.out.printf("100 days capped: %.2f EUR%n", capped.calculate(100)); // 10.00 EURIf somebody adds a second abstract method, the compiler prevents it:
@FunctionalInterface
public interface FinePolicy {
double calculate(int days);
double calculateWithDiscount(int days, double discount); // second abstract
}error: Unexpected @FunctionalInterface annotation
FinePolicy is not a functional interface
multiple non-overriding abstract methods found in interface FinePolicyIt is not mandatory. Any interface with a single abstract method can be used with lambdas, whether it carries the annotation or not. What @FunctionalInterface does is protect the contract: it declares the intent and stops a colleague from breaking every user of the interface by adding a method. It is documentation that the compiler verifies, which is the best kind of documentation.
- Creating your own annotations:
@interface
@interfaceAn annotation is declared with @interface. Formally it is an interface (it implicitly extends java.lang.annotation.Annotation), although it is not used as one.
The simplest one is a marker annotation, with no elements:
package com.nexussoftware.bibliotech.annotations;
/**
* Marks an element that is not finished yet.
* On its own it does absolutely nothing.
*/
public @interface UnderConstruction {
}It compiles and nothing happens. Literally nothing: the annotation is SOURCE by default... no, and this is the first detail to pin down: by default the retention is CLASS, neither SOURCE nor RUNTIME. It stays in the .class but it is not visible through reflection. It is the least useful default of the three, and the number one cause of the question "why does my annotation not show up in getAnnotations()?". We will come back to it in section 11.
An annotation with elements:
package com.nexussoftware.bibliotech.annotations;
/**
* Documents who owns a component and since when.
*/
public @interface Owner {
/** Name of the owner. Mandatory: it has no default. */
String name();
/** Team. Optional. */
String team() default "Platform";
/** Date in ISO format. Optional. */
String since() default "";
/** Backup people. Optional, empty array by default. */
String[] backup() default {};
}@Owner(name = "Marta Ruiz", team = "Catalogue", since = "2026-01-15",
backup = {"Diego Alonso", "Nuria Vidal"})
public class CatalogService { }
@Owner(name = "Nuria Vidal") // the rest take their default
public class NoticeService { }Notice the syntax of the elements: they are declared as methods with no body (String name();), not as fields. That is because an annotation is an interface, and when reading it by reflection you will call annotation.name().
An element with no default is mandatory. Omitting it is a compile error:
- Elements: allowed types,
default and the value element
default and the value elementAllowed types
The types an annotation element can have are very restricted, and it is not arbitrary: its value must be storable in the .class file as a constant and be known at compile time.
| Allowed | Example |
|---|---|
| Primitives | int order(); boolean required(); double factor(); |
String |
String name(); |
Class or Class<?> |
Class<?> converter(); |
enum types |
Severity level(); |
| Other annotations | Owner owner(); |
| Arrays of all of the above | String[] aliases(); Class<?>[] groups(); |
Not allowed: anything else. No List<String>, Object, LocalDate, your own classes or generic types.
public @interface Bad {
List<String> tags(); // ERROR: invalid type for annotation member
LocalDate created(); // ERROR
Book book(); // ERROR
}That is why dates in annotations are written as a String in ISO format ("2026-01-15") and parsed when read (you will see how in 10-05), and lists are declared as arrays.
The values must be compile-time constants. This compiles:
This does not:
No element can be null. There is no way of expressing it, neither as a value nor as a default. The convention is to use the empty string or a sentinel value:
public @interface CsvField {
String name() default ""; // "" means "use the field's name"
int order() default Integer.MAX_VALUE; // sentinel: "at the end"
}The value element and its shorthand syntax
If an annotation has an element called exactly value, whoever uses it can omit the name:
The shorthand also works if there are more elements, provided every other one has a default and only value is specified:
@Auditable("lend") // OK: level takes its default
@Auditable(value = "lend", level = Severity.HIGH) // OK: full form mandatory
// @Auditable("lend", level = Severity.HIGH) // ERROR: they cannot be mixedDesign rule: if your annotation has a clearly principal element, call it value. @SuppressWarnings("unchecked") and Spring's @RequestMapping("/books") read well precisely because of this.
- Meta-annotations:
@Retention
@RetentionA meta-annotation is an annotation that annotates another annotation. There are five, and the first two are the ones that really define your annotation's behaviour.
@Retention decides how far your annotation lives. It is the most important decision you will make when designing it.
| Policy | Present in the .java |
Present in the .class |
Visible through reflection | What for |
|---|---|---|---|---|
RetentionPolicy.SOURCE |
Yes | No | No | Annotations for the compiler or for processors: @Override, @SuppressWarnings, Lombok |
RetentionPolicy.CLASS |
Yes | Yes | No | Tools that analyse bytecode without loading classes. It is the default value |
RetentionPolicy.RUNTIME |
Yes | Yes | Yes | Frameworks that act at run time: Spring, Hibernate, JUnit, and 10-03's engine |
graph LR
S["Source code<br/>.java"] --> C["Compilation"]
C --> B["Bytecode<br/>.class"]
B --> R["Run time<br/>JVM"]
S -.->|"SOURCE reaches this far"| C
B -.->|"CLASS reaches this far"| B2["end"]
R -.->|"RUNTIME reaches the very end"| R2["getAnnotation works"]
The default-value trap. If you do not put @Retention, your annotation is CLASS and isAnnotationPresent() will return false with no explanation whatsoever:
public @interface Auditable { } // no @Retention -> CLASS
// elsewhere
boolean has = method.isAnnotationPresent(Auditable.class);
System.out.println(has); // false, and you do not understand whyIf your annotation is going to be read by code at run time —which is 90% of the cases for an annotation of your own— always put @Retention(RetentionPolicy.RUNTIME). It is the most frequent beginner's mistake with annotations and it takes half an afternoon to find.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable { } // now it works
- Meta-annotations:
@Target
@Target@Target restricts where your annotation can be put. It takes an array of ElementType:
ElementType |
Can be put on |
|---|---|
TYPE |
Class, interface, enum, record, annotation |
FIELD |
Field (including enum constants) |
METHOD |
Method |
PARAMETER |
Parameter of a method or constructor |
CONSTRUCTOR |
Constructor |
LOCAL_VARIABLE |
Local variable |
ANNOTATION_TYPE |
Another annotation (for meta-annotations) |
PACKAGE |
Package (in package-info.java) |
TYPE_PARAMETER |
Generic type parameter (Java 8) |
TYPE_USE |
Any use of a type (Java 8) |
MODULE |
Module declaration (Java 9) |
RECORD_COMPONENT |
Component of a record (Java 16) |
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
public @interface CsvField {
String name() default "";
int order();
}With that, putting it where it does not belong is a compile error:
Without @Target, the annotation can be put almost anywhere, which is usually an oversight rather than a decision. Adding @Target documents the intent and prevents absurd uses.
One detail about RECORD_COMPONENT: when you annotate a record's component, the annotation is propagated to the field, to the canonical constructor's parameter and to the accessor method, according to which ElementType it is applicable to. It is an important convenience for making annotations work with records.
@Documented and @Inherited
@Documented and @InheritedTwo minor meta-annotations, but with concrete effects.
@Documented: makes the annotation appear in the generated Javadoc of the annotated element. Without it, whoever reads your class's documentation will not see that it carries @Owner.
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Owner {
String name();
}Simple rule: if the annotation is part of the public contract of what it annotates, add it. If it is an internal detail, do not.
@Inherited: makes the annotation be inherited by subclasses. It only works on TYPE annotations, and it is only inherited from classes, not from interfaces.
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Audited {
String module();
}
@Audited(module = "catalogue")
public abstract class Material { }
public class Book extends Material { } // no annotation of its ownSystem.out.println(Book.class.isAnnotationPresent(Audited.class)); // true, thanks to @Inherited
System.out.println(Book.class.getAnnotation(Audited.class).module()); // catalogueWithout @Inherited, that first println would print false.
Careful with the asymmetry: getAnnotations() includes inherited ones; getDeclaredAnnotations() only those placed directly. It is the same distinction between getFields and getDeclaredFields that you will see in 10-03, and it confuses people just as much.
@Repeatable and its container annotation
@Repeatable and its container annotationBy default, an annotation cannot be repeated on the same element:
@Owner(name = "Marta Ruiz")
@Owner(name = "Diego Alonso") // ERROR before Java 8
public class CatalogService { }Before Java 8 the solution was manual and ugly: define a second annotation holding an array. Java 8 automated the pattern with @Repeatable, but there are still two annotations: the repeatable one and its container.
package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/** The CONTAINER annotation: it holds an array of the repeatable ones. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Owners {
Owner[] value(); // the element MUST be called value and be an array
}package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/** The REPEATABLE annotation: it declares who contains it. */
@Repeatable(Owners.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Owner {
String name();
String team() default "Platform";
}Now it works:
@Owner(name = "Marta Ruiz", team = "Catalogue")
@Owner(name = "Diego Alonso", team = "Loans")
public class CatalogService { }What the compiler does underneath: when it sees two or more @Owner, it automatically wraps them in an @Owners({...}). And this has a practical consequence that surprises you when reading them by reflection:
// With ONE single @Owner
CatalogService.class.getAnnotation(Owner.class); // returns the annotation
CatalogService.class.getAnnotation(Owners.class); // null
// With TWO @Owner
CatalogService.class.getAnnotation(Owner.class); // null! they are inside the container
CatalogService.class.getAnnotation(Owners.class); // returns the containerSo as not to have to distinguish the two cases, Java 8 added getAnnotationsByType, which works the same with one or with several:
Owner[] all = CatalogService.class.getAnnotationsByType(Owner.class);
for (Owner o : all) {
System.out.println(o.name() + " (" + o.team() + ")");
}Rule: for repeatable annotations, always use getAnnotationsByType. It is the only way not to get it wrong.
Requirements the compiler imposes on the container:
- It must have a
valueelement that is an array of the repeatable type. - Its
@Retentionmust be equal to or wider than the repeatable one's. - Its
@Targetmust include every target of the repeatable one.
- Type annotations (Java 8)
Until Java 8, an annotation was placed on declarations. Java 8 added two ElementType values that allow placing them on any use of a type:
TYPE_USE: anywhere a type appears.TYPE_PARAMETER: on a generic type parameter (10-01's<T>).
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface NonNull { }And now you can write:
// In a variable declaration
@NonNull String title = "Effective Java";
// In the type of a generic parameter
List<@NonNull Book> catalog = new ArrayList<>();
// In a cast
String s = (@NonNull String) object;
// In a new
Book book = new @NonNull Book("Refactoring", "978-0000000003");
// In a throws and in an implements
public class Catalog implements @NonNull Identifiable { }
// In arrays: it annotates the ELEMENT TYPE
@NonNull String[] names; // array of non-null String
String @NonNull [] names2; // non-null array of StringWith TYPE_PARAMETER:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_PARAMETER)
public @interface DomainEntity { }
public class Repository<@DomainEntity T extends Identifiable> { }What is this for? Not for the Java compiler, which ignores them. It is for external type checkers (pluggable type checkers) that analyse the code looking for errors Java's type system cannot detect. The emblematic case is the Checker Framework, capable of proving at compile time that a program cannot throw NullPointerException thanks to @Nullable/@NonNull annotations on type uses.
In day-to-day enterprise development you will mostly see them in the form of Bean Validation's or the IDE's @NotNull. You will not need them to write your own business annotations, but it is worth recognising them when they turn up in other people's signatures.
- How they are read (I): annotation processors at compile time
Here is the first of the two ways for an annotation to "do something". An annotation processor is a program that javac runs during compilation, to which it hands the annotated elements and which can generate new source code, which is in turn compiled.
The mechanism lives in javax.annotation.processing and works in rounds:
graph TD
A[".java sources with annotations"] --> B["javac round 1"]
B --> C{"Any processors<br/>registered?"}
C -->|"yes"| D["The processor receives the annotated elements"]
D --> E["It generates new .java files"]
E --> F["javac round 2 compiles what was generated"]
F --> C
C -->|"none left"| G["Final bytecode .class"]
A processor, in skeleton form:
package com.nexussoftware.bibliotech.processor;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.Set;
@SupportedAnnotationTypes("com.nexussoftware.bibliotech.annotations.CsvField")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class CsvFieldProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment environment) {
for (Element element : environment.getElementsAnnotatedWith(CsvField.class)) {
// Check at COMPILE TIME
if (element.getModifiers().contains(Modifier.STATIC)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@CsvField cannot be put on a static field",
element);
}
// Here code could be generated with
// processingEnv.getFiler().createSourceFile(...)
}
return true; // true = these annotations are already processed
}
}It is registered in META-INF/services/javax.annotation.processing.Processor and activated with -processorpath.
The canonical example is Lombok. When you write:
...Lombok's processor generates during compilation the getIsbn(), getTitle(), setIsbn(), setTitle(), equals(), hashCode() and toString(). The resulting .class contains them all, even though none of them appears in your source. That is why Lombok's annotations are SOURCE: they do their job in the compiler and disappear. You will see Lombok in 11-07.
| Aspect | Processor (compile time) | Reflection (run time) |
|---|---|---|
| When it acts | During javac |
With the program running |
| Retention needed | SOURCE is enough |
RUNTIME mandatory |
| Can generate code | Yes | No (except proxies, 10-03) |
| Can fail the compilation | Yes | No |
| Cost at run time | Zero | Yes, measurable |
| Examples | Lombok, MapStruct, Dagger | Spring, Hibernate, JUnit |
This table also explains a real trend in the ecosystem: more and more frameworks move work from run time to compile time (Micronaut, Quarkus, Spring AOT), because starting faster and using less memory matters a lot in containers.
- How they are read (II): reflection at run time
The second way, and the one you will develop fully in the next lesson. At run time, any annotated element responds to these methods:
import java.lang.reflect.Method;
Method method = LoanManager.class.getMethod("lend", String.class, String.class);
// Does it have the annotation?
if (method.isAnnotationPresent(Auditable.class)) {
// Get it and read its elements
Auditable auditable = method.getAnnotation(Auditable.class);
System.out.println("Operation: " + auditable.value());
System.out.println("Level: " + auditable.level());
}
// All the annotations (including inherited ones)
for (Annotation a : method.getAnnotations()) {
System.out.println(a);
}
// Only the directly declared ones
for (Annotation a : method.getDeclaredAnnotations()) { }
// Repeatable ones
Owner[] owners = CatalogService.class.getAnnotationsByType(Owner.class);Operation: lend
Level: HIGH
@com.nexussoftware.bibliotech.annotations.Auditable(value="lend", level=HIGH)Absolute requirement: @Retention(RetentionPolicy.RUNTIME). Without it, isAnnotationPresent returns false and there is no message explaining why.
This is exactly what Spring, Hibernate and JUnit do at start-up: they walk the classes, look for their annotations and build the application's configuration from them. In 10-03 you will write one.
- What real frameworks use them for
A quick tour, just so you recognise the pattern. All of this is module 11; here we only care about seeing that they are ordinary annotations read by reflection.
Hibernate / JPA (11-03) — they map classes to tables:
@Entity
@Table(name = "materials")
public class Book {
@Id
@Column(name = "isbn", length = 17)
private String isbn;
@Column(name = "title", nullable = false)
private String title;
}At start-up, Hibernate reads these annotations by reflection and builds the object-relational mapping: it knows which table, which columns, which is the primary key and how to generate the SQL.
Spring (11-02) — they declare components and dependency injection:
@Service
public class LoanManager {
private final Repository<Material> catalog;
@Autowired
public LoanManager(Repository<Material> catalog) {
this.catalog = catalog;
}
@Transactional
public Result<Loan> lend(String isbn, String employee) { ... }
}Spring scans the classpath, finds the classes with @Service, instantiates them, resolves their dependencies by type and injects them. And @Transactional does something you will recognise in 10-03: it wraps the object in a dynamic proxy that opens a transaction before every call and commits it afterwards.
JUnit (11-04) — they mark what to run:
class LoanManagerTest {
@BeforeEach
void prepareCatalog() { ... }
@Test
@DisplayName("Lending an available material returns success")
void lendAvailable() { ... }
}JUnit walks the test classes, looks for the methods with @Test and invokes them by reflection.
The pattern is always the same:
graph LR
A["You write an annotation<br/>on your class"] --> B["The framework scans<br/>at start-up"]
B --> C["It reads the annotation<br/>by reflection"]
C --> D["It builds configuration<br/>or behaviour"]
D --> E["It instantiates, injects,<br/>wraps in a proxy, invokes"]
When in 10-03 you write BiblioTech's AnnotatedExporter, you will be doing exactly steps 2, 3 and 4 with your own hands. After that, Spring will stop being magic.
- Configuration next to the code versus XML
It deserves its own section because it explains why the ecosystem moved towards annotations.
Before annotations (Java 5, 2004), all a framework's configuration lived in XML separate from the code:
<bean id="loanManager" class="com.nexussoftware.bibliotech.service.LoanManager">
<constructor-arg ref="catalogRepository"/>
</bean>
<bean id="catalogRepository" class="com.nexussoftware.bibliotech.service.Repository">
<constructor-arg value="catalogue"/>
</bean>Against:
| Criterion | Annotations | External XML |
|---|---|---|
| Proximity | The configuration is where the code is | You have to jump between two files |
| Verbosity | Minimal | A lot of ceremony |
| Refactoring | The IDE renames class and annotation at once | The XML keeps the old name and fails at run time |
| Verification | The compiler checks types and names | Unchecked text strings |
| Changing without recompiling | Impossible | Yes |
| Different config per environment | Hard | Natural |
| Seeing all the config at a glance | Impossible, it is scattered | Yes, in one file |
Neither of the two wins at everything, and that is why modern frameworks use both: annotations for the structural parts (what is a service, what gets injected, what maps to which table) and external files for what changes between environments (database URL, credentials, pool sizes). It is exactly what you already did in 07-07 with Configuration and BusinessRules on top of Properties, and what you will see formalised in 11-02 with application.properties.
- When NOT to use annotations
Annotations are attractive and they get abused. Four clear cases where they are the wrong tool:
1. Business logic. An annotation cannot contain code. Trying to express complex rules in annotation elements ends up in strings you have to parse:
// BAD: you have invented a language inside a string
@Rule("if daysLate > 15 && employee.category != 'MANAGEMENT' then fine = daysLate * 0.25")
public double calculateFine(Loan l) { ... }That is code in disguise, with no compiler checking, no debugger, no autocompletion. Write it in Java.
2. Configuration that changes per environment. An annotation is compiled inside the .class. Changing it requires recompiling and redeploying:
// BAD: changing server forces a recompile
@DbConnection(url = "jdbc:postgresql://production.nexus:5432/bibliotech")
public class LoanStore { }That goes in a .properties file or in an environment variable.
3. Values that change frequently. Thresholds, limits, terms. @LoanLimit(5) looks convenient until management wants 7 on a Tuesday afternoon.
4. When an interface expresses it better. If you want to say "this class can be exported", an Exportable interface gives you compiler checking, autocompletion and polymorphism. An @Exportable annotation only gives you a label that has to be read by reflection.
| Use an annotation when... | Use something else when... |
|---|---|
| The information is structural and stable | The value changes per environment → Properties |
| It is cross-cutting across many classes (auditing, transactions) | It is logic → write it in Java |
| It is going to be consumed by a tool or framework | The behaviour is polymorphic → interface |
| It is tied to the code element | It is a long, hierarchical list → external file |
- BiblioTech:
@CsvField
@CsvFieldTime to apply it. In 07-07 you wrote CsvCatalogExporter by hand, and it has a real problem:
public class CsvCatalogExporter {
public String toLine(Book book) {
return String.join(";",
book.getIsbn(),
book.getTitle(),
book.getAuthor(),
String.valueOf(book.getPages()));
}
public String header() {
return "ISBN;Title;Author;Pages";
}
}Three defects: you have to write an exporter for every class, the order of the header and that of the values can drift apart with nobody warning you, and adding a field to Book forces you to touch two methods in another file (and if it is forgotten, the CSV comes out wrong silently).
The solution is to mark the fields in the class itself:
package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/**
* Marks a field as exportable to CSV.
*
* It is read by 10-03's AnnotatedExporter, which walks by reflection
* the fields of any entity, keeps the annotated ones,
* sorts them by 'order' and builds the header and the line.
*
* RUNTIME is MANDATORY: without it, reflection will not see it.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
public @interface CsvField {
/**
* Column header.
* Empty = use the field's name (null cannot be used: section 10).
*/
String name() default "";
/** Column position. The fields are sorted by this value. */
int order();
/** Optional format for String.format. Empty = String.valueOf. */
String format() default "";
/** If true, the value is masked when exporting (personal data). */
boolean sensitive() default false;
}And it is applied:
package com.nexussoftware.bibliotech.domain;
import com.nexussoftware.bibliotech.annotations.CsvField;
public class Book extends Material {
@CsvField(name = "ISBN", order = 1)
private final String isbn;
@CsvField(name = "Title", order = 2)
private final String title;
@CsvField(name = "Author", order = 3)
private final String author;
@CsvField(name = "Pages", order = 4)
private final int pages;
@CsvField(name = "Rating", order = 5, format = "%.2f")
private final double rating;
// NO annotation: it is internal state, it is not exported
private boolean onLoan;
// NO annotation: internal cache
private transient String computedSummary;
public Book(String isbn, String title, String author, int pages, double rating) {
super(isbn, title);
this.isbn = isbn;
this.title = title;
this.author = author;
this.pages = pages;
this.rating = rating;
}
// getters...
}And on the record Card from 04-07, taking advantage of RECORD_COMPONENT:
package com.nexussoftware.bibliotech.domain;
import com.nexussoftware.bibliotech.annotations.CsvField;
public record Card(
@CsvField(name = "ISBN", order = 1) String isbn,
@CsvField(name = "Title", order = 2) String title,
@CsvField(name = "Available", order = 3) boolean available) {
}And on Loan, with a sensitive field:
public class Loan implements Identifiable {
@CsvField(name = "Reference", order = 1)
private final String reference;
@CsvField(name = "ISBN", order = 2)
private final String isbn;
@CsvField(name = "Employee", order = 3, sensitive = true)
private final String employee;
@CsvField(name = "Loan day", order = 4)
private final int loanDay; // it will stay an int until 10-05
private final List<Incident> incidents = new ArrayList<>(); // not exported
}Notice what has changed conceptually. The information "this field is exported and in this position" has moved from CsvCatalogExporter to sitting next to the field it describes. When somebody adds a field to Book, the decision of whether to export it is in plain sight, on the same line. And the exporter —which does not exist yet— will work for Book, Loan, Card, MeetingRoom and anything else that comes later, without knowing them.
That is exactly what Jackson does with @JsonProperty (11-07) and what Hibernate does with @Column (11-03).
- BiblioTech:
@Auditable
@AuditableThe second case. Nexus Software requires recording who does what with the materials. In 06-07 you solved it by calling OperationLog by hand in every method:
public Result<Loan> lend(String isbn, String employee) {
log.record("lend", employee, isbn); // repeated in 14 methods
// ... the real logic
}Fourteen identical calls that you have to remember to write, that nobody checks and that clutter the logic. It is a cross-cutting concern: it does not belong to the loans logic, but it cuts across all its methods. And it is precisely the case where an annotation shines.
package com.nexussoftware.bibliotech.annotations;
import com.nexussoftware.bibliotech.domain.Severity;
import java.lang.annotation.*;
/**
* Marks an operation that must be recorded in the audit.
*
* On its own it records NOTHING: it is a label. The dynamic proxy
* of 10-03 reads it and intercepts the calls to the marked methods.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Auditable {
/**
* Name of the operation in the log.
* It is called 'value' to allow the shorthand form @Auditable("lend").
* Empty = use the method's name.
*/
String value() default "";
/** Importance of the operation. */
Severity level() default Severity.MEDIUM;
/** If true, the call's arguments are recorded too. */
boolean logArguments() default false;
/** Indices of the arguments that must NOT appear in the log. */
int[] sensitiveArguments() default {};
}Applied to the service:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.annotations.Auditable;
import com.nexussoftware.bibliotech.domain.*;
/**
* @Auditable at TYPE level: default value for the whole class.
* Methods can refine it with their own annotation.
*/
@Auditable(level = Severity.LOW)
public class LoanManager implements LoanService {
private final Repository<Material> catalog;
private final Repository<Loan> loans;
public LoanManager(Repository<Material> catalog, Repository<Loan> loans) {
this.catalog = catalog;
this.loans = loans;
}
@Override
@Auditable(value = "lend", level = Severity.HIGH, logArguments = true)
public Result<Loan> lend(String isbn, String employee) {
Material material = catalog.findById(isbn).orElse(null);
if (material == null) {
return Result.failure("There is no material with ISBN " + isbn);
}
if (material.isOnLoan()) {
return Result.failure("The material '" + material.getTitle() + "' is already on loan");
}
material.markOnLoan(employee);
Loan loan = new Loan(nextReference(), isbn, employee);
loans.save(loan);
return Result.success(loan);
// Not a single line of auditing: the logic is CLEAN
}
@Override
@Auditable(value = "return", level = Severity.HIGH, logArguments = true)
public Result<Loan> returnItem(String reference) { ... }
@Override
@Auditable(value = "changePassword", logArguments = true, sensitiveArguments = {1})
public Result<Void> changeEmployeePassword(String employee, String newPassword) { ... }
// NO @Auditable: read-only query, it is not audited
@Override
public List<Loan> listActive() { ... }
}Three design details worth attention:
The class-level annotation acts as a default value. 10-03's engine will look at the method first and, if it does not find it there, at the class. It is exactly Spring's @Transactional mechanism.
sensitiveArguments = {1} elegantly solves what in 09-06 was a rule written in a comment: "never log tokens or credentials". Now the rule is encoded next to the method it applies to, and the engine cannot forget it.
The method's logic is clean. lend() talks about loans and nothing else. Auditing is a decision declared in a label.
And now, the important sentence: none of this does anything at all yet. You can run BiblioTechApp, lend twenty books and not a single line will be recorded. @CsvField exports nothing and @Auditable audits nothing, because they are labels and nobody is reading them.
Writing that reader is the next lesson.
Common Mistakes and Tips
1. Forgetting @Retention(RetentionPolicy.RUNTIME). The number one mistake, no argument. The default retention is CLASS: the annotation stays in the .class but it is not visible through reflection, and isAnnotationPresent() returns false with no clue. If your annotation is going to be read by code at run time, the meta-annotation is mandatory.
2. Believing that an annotation does something. @Auditable does not audit. @Transactional does not open transactions — the proxy Spring creates when reading it does. If you put your annotation on and nothing happens, it is not broken: the reader is missing. Corollary: @Transactional on a private method or one called from inside the same class does not work, because the call does not go through the proxy.
3. Putting @SuppressWarnings("all"). It silences everything, including warnings that do not exist yet. Use the specific identifier and the minimum scope.
4. Omitting @Override. An equals(Material) instead of equals(Object) compiles, looks right and breaks every collection. Always add it.
5. Trying to use null in an element. You cannot, neither as a value nor as a default. Use "", an empty array {} or a sentinel constant.
6. Trying to use disallowed types. List<String>, LocalDate or your own classes are not valid. Only primitives, String, Class, enum, annotations and arrays of those.
7. Not using getAnnotationsByType with repeatable ones. With a single occurrence, getAnnotation works; with two, it returns null because the compiler put them into the container. getAnnotationsByType works in both cases.
8. Putting environment configuration into annotations. An annotated database URL forces a recompile to change server. That goes in Properties.
9. Annotations with no @Target. They can be put anywhere, including places that make no sense, and your engine will receive elements it does not expect. Declaring the target is verified documentation.
Tip 1: design the annotation thinking of whoever writes it. Name the principal element value to allow the shorthand form, and put a default on everything else. Comparing @CsvField(order = 3) with @CsvField(name = "", order = 3, format = "", sensitive = false) makes it clear why.
Tip 2: use an enum instead of a String for closed sets of values. Severity level() gives autocompletion and compiler checking; String level() gives typos that fail at run time.
Tip 3: document what the reader does, not what the annotation does. The Javadoc of @CsvField should say "it is read by AnnotatedExporter, which sorts by order and uses name as the header". Without that, whoever puts it on does not know what to expect.
Tip 4: keep an annotations package of your own. Having them together in com.nexussoftware.bibliotech.annotations makes the project's metadata inventory obvious and prevents duplicates.
Tip 5: when in doubt between an annotation and an interface, choose the interface. The compiler checks interfaces. Reserve annotations for what an interface cannot express: information with parameters (order = 3) or cross-cutting behaviour.
Exercises
Exercise 1: @Validate with declarative rules
Define a @Validate annotation for BiblioTech that allows declaring constraints on an entity's fields:
- Applicable to fields and
recordcomponents, visible at run time. - Elements:
required(boolean,trueby default),minLength(int,0by default),maxLength(int,Integer.MAX_VALUEby default),pattern(string, empty by default) andmessage(string, empty by default). - Annotate
Book's fields with it: the ISBN mandatory with pattern978-\d{10}, the title mandatory and between 1 and 200 characters, the author optional with a maximum of 120. - Also write a repeatable annotation
@Example(String)with its container, to document valid values for each field, and put it twice on the ISBN. - Do not write the validator: that is 10-03. Do write a
mainthat prints, usinggetDeclaredFields()andgetAnnotation(), which fields are annotated and with what values.
Exercise 2: Standard annotations correctly placed
You are given this BiblioTech class, written with no annotations at all and with several latent problems. Fix it by adding the JDK's standard annotations where they belong and repairing whatever the annotations uncover.
public class LegacyCatalog {
private final Map<String, Material> materials = new HashMap<>();
public boolean equals(LegacyCatalog other) {
return materials.equals(other.materials);
}
public String toString() {
return "LegacyCatalog with " + materials.size() + " materials";
}
public void addAll(List... lists) {
for (List list : lists) {
for (Object o : list) {
Material m = (Material) o;
materials.put(m.getId(), m);
}
}
}
public void addBook(String isbn, String title) {
materials.put(isbn, new Book(isbn, title));
}
public interface MaterialFilter {
boolean accepts(Material m);
}
}Requirements: addBook must be deprecated since version 3.1, marked for removal, with add(Material) as the alternative. addAll must accept generic varargs with no warnings. MaterialFilter must be protected as a functional interface. And equals must really work with HashMap.
Exercise 3: @Retryable with a declarative policy
In 09-06 you wrote ResilientHttpClient with the retry policy hard-coded inside the method. Design an annotation that declares it:
@Retryable, applicable to methods, visible at run time.- Elements:
attempts(3 by default),initialWaitMs(200 by default),growthFactor(double, 2.0 by default),exceptions(array ofClass<? extends Exception>,{Exception.class}by default), andidempotentOnly(boolean,trueby default). - Annotate three methods of
MetadataClientwith it, using different policies. - Write a method
describePolicy(Class<?>)that, by reflection, prints a readable table of each annotated method's policy, computing and showing the maximum accumulated wait.
Also add a meta-annotation of your own, @CrossCutting (applicable only to other annotations), that marks @Auditable and @Retryable as cross-cutting aspects, and show that it can be read from Auditable.class.
Solutions
Solution 1
package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/**
* Declarative constraints on a field's value.
* The validator that consumes them is written in 10-03.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME) // MANDATORY in order to read it by reflection
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
public @interface Validate {
boolean required() default true;
int minLength() default 0;
int maxLength() default Integer.MAX_VALUE;
/** Regular expression. Empty = no pattern (null cannot be used). */
String pattern() default "";
/** Custom message. Empty = the engine generates one. */
String message() default "";
}package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/** CONTAINER of @Example. Its value is an array of the repeatable type. */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
public @interface Examples {
Example[] value();
}package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/** Documents a valid example value. Repeatable. */
@Documented
@Repeatable(Examples.class) // declares who contains it
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
public @interface Example {
String value(); // 'value' allows @Example("978-0000000001")
}The annotated entity:
package com.nexussoftware.bibliotech.domain;
import com.nexussoftware.bibliotech.annotations.*;
public class Book extends Material {
@Validate(required = true, pattern = "978-\\d{10}",
message = "The ISBN must follow the format 978-XXXXXXXXXX")
@Example("978-0000000001")
@Example("978-0000000002")
private final String isbn;
@Validate(required = true, minLength = 1, maxLength = 200)
@Example("Effective Java")
private final String title;
@Validate(required = false, maxLength = 120)
private final String author;
// Not annotated: not validated
private boolean onLoan;
public Book(String isbn, String title, String author) {
super(isbn, title);
this.isbn = isbn;
this.title = title;
this.author = author;
}
}The inspector:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.annotations.*;
import com.nexussoftware.bibliotech.domain.Book;
import java.lang.reflect.Field;
public class RuleInspector {
public static void main(String[] args) {
System.out.printf("%-10s %-6s %-5s %-6s %-20s%n",
"FIELD", "REQ", "MIN", "MAX", "PATTERN");
System.out.println("-".repeat(56));
for (Field field : Book.class.getDeclaredFields()) {
// Without the annotation, this field is not validated
if (!field.isAnnotationPresent(Validate.class)) {
continue;
}
Validate rule = field.getAnnotation(Validate.class);
System.out.printf("%-10s %-6s %-5d %-6s %-20s%n",
field.getName(),
rule.required() ? "yes" : "no",
rule.minLength(),
rule.maxLength() == Integer.MAX_VALUE ? "-" : rule.maxLength(),
rule.pattern().isEmpty() ? "-" : rule.pattern());
if (!rule.message().isEmpty()) {
System.out.println(" message: " + rule.message());
}
// REPEATABLE: getAnnotationsByType works with 0, 1 or N occurrences.
// getAnnotation(Example.class) would return null when there are two.
Example[] examples = field.getAnnotationsByType(Example.class);
if (examples.length > 0) {
StringBuilder sb = new StringBuilder(" examples: ");
for (Example e : examples) {
sb.append(e.value()).append(" ");
}
System.out.println(sb);
}
}
// Demonstration of the repeatable trap
System.out.println();
try {
Field isbn = Book.class.getDeclaredField("isbn");
System.out.println("isbn: getAnnotation(Example) -> " + isbn.getAnnotation(Example.class));
System.out.println("isbn: getAnnotation(Examples) -> present="
+ isbn.isAnnotationPresent(Examples.class));
System.out.println("isbn: getAnnotationsByType(Example) -> "
+ isbn.getAnnotationsByType(Example.class).length + " elements");
Field title = Book.class.getDeclaredField("title");
System.out.println("title: getAnnotation(Example) -> " + title.getAnnotation(Example.class));
} catch (NoSuchFieldException e) {
throw new IllegalStateException(e);
}
}
}FIELD REQ MIN MAX PATTERN
--------------------------------------------------------
isbn yes 0 - 978-\d{10}
message: The ISBN must follow the format 978-XXXXXXXXXX
examples: 978-0000000001 978-0000000002
title yes 1 200 -
examples: Effective Java
author no 0 120 -
isbn: getAnnotation(Example) -> null
isbn: getAnnotation(Examples) -> present=true
isbn: getAnnotationsByType(Example) -> 2 elements
title: getAnnotation(Example) -> @...Example(value="Effective Java")Comments. The final output is the instructive part.
With two @Example, getAnnotation(Example.class) returns null. The compiler wrapped them in @Examples({...}), so the field no longer carries any @Example directly. With a single one (the title) it does work. This asymmetry is exactly why you must always use getAnnotationsByType with repeatable annotations.
The pattern "978-\\d{10}" carries a double backslash because it is a Java string containing a regular expression: \\d in the source is \d in the string.
The onLoan field does not appear because it is not annotated, and the continue skips it. That is the basic mechanism of every annotation engine: walk everything and keep what is marked.
Solution 2
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
import java.util.*;
public class LegacyCatalog {
private final Map<String, Material> materials = new HashMap<>();
/**
* FIXED: the original signature was equals(LegacyCatalog), which OVERLOADS
* instead of overriding. With @Override the compiler would have stopped it.
* HashMap, HashSet and contains() call equals(Object).
*/
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof LegacyCatalog)) return false;
return materials.equals(((LegacyCatalog) other).materials);
}
/**
* Overriding equals FORCES you to override hashCode (03-09).
* The @Override on equals reminded us that it was missing.
*/
@Override
public int hashCode() {
return Objects.hash(materials);
}
@Override
public String toString() {
return "LegacyCatalog with " + materials.size() + " materials";
}
/**
* FIXED: it was List... (a raw type) with a cast to Material inside.
* Now it is generic and typed. @SafeVarargs because the method
* ONLY READS from the varargs array: it neither stores nor returns it.
* It must be final (or static/private) in order to be annotated.
*/
@SafeVarargs
public final void addAll(List<? extends Material>... lists) {
for (List<? extends Material> list : lists) { // ? extends: we only read (PECS, 10-01)
for (Material m : list) {
materials.put(m.getId(), m);
}
}
}
/** Replacement for addBook. */
public void add(Material material) {
Objects.requireNonNull(material, "material");
materials.put(material.getId(), material);
}
/**
* @deprecated Since 3.1 use {@link #add(Material)}, which accepts
* any material and not only books. It will be removed in 4.0.
*/
@Deprecated(since = "3.1", forRemoval = true)
public void addBook(String isbn, String title) {
add(new Book(isbn, title));
}
/**
* @FunctionalInterface protects the contract: if somebody adds
* a second abstract method, the compilation fails here
* instead of breaking everybody who uses it with lambdas.
*/
@FunctionalInterface
public interface MaterialFilter {
boolean accepts(Material m);
// default methods do NOT count as abstract
default MaterialFilter and(MaterialFilter other) {
return m -> this.accepts(m) && other.accepts(m);
}
}
}Demonstration that it now works:
public class LegacyCatalogTest {
@SuppressWarnings("removal") // we deliberately use the deprecated method, and we justify it
public static void main(String[] args) {
LegacyCatalog a = new LegacyCatalog();
LegacyCatalog b = new LegacyCatalog();
List<Book> books = List.of(
new Book("978-0000000001", "Effective Java"),
new Book("978-0000000002", "Design Patterns"));
a.addAll(books); // no warnings: safe generic varargs
b.addAll(books);
// With the correct equals, this NOW works
System.out.println("a.equals(b): " + a.equals(b));
Set<LegacyCatalog> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("Set size: " + set.size() + " (it would have been 2 before)");
// The deprecated method still works, with a compile warning
a.addBook("978-0000000003", "Refactoring");
// The functional interface is used with a lambda
LegacyCatalog.MaterialFilter available = m -> !m.isOnLoan();
LegacyCatalog.MaterialFilter titled = m -> m.getTitle() != null;
LegacyCatalog.MaterialFilter both = available.and(titled);
System.out.println("Composite filter created: " + (both != null));
}
}Comments.
@Override on equals uncovered two bugs, not one. The first, the wrong signature. The second, as a knock-on effect: fixing it means Object's contract (03-09) requires a coherent hashCode(), which was missing. A HashSet with a correct equals and an inherited hashCode would still give size 2.
@SafeVarargs requires final. The method went from public void to public final void. Without final, static or private, the compiler rejects the annotation: you cannot promise that a future implementation will be safe.
The @SuppressWarnings("removal") on main is justified in the comment, which is the only legitimate way to use it: here we deliberately call the deprecated method to show that it still works.
Solution 3
package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/**
* META-ANNOTATION: it can only be put on other annotations.
* It marks an aspect that cuts across the application instead of
* belonging to a specific layer.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE) // ONLY on annotations
public @interface CrossCutting {
/** Order of application when several aspects are stacked. */
int order() default 0;
String description() default "";
}package com.nexussoftware.bibliotech.annotations;
import java.lang.annotation.*;
/**
* Declares the retry policy of a network operation.
* Replaces the policy hard-coded by hand in ResilientHttpClient (09-06).
*/
@Documented
@CrossCutting(order = 20, description = "Retries with increasing wait")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retryable {
/** Total number of attempts, including the first one. */
int attempts() default 3;
/** Wait before the second attempt, in milliseconds. */
long initialWaitMs() default 200;
/** Multiplier of the wait on each retry. */
double growthFactor() default 2.0;
/**
* Exceptions that trigger the retry.
* Class<? extends Exception>[] is an ALLOWED type in annotations
* (Class and arrays of Class are), and it is bounded too (10-01).
*/
Class<? extends Exception>[] exceptions() default { Exception.class };
/** If true, it only retries when the operation is idempotent (09-06). */
boolean idempotentOnly() default true;
}Applied with three different policies:
package com.nexussoftware.bibliotech.network;
import com.nexussoftware.bibliotech.annotations.Retryable;
import java.io.IOException;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
public class MetadataClient {
/** Read-only query: it can be retried with confidence. */
@Retryable(attempts = 5, initialWaitMs = 100, growthFactor = 2.0,
exceptions = { SocketTimeoutException.class, ConnectException.class })
public String lookupByIsbn(String isbn) throws IOException { ... }
/** Heavy download: fewer attempts and longer waits. */
@Retryable(attempts = 2, initialWaitMs = 1000, growthFactor = 3.0,
exceptions = { IOException.class })
public byte[] downloadCover(String isbn) throws IOException { ... }
/** Non-idempotent POST: NEVER retried blindly (09-06). */
@Retryable(attempts = 1, idempotentOnly = true)
public void publishRating(String isbn, int stars) throws IOException { ... }
/** No annotation: no retry policy. */
public boolean isAvailable() { ... }
}The describer:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.annotations.*;
import com.nexussoftware.bibliotech.network.MetadataClient;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
public class PolicyDescriptor {
public static void main(String[] args) {
describePolicy(MetadataClient.class);
System.out.println();
describeMetaAnnotations(Retryable.class);
describeMetaAnnotations(Auditable.class);
}
public static void describePolicy(Class<?> type) {
System.out.println("Retry policies in " + type.getSimpleName());
System.out.printf("%-22s %-8s %-9s %-7s %-11s %s%n",
"METHOD", "ATTEMPTS", "WAIT_1ST", "FACTOR", "MAX_WAIT", "EXCEPTIONS");
System.out.println("-".repeat(96));
// getDeclaredMethods guarantees no order: we fix it so the output is stable
Method[] methods = type.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
for (Method method : methods) {
Retryable policy = method.getAnnotation(Retryable.class);
if (policy == null) {
continue; // no declared policy
}
// Accumulated wait: w, w*f, w*f^2, ... for (attempts - 1) retries
double totalWait = 0;
double wait = policy.initialWaitMs();
for (int i = 1; i < policy.attempts(); i++) {
totalWait += wait;
wait *= policy.growthFactor();
}
String exceptions = Arrays.stream(policy.exceptions())
.map(Class::getSimpleName)
.reduce((a, b) -> a + ", " + b)
.orElse("-");
System.out.printf("%-22s %-8d %-9d %-7.1f %-11.0f %s%n",
method.getName(),
policy.attempts(),
policy.initialWaitMs(),
policy.growthFactor(),
totalWait,
exceptions);
if (policy.idempotentOnly() && policy.attempts() > 1) {
System.out.println(" warning: it requires idempotency; check that the method is");
}
}
}
/** Reads the META-annotations of an annotation: it is reflection on reflection. */
public static void describeMetaAnnotations(Class<? extends java.lang.annotation.Annotation> annotation) {
System.out.println("Meta-annotations of @" + annotation.getSimpleName() + ":");
CrossCutting c = annotation.getAnnotation(CrossCutting.class);
if (c != null) {
System.out.printf(" @CrossCutting(order=%d) %s%n", c.order(), c.description());
} else {
System.out.println(" it is not a cross-cutting aspect");
}
Retention r = annotation.getAnnotation(Retention.class);
Target t = annotation.getAnnotation(Target.class);
System.out.println(" retention: " + (r == null ? "CLASS (by default)" : r.value()));
System.out.println(" targets: " + (t == null ? "any" : Arrays.toString(t.value())));
}
}Adding @CrossCutting(order = 10, description = "Auditing of operations") on @Auditable, the output is:
Retry policies in MetadataClient
METHOD ATTEMPTS WAIT_1ST FACTOR MAX_WAIT EXCEPTIONS
------------------------------------------------------------------------------------------------
downloadCover 2 1000 3.0 1000 IOException
warning: it requires idempotency; check that the method is
lookupByIsbn 5 100 2.0 1500 SocketTimeoutException, ConnectException
publishRating 1 200 2.0 0 Exception
Meta-annotations of @Retryable:
@CrossCutting(order=20) Retries with increasing wait
retention: RUNTIME
targets: [METHOD]
Meta-annotations of @Auditable:
@CrossCutting(order=10) Auditing of operations
retention: RUNTIME
targets: [METHOD, TYPE]Comments. Four observations.
Class<? extends Exception>[] works because Class is an allowed type, and it is bounded with generics too (10-01): you cannot put String.class there, the compiler prevents it. It is a good example of the two features cooperating.
publishRating with attempts = 1 has a maximum wait of 0, and that is exactly what is wanted: a non-idempotent POST that is not retried. The annotation documents the decision instead of it being an if lost inside the method.
@CrossCutting is a genuine meta-annotation, with @Target(ANNOTATION_TYPE). Putting it on a class does not compile. And reading it from Auditable.class shows something conceptually important: an annotation is a class, and therefore it can be inspected like any other. Retention and Target are read just like yours, because there is nothing special about them.
And the essential part is missing: none of this retries anything. describePolicy only describes. For @Retryable to really work you need to intercept the call, run it, catch the exception, wait and repeat — that is, a dynamic proxy. That is the next lesson.
Conclusion
You now know what an annotation is and, above all, what it is not.
An annotation is information about the code: a label attached to a class, a method, a field or a parameter that does not change its behaviour. @Auditable does not audit. @Entity does not create tables. @Test runs nothing. Somebody reads them and acts, and that is the only way an annotation produces effects.
You know the JDK's standard ones and what each really does: @Override verifies that you really override something and saves you from the equals(Material) that overloads instead of overriding and silently breaks every collection; @Deprecated warns, with since to know how long it has been that way and forRemoval to distinguish "there is something better" from "this is going away"; @SuppressWarnings silences specific warnings, with minimum scope and a written justification; @SafeVarargs is your promise that you do not expose the varargs array, and it requires final, static or private in order to make it; @FunctionalInterface protects the contract of an interface with a single abstract method.
You write your own with @interface, whose elements are declared as methods with no body, with default to make them optional and with value as the magic name that enables the shorthand form. You know which types are allowed —primitives, String, Class, enum, annotations and arrays of those— and that null is not a possible value, so the convention is "", {} or a sentinel.
You have mastered the meta-annotations, starting with the one that decides whether your annotation will even exist: @Retention. SOURCE dies in the compiler, CLASS —the default— stays in the .class but is invisible to reflection, and RUNTIME is the only one that reaches run time alive. That default is the number one mistake with your own annotations and it will not catch you out now. With @Target you declare where it can be put, with @Documented that it becomes part of the public contract, with @Inherited that subclasses inherit it, and with @Repeatable that it can appear several times — knowing that the compiler wraps them in the container and that is why you must always use getAnnotationsByType. And you recognise Java 8's type annotations (TYPE_USE, TYPE_PARAMETER), which exist for external checkers such as the Checker Framework.
You know there are exactly two ways of reading them. At compile time, with an annotation processor that receives the marked elements, can fail the compilation and can generate new code — which is literally what Lombok does when @Data produces the getters, the setters, equals, hashCode and toString you did not write (11-07). And at run time, with reflection: isAnnotationPresent, getAnnotation, getAnnotationsByType. The first costs zero at run time; the second is what Spring, Hibernate and JUnit do at start-up, and it is the next lesson.
You understand why the ecosystem moved from external XML to configuration next to the code —proximity, less ceremony, safe refactoring, compiler verification— and why it did not win outright: what changes per environment still lives outside, in Properties. And you know when not to use annotations: for business logic (you end up inventing a language inside a string), for environment configuration (it forces a recompile), for values that change often, and when an interface would express the same thing with compiler checking.
BiblioTech now has a com.nexussoftware.bibliotech.annotations package. @CsvField(name, order, format, sensitive) marks which fields are exported and in which column, and it is placed on Book, Loan and the record Card —taking advantage of RECORD_COMPONENT—, moving that decision from CsvCatalogExporter to the line of the field it describes. @Auditable(value, level, logArguments, sensitiveArguments) marks which operations are recorded, with the class annotation acting as the default value and sensitiveArguments encoding 09-06's rule of not logging credentials. And LoanManager.lend() has been left clean: fourteen repeated calls to OperationLog have disappeared from the logic.
And nothing works. You can lend twenty books and not a single line will be recorded; you can export the catalogue and @CsvField will have been of no use at all. The labels are in place and there is nobody reading them.
In 10-03, Reflection, you write that reader. You will learn to obtain the Class<?> object in three different ways, to inspect fields, methods and constructors —and the difference between getFields and getDeclaredFields, which confuses everybody—, to create instances and invoke methods without knowing them at compile time, and to bypass encapsulation with setAccessible(true). With that you will write the AnnotatedExporter that walks the fields marked with @CsvField, sorts them and generates the CSV line of any entity without knowing it. And you will go one step further: a dynamic proxy that intercepts every call to LoanManager, sees the @Auditable and records the operation without the method noticing. When that proxy works, you will understand from the inside how Spring's @Transactional works — and module 11's frameworks will stop looking like magic.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
