Unlock Micronaut's Magic: Create Custom Annotations for Cleaner, Smarter Code

Custom annotations in Micronaut enhance code modularity and reduce boilerplate. They enable features like method logging, retrying operations, timing execution, role-based security, and caching. Annotations simplify complex behaviors, making code cleaner and more expressive.

Unlock Micronaut's Magic: Create Custom Annotations for Cleaner, Smarter Code

Micronaut’s power lies in its ability to be extended and customized, and one of the coolest ways to do that is by creating your own annotations. It’s like giving your code superpowers! Let’s dive into how you can implement custom annotations in Micronaut to make your life easier and your code more modular.

First things first, why bother with custom annotations? Well, they’re fantastic for reducing boilerplate code, enforcing certain patterns, and adding metadata to your classes and methods. Think of them as little magic spells you can cast on your code to make it do amazing things.

To get started, you’ll need to create an annotation interface. It’s pretty straightforward:

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MyCustomAnnotation {
    String value() default "";
}

This creates a simple annotation that can be applied to classes and methods. The @Retention annotation tells Java to keep this annotation information available at runtime, while @Target specifies where the annotation can be used.

But an annotation by itself doesn’t do much. The real magic happens when you create an annotation processor to handle it. In Micronaut, you can do this by implementing the TypeElementVisitor interface:

import io.micronaut.inject.visitor.TypeElementVisitor;
import io.micronaut.inject.visitor.VisitorContext;

public class MyCustomAnnotationProcessor implements TypeElementVisitor<MyCustomAnnotation, Object> {
    @Override
    public void visitClass(ClassElement element, VisitorContext context) {
        // Do something when the annotation is found on a class
    }

    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        // Do something when the annotation is found on a method
    }
}

Now, to make Micronaut aware of your processor, you need to create a file named META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor in your resources directory, and add the fully qualified name of your processor class to it.

Let’s say you want to create a custom annotation that automatically logs method entry and exit. You could create an annotation like this:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LogMethod {
}

And then implement a processor for it:

public class LogMethodProcessor implements TypeElementVisitor<LogMethod, Object> {
    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        MethodElement declaredMethod = element.getDeclaringType().getEnclosedElement(Element.METHOD, element.getName(), element.getParameters().toArray(new ClassElement[0])).get();
        
        declaredMethod.intercept(context.getClassWriterOutputVisitor(), methodContext -> {
            methodContext.writeMethodEntryCode(writer -> {
                writer.loadConstant("Entering method: " + element.getName());
                writer.invokeStatic(Logger.class, "info", void.class, String.class);
            });

            methodContext.invokeMethod(methodContext.getMethodReference());

            methodContext.writeMethodExitCode(writer -> {
                writer.loadConstant("Exiting method: " + element.getName());
                writer.invokeStatic(Logger.class, "info", void.class, String.class);
            }, null);
        });
    }
}

This processor intercepts the annotated method, adds logging statements at the beginning and end, and then calls the original method.

Now you can use your custom annotation like this:

@Controller("/hello")
public class HelloController {
    @Get("/")
    @LogMethod
    public String hello() {
        return "Hello, World!";
    }
}

And voila! Your method will automatically log when it’s entered and exited.

But why stop there? You can get really creative with custom annotations. For example, you could create an annotation to automatically retry failed operations:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
    int maxAttempts() default 3;
    long delay() default 1000;
}

The processor for this annotation could look something like:

public class RetryProcessor implements TypeElementVisitor<Retry, Object> {
    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        Retry retry = element.getAnnotation(Retry.class);
        int maxAttempts = retry.maxAttempts();
        long delay = retry.delay();

        MethodElement declaredMethod = element.getDeclaringType().getEnclosedElement(Element.METHOD, element.getName(), element.getParameters().toArray(new ClassElement[0])).get();
        
        declaredMethod.intercept(context.getClassWriterOutputVisitor(), methodContext -> {
            methodContext.pushVariable("attempts", int.class, 0);

            methodContext.writeWhileLoop(writer -> writer.loadVariable("attempts", int.class).loadConstant(maxAttempts).integerCompare(BytecodeVisitor.Type.INT_TYPE).isLessThan(), () -> {
                methodContext.writeMethodEntryCode(writer -> {
                    writer.loadVariable("attempts", int.class);
                    writer.integerIncrement(1);
                    writer.storeVariable("attempts", int.class);
                });

                methodContext.invokeMethod(methodContext.getMethodReference());

                methodContext.writeMethodExitCode(writer -> {
                    writer.returnValue();
                }, null);
            });

            methodContext.writeMethodEntryCode(writer -> {
                writer.loadConstant(delay);
                writer.invokeStatic(Thread.class, "sleep", void.class, long.class);
            });
        });
    }
}

This processor wraps the method in a loop that retries the operation up to the specified number of times, with a delay between attempts.

Custom annotations can also be used to implement aspect-oriented programming (AOP) concepts. For instance, you could create a @Timed annotation to measure the execution time of methods:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Timed {
    String value() default "";
}

And its processor:

public class TimedProcessor implements TypeElementVisitor<Timed, Object> {
    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        Timed timed = element.getAnnotation(Timed.class);
        String metricName = timed.value().isEmpty() ? element.getName() : timed.value();

        MethodElement declaredMethod = element.getDeclaringType().getEnclosedElement(Element.METHOD, element.getName(), element.getParameters().toArray(new ClassElement[0])).get();
        
        declaredMethod.intercept(context.getClassWriterOutputVisitor(), methodContext -> {
            methodContext.pushVariable("startTime", long.class, 0L);

            methodContext.writeMethodEntryCode(writer -> {
                writer.invokeStatic(System.class, "nanoTime", long.class);
                writer.storeVariable("startTime", long.class);
            });

            methodContext.invokeMethod(methodContext.getMethodReference());

            methodContext.writeMethodExitCode(writer -> {
                writer.invokeStatic(System.class, "nanoTime", long.class);
                writer.loadVariable("startTime", long.class);
                writer.longSubtract();
                writer.loadConstant(metricName);
                writer.invokeStatic(MetricsRegistry.class, "recordTime", void.class, long.class, String.class);
            }, null);
        });
    }
}

This processor adds timing logic around the method and records the execution time to a hypothetical MetricsRegistry.

Custom annotations can also be used to implement security features. For example, you could create a @RequireRole annotation to check if the current user has a specific role before allowing method execution:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequireRole {
    String value();
}

And its processor:

public class RequireRoleProcessor implements TypeElementVisitor<RequireRole, Object> {
    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        RequireRole requireRole = element.getAnnotation(RequireRole.class);
        String requiredRole = requireRole.value();

        MethodElement declaredMethod = element.getDeclaringType().getEnclosedElement(Element.METHOD, element.getName(), element.getParameters().toArray(new ClassElement[0])).get();
        
        declaredMethod.intercept(context.getClassWriterOutputVisitor(), methodContext -> {
            methodContext.writeMethodEntryCode(writer -> {
                writer.invokeStatic(SecurityContext.class, "getCurrentUser", User.class);
                writer.loadConstant(requiredRole);
                writer.invokeVirtual(User.class, "hasRole", boolean.class, String.class);
                writer.ifFalse(() -> {
                    writer.throwException(AccessDeniedException.class, "User does not have required role: " + requiredRole);
                });
            });

            methodContext.invokeMethod(methodContext.getMethodReference());
        });
    }
}

This processor checks if the current user has the required role before allowing the method to execute, throwing an exception if they don’t.

Custom annotations can also be used to implement caching. Here’s a simple @Cacheable annotation:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cacheable {
    String key();
}

And its processor:

public class CacheableProcessor implements TypeElementVisitor<Cacheable, Object> {
    @Override
    public void visitMethod(MethodElement element, VisitorContext context) {
        Cacheable cacheable = element.getAnnotation(Cacheable.class);
        String cacheKey = cacheable.key();

        MethodElement declaredMethod = element.getDeclaringType().getEnclosedElement(Element.METHOD, element.getName(), element.getParameters().toArray(new ClassElement[0])).get();
        
        declaredMethod.intercept(context.getClassWriterOutputVisitor(), methodContext -> {
            methodContext.pushVariable("cacheResult", Object.class, null);

            methodContext.writeMethodEntryCode(writer -> {
                writer.loadConstant(cacheKey);
                writer.invokeStatic(CacheManager.class, "get", Object.class, String.class);
                writer.storeVariable("cacheResult", Object.class);
                writer.loadVariable("cacheResult", Object.class);
                writer.ifNotNull(() -> {
                    writer.loadVariable("cacheResult", Object.class);
                    writer.returnValue();
                });
            });

            methodContext.invokeMethod(methodContext.getMethodReference());

            methodContext.writeMethodExitCode(writer -> {
                writer.dup();
                writer.loadConstant(cacheKey);
                writer.swap();
                writer.invokeStatic(CacheManager.class, "put", void.class, String.class, Object.class);
            }, null);
        });
    }
}

This processor checks the cache before executing the method, and stores the result in the cache after execution.

Custom annotations can be incredibly powerful tools in your Micronaut toolbox. They allow you to encapsulate complex behaviors and apply them declaratively to your code. This not only makes your code cleaner and more modular, but also more expressive and easier to understand.

Remember, the key to creating good custom annotations is to identify repetitive patterns in your code that could benefit from abstraction. Look for things you find yourself doing over and over again, and consider if they could be simplified with a custom annotation.

When implementing custom annotations, always keep in mind the principle of least astonishment. Your annotations should behave in a way that’s intuitive and consistent with how other annotations in the Java ecosystem work.