Groovy Custom Annotations – Create Your Own with 10 Tested Examples

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 @Timed annotation with runtime interception

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 @interface definition 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
  • String
  • Class (or Class<?> 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 @CompileStatic if 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 @Retention explicitly. If you omit it, the default is CLASS – which means your annotation survives into bytecode but is invisible to reflection. Most custom annotations need RUNTIME. Only use SOURCE for 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 default values 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 it value. 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 @AnnotationCollector over custom AST transformations when you just need to combine existing annotations. Writing a full ASTTransformation is 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.

Previous in Series: Groovy Shell – Interactive Groovy Programming

Next in Series: Groovy Gradle – Build Automation with Groovy

Related Topics You Might Like:

This post is part of the Groovy Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *