Challenge

Make the annotation take effect

20 minintermediate28 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • Only calls crossing the proxy boundary are advised
  • Private and final methods cannot be intercepted at all
  • Swallowing an exception inside the method commits the transaction
  • The rollback default is unchecked-only, and it is rarely what you want

Starter

Starter.java
import java.lang.reflect.*;
import java.util.*;

/**
 * Challenge: four call sites, and only some of them get a transaction.
 *
 * Predict which before running. Then fix the ones that should be transactional
 * and are not.
 */
public class Starter {

    interface AccountService {
        void debit(String account, int paise) throws Exception;
        void transfer(String from, String to, int paise) throws Exception;
        void audit(String message);
    }

    static class RealAccountService implements AccountService {
        @Override
        public void debit(String account, int paise) {
            System.out.println("    debit " + account + " " + paise);
        }

        @Override
        public void transfer(String from, String to, int paise) throws Exception {
            debit(from, paise);              // self-invocation
            if (paise > 100_000) {
                throw new Exception("limit exceeded");   // checked
            }
            System.out.println("    credit " + to + " " + paise);
        }

        @Override
        public void audit(String message) {
            try {
                System.out.println("    audit " + message);
                throw new IllegalStateException("audit store unavailable");
            } catch (Exception e) {
                // Swallowed. What does the proxy see?
                System.out.println("    (audit failure logged and ignored)");
            }
        }
    }

    static AccountService transactional(AccountService target) {
        return (AccountService) Proxy.newProxyInstance(
            AccountService.class.getClassLoader(),
            new Class<?>[] { AccountService.class },
            (proxy, method, args) -> {
                System.out.println("  BEGIN   " + method.getName());
                try {
                    Object r = method.invoke(target, args);
                    System.out.println("  COMMIT  " + method.getName());
                    return r;
                } catch (InvocationTargetException e) {
                    Throwable cause = e.getCause();
                    // Spring's default rule, reproduced.
                    if (cause instanceof RuntimeException || cause instanceof Error) {
                        System.out.println("  ROLLBACK " + method.getName()
                            + " — " + cause.getMessage());
                    } else {
                        System.out.println("  COMMIT  " + method.getName()
                            + " — checked: " + cause.getMessage());
                    }
                    throw cause;
                }
            });
    }

    public static void main(String[] args) {
        AccountService service = transactional(new RealAccountService());

        System.out.println("1. direct call:");
        try { service.debit("ACC1", 5_000); } catch (Exception e) { }

        System.out.println("2. transfer within the limit:");
        try { service.transfer("ACC1", "ACC2", 5_000); } catch (Exception e) { }

        System.out.println("3. transfer over the limit (checked exception):");
        try { service.transfer("ACC1", "ACC2", 500_000); } catch (Exception e) { }

        System.out.println("4. audit, which swallows its own exception:");
        service.audit("transfer attempted");

        // TODO 1: in case 2, how many transactions were opened, and how many
        // debits happened? Is the debit inside the transfer's transaction?

        // TODO 2: case 3 debited and then threw. What is committed? Change ONE
        // thing in the proxy so a checked exception rolls back, and say what
        // the Spring equivalent of that change is.

        // TODO 3: case 4 swallows its exception. Did the proxy roll back?
        // Explain what Spring would do here, including what
        // UnexpectedRollbackException is and when you would see it.

        // TODO 4: make the debit inside transfer genuinely transactional
        // without using AopContext. Two classes.
    }
}

Run it locally:

cd exercises/java/spring-data/transactional-internals/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Ask one question per call site: does this call go through the proxy, or straight to the target object?

  2. Hint 2

    A JDK proxy implements an interface; a CGLIB proxy subclasses the class. Neither can intercept something it cannot override.

  3. Hint 3

    If the proxy never sees an exception, it has no reason to roll back.

  4. Hint 4

    Spring's default rolls back on RuntimeException and Error only.

Done when

  • Every call that should be transactional is, and you can prove it from the output
  • A checked exception rolls back, and you changed one thing to make it
  • A comment explains why catching inside the method broke the rollback

← Back to How does @Transactional actually work, and when does it silently do nothing?