Build1 publisher3 min readPublished
The proxy in your call path decides whether @Transactional does anything at all
Spring's annotations run in a generated wrapper around your bean, not inside your method. Which wrapper you get, and how callers reach it, decides whether the behaviour fires.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- Putting @Transactional on a method causes a database transaction to open before the method runs and commit when it returns, even though the developer wrote no line of code to start or end one.
- With @Cacheable, the second call with the same arguments skips the method body entirely.
- The extra behaviour runs inside a proxy: a stand-in object Spring slips between whoever calls the bean and the real bean itself. Proxies appear with any Spring annotation that wraps behaviour around a method, including transactions, caching, security, retries and async.
- The hand-written equivalent is a second class that implements the same interface, holds the real object (the target) as a field, adds the extra behaviour and forwards the call to the target; callers cannot tell the difference because it is the same interface type.
- Spring manufactures the wrapper for you at runtime, for a class it has never seen before, rather than requiring you to write it.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A dev.to write-up on Spring proxies restates something worth keeping on a whiteboard: `@Transactional` does not open your transaction and `@Cacheable` does not skip your method body [1][2]. A generated stand-in object sitting between the caller and the bean does both, which means any call path that misses the stand-in runs the plain method body and returns normally with none of the behaviour attached [3][1].
The shape is the old wrapper pattern. Write a second class that implements the same interface, hold the real object as a field, add your logging or your transaction handling, then forward the call; callers cannot tell the difference [4]. Spring's only twist is that you do not write the wrapper. It manufactures one at runtime for a class it has never seen [5]. The reason is bookkeeping: transactions, security, caching and metrics cut across many unrelated methods, so the annotation leaves your method body clean and the behaviour lives in the wrapper instead [6]. That is the whole bargain, and it is also the failure mode.
There are two ways Spring builds the wrapper, and they fail differently. The first is `java.lang.reflect.Proxy`, shipped with Java since 1.3: hand it a set of interfaces and it generates, in memory, a new class that implements them [7]. Every call on that object funnels into one `InvocationHandler`, which receives the method and args, does the extra work, and forwards with `method.invoke(real, args)` [8]. The constraint is in the argument list. A JDK dynamic proxy can only mimic interfaces; the generated class implements `PaymentService` but is not a `RealPaymentService`, just a synthetic class sharing the surface, so the mechanism works only when the bean has an interface to stand behind [9]. Anything in your codebase that casts to the concrete class, or checks for it, is not looking at that object's type [2].
The second is CGLIB, which Spring reaches for when the bean has no interface. It generates a subclass of your class at runtime and overrides the methods [10]. Interception is therefore override-based, which means a method a subclass cannot override cannot carry proxied behaviour [3]. Nothing about the annotation changes; the hook simply has nowhere to attach.
The trap that costs the most is neither of those. In the pattern the article describes, the wrapper holds the target, and the target holds no reference back to the wrapper [4][3]. So when a bean calls one of its own annotated methods, the call goes straight to the body and never passes through the proxy [4]. The transaction does not open. The cache is not consulted. The method returns a correct-looking value [1].
Three checks are worth doing before you trust an annotation in a hot path. Whether each annotated bean has an interface, because that determines which mechanism you got [9][10]. Whether the annotated method is ever reached from inside its own class rather than through the injected reference [4]. And whether any method you annotated is one a generated subclass could not override [3]. All three are cheap to establish and none of them announce themselves at runtime.