Java Annotations
Purpose
Keep metadata honest: an annotation changes nothing by itself, so every annotation in a
codebase must have an identifiable reader — a compiler check, an annotation processor, a
runtime framework — or it is a comment with a compiler-checked spelling. Two failure modes:
the annotation that is believed to be enforcing something it is not (a @NotNull with no
validator on the path, a @Transactional bypassed by self-invocation), and the naming
convention or magic string doing a job an annotation would do with compile-time checking.
Workflow
- Establish compatibility. Inspect compiler release/toolchain, processor configuration,
resolved framework versions and runtime. No authoring baseline was previously declared:
language references here use Java 17; records require Java 16+ without preview, and
Deprecated.since/forRemovalrequire Java 9+. Adapt to the project without upgrading it, enabling preview or adding dependencies implicitly. If configuration is missing, keep enforcement diagnoses conditional and name the evidence needed. - Name the consumer before defining the annotation. It may be the compiler, processor, framework, static-analysis tool, documentation generator or a human-facing API contract. If no consumer benefits from structured metadata, Javadoc is usually clearer.
- Pick the retention from the reader.
SOURCEfor compile-time-only checks,CLASSfor bytecode tools,RUNTIMEonly when something reflects over it at runtime. Retention is not a default to copy from the last annotation you wrote. - Constrain the targets.
@Targetrestricts where it can be applied; without it, an annotation is legal in places the reader never looks, which is how "the annotation does nothing here" bugs happen. - Verify the enforcement path end to end, with a test that violates the annotated constraint and asserts the failure. An annotation with no failing test proves nothing.
- Check what happens under proxying, native image and module boundaries — the three places where annotation-driven behaviour silently stops applying.
Rules
Use
@Overrideon every method that overrides or implements one. It is the cheapest correctness check the language offers, and the one it catches is the expensive one: anequals(MyType other)that overloads instead of overriding, so the collection callsObject.equalsand identity semantics apply. Interface implementations included — it is allowed there and catches the same drift when the interface changes.Prefer an annotation to a naming pattern when the tool/API supports metadata (
@TestovertestFoo,@Deprecatedplus migration Javadoc). The compiler checks annotation syntax and target, while the annotation's processor/framework still owns semantic validation.Prefer a marker interface when the marked thing is a type and something should be checked at compile time: an interface defines a type, so it can be a parameter or return type, and the compiler enforces it at every use. Prefer a marker annotation when the target is not a type (methods, fields, parameters, packages, modules), when the marking must be added later without touching the type hierarchy, or when the marker may gain parameters.
Give every annotation an explicit
@Retentionand@Target. The default retention isCLASS, which is almost never what a runtime framework needs, and is the reason a hand-written annotation is silently invisible to reflection.@Inheritedapplies only to annotations on classes, and only along the superclass chain. It does not make an annotation inherited from an interface, and it does not apply to methods or fields. Framework meta-annotation mechanisms (Spring's@AliasFor,MergedAnnotations) implement their own richer rules — those are the framework's semantics, not the language's.An annotation on a record component follows target and declaration rules, including
RECORD_COMPONENTandTYPE_USE. Explicit accessors and normal canonical constructors differ from generated members; read the retention reference before changing either. A validation annotation that targets onlyFIELDwill not be seen by a framework reading the constructor parameters. State the targets, and test that the constraint actually fires.Annotations do not validate anything. Jakarta Bean Validation constraints run only when a
Validatoris invoked — by the framework at a@Validparameter, or by your code. A DTO covered in@NotBlankthat is deserialised and used without validation is unvalidated input; java-defensive-programming covers where the check belongs.Proxy-based annotations (
@Transactional,@Cacheable,@Retryable,@Asyncand equivalents) depend on the configured advice mechanism. In ordinary Spring proxy mode, self-invocation and private methods bypass advice; final classes/methods block subclass proxies but interface-based proxies differ. AspectJ weaving and programmatic APIs have other semantics. Test the actual call path; framework-coupling-and-independence covers making it visible.RUNTIMEmetadata does not itself force whole-classpath scanning or defeat AOT. Startup cost depends on framework indexing, scan scope and caching; native-image reachability depends on what build-time analysis can discover and supplied metadata. Prefer annotation processing or build-time generation when it provides equivalent semantics and its build/debugging cost is acceptable. On JDK 23+, command-linejavacruns processors only when annotation processing is explicitly configured (for example--processor-path,-processor, or-proc:full); ensure the build tool declares processors rather than relying on classpath discovery.Do not put secrets or environment-specific policy in annotation elements. Element values are restricted to annotation-compatible constants/types and are baked into class metadata; they require recompilation to change and may be visible through bytecode/reflection.
For repeatable annotations, inspect both the repeated annotation and its container: retention, target and inheritance must be compatible. For
TYPE_USE, decide whether the consumer reads declaration annotations or type annotations; they occupy different class-file/reflection APIs.Deprecate with
@Deprecated(since = "…", forRemoval = …)plus@deprecatedJavadoc saying what to use instead.forRemoval = trueturns usage warnings into a stronger signal and is part of the API contract — see java-api-design.
Deliverable
Identify the reader and call path, observed metadata/configuration, consequence, smallest correction and a check that confirms it. Distinguish static evidence from executed rejection tests. For a new annotation, supply its retention/target contract and consumer integration with positive and negative checks. Annotation presence or reflection visibility alone does not demonstrate enforcement.
References
- Retention, targets and processing — read when defining an annotation, when one appears to have no effect, or when choosing between runtime reflection, an annotation processor and build-time generation.
- Markers, custom annotations and enforcement — read when choosing between a marker interface and a marker annotation, when designing an annotation that must be enforced, or when annotation-driven behaviour is silently not applying.