What are the architectural consequences and limits of interface-based JDK proxying that you'd weigh when designing Spring components?
answer
- only interface methods advised/visible
- no concrete-type injection
- self-invocation bypasses proxy (both types)
- interface = the public contract
- CGLIB escape hatch but no final classes/methods
basics
~10 sOnly interface-declared methods can be advised or reached; concrete-type dependencies break; you must program to interfaces. If those constraints hurt, switch to CGLIB (proxyTargetClass=true), accepting its own limits (no final classes/methods).
solid answer
~50 sJDK proxying forces an interface-first design. Consequences: (1) Only methods on the proxied interface are advised — a public method that exists solely on the impl is invisible to aspects and unreachable through the injected interface reference. (2) Beans must be consumed by interface type; injecting the concrete class fails because the proxy isn't assignable to it. (3) Self-invocation (this.method()) bypasses the proxy entirely, so internal calls skip advice regardless of proxy type. (4) The interface becomes the true public contract, which is generally healthy for testability and boundaries. When these constraints conflict with reality — no meaningful interface, callers needing the concrete type, or advice needed on non-interface methods — you move to CGLIB via proxyTargetClass=true, trading in its limits: can't proxy final classes/methods, constructor semantics differ, and it couples the proxy to the concrete class. Many teams standardize on one strategy for consistency.
code
java · 19 linespublic interface OrderService {
void place(Order o); // advised by @Transactional
}
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
@Override public void place(Order o) {
validate(o); // self-invocation: @Transactional on validate
audit(o); // would NOT apply — bypasses the proxy
}
// Not on the interface -> under JDK proxying this is neither
// advisable nor reachable through an OrderService reference.
@Transactional public void audit(Order o) { /* ... */ }
private void validate(Order o) { /* ... */ }
}go deeper
Grasp that you should depend on interfaces.
Explain interface-only advising and the concrete-injection failure.
Add self-invocation, the CGLIB escape hatch, and its final-method limits.
Set a codebase-wide proxy policy and enforce interface consumption via architecture rules; reason about the full trade-off matrix.
## The core constraint A JDK dynamic proxy implements **interfaces only**. That single fact ripples into several design consequences. ### 1. The interface is the advisable/visible surface Only methods **declared on a proxied interface** are: - **Advisable** — an aspect (`@Transactional`, `@Around`, etc.) can only intercept interface methods. A `public` method that lives only on `MyServiceImpl` (not on the interface) is **never advised** under JDK proxying, and it's **unreachable** through the injected `MyService` reference. This is a common source of "my @Transactional didn't apply" bugs. - Practical rule: anything that needs advice must be on the interface. ### 2. Concrete-type dependencies break The proxy is **not assignable** to the impl class (it extends `java.lang.reflect.Proxy`). So `@Autowired MyServiceImpl` fails. Design components to depend on **interfaces**, which also improves testability (easy to substitute test doubles) and enforces module boundaries — a natural fit for a Spring Modulith codebase where modules expose `*Api` interface surfaces and hide implementations. ### 3. Self-invocation bypasses the proxy (any proxy type) Because advice lives on the **proxy**, and the target holds a plain `this` reference to itself, an internal call `this.otherMethod()` goes straight to the target and **skips all advice**. This is true for both JDK and CGLIB. Mitigations: split responsibilities across beans, inject the proxy into itself, or use `AopContext.currentProxy()` (requires `exposeProxy = true`). This is an architectural smell worth designing around, not patching. ### 4. The interface as contract Forcing an interface can be a feature: it makes the public contract explicit, keeps the impl swappable, and aligns with dependency inversion. The cost is boilerplate (an interface per service) and indirection. ## When JDK proxying is the wrong tool -> CGLIB Switch to CGLIB (`@EnableAspectJAutoProxy(proxyTargetClass = true)` / `spring.aop.proxy-target-class=true`; Boot defaults this to true) when: - there's no meaningful interface, - callers legitimately need the concrete type, - you must advise methods that aren't on an interface. CGLIB's own limits to weigh: - **Cannot subclass `final` classes**, and **cannot override `final`/`private`/`static` methods** — advice on those is silently skipped (another "aspect didn't fire" trap). - The proxy is a **subclass**, coupling it to the concrete class; field access on the target vs proxy can surprise (proxy fields are uninitialized — always go through methods). - Constructor semantics: modern Spring uses **Objenesis** to instantiate the proxy without running the constructor, so don't rely on constructor side effects for proxied beans. ## Governance decision At scale, teams usually **standardize**: either commit to interface-based design + JDK proxies, or set `proxyTargetClass=true` everywhere for uniformity (Boot's default). Mixing leads to surprising "works here, not there" advice behavior. The principal-level judgment is choosing the policy and encoding it (a config + a lint/ArchUnit rule that services are consumed by interface), rather than leaving it to per-bean accident. ## Summary trade-off table - JDK: clean contracts, only interface methods advised, no concrete injection, needs interfaces. - CGLIB: no interface needed, concrete injection works, but no final classes/methods, subclass coupling, constructor caveats. Both share the **self-invocation** limitation.
- A @Transactional method is invoked from another method of the same bean and no transaction starts. Why, and how do you fix it structurally?Self-invocation bypasses the proxy so advice never runs. Move the transactional method to a separate collaborating bean (or use AopContext.currentProxy() with exposeProxy=true) so the call crosses a proxy boundary.
- How would you enforce that services are always consumed via their interface across a large codebase?Encode it as an ArchUnit/architecture rule (e.g. no field or parameter typed to an *Impl class) plus a fixed proxy policy in config, so violations fail the build rather than surfacing as runtime advice gaps.
saying these in an interview costs you the question
- Believing self-invocation is a JDK-only problem (CGLIB has it too).
- Assuming switching to CGLIB advises final/private/static methods.
- Thinking a public impl-only method gets advised under JDK proxying.
- Relying on the proxy's constructor side effects (Objenesis bypasses it).