Groovy custom annotations are versatile. See 10 tested examples covering annotation declaration, retention policies, AST transformations, and runtime processing.
“Annotations are metadata that give your code a voice – they tell the compiler, the runtime, and other developers what your intentions are without cluttering your logic.”
Joshua Bloch, Effective Java
Last Updated: March 2026 | Tested on: Groovy 4.x / 5.x, Java 11+ | Difficulty: Advanced | Reading Time: 18 minutes
Groovy ships with a rich set of built-in annotations – @Immutable, @ToString, @Builder, and dozens more. But what happens when the built-in options don’t fit your exact need? That’s where Groovy custom annotations come in. The official guide to developing AST transformations covers the compile-time side in detail. You define your own annotation type, decide when it’s available (source, class, or runtime), and then process it however you like – through reflection at runtime or through AST transformations at compile time.
This post covers how to declare annotations, attach elements and default values, control retention and targeting, process annotations at runtime with reflection, create marker annotations, and combine your annotations with AST transformations. We’ll finish with a real-world @Timed annotation that measures method execution time.
If you’re new to AST transformations, you might want to skim our Groovy AST Transformations guide first. Familiarity with Groovy Metaprogramming will also help, but is not strictly required.
What you’ll learn:
- How to declare custom annotations with
@interface - How retention policies (
SOURCE,CLASS,RUNTIME) affect annotation visibility - How to target specific code elements with
@Target - How to process annotations at runtime using reflection
- How to create marker annotations and use default values effectively
- How to compose annotations with
@AnnotationCollector - How to build a real-world
@Timedannotation with runtime interception
Table of Contents
What Are Custom Annotations in Groovy?
An annotation in Groovy (and Java) is a special form of metadata that you attach to code elements – classes, methods, fields, parameters, and more. Custom annotations are ones you define yourself using the @interface keyword. They look like interfaces but carry no implementation. Instead, they carry data that tools, frameworks, or your own code can read and act upon.
Groovy custom annotations work exactly like Java annotations because Groovy compiles down to JVM bytecode. The key difference is that Groovy gives you easier ways to process them – especially through AST transformations and the MOP (Meta-Object Protocol).
Three things define an annotation:
- Declaration – the
@interfacedefinition with its elements (parameters) - Retention – how long the annotation survives: source only, class file, or runtime
- Target – which code elements the annotation can be applied to
When to Create Custom Annotations
Before you write a custom annotation, ask yourself if you actually need one. Here are legitimate use cases:
- Cross-cutting concerns – logging, timing, caching, authorization checks that apply to many methods
- Code generation – generating boilerplate code at compile time via AST transformations
- Framework integration – marking classes or methods for special treatment by a framework (dependency injection, routing, serialization)
- Documentation and validation – enforcing rules or providing hints that tools can check
- Testing – tagging tests with categories, expected behaviors, or data providers
If your use case doesn’t fit any of these, a simple method call or configuration file is probably a better choice. Annotations add indirection – make sure that indirection earns its keep.
10 Practical Examples of Groovy Custom Annotations
Let’s build custom annotations from the ground up. Each example is self-contained, tested, and progressively more advanced.
Example 1: Basic Annotation Declaration
What we’re doing: Declaring the simplest possible custom annotation and applying it to a class. This is the “hello world” of annotation creation.
Example 1: Basic Annotation Declaration
import java.lang.annotation.*
// Declare a custom annotation
@Retention(RetentionPolicy.RUNTIME)
@interface Author {
String value()
}
// Apply the annotation to a class
@Author('Rahul')
class Calculator {
int add(int a, int b) { a + b }
}
// Read the annotation at runtime
def annotation = Calculator.getAnnotation(Author)
println "Author: ${annotation.value()}"
println "Class: ${Calculator.simpleName}"
Output
Author: Rahul Class: Calculator
What happened here: We used @interface to declare a new annotation called Author. The value() method defines the single element it carries. The @Retention(RetentionPolicy.RUNTIME) meta-annotation ensures the annotation is available at runtime so we can read it via reflection with getAnnotation(). Without runtime retention, the annotation would be discarded after compilation.
Example 2: Annotation with Elements (Parameters)
What we’re doing: Creating an annotation with multiple elements – essentially named parameters that carry structured metadata.
Example 2: Annotation with Multiple Elements
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@interface ApiEndpoint {
String path()
String method() default 'GET'
String[] produces() default ['application/json']
boolean authenticated() default false
}
@ApiEndpoint(
path = '/api/users',
method = 'POST',
authenticated = true
)
class UserController {
def createUser(Map userData) {
println "Creating user: ${userData.name}"
}
}
// Read the annotation
def endpoint = UserController.getAnnotation(ApiEndpoint)
println "Path: ${endpoint.path()}"
println "Method: ${endpoint.method()}"
println "Produces: ${endpoint.produces()}"
println "Authenticated: ${endpoint.authenticated()}"
Output
Path: /api/users Method: POST Produces: [application/json] Authenticated: true
What happened here: The @ApiEndpoint annotation has four elements: path, method, produces, and authenticated. Three of them have default values (using the default keyword), so only path is required. Elements can be primitive types, String, Class, enums, other annotations, or arrays of those types. This is the same restriction as Java annotations – you cannot use arbitrary objects like lists or maps.
Example 3: Retention Policies (SOURCE, CLASS, RUNTIME)
What we’re doing: Demonstrating all three retention policies and showing when each one is appropriate.
Example 3: Retention Policies Compared
import java.lang.annotation.*
// SOURCE - discarded by compiler after processing
// Use case: compile-time checks, code generation hints
@Retention(RetentionPolicy.SOURCE)
@interface CompileOnly {
String value() default ''
}
// CLASS - written to .class file but NOT available via reflection
// Use case: bytecode analysis tools, annotation processors
@Retention(RetentionPolicy.CLASS)
@interface BytecodeOnly {
String value() default ''
}
// RUNTIME - available via reflection at runtime
// Use case: frameworks, runtime processing, dependency injection
@Retention(RetentionPolicy.RUNTIME)
@interface RuntimeAvailable {
String value() default ''
}
@CompileOnly('source only')
@BytecodeOnly('class file only')
@RuntimeAvailable('full runtime')
class MyService {
void process() { println 'Processing...' }
}
// Try to read each annotation at runtime
def sourceAnno = MyService.getAnnotation(CompileOnly)
def classAnno = MyService.getAnnotation(BytecodeOnly)
def runtimeAnno = MyService.getAnnotation(RuntimeAvailable)
println "SOURCE annotation found: ${sourceAnno != null}"
println "CLASS annotation found: ${classAnno != null}"
println "RUNTIME annotation found: ${runtimeAnno != null}"
if (runtimeAnno) {
println "RUNTIME value: ${runtimeAnno.value()}"
}
Output
SOURCE annotation found: false CLASS annotation found: false RUNTIME annotation found: true RUNTIME value: full runtime
What happened here: Only RetentionPolicy.RUNTIME makes the annotation visible through reflection. SOURCE annotations are gone after the compiler finishes – they’re useful for AST transformations and compile-time code generators. CLASS annotations survive into the bytecode but the JVM’s reflection API doesn’t expose them – they’re useful for bytecode analysis tools. For most Groovy custom annotations that you want to process in your own code, RUNTIME is the right choice.
Example 4: Target Element Types (TYPE, METHOD, FIELD)
What we’re doing: Using @Target to restrict where an annotation can be applied – preventing misuse at compile time.
Example 4: Targeting Specific Element Types
import java.lang.annotation.*
// Can only be applied to methods
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Cacheable {
int ttlSeconds() default 300
String keyPrefix() default ''
}
// Can only be applied to fields
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Inject {
String name() default ''
}
// Can be applied to both types and methods
@Retention(RetentionPolicy.RUNTIME)
@Target([ElementType.TYPE, ElementType.METHOD])
@interface Secured {
String[] roles() default ['USER']
}
@Secured(roles = ['ADMIN'])
class OrderService {
@Inject(name = 'orderRepo')
def repository
@Cacheable(ttlSeconds = 60, keyPrefix = 'order')
@Secured(roles = ['USER', 'ADMIN'])
def findOrder(long id) {
println "Looking up order ${id}"
return [id: id, status: 'SHIPPED']
}
}
// Read class-level annotation
def classSecured = OrderService.getAnnotation(Secured)
println "Class roles: ${classSecured.roles()}"
// Read method-level annotations
def method = OrderService.getDeclaredMethod('findOrder', long)
def cache = method.getAnnotation(Cacheable)
def methodSecured = method.getAnnotation(Secured)
println "Cache TTL: ${cache.ttlSeconds()}s, prefix: '${cache.keyPrefix()}'"
println "Method roles: ${methodSecured.roles()}"
Output
Class roles: [ADMIN] Cache TTL: 60s, prefix: 'order' Method roles: [USER, ADMIN]
What happened here: The @Target meta-annotation restricts where your annotation can appear. ElementType.METHOD means methods only, ElementType.FIELD means fields only, and you can combine multiple targets with an array. If you try to put @Cacheable on a class or @Inject on a method, Groovy will throw a compile error. This is a safety net – it catches annotation misuse early. Common targets include TYPE (classes, interfaces), METHOD, FIELD, PARAMETER, CONSTRUCTOR, and LOCAL_VARIABLE.
Example 5: Runtime Annotation Processing with Reflection
What we’re doing: Building a mini-framework that scans a class for annotated methods and executes them based on annotation metadata. This is how real frameworks like Spring and Grails work under the hood.
Example 5: Runtime Annotation Processing
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route {
String path()
String method() default 'GET'
}
class WebApp {
@Route(path = '/home', method = 'GET')
def homePage() {
return 'Welcome to the home page!'
}
@Route(path = '/users', method = 'GET')
def listUsers() {
return 'User list: Alice, Bob, Carol'
}
@Route(path = '/users', method = 'POST')
def createUser() {
return 'User created successfully'
}
def helperMethod() {
return 'I am not a route'
}
}
// --- Route scanner / mini-framework ---
def scanRoutes(Class clazz) {
def routes = []
clazz.declaredMethods.each { method ->
def route = method.getAnnotation(Route)
if (route) {
routes << [
path: route.path(),
httpMethod: route.method(),
handler: method.name
]
}
}
return routes
}
// Simulate request dispatch
def dispatch(Object controller, String path, String httpMethod) {
def clazz = controller.getClass()
for (method in clazz.declaredMethods) {
def route = method.getAnnotation(Route)
if (route && route.path() == path && route.method() == httpMethod) {
return method.invoke(controller)
}
}
return '404 Not Found'
}
// Scan and display all routes
def app = new WebApp()
def routes = scanRoutes(WebApp)
println '=== Registered Routes ==='
routes.each { r ->
println "${r.httpMethod.padRight(6)} ${r.path.padRight(15)} -> ${r.handler}()"
}
// Dispatch requests
println "\n=== Dispatching Requests ==="
println "GET /home -> ${dispatch(app, '/home', 'GET')}"
println "POST /users -> ${dispatch(app, '/users', 'POST')}"
println "GET /404 -> ${dispatch(app, '/missing', 'GET')}"
Output
=== Registered Routes === GET /home -> homePage() GET /users -> listUsers() POST /users -> createUser() === Dispatching Requests === GET /home -> Welcome to the home page! POST /users -> User created successfully GET /404 -> 404 Not Found
What happened here: We built a tiny routing framework using custom annotations. The @Route annotation marks methods as HTTP handlers with a path and HTTP method. The scanRoutes() function uses reflection to find all annotated methods and extract their metadata. The dispatch() function matches an incoming request to the right handler. This is exactly the pattern used by web frameworks like Spring MVC’s @RequestMapping and Grails’ URL mappings – just stripped to the essentials.
Example 6: Marker Annotations
What we’re doing: Creating annotations with no elements – pure markers that simply flag a code element for special treatment.
Example 6: Marker Annotations
import java.lang.annotation.*
// Marker annotation - no elements, just a flag
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface Singleton {}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Deprecated2 {}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface TestMethod {}
// Apply marker annotations
@Singleton
class ConfigManager {
private static ConfigManager instance
Map config = [:]
@Deprecated2
def loadFromFile(String path) {
println "Loading from file (deprecated): ${path}"
}
def loadFromMap(Map data) {
config.putAll(data)
println "Config loaded: ${config}"
}
@TestMethod
def verify() {
assert config != null
println 'Verification passed'
}
}
// Check for marker annotations
def clazz = ConfigManager
if (clazz.isAnnotationPresent(Singleton)) {
println "${clazz.simpleName} is marked as @Singleton"
}
// Scan methods for markers
clazz.declaredMethods.each { method ->
if (method.isAnnotationPresent(Deprecated2)) {
println "WARNING: ${method.name}() is deprecated"
}
if (method.isAnnotationPresent(TestMethod)) {
println "TEST: Running ${method.name}()..."
def instance = clazz.getDeclaredConstructor().newInstance()
instance.config = [env: 'test']
method.invoke(instance)
}
}
Output
ConfigManager is marked as @Singleton WARNING: loadFromFile() is deprecated TEST: Running verify()... Verification passed
What happened here: Marker annotations carry no data – they’re pure flags. You check for their presence using isAnnotationPresent(). This pattern is everywhere in the JVM ecosystem: Java’s own @Override, @FunctionalInterface, and @Deprecated are all marker annotations. They’re the simplest kind of custom annotation to create and are perfect when all you need is a yes/no signal on a code element.
Example 7: Annotation with Default Values
What we’re doing: Building an annotation where most elements have sensible defaults, making the annotation easy to use out of the box while remaining configurable.
Example 7: Annotation with Defaults
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Retry {
int maxAttempts() default 3
long delayMs() default 1000
Class[] retryOn() default [Exception]
String fallbackMethod() default ''
}
class PaymentService {
// Use all defaults
@Retry
def processPayment(BigDecimal amount) {
println "Processing payment of \$${amount}"
return [status: 'OK', amount: amount]
}
// Override some defaults
@Retry(maxAttempts = 5, delayMs = 2000, fallbackMethod = 'offlineCharge')
def chargeCard(String cardNumber, BigDecimal amount) {
println "Charging card ending in ${cardNumber[-4..-1]}: \$${amount}"
return [status: 'CHARGED']
}
// Override retryOn to specific exceptions
@Retry(retryOn = [IOException, ConnectException])
def callExternalApi(String endpoint) {
println "Calling ${endpoint}"
return [status: 'SUCCESS']
}
def offlineCharge(String cardNumber, BigDecimal amount) {
println "Offline charge for card ${cardNumber[-4..-1]}: \$${amount}"
}
}
// Inspect retry configuration for each method
PaymentService.declaredMethods
.findAll { it.isAnnotationPresent(Retry) }
.each { method ->
def retry = method.getAnnotation(Retry)
println "--- ${method.name}() ---"
println " Max attempts: ${retry.maxAttempts()}"
println " Delay: ${retry.delayMs()}ms"
println " Retry on: ${retry.retryOn()*.simpleName}"
println " Fallback: ${retry.fallbackMethod() ?: '(none)'}"
}
Output
--- processPayment() --- Max attempts: 3 Delay: 1000ms Retry on: [Exception] Fallback: (none) --- chargeCard() --- Max attempts: 5 Delay: 2000ms Retry on: [Exception] Fallback: offlineCharge --- callExternalApi() --- Max attempts: 3 Delay: 1000ms Retry on: [IOException, ConnectException] Fallback: (none)
What happened here: Default values make annotations ergonomic. When you write @Retry with no parentheses, all four elements use their defaults. You only specify what you want to override. The default keyword after each element declaration sets the fallback value. Notice how retryOn() uses Class[] – annotation elements support arrays of primitives, strings, enums, classes, and other annotations. This pattern keeps your annotation API clean and backward-compatible: adding new elements with defaults won’t break existing usages.
Example 8: Annotations on Closures and Local Variables
What we’re doing: Exploring where annotations can be placed beyond classes, methods, and fields – including local variables and parameters.
Example 8: Annotations on Variables and Parameters
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@interface NotNull {
String message() default 'must not be null'
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@interface Range {
int min() default 0
int max() default Integer.MAX_VALUE
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.LOCAL_VARIABLE)
@interface Debug {}
class UserService {
def createUser(@NotNull String name, @Range(min = 18, max = 120) int age) {
// Local variable annotation (available in bytecode, not always via reflection)
@Debug
def result = "Created user: ${name}, age: ${age}"
println result
return result
}
}
// Build a simple parameter validator using reflection
def validate(Object instance, String methodName, Object... args) {
def method = instance.getClass().declaredMethods.find { it.name == methodName }
def params = method.parameters
params.eachWithIndex { param, idx ->
def notNull = param.getAnnotation(NotNull)
if (notNull && args[idx] == null) {
throw new IllegalArgumentException(
"Parameter '${param.name}' ${notNull.message()}"
)
}
def range = param.getAnnotation(Range)
if (range && args[idx] instanceof Number) {
def val = args[idx] as int
if (val < range.min() || val > range.max()) {
throw new IllegalArgumentException(
"Parameter '${param.name}' must be between ${range.min()} and ${range.max()}, got ${val}"
)
}
}
}
return method.invoke(instance, args)
}
def service = new UserService()
// Valid call
println validate(service, 'createUser', 'Alice', 25)
// Invalid: null name
try {
validate(service, 'createUser', null, 25)
} catch (IllegalArgumentException e) {
println "Validation error: ${e.message}"
}
// Invalid: age out of range
try {
validate(service, 'createUser', 'Bob', 200)
} catch (IllegalArgumentException e) {
println "Validation error: ${e.message}"
}
Output
Created user: Alice, age: 25 Created user: Alice, age: 25 Validation error: Parameter 'name' must not be null Validation error: Parameter 'age' must be between 18 and 120, got 200
What happened here: Annotations on parameters are fully supported and accessible via reflection – we used them to build a parameter validator. The @Debug annotation on a local variable compiles fine with ElementType.LOCAL_VARIABLE, but JVM reflection can’t access local variable annotations at runtime (they exist in bytecode for debugging tools). For parameter annotations, use method.parameters to get parameter metadata including annotations. This pattern is the basis for validation frameworks like Bean Validation (JSR 380).
Example 9: Combining with AST Transformations (Basic)
What we’re doing: Creating a custom annotation that triggers a compile-time AST transformation. This is where Groovy’s annotation story diverges from Java – Groovy can modify your code’s Abstract Syntax Tree during compilation.
For a full-featured AST transformation, you typically need a separate compiled annotation + transformation class. Here, we’ll demonstrate the concept using Groovy’s built-in @AnnotationCollector – which lets you compose multiple existing annotations into a single custom one.
Example 9: Annotation Collector (Composed Annotations)
import groovy.transform.*
// Compose multiple annotations into one custom annotation
@ToString(includeNames = true)
@EqualsAndHashCode
@TupleConstructor
@AnnotationCollector
@interface DataClass {}
// Apply our composed annotation - gets @ToString, @EqualsAndHashCode, @TupleConstructor
@DataClass
class Product {
String name
BigDecimal price
String category
}
// Another composed annotation for immutable value objects
@ToString(includeNames = true)
@EqualsAndHashCode
@Immutable
@AnnotationCollector
@interface ValueObject {}
@ValueObject
class Money {
BigDecimal amount
String currency
}
// Test @DataClass behavior
def p1 = new Product('Laptop', 999.99, 'Electronics')
def p2 = new Product('Laptop', 999.99, 'Electronics')
println "Product: ${p1}"
println "Equal: ${p1 == p2}"
println "HashCode match: ${p1.hashCode() == p2.hashCode()}"
// Test @ValueObject behavior
def m1 = new Money(49.99, 'USD')
def m2 = new Money(49.99, 'USD')
println "\nMoney: ${m1}"
println "Equal: ${m1 == m2}"
// Immutability check
try {
m1.amount = 100.00
} catch (ReadOnlyPropertyException e) {
println "Cannot modify: ${e.message}"
}
Output
Product: Product(name:Laptop, price:999.99, category:Electronics) Equal: true HashCode match: true Money: Money(amount:49.99, currency:USD) Equal: true Cannot modify: Cannot set readonly property: amount for class: Money
What happened here: @AnnotationCollector is Groovy’s way of creating composed annotations – a single annotation that expands into multiple annotations at compile time. Our @DataClass annotation is equivalent to applying @ToString, @EqualsAndHashCode, and @TupleConstructor individually. This is an AST transformation under the hood – the collector runs during compilation and replaces your composed annotation with the individual ones. For deeper AST work (like writing your own ASTTransformation class), check our Groovy AST Transformations guide. Also see how @Immutable works in our Groovy Immutable Objects post.
Example 10: Real-World Custom @Timed Annotation
What we’re doing: Building a production-style @Timed annotation that measures method execution time. We’ll combine annotation declaration with runtime proxy-based processing to intercept method calls transparently.
Example 10: Custom @Timed Annotation
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Timed {
String label() default ''
boolean logResult() default false
}
// The service class with @Timed methods
class DataProcessor {
@Timed(label = 'sort-data')
def sortData(List items) {
Thread.sleep(50) // Simulate work
return items.sort()
}
@Timed(label = 'filter-data', logResult = true)
def filterPositive(List<Integer> numbers) {
Thread.sleep(30) // Simulate work
return numbers.findAll { it > 0 }
}
@Timed
def generateReport(Map data) {
Thread.sleep(80) // Simulate work
return "Report: ${data.size()} entries processed"
}
def untimed() {
return 'This method is not timed'
}
}
// --- Timing interceptor using Groovy's metaprogramming ---
class TimingInterceptor {
static Map<String, List<Long>> metrics = [:].withDefault { [] }
static Object createProxy(Object target) {
def targetClass = target.getClass()
def proxy = new Expando()
targetClass.declaredMethods.each { method ->
def timed = method.getAnnotation(Timed)
if (timed) {
def label = timed.label() ?: method.name
def logResult = timed.logResult()
proxy."${method.name}" = { Object... args ->
def start = System.nanoTime()
def result = method.invoke(target, args)
def elapsed = (System.nanoTime() - start) / 1_000_000.0
metrics[label] << elapsed.round(2)
println "[TIMER] ${label}: ${elapsed.round(2)}ms"
if (logResult) {
println "[TIMER] ${label} result: ${result}"
}
return result
}
} else {
proxy."${method.name}" = { Object... args ->
method.invoke(target, args)
}
}
}
return proxy
}
static void printReport() {
println "\n=== Timing Report ==="
metrics.each { label, times ->
def avg = times.sum() / times.size()
println "${label.padRight(20)} | calls: ${times.size()} | avg: ${avg.round(2)}ms | total: ${times.sum().round(2)}ms"
}
}
}
// --- Usage ---
def processor = TimingInterceptor.createProxy(new DataProcessor())
processor.sortData([5, 3, 1, 4, 2])
processor.filterPositive([-3, 7, -1, 4, 0, 9, -5])
processor.generateReport([users: 100, orders: 250, products: 50])
// Run again to collect multiple samples
processor.sortData([10, 8, 6])
processor.filterPositive([1, -2, 3])
TimingInterceptor.printReport()
Output
[TIMER] sort-data: 52.34ms [TIMER] filter-data: 31.87ms [TIMER] filter-data result: [7, 4, 9] [TIMER] generateReport: 81.23ms [TIMER] sort-data: 51.12ms [TIMER] filter-data: 30.45ms [TIMER] filter-data result: [1, 3] === Timing Report === sort-data | calls: 2 | avg: 51.73ms | total: 103.46ms filter-data | calls: 2 | avg: 31.16ms | total: 62.32ms generateReport | calls: 1 | avg: 81.23ms | total: 81.23ms
What happened here: We created a complete annotation-driven timing system. The @Timed annotation marks methods that should be measured. The TimingInterceptor reads these annotations via reflection and creates a proxy that wraps each annotated method with timing logic. The label element provides a custom metric name, and logResult optionally prints the return value. Metrics are collected across multiple calls, and the report shows call count, average time, and total time. This is the same pattern used by monitoring libraries like Micrometer’s @Timed and Dropwizard Metrics. In production Groovy, you’d use Groovy’s MethodInterceptor or MetaClass instead of Expando for a cleaner proxy.
Edge Cases and Common Mistakes
Before we wrap up with best practices, let’s address the pitfalls that trip up developers when working with Groovy custom annotations.
Forgetting @Retention
The single most common mistake. If you forget @Retention, the default is RetentionPolicy.CLASS – your annotation compiles into the bytecode but is invisible to reflection. You’ll spend 30 minutes wondering why getAnnotation() keeps returning null.
Common Mistake: Missing @Retention
// BAD - no @Retention, defaults to CLASS
@interface Broken {
String value()
}
@Broken('test')
class Oops {}
// This will always be null!
def anno = Oops.getAnnotation(Broken)
println "Found: ${anno}" // Found: null
// FIX - always add @Retention(RetentionPolicy.RUNTIME)
Annotation Inheritance
Annotations are not inherited by default. If you annotate a superclass, the subclass does not carry that annotation unless you add the @Inherited meta-annotation. And even then, @Inherited only works for class-level annotations – method and field annotations are never inherited.
Annotation Inheritance Behavior
import java.lang.annotation.*
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Target(ElementType.TYPE)
@interface Audited {
String level() default 'BASIC'
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface NotInherited {
String value() default ''
}
@Audited(level = 'FULL')
@NotInherited('parent')
class BaseService {}
class ChildService extends BaseService {}
// @Inherited annotation IS visible on child
def audited = ChildService.getAnnotation(Audited)
println "Child has @Audited: ${audited != null}" // true
println "Audit level: ${audited?.level()}" // FULL
// Non-inherited annotation is NOT visible on child
def notInherited = ChildService.getAnnotation(NotInherited)
println "Child has @NotInherited: ${notInherited != null}" // false
Repeatable Annotations
By default, you cannot apply the same annotation twice to the same element. Java 8+ (and Groovy) support repeatable annotations via @Repeatable, but you must define a container annotation.
Repeatable Annotations
import java.lang.annotation.*
// Container annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Schedules {
Schedule[] value()
}
// Repeatable annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(Schedules)
@interface Schedule {
String cron()
String timezone() default 'UTC'
}
class ReportJob {
@Schedule(cron = '0 0 8 * * MON-FRI', timezone = 'US/Eastern')
@Schedule(cron = '0 0 14 * * MON-FRI', timezone = 'Europe/London')
def generateDailyReport() {
println 'Generating report...'
}
}
// Read repeatable annotations
def method = ReportJob.getDeclaredMethod('generateDailyReport')
def schedules = method.getAnnotationsByType(Schedule)
println "Found ${schedules.length} schedules:"
schedules.each { s ->
println " Cron: ${s.cron()} (${s.timezone()})"
}
Output
Found 2 schedules: Cron: 0 0 8 * * MON-FRI (US/Eastern) Cron: 0 0 14 * * MON-FRI (Europe/London)
Annotation Element Restrictions
New developers often try to use List, Map, or custom classes as annotation elements. This will not compile. Annotation elements are limited to:
- Primitive types:
int,long,double,boolean,float,byte,short,char StringClass(orClass<?>with bounds)- Enum types
- Other annotation types
- One-dimensional arrays of any of the above
If you need to pass complex data, use a Class element that references a configuration class, or use a String element with a resource path. Don’t fight the type system here – it’s a fundamental JVM constraint, not a Groovy limitation.
Groovy vs Java Annotation Differences
While Groovy annotations are fully compatible with Java, there are a few Groovy-specific behaviors to keep in mind:
- Closure annotation parameters – Some Groovy annotations (like
@ConditionalInterrupt) accept closures as parameters. This is a Groovy extension – Java annotations cannot have closure elements. These work through special AST handling. - @AnnotationCollector – This is Groovy-only. Java has no equivalent for composing annotations at compile time without writing an annotation processor.
- Dynamic typing interactions – When using Groovy’s dynamic typing, annotation processors that rely on type information might behave differently than in Java. Use
@CompileStaticif your annotation processing depends on accurate type data. - Script-level annotations – Groovy scripts can have annotations on the implicit script class, applied using
@before the script body. This has no Java equivalent since Java doesn’t have scripts.
Best Practices
After working through all 10 examples, here are the guidelines that will save you time and headaches when creating Groovy custom annotations.
- Always set
@Retentionexplicitly. If you omit it, the default isCLASS– which means your annotation survives into bytecode but is invisible to reflection. Most custom annotations needRUNTIME. Only useSOURCEfor AST transformations that don’t need runtime access. - Always set
@Target. Without it, your annotation can be applied anywhere – classes, methods, fields, parameters, local variables. That sounds flexible, but it makes misuse invisible. Be specific about where your annotation belongs. - Use
defaultvalues generously. The fewer required elements, the easier your annotation is to adopt. Put the most commonly used value as the default. This also makes your annotation backward-compatible when you add new elements later. - Name the primary element
value(). If your annotation has one main element, name itvalue. This lets users write@MyAnnotation('data')instead of@MyAnnotation(value = 'data'). It’s a small convenience that adds up. - Keep element types simple. Annotation elements can only be primitives,
String,Class, enums, other annotations, or arrays of these. Don’t fight this constraint – if you need complex configuration, use the annotation to point to a configuration class or file. - Prefer
@AnnotationCollectorover custom AST transformations when you just need to combine existing annotations. Writing a fullASTTransformationis powerful but complex – save it for cases where you genuinely need to modify the AST. See our AST Transformations guide for when that complexity is justified. - Document your annotations. Custom annotations are an API. Add Javadoc explaining what the annotation does, what each element means, and show a usage example. Future developers (including future you) will thank you.
- Test annotation processing separately. Write unit tests that verify your annotation processor behaves correctly – test that it finds the right methods, reads the right values, handles missing annotations, and fails gracefully on bad input.
Conclusion
Custom annotations in Groovy give you a clean way to attach metadata to your code and act on it – either at compile time through AST transformations or at runtime through reflection. We started with basic declarations and retention policies, moved through targeting and marker annotations, built a parameter validator and a routing framework, composed annotations with @AnnotationCollector, and finished with a real-world @Timed interceptor.
The main points: always set @Retention and @Target, use defaults to keep your annotation API simple, and prefer @AnnotationCollector over full AST transformations when you can. Custom annotations are a powerful abstraction – they separate the “what” from the “how” and let you build frameworks that are both expressive and type-safe.
To go deeper into compile-time code generation, read our Groovy AST Transformations guide. For runtime metaprogramming techniques that complement annotations, see Groovy Metaprogramming. And if you want to see how Groovy’s built-in annotations like @Immutable and @Builder work under the hood, check Groovy Immutable Objects and Groovy Builder Pattern.
Up next: Groovy Gradle – Build Automation with Groovy
Frequently Asked Questions
How do I create a custom annotation in Groovy?
Define an annotation using the @interface keyword, just like in Java. Add @Retention(RetentionPolicy.RUNTIME) to make it available via reflection, and @Target to control where it can be applied. Annotation elements are declared as methods with optional default values. For example: @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface MyAnnotation { String value() default '' }. You can then apply it with @MyAnnotation('data') and read it at runtime using method.getAnnotation(MyAnnotation).
What is the difference between SOURCE, CLASS, and RUNTIME retention in Groovy annotations?
RetentionPolicy.SOURCE means the annotation is discarded by the compiler after processing – useful for AST transformations. RetentionPolicy.CLASS means the annotation is written to the .class file but not accessible via reflection at runtime – useful for bytecode analysis tools. RetentionPolicy.RUNTIME means the annotation is available at runtime through reflection – this is what most custom annotations need for frameworks, validation, and runtime processing.
Can I combine multiple Groovy annotations into a single custom annotation?
Yes, use @AnnotationCollector. Stack the annotations you want to combine above @AnnotationCollector on your custom annotation definition. For example, combining @ToString, @EqualsAndHashCode, and @TupleConstructor into a single @DataClass annotation. At compile time, Groovy’s AST transformation expands your composed annotation into the individual ones. This avoids writing a full AST transformation while still giving you a clean, single-annotation API.
How do I process custom annotations at runtime in Groovy?
Use Java reflection APIs, which work directly in Groovy. Call Class.getAnnotation(MyAnnotation) for class-level annotations, Method.getAnnotation(MyAnnotation) for method-level ones, and Parameter.getAnnotation(MyAnnotation) for parameters. Use isAnnotationPresent() to check existence without reading values. The annotation must have @Retention(RetentionPolicy.RUNTIME) or it won’t be visible to reflection.
What types can annotation elements have in Groovy?
Annotation elements are restricted to: primitive types (int, long, boolean, etc.), String, Class, enum types, other annotation types, and one-dimensional arrays of any of these types. You cannot use List, Map, or arbitrary objects. If you need complex configuration, use the annotation to reference a configuration class or resource file that holds the full configuration.
Related Posts
Previous in Series: Groovy Shell – Interactive Groovy Programming
Next in Series: Groovy Gradle – Build Automation with Groovy
Related Topics You Might Like:
- Groovy AST Transformations – Compile-Time Metaprogramming
- Groovy Immutable Objects
- Groovy Metaprogramming – Runtime and Compile-Time
This post is part of the Groovy Cookbook series on TechnoScripts.com

No comment