Guice AOP: Wrapping Methods Without Touching Them


Series: Guice for JVM Engineers · II (Advanced & Real-World) — Part 4 of 8

Some concerns refuse to live in one place. Timing, logging, retries, transactions, metrics — they aren’t what a method does, they’re a tax you pay around every method that does something interesting. You can hand-write that tax at the top and bottom of each method, scatter the same six lines across a hundred services, and pray nobody forgets one. Or you can declare the rule once and let the container wrap the calls for you. That second option is aspect-oriented programming, and Guice ships a small, honest slice of it: method interception.

Honest is the operative word. Guice’s AOP isn’t the sprawling pointcut language of full AspectJ — it intercepts method calls on objects Guice itself constructs, and that’s the whole sandbox. Stay inside it and interception is delightful. Wander outside and it silently does nothing, which is exactly the failure mode worth understanding up front. Let’s build one, wire it, make it annotation-driven, and then map the edges of the sandbox so you don’t fall off them.

A method interceptor

The interceptor contract doesn’t come from Guice at all — it comes from the AOP Alliance, a tiny shared-interface library (org.aopalliance.intercept) that Guice pulls in transitively. You implement MethodInterceptor, which has exactly one method: invoke(MethodInvocation). Inside it you do your before-work, call invocation.proceed() to run the real method (or the next interceptor in the chain), do your after-work, and return whatever proceed() returned.

Here’s a @Timed interceptor that measures wall-clock time and prints it:

import org.aopalliance.intercept.MethodInterceptor
import org.aopalliance.intercept.MethodInvocation

class TimingInterceptor : MethodInterceptor {
    override fun invoke(invocation: MethodInvocation): Any? {
        val method = invocation.method          // java.lang.reflect.Method
        val start = System.nanoTime()
        try {
            return invocation.proceed()         // run the real method
        } finally {
            val micros = (System.nanoTime() - start) / 1_000
            println("${method.declaringClass.simpleName}.${method.name} took ${micros}µs")
        }
    }
}
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import java.lang.reflect.Method;

public class TimingInterceptor implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Method method = invocation.getMethod();   // java.lang.reflect.Method
        long start = System.nanoTime();
        try {
            return invocation.proceed();           // run the real method
        } finally {
            long micros = (System.nanoTime() - start) / 1_000;
            System.out.printf("%s.%s took %dµs%n",
                method.getDeclaringClass().getSimpleName(), method.getName(), micros);
        }
    }
}

A few things to notice. proceed() is declared throws Throwable, so in Java your invoke re-throws everything transparently — the caller sees the method’s real exceptions, unwrapped. (Kotlin has no checked exceptions, so the override is clean.) The try/finally matters: put your timing in finally and you measure the call even when it blows up. And MethodInvocation gives you the reflective Method plus getArguments() (an Object[]) and getThis() (the target object) if you want to inspect or log them.

Note the small API seam between the tabs. The AOP Alliance interface is getMethod() / getArguments() in Java; Kotlin sees getMethod() as the synthetic property method (and getArguments() as arguments). Same calls, Kotlin sugar.

Binding the interceptor

An interceptor is inert until you tell Guice where to apply it. That’s bindInterceptor, available on Binder and surfaced as a protected method on AbstractModule. Its signature is three parts:

bindInterceptor(
    Matcher<? super Class<?>> classMatcher,
    Matcher<? super Method>   methodMatcher,
    MethodInterceptor...      interceptors)

The two matchers form a sieve: a method is intercepted only if its declaring class passes the class matcher and the method itself passes the method matcher. You build matchers with the static factory methods on com.google.inject.matcher.Matchers. The ones you’ll actually reach for:

  • Matchers.any() — matches everything.
  • Matchers.subclassesOf(SomeType::class.java) — the class is (a subclass of) SomeType.
  • Matchers.annotatedWith(Transactional::class.java) — annotated with a given annotation (works for both classes and methods, since both are AnnotatedElements).
  • Matchers.returns(subclassesOf(Future::class.java)) — the method’s return type matches a nested matcher.
  • Matchers.inPackage(...) / Matchers.not(...) — round out the set.

To time every method on every class in our app package:

import com.google.inject.AbstractModule
import com.google.inject.matcher.Matchers

class TimingModule : AbstractModule() {
    override fun configure() {
        bindInterceptor(
            Matchers.any(),                  // any class
            Matchers.any(),                  // any method
            TimingInterceptor(),
        )
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.matcher.Matchers;

public class TimingModule extends AbstractModule {
    @Override
    protected void configure() {
        bindInterceptor(
            Matchers.any(),                  // any class
            Matchers.any(),                  // any method
            new TimingInterceptor());
    }
}

any()/any() is the firehose — it’ll wrap toString(), getters, everything Guice can reach. Useful for a demo, reckless in production. You almost always want to narrow at least one matcher, and the cleanest way to narrow is to let the code being intercepted opt in.

The annotation-driven pattern

The idiomatic move — the one that makes AOP feel like a feature instead of a trap — is a custom annotation that marks the methods you want wrapped. You match on the annotation, and now interception is opt-in, visible at the call site, and grep-able.

Define the annotation, match it, and decorate the methods:

import com.google.inject.AbstractModule
import com.google.inject.matcher.Matchers

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)        // MUST be runtime-retained
annotation class Timed

class TimingModule : AbstractModule() {
    override fun configure() {
        bindInterceptor(
            Matchers.any(),
            Matchers.annotatedWith(Timed::class.java),
            TimingInterceptor(),
        )
    }
}

class ReportService {
    @Timed
    open fun generate(month: String): String {           // note: open
        Thread.sleep(50)
        return "report for $month"
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.matcher.Matchers;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)            // MUST be runtime-retained
@interface Timed {}

class TimingModule extends AbstractModule {
    @Override
    protected void configure() {
        bindInterceptor(
            Matchers.any(),
            Matchers.annotatedWith(Timed.class),
            new TimingInterceptor());
    }
}

class ReportService {
    @Timed
    public String generate(String month) throws InterruptedException {
        Thread.sleep(50);
        return "report for " + month;
    }
}

Now ask Guice for a ReportService and call generate(...), and the timing prints itself. Crucially, the interceptor knows nothing about timing-worthy methods, the service knows nothing about timing, and the module is the one line that joins them. Swap TimingInterceptor for a RetryInterceptor and the services don’t change. That separation is the entire pitch of AOP.

The runtime retention is not optional. Guice’s annotatedWith matcher checks the annotation at runtime via reflection, and it will reject an annotation type that isn’t RUNTIME-retained — Matchers calls a checkForRuntimeRetention on the annotation type when you build the matcher, so a SOURCE/CLASS-retained annotation fails fast with an error rather than silently never matching. Small mercy, worth knowing.

This is exactly the shape of a @Transactional interceptor: match annotatedWith(Transactional::class.java), open a transaction before proceed(), commit on success, roll back on a thrown exception, all in the try/catch/finally. The mechanics are identical to @Timed; only the body of invoke gets more interesting.

Going deeper: how it actually works, and where it stops

Here’s the mechanism, because it explains every limitation in one stroke. Guice doesn’t rewrite your bytecode or weave at compile time. When a class has at least one intercepted method, Guice generates a dynamic subclass of it at injector-creation time, overriding the matched methods to run the interceptor chain before delegating to super. The instance Guice hands you is an instance of that generated subclass. (In Guice 7 this requires bytecode generation to be enabled — the default — which is why these interceptors don’t fire under environments that disable it.)

Once you see “it’s a subclass that overrides your methods,” the rules write themselves — interception works only where method overriding works:

  • Guice must construct the instance. Interception lives in the generated subclass, and you only get that subclass if you obtain the object from the injector (directly, or as an injected dependency). new ReportService() gives you a plain, un-proxied object — zero interception. This is the single most common “why isn’t my interceptor firing” bug.
  • Methods must be overridable: public or package-private, and non-final, non-static, non-private. A final method can’t be overridden, so it can’t be wrapped. A private method isn’t visible to a subclass. A static method isn’t dispatched through an instance at all. Guice quietly skips these — no error, no interception. (This is why the Kotlin ReportService.generate above is open: Kotlin methods are final by default, and a final method is invisible to AOP.)
  • Self-invocation bypasses interception. If generate() calls another @Timed method on this, that inner call is a plain virtual dispatch on the real object — it does not go back through the generated subclass’s wrapper. Interception only happens when the call crosses into the object from outside. If you need the inner method intercepted too, inject the bean into itself, or restructure so the call comes through the injected reference.
  • Constructor injection still works, untouched. A common worry: “if Guice subclasses my class, does my @Inject constructor still get its dependencies?” Yes. The generated subclass invokes your real constructor with the resolved dependencies; AOP and constructor injection compose cleanly. You don’t change a thing about how you write the class.

The Kotlin final-by-default issue deserves a flag of its own: it bites everyone the first time. Either mark intercepted methods (and their class) open, or use the all-open compiler plugin to make annotated classes open automatically — which is precisely what frameworks like Misk do so you never think about it.

Gotchas

  • new defeats it, silently. The number-one failure mode. If interception isn’t firing, your first question is “did this object come from Guice, or did something new it?” Test factories and manual wiring are the usual culprits.
  • Kotlin final-by-default. Methods and classes are final unless marked open. A final method is unoverridable and therefore un-interceptable, and Guice won’t warn you — it just doesn’t wrap it. Reach for the all-open plugin in real projects.
  • Self-calls don’t re-enter the proxy. this.otherTimedMethod() skips interception entirely. If your @Transactional “isn’t transactional” for an inner call, this is why.
  • any()/any() is a footgun. Matching every method on every class wraps toString(), equals(), every getter — overhead and noise everywhere, and a real risk of intercepting methods you never meant to. Always narrow with an annotation, a subclassesOf, or a package matcher.
  • The annotation must be RUNTIME-retained. annotatedWith checks at runtime and rejects non-runtime annotations when you build the matcher. Default Java annotation retention is CLASS, so you must say @Retention(RUNTIME) explicitly.
  • Order is the order you bind. Multiple interceptors on the same method run in the order passed to (and across calls to) bindInterceptor, nesting like an onion. Each one’s proceed() calls into the next; the innermost calls the real method. If your logging wraps your timing or vice-versa, that ordering is the lever.

What’s next

Method interception lets you wrap behavior without touching the classes being wrapped — as long as Guice built them, the methods are overridable, and the call comes from outside. Used with a custom annotation it’s one of the cleaner ways to add timing, transactions, or retries to a JVM service. Used with any()/any() it’s a way to slow everything down and confuse your future self. Choose the narrow path.

Next we’ll stop wrapping methods and start hiding them: Part 5 — Private Modules, where bindings become encapsulated and a module can expose exactly what it wants while keeping its internals to itself.


Target keyword(s): guice aop, guice method interceptor.

Comments