Lesson 12 / 19
Transaction Management
Two call paths are run side by side, and how many times the declarative transaction boundary opens is counted. A component calling itself skips the proxy: the boundary never opens for the inner call, no error comes out, and this silence is marked nowhere in the source.
Published
In this lesson
- Runs two call paths side by side and counts how many times the declarative transaction boundary opens
- Shows that a component calling itself skips the proxy, and that the boundary never opens for the inner call
- Measures that the transaction boundary does not open even once on the same object when it is called without ever setting up a proxy
Contents
The previous lesson measured when an object’s fields load, but it never asked where this loading, and the changes made on top of it, begin and end. This lesson takes this up: it measures that a declarative transaction boundary is an annotation in the source, and how a component calling itself skips this boundary. The transaction itself — a set of changes applying either completely or not at all, and isolation levels — was established as a concept in the Relational Database Administration course and is not repeated here; this lesson’s question is separate from that: where the boundary opens and closes, and what draws that boundary.
There is no real database connection here either. A transaction boundary is modeled as
the increment of a counter called boundariesOpened — in a real transaction, this is a
connection being put into transactional mode and then committed or rolled back at the end;
here only the question “how many times did the boundary open” is measured, not what happens
inside an opened boundary. The declarative boundary arises, with no beginTransaction()
call anywhere in the source, from a @Transaction annotation on top of a method alone.
The word “declarative” describes exactly this: the party that draws the boundary is not the code that calls the method, it is the annotation standing on top of the method. An earlier lesson saw this same declaration pattern in the validation rules — a field’s constraint stood in an annotation too, and the party reading it was the container. The difference in the transaction boundary is this: whether validation applied depended only on which method was called, while whether the transaction boundary opens depends also on where the call comes from. This difference is the real subject of the rest of the lesson.
Two Call Paths, Two Separate Boundary Counts
- WD25.
@Transactionstands on both thedepositanddepositAndNotifymethods. Both are members of the same implementation class. - WD26. The proxy looks up the called method again in the target object’s own class
and checks for the
@Transactionannotation there; this is done before them.invokecall, so the decision to open a boundary is made before the real call.
AccountImplementation.depositAndNotify calls deposit(amount) inside itself — this is one
method of the same object calling another of its own methods, that is, a
self-invocation. Container.wrap puts both of these methods behind a proxy, and the
proxy only sees calls coming from outside.
The measurement is done with two separate setups, and both use the same implementation
class. The only difference between the setups is which methods the outside caller calls and
how many times; AccountImplementation’s own source code stays byte-for-byte the same
across both setups. This differs in one respect from the “two setups” measurements in the
previous lessons: there, what changed was usually the scan order or the implementation
choice; here, what changes is only the pattern of calls coming from outside.
// Container.java — declarative transaction boundary arises from an annotation, self-invocation skips the proxy
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
public class Container {
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Transaction {}
static int boundariesOpened = 0;
static List<String> log = new ArrayList<>();
@SuppressWarnings("unchecked")
static <T> T wrap(T target, Class<T> iface) {
return (T) Proxy.newProxyInstance(
Container.class.getClassLoader(),
new Class<?>[]{iface},
(v, m, args) -> {
Method real = target.getClass().getMethod(m.getName(), m.getParameterTypes());
if (real.isAnnotationPresent(Transaction.class)) {
boundariesOpened++;
log.add("boundary-opened: " + m.getName());
}
try {
return m.invoke(target, args);
} catch (InvocationTargetException e) {
throw e.getCause();
}
});
}
public static void main(String[] args) throws Exception {
AccountImplementation real = new AccountImplementation();
Account proxy = wrap(real, Account.class);
System.out.println("-- path A: only depositAndNotify called from outside --");
System.out.println(proxy.depositAndNotify(100));
System.out.println("boundaries opened: " + boundariesOpened);
System.out.println("log: " + log);
boundariesOpened = 0;
log.clear();
real = new AccountImplementation();
proxy = wrap(real, Account.class);
System.out.println("-- path B: deposit and depositAndNotify called separately from outside --");
System.out.println(proxy.deposit(50));
System.out.println(proxy.depositAndNotify(100));
System.out.println("boundaries opened: " + boundariesOpened);
System.out.println("log: " + log);
System.out.println("-- the inner call itself never saw the proxy --");
System.out.println("the 'deposit(amount)' call inside AccountImplementation.depositAndNotify "
+ "is made through 'this', not through the proxy");
System.out.println("confirmation: path B had two calls from outside, and with the inner call "
+ "deposit/depositAndNotify ran three times in total, but the boundary opened only twice");
System.out.println("defect type: silent (no exception, the inner call ran with no boundary opened at all)");
System.out.println("-- manually constructed object: the container is never involved --");
AccountImplementation manual = new AccountImplementation();
boundariesOpened = 0;
System.out.println(manual.depositAndNotify(100));
System.out.println("boundaries opened (proxy never constructed): " + boundariesOpened);
}
}
interface Account {
String deposit(int amount);
String depositAndNotify(int amount);
}
class AccountImplementation implements Account {
int balance = 0;
@Container.Transaction
public String deposit(int amount) {
balance += amount;
return "balance=" + balance;
}
@Container.Transaction
public String depositAndNotify(int amount) {
String result = deposit(amount);
return result + "; notified";
}
}
-- path A: only depositAndNotify called from outside -- balance=100; notified boundaries opened: 1 log: [boundary-opened: depositAndNotify] -- path B: deposit and depositAndNotify called separately from outside -- balance=50 balance=150; notified boundaries opened: 2 log: [boundary-opened: deposit, boundary-opened: depositAndNotify] -- the inner call itself never saw the proxy -- the 'deposit(amount)' call inside AccountImplementation.depositAndNotify is made through 'this', not through the proxy confirmation: path B had two calls from outside, and with the inner call deposit/depositAndNotify ran three times in total, but the boundary opened only twice defect type: silent (no exception, the inner call ran with no boundary opened at all) -- manually constructed object: the container is never involved -- balance=100; notified boundaries opened (proxy never constructed): 0
In path A there is a single outside call — proxy.depositAndNotify(100) — and the boundary
opens once. In path B there are two outside calls — proxy.deposit(50) and
proxy.depositAndNotify(100) — and the boundary opens twice. The only difference between the
two is how many calls come from outside; depositAndNotify calling deposit inside itself
is never added to the boundary count in either setup. This is the plainest form of what the
course calls propagation: the question of whether an inner call opens its own boundary
or stays inside the outer boundary — here the answer is a third possibility, the inner call
never entering any boundary at all, because the proxy never sees it.
The Inner Call Never Sees the Proxy
- WD27.
wrapreturns only a proxy object implementing theAccountinterface;AccountImplementationitself never changes,thisstill refers to the real object. - WD28. For the proxy to intercept anything, a call has to pass through the object it
produced. The
deposit(amount)call insidedepositAndNotifynever uses this object at all.
The reason for this lies in how the proxy works. Proxy.newProxyInstance produces a new
object — the proxy — and this object intercepts every call coming from outside, increments
boundariesOpened, and then forwards it to the real object (target). But when
deposit(amount) is written inside AccountImplementation‘s own source code, this call is
not target.deposit(amount), it is an ordinary Java method call made through the real
object’s own this. The object itself never has the information “I am behind a proxy” — the
proxy is a separate layer placed around the object, not a part of the object. This is why
the @Transaction annotation on deposit is never read for this inner call: the only party
that would read it is the proxy’s call handler, and the call handler only sees calls that
pass through the proxy.
This is the lesson’s bounding measurement: a component calling itself skips the proxy, and
the annotation never applies at all — because the call comes from inside, wrapping never
engages, no error comes out, no boundary opens. Path B’s log confirms this: deposit and
depositAndNotify ran three times in total (two from outside, one from inside), but the log
carries only two boundary-opened entries. No exception fires, no warning is printed — the
program produces a response that looks entirely normal, balance=150; notified. Someone
reading the source, seeing @Transaction on top of deposit, might assume it opens a
boundary every time it is called; this assumption is wrong for calls coming through
depositAndNotify, and the only thing that shows this wrongness is the counter, not any
error message.
The root of this behavior lies in how the proxy is set up. Container.wrap produces a
new object, it does not modify the existing one — not a single line was added to
AccountImplementation‘s own bytecode, no method was replaced with an intervening call. The
proxy is a runtime-generated instance of a separate class that wraps target, and since this
instance implements only the Account interface, it is on no path at all except calls
coming through that interface. A call written inside an implementation class sees the only
object that class itself knows: itself. Being unaware of the proxy’s existence is not a
shortcoming of the implementation class — the proxy is added entirely from outside, leaving
no trace on the class itself in the first place.
The Boundary Never Opens on a Manually Constructed Object
The last measurement is made with an AccountImplementation object constructed without ever
calling wrap. The call manual.depositAndNotify(100) completes normally, returning
balance=100; notified — but boundariesOpened stays at zero. This is the
transaction-management counterpart of a rule seen repeatedly in the previous lessons: the
@Transaction annotation keeps standing on the AccountImplementation class, but if no
proxy was ever set up to read it, no boundary ever opens at all. The difference between the
inner call in path B and this manually constructed object is subtle but important: in the
inner call, the proxy exists, it does not engage for that call; in the manually
constructed object, the proxy does not exist at all. Both produce the same outcome — the
boundary not opening — but one comes from the object container’s own rule (only calls coming
from outside get wrapped), the other comes from the container never being involved at all.
What these three setups show together is this: knowing that a declarative boundary exists
does not mean knowing that it will always run. The boundary opening depends on three
conditions all at once — the annotation has to be on the class, the call has to pass through
the proxy, and the call has to come from outside. Unless all three are met, @Transaction
being written in the source guarantees nothing.
None of these three conditions is written in one single place in the source text. The
@Transaction annotation is inside AccountImplementation, the proxy setup is inside
main, and whether a call comes from outside or from inside can only be understood by
looking at which variable the call is made through — proxy.deposit(...), or the bare
deposit(...) that amounts to this.deposit(...). Holding these three pieces of
information at once is something a code review can miss: someone looking at the body of
depositAndNotify can pass over which object the deposit call is made through without
noticing, because Java’s syntax does not distinguish deposit(...) from this.deposit(...)
— they are the same call, the same appearance.
Summary
- On the path where only
depositAndNotifywas called from outside, the boundary opened once; on the path wheredepositanddepositAndNotifywere called separately from outside, the boundary opened twice. - The
deposit(amount)inner call insidedepositAndNotifywas never added to the boundary count in any setup; the proxy sees only calls coming from outside. - Because the inner call was made through
this, it never saw the proxy; even though the@Transactionannotation kept standing on the class, it was never read for this call. - The defect was silent: the inner call threw no exception, printed no warning, it just completed without opening the expected boundary.
- On the same class called without ever setting up a proxy, the boundary did not open even once; the boundary working depends not on the annotation’s existence, but on all three conditions being met together.
Next Step
This topic ends here. This lesson showed that an annotation says where the boundary opens, and that a call coming from inside never sees that boundary. The next topic asks the same question somewhere else: in security, testing, and distributed setup, a boundary too — this time an authorization boundary — is drawn again by an annotation, and whether a call coming from inside sees it is measured once more.
To keep your progress and take notes, Log in
My notes
Log in to take notes.