Class-based proxies cannot override final or private methods, and self-calls bypass proxy advice even when the method is open.
Spring AOP final methods: advice requires an interceptable call
The proxy is the boundary
A receipt reservation annotated @Transactional receives advice when another bean calls it through the Spring proxy. A direct call on this from inside the same object does not cross the proxy. With class-based proxying, a final class cannot be subclassed and a final or private method cannot be overridden for advice. Interface-based proxies have different method visibility, but they do not make private internal calls interceptable. Self-invocation and method security share this shape.
Put the boundary on a service API
Make the advised operation a public, non-final method on a bean injected into its caller. Keep helpers private if they do not require independent advice. If the operation needs a second transaction, move it to a separate service rather than trying to manipulate proxy internals. The example gives an order service a distinct ledger collaborator; the caller crosses the proxy at reserve, so transaction advice can run. A comment on a private helper would not change execution.
Test the behavior, not the annotation
Call the service through the application context, force a repository failure and assert the reservation rolls back. Then test a direct internal call only if the design still has one, so the test makes the difference visible. A unit test that constructs ReceiptLedger with new bypasses Spring advice and cannot prove transaction or security semantics. Proxy type may change with configuration, so prefer behavioral checks over assertions on generated class names.
Implementation contract
@Service
class ReceiptLedger {
private final ReservationRepository reservations;
ReceiptLedger(ReservationRepository reservations) {
this.reservations = reservations;
}
@Transactional
public void reserve(ReceiptCommand command) {
reservations.insert(command.receiptId(), command.amount());
}
}
@Service
class ReceiptOrderService {
ReceiptOrderService(ReceiptLedger ledger) { this.ledger = ledger; }
private final ReceiptLedger ledger;
void accept(ReceiptCommand command) { ledger.reserve(command); }
}Cost and verification
A proxy adds small dispatch overhead compared with database work. The real cost of a misplaced boundary is silent loss of transaction, cache, async or authorization behavior; an integration test is cheaper than recovering corrupted state.
Common Mistakes
- Do not put @Transactional on a private or final method and expect class-based advice.
- Do not call an advised method through this and expect the proxy to intercept it.
- Do not prove AOP behavior with only a new-constructed unit object.
Read next
Spring AOP proxies: self-invocation bypasses proxy advice, Spring method security: authorization advice runs through the bean proxy, Spring transaction events: run a listener after commit without claiming durability, Spring BeanPostProcessor: instance callbacks and the early-bean trap, Spring ApplicationContextRunner: check conditional assembly in isolation.
