Anotaciones personalizadas en Java
Define tus propios tipos de anotación en Java, configura su retención y destinos, y léelos en tiempo de ejecución con reflexión.
Una anotación personalizada es un tipo de anotación que declaras tú mismo, en lugar de una suministrada por el JDK (como @Override) o un framework (como @Test). La sintaxis se parece a una interfaz, pero las reglas son más estrictas. Una vez declarada, tu anotación se convierte en un tipo real que puedes adjuntar al código, buscar mediante reflexión y procesar en tiempo de compilación.
Este capítulo es la guía práctica para escribir las tuyas propias: la palabra clave @interface, qué tipos de elementos están permitidos, en qué se diferencian los elementos requeridos y opcionales, y cómo un procesador lee los valores en tiempo de ejecución. Si aún no conoces las anotaciones, empieza con anotaciones de Java y las anotaciones integradas; para controlar dónde puede aparecer tu anotación y cuánto tiempo vive, consulta las meta-anotaciones.
La declaración @interface
Un tipo de anotación se declara con la palabra clave @interface:
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Audited {
String value(); // required element
String level() default "INFO"; // element with a default
String[] tags() default {}; // array element with default
}Esto declara un nuevo tipo de anotación llamado Audited cuyos elementos parecen métodos de una interfaz, pero se comportan como valores con nombre en los sitios de uso. Cada "método" es un elemento.
Úsalo así:
@Audited("UserService.login") // value omitted name → "value" element
public User login(String user, String password) { ... }
@Audited(value = "Service.save", level = "WARN", tags = {"db", "write"})
public void save(Entity e) { ... }El atajo value (@Audited("...") en lugar de @Audited(value = "...")) solo está disponible cuando el elemento se llama literalmente value, que es por eso que tantas anotaciones usan exactamente ese nombre para su parámetro principal.
Qué elementos están permitidos
El cuerpo de un @interface es un conjunto cerrado de declaraciones de elementos. El tipo de retorno de cada elemento debe ser uno de los siguientes:
- Un primitivo (
int,long,double,boolean, ...). String.Classo unaClass<?>parametrizada.- Un tipo enum.
- Otro tipo de anotación.
- Un array de cualquiera de los anteriores.
Los valores predeterminados se escriben con default. El valor predeterminado debe ser una constante en tiempo de compilación del tipo correcto:
@interface RetryPolicy {
int attempts() default 3;
long delayMs() default 100;
Class<? extends Exception>[] on() default {Exception.class};
Level level() default Level.WARN;
enum Level { DEBUG, INFO, WARN, ERROR }
}Lo que no puedes declarar en una anotación:
- Métodos que acepten parámetros (los
()son obligatorios pero siempre están vacíos). - Elementos genéricos (
<T> T value();es ilegal). - Cláusulas
throws. - Herencia de otra interfaz (las anotaciones extienden implícitamente
java.lang.annotation.Annotation). - Constructores.
Puedes anidar tipos dentro de una declaración de anotación — el enum Level del ejemplo anterior vive dentro de @RetryPolicy. Es un patrón muy útil: mantiene las opciones relacionadas vinculadas a la anotación que las usa.
Elementos requeridos vs. opcionales
Un elemento sin default es requerido en los sitios de uso. El compilador falla si lo olvidas:
@interface Issue { String id(); } // required
@Issue // compile error: missing 'id'
public void brokenLogin() { }
@Issue(id = "JIRA-123") // OK: 'id' supplied
public void fixedLogin() { }Una pequeña nota de estilo: si hay un único valor obvio, nómbralo value y hazlo requerido. Si hay varios ajustes, nómbralos y dales valores predeterminados sensatos para que la llamada más común sea breve.
Anotaciones marcadoras
Una anotación sin elementos es un marcador:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface ThreadSafe { }Las anotaciones marcadoras no llevan datos; su presencia o ausencia es la señal completa. La reflexión pregunta "¿tiene esta clase @ThreadSafe?" con getAnnotation(ThreadSafe.class) != null o isAnnotationPresent(ThreadSafe.class).
Leer anotaciones en tiempo de ejecución
Para una anotación RUNTIME, la reflexión expone varios métodos en Class, Method, Field, Constructor y Parameter (consulta leer anotaciones con reflexión para la superficie completa):
isAnnotationPresent(Class)— respuesta rápida sí/no.getAnnotation(Class)— devuelve la instancia de la anotación, onull.getAnnotations()— devuelve todas las anotaciones del elemento (declaradas + heredadas mediante@Inherited).getDeclaredAnnotations()— solo las declaradas directamente en el elemento, ignorando@Inherited.getAnnotationsByType(Class)— maneja correctamente el caso@Repeatable.
La lectura tiene la misma forma independientemente del tipo de destino con el que trabajes:
Method m = ...;
if (m.isAnnotationPresent(Audited.class)) {
Audited a = m.getAnnotation(Audited.class);
log(a.value(), a.level(), a.tags());
}El Audited devuelto es un proxy generado por la JVM — los métodos de elemento (value(), level(), tags()) son llamadas a métodos reales sobre él.
Igualdad, identidad y toString de anotaciones
Los valores de las anotaciones implementan equals, hashCode y toString tal como los define java.lang.annotation.Annotation:
- Dos instancias de anotación son iguales cuando son del mismo tipo y cada elemento compara igual (con igualdad profunda de arrays).
hashCodese deriva de los valores de los elementos de manera definida.toStringproduce una representación estable similar a la del código fuente — útil para el registro.
La reflexión a veces devuelve el mismo proxy para búsquedas repetidas en el mismo elemento, y a veces devuelve uno nuevo. Usa equals, nunca ==, al comparar instancias de anotaciones.
Un ejemplo completo: definir, adjuntar y reflejar
El programa declara dos anotaciones (@Audited y @Retry), las usa en una clase y recorre los métodos con reflexión — ejecutando cada método dentro de un envoltorio de auditoría o con un bucle de reintentos. Las anotaciones son puro metadata; el comportamiento vive en el ejecutor.
Qué extraer de la ejecución:
greetsolo llevaba@Audited, por lo que el ejecutor imprimió un par entrada/salida alrededor del método pero no realizó reintentos. El mismo ejecutor manejósavedetectando@Retryademás de@Audited: la primera invocación lanzó una excepción (saveCalls == 1), el auxiliar registró el fallo y repitió el bucle, y el segundo intento devolviósaved: data. Las anotaciones en sí no hicieron nada — el auxiliarinvokeproporcionó el comportamiento.unannotatedpasó por el mismo bucle porque el ejecutor es uniforme.isAnnotationPresentdevolviófalsepara ambas anotaciones, por lo que el auxiliar ni registró ni reintentó; el método simplemente se ejecutó una vez. Ese es el patrón para los procesadores: examinar las anotaciones, actuar razonablemente cuando están ausentes, nunca tratar de manera especial el "camino anotado".- Cada acceso a elemento (
a.value(),r.attempts(),r.when()) devolvió el valor escrito en el código fuente.Retry.when()devolvió la constante del enumALWAYSporque el sitio de llamada usó el valor predeterminado. Los valores predeterminados son incorporados en el proxy de la anotación por el compilador; el llamador no puede distinguir si un valor fue explícito o predeterminado. - El
toStringdeAuditedimprimió una forma similar al código fuente como@...Audited(level="WARN", value="Service.save"). Esa es una propiedad de cada proxy de anotación — útil para el registro y paraassertEqualsen pruebas. (El orden en que los elementos aparecen dentro de los paréntesis no está garantizado y varía entre versiones del JDK, así que no hagas aserciones sobre la cadena exacta.) - Las dos anotaciones son completamente independientes a nivel de código fuente: un método lleva ambas a la vez y la reflexión devolvió felizmente ambas. No hay jerarquía de herencia entre tipos de anotación; combinar comportamientos se logra apilando anotaciones en el mismo elemento, no extendiendo una anotación de otra.
Dónde esto deja de funcionar
Algunas sorpresas comunes:
- La retención SOURCE no puede reflejarse. Si olvidas
@Retention(RUNTIME), la reflexión silenciosamente devuelvenull. El valor predeterminado esCLASS, noRUNTIME. - Los destinos deben coincidir. Si
@Target(METHOD)y pones la anotación en una clase, el compilador lo rechaza. - Los valores predeterminados de los elementos deben ser constantes en tiempo de compilación. No puedes usar
new ArrayList<>()como predeterminado; sí puedes usar{}para un array, una constante de enum, un literalClasso un literal primitivo. - Las anotaciones no pueden referenciarse a sí mismas cíclicamente. Un elemento del tipo
MyAnndentro de@interface MyAnnes rechazado.
El siguiente capítulo, procesamiento de anotaciones, muestra el lado en tiempo de compilación — generar nuevos archivos fuente en respuesta a tus anotaciones personalizadas, en lugar de (o además de) leerlas en tiempo de ejecución.