Skip to content
Spring Boot sb design-patterns 3 min read

Proxy Pattern & AOP

The proxy pattern puts a stand-in object in front of a real one to control access to it — adding behavior before, after, or around each call without changing the target. This is the engine behind Spring AOP and behind annotations like @Transactional, @Cacheable, and @Async. When you annotate a method, Spring quietly wraps your bean in a proxy that adds the cross-cutting behavior.

Why proxies — cross-cutting concerns

Transactions, caching, retries, security checks, and logging are cross-cutting concerns: they apply across many methods but are not the method’s real job. Scattering that code into every method is repetitive and error-prone. A proxy lets Spring inject the behavior from the outside, keeping your method focused on business logic.

@Service
public class AccountService {

    @Transactional                       // Spring wraps this method in a tx proxy
    public void transfer(Long from, Long to, BigDecimal amount) {
        debit(from, amount);
        credit(to, amount);
        // commit on normal return; rollback on RuntimeException
    }
}

You never wrote connection.commit() — the proxy did. See transactions for the semantics.

How the proxy is created

At startup, a BeanPostProcessor examines each bean. If a method carries an AOP-relevant annotation (or matches a pointcut), Spring replaces the bean reference in the container with a proxy that has the same type. Callers inject the proxy and never see the raw object.

Spring chooses between two proxy mechanisms:

MechanismWhen usedHow it worksLimitation
JDK dynamic proxyBean implements an interfaceGenerates a proxy implementing the same interface(s)Only interface-declared methods are advised
CGLIBBean has no interface (or proxyTargetClass=true)Generates a runtime subclass that overrides methodsCannot proxy final classes/methods

Spring Boot defaults to CGLIB (proxy-target-class=true) so proxying works whether or not your bean implements an interface.

caller ──▶ [ AccountService$$Proxy ]  ──▶ begin tx
                                       ──▶ real AccountService.transfer(...)
                                       ──▶ commit / rollback

Annotations that ride on proxies

The same proxy machinery powers several familiar annotations:

@Service
public class CatalogService {

    @Cacheable("products")               // result cached by the proxy
    public Product findById(Long id) {
        return slowLookup(id);
    }

    @Async                               // run on another thread by the proxy
    public CompletableFuture<Report> buildReport() {
        return CompletableFuture.completedFuture(new Report());
    }
}

@Cacheable checks the cache before delegating; @Async submits the call to an executor. Both are pure proxy behavior added around your method. See async for @Async details.

The self-invocation pitfall

Because the behavior lives in the proxy, it only applies to calls that go through the proxy — i.e. calls from another bean. A method calling another annotated method on this bypasses the proxy entirely, so the annotation does nothing.

@Service
public class ReportService {

    public void generateAll() {
        for (Long id : ids()) {
            buildOne(id);     // BUG: internal call — @Transactional is IGNORED
        }
    }

    @Transactional
    public void buildOne(Long id) { /* ... */ }
}

generateAll() calls buildOne() directly on this, not on the proxy, so each buildOne runs with no transaction. The same trap applies to @Cacheable and @Async.

Fixes:

  • Move the annotated method to a different bean and inject it (the clean, preferred fix).
  • Inject the proxy of yourself via ObjectProvider or @Lazy AccountService self and call self.buildOne(id).
  • Use AopContext.currentProxy() (requires exposeProxy = true) — works but couples code to AOP.

Warning: Self-invocation silently disabling @Transactional is one of the most common Spring bugs. If a transaction “isn’t rolling back,” check whether the annotated method is being called from within the same class.

Note: Proxy-based AOP only intercepts public methods reached through a Spring-managed bean. private, static, and final methods cannot be advised by the proxy. If you need to advise those, you would have to switch to compile/load-time weaving with AspectJ.

Writing your own aspect

You can add custom cross-cutting behavior with @Aspect, using the same proxy machinery:

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimingAspect {

    @Around("@annotation(Timed)")
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();        // call the real method
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.println(pjp.getSignature() + " took " + ms + "ms");
        }
    }
}

Requires spring-boot-starter-aop. The aspect runs around every method annotated @Timed — a decorator/proxy you defined yourself.

Last updated June 13, 2026
Was this helpful?