Skip to content
academia.sh

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. @Transaction stands on both the deposit and depositAndNotify methods. 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 @Transaction annotation there; this is done before the m.invoke call, 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. wrap returns only a proxy object implementing the Account interface; AccountImplementation itself never changes, this still 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 inside depositAndNotify never 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 depositAndNotify was called from outside, the boundary opened once; on the path where deposit and depositAndNotify were called separately from outside, the boundary opened twice.
  • The deposit(amount) inner call inside depositAndNotify was 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 @Transaction annotation 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close