Career OS

Services, Transactions & Errors

A payment app inserts the expense row, then crashes before writing the splits. The database now says Asha paid Rs 3000 — but nobody owes her anything. Money vanished from the books, no error told anyone, and the bug surfaces three weeks later as “balances look wrong.” This module builds the layer that decides the rules, and the machinery that keeps half-finished writes out of your database.

The Goal

By the end of this module you can:

  • Place business rules in the service layer and defend why they don’t belong in the controller or the repository
  • Port the Core Java Ledger into a LedgerService — and watch your capstone logic survive the move almost untouched
  • De-magic @Transactional down to what it really is: a proxy doing begin → your method → commit-or-rollback
  • Reproduce the three classic @Transactional traps on purpose, so they never get you by surprise
  • Build a custom exception hierarchy plus one global handler that gives the entire API a single error JSON shape

The Lesson

The hole in the middle of your app

In module 03 you stepped a request through “: filter chain → DispatcherServlet → controller → service → repository → DB. Every layer in that animation now exists in your project — except one. The service hop has been empty. Your controllers call repositories directly, which means right now nobody is in charge of the rules.

LayerIts one jobWhat it must NOT do
ControllerParse HTTP in, shape HTTP outDecide anything about money
ServiceDecide — every business rule lives hereKnow that HTTP exists
RepositoryStore and fetchValidate, calculate, decide

The controller parses. The repository stores. The service decides. For SplitEase, “decides” means a concrete rule set:

RuleWhy it’s a business rule, not a field check
The payer must be a real, existing friendBean Validation can check payerId is not null — only code that queries the DB can check the friend exists
The sharer list must be non-empty and all realAn expense shared by nobody corrupts every balance downstream
Splits derive from the amount — the server computes sharesIf the client sent shares, a buggy or hostile client could send shares that don’t sum to the amount
Settle-up math: net balances must always sum to zeroThis is the capstone’s invariant — it’s a property of the whole ledger, no single field can express it

This is exactly the line you drew in the Core Java capstone: the CLI never does math, the Ledger never prints. Same rule, new costume — the controller never does math, the service never touches HTTP.

The payoff moment — your Ledger becomes LedgerService

You were told in the capstone that the Ledger would survive and the CLI would be thrown away. Today that promise gets kept. Compare what changes:

Capstone LedgerLedgerServiceWhat actually changed
Set<Friend> friendsFriendRepository friendsStorage: memory → PostgreSQL
List<Expense> expensesExpenseRepository expensesStorage again
Balance math over expensesIdentical mathNothing
Greedy settle-up algorithmIdentical algorithmNothing
Throws on unknown friendThrows on unknown friendOnly the exception names

The hours you spent hand-working the settle-up math were not CLI work. They were backend work that happened to wear a CLI. Here is the service — read it next to your capstone Ledger:

@Service
public class LedgerService {

    private final FriendRepository friends;
    private final ExpenseRepository expenses;

    public LedgerService(FriendRepository friends, ExpenseRepository expenses) {
        this.friends = friends;
        this.expenses = expenses;
    }

    @Transactional
    public Expense createExpense(CreateExpenseRequest request) {
        Friend payer = friends.findById(request.payerId())
                .orElseThrow(() -> new FriendNotFoundException(request.payerId()));
        if (request.sharerIds() == null || request.sharerIds().isEmpty()) {
            throw new InvalidExpenseException("An expense needs at least one sharer");
        }
        List<Friend> sharers = request.sharerIds().stream()
                .map(id -> friends.findById(id)
                        .orElseThrow(() -> new FriendNotFoundException(id)))
                .toList();
        return expenses.save(new Expense(request.amountPaise(),
                request.description(), payer, sharers));
    }

    @Transactional(readOnly = true)
    public Map<String, Long> balances() {
        Map<String, Long> net = new HashMap<>();
        for (Expense e : expenses.findAll()) {
            long share = e.getAmountPaise() / e.getSharers().size();
            long remainder = e.getAmountPaise() % e.getSharers().size();
            net.merge(e.getPayer().getName(), e.getAmountPaise(), Long::sum);
            for (Friend sharer : e.getSharers()) {
                // same rule as the CLI capstone: leftover paise ride on the payer's share
                long owed = share + (sharer.equals(e.getPayer()) ? remainder : 0);
                net.merge(sharer.getName(), -owed, Long::sum);
            }
        }
        return net;
    }

    @Transactional(readOnly = true)
    public List<Settlement> settleUp() {
        Map<String, Long> net = new HashMap<>(balances());
        List<Settlement> payments = new ArrayList<>();
        while (true) {
            String creditor = null, debtor = null;
            for (var e : net.entrySet()) {
                if (creditor == null || e.getValue() > net.get(creditor)) creditor = e.getKey();
                if (debtor == null || e.getValue() < net.get(debtor)) debtor = e.getKey();
            }
            if (creditor == null || net.get(creditor) <= 0) break;
            long amount = Math.min(net.get(creditor), -net.get(debtor));
            payments.add(new Settlement(debtor, creditor, amount));
            net.merge(creditor, -amount, Long::sum);
            net.merge(debtor, amount, Long::sum);
        }
        return payments;
    }
}

With a small record next to it:

public record Settlement(String from, String to, long amountPaise) {}

Money still in paise as long, balances still sum to zero, biggest debtor still pays biggest creditor. Your capstone test case — Asha pays 3000 dinner among three, you pay 600 cab among three — must produce the same numbers here. If it doesn’t, the port has a bug, and your hand-worked example catches it.

@Transactional, de-magicked

createExpense does several writes (the expense row plus the join rows linking sharers). If the process dies between them, the database holds a half-expense — the corrupted-balances scenario from the hook. Plain Java has always had the fix; it’s just tedious:

Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);     // begin
    // ... your actual logic, several writes ...
    conn.commit();                 // all of it becomes real, atomically
} catch (RuntimeException e) {
    conn.rollback();               // none of it ever happened
    throw e;
} finally {
    conn.close();
}

@Transactional is Spring writing that wrapper for you. The mechanism matters: at startup, Spring doesn’t hand the controller your LedgerService — it hands over a proxy, a generated stand-in that wraps your bean.

flowchart TD
    A[Controller calls createExpense] --> B["Proxy that Spring generated around LedgerService"]
    B --> C[1 begin transaction]
    C --> D["2 invoke YOUR createExpense body"]
    D -->|returns normally| E[3 commit]
    D -->|RuntimeException escapes| F[3 rollback]
    E --> G[(PostgreSQL)]
    F --> G

Begin, your code, commit — or rollback if a runtime exception escapes. That’s the whole trick. This is the same de-magic pattern as module 01: the annotation automates plain-Java machinery you could write yourself. And like all proxies, it has blind spots. Three of them bite constantly in production.

Trap 1 — self-invocation bypasses the proxy

@Service
public class LedgerService {

    // NO @Transactional here
    public void importTrip(List<CreateExpenseRequest> batch) {
        for (CreateExpenseRequest r : batch) {
            createExpense(r);   // this.createExpense — the proxy never sees this call
        }
    }

    @Transactional
    public Expense createExpense(CreateExpenseRequest request) { ... }
}

The failing scenario: someone calls importTrip with five expenses, the fourth has an unknown payer, the exception fires — and the first three are already permanently saved. Half a trip in the database.

Why: the transaction lives in the proxy, and the proxy only intercepts calls arriving from outside the object. this.createExpense(r) is a direct Java method call on the raw object — it never passes through the wrapper, so the @Transactional on createExpense is invisible for that call.

flowchart LR
    X[Caller outside the class] -->|intercepted| P["Proxy - adds transaction"]
    P --> R[Real LedgerService object]
    R -->|"this.createExpense - direct call, no proxy"| R

Fix: annotate the entry point — put @Transactional on importTrip, the method outsiders actually call. (Side note: settleUp calling balances() above is also self-invocation — harmless there, because settleUp already opened a transaction that the inner work happily runs inside. The trap only bites when the inner method needed its own transaction behavior and silently didn’t get it.)

Trap 2 — checked exceptions do NOT roll back

@Transactional
public Expense createExpense(CreateExpenseRequest request) throws IOException {
    Expense saved = /* validate and save as before */;
    exportReceipt(saved);   // throws IOException
    return saved;
}

The failing scenario: exportReceipt throws IOException, the caller gets a 500 — and the expense committed anyway. The user sees an error, retries, and now the expense exists twice.

Why: Spring’s default rule, inherited from old EJB conventions, is runtime exception = unexpected bug = rollback; checked exception = anticipated outcome the caller handles = commit. Nobody remembers the convention; everyone hits the trap. Fix, when any failure must undo the writes:

@Transactional(rollbackFor = Exception.class)

Trap 3 — @Transactional on private methods does nothing

@Transactional   // silently ignored
private void recalculateCache() { ... }

The failing scenario: a teammate “cleans up” by making a helper private, the annotation stays, code review approves it, and the method now runs with no transaction at all — no warning, no error.

Why: the proxy works by overriding or delegating your public methods. Private methods can’t be overridden or intercepted from outside, so the annotation is dead weight. Spring won’t stop you writing it; it just won’t do anything.

TrapSymptom in productionFix
Self-invocationPartial batch saved despite an exceptionAnnotate the public entry-point method
Checked exceptionError returned, data committed anywayrollbackFor = Exception.class
Private methodNo transaction at all, silently@Transactional on public methods only

Why money operations demand this

An expense write touches multiple rows that must agree. Either all of it happens or none of it does — that’s the A in ACID (atomicity), which Week 5 put on your schedule and this module makes concrete. A failed request is fine: the client retries. A half-saved expense is poison: every balance computed afterwards is wrong, the error is silent, and you find it weeks later when two friends argue over who owes whom. UPI doesn’t half-transfer; neither does SplitEase.

One error shape for the whole API

Right now, an unknown payer produces whatever Spring’s default error page emits — a different shape than a validation failure, which is different again from a 500. Any client (your future React frontend) would need three parsers and a prayer. Fix it with a small exception hierarchy plus one global handler.

The exceptions — unchecked, on purpose (they trigger rollback by default, and they can fly up to the handler without throws clutter; the same call you made in capstone M5):

public class FriendNotFoundException extends RuntimeException {
    public FriendNotFoundException(Long id) {
        super("No friend with id " + id);
    }
}

public class InvalidExpenseException extends RuntimeException {
    public InvalidExpenseException(String message) {
        super(message);
    }
}

One record defines the single error shape every endpoint will speak:

public record ApiError(Instant timestamp, int status, String error,
                       String message, String path) {}

And @RestControllerAdvice — a controller-shaped bean whose handlers apply to every controller in the app:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(FriendNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError friendNotFound(FriendNotFoundException ex, HttpServletRequest req) {
        return new ApiError(Instant.now(), 404, "FRIEND_NOT_FOUND",
                ex.getMessage(), req.getRequestURI());
    }

    @ExceptionHandler(InvalidExpenseException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError invalidExpense(InvalidExpenseException ex, HttpServletRequest req) {
        return new ApiError(Instant.now(), 400, "INVALID_EXPENSE",
                ex.getMessage(), req.getRequestURI());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError unexpected(Exception ex, HttpServletRequest req) {
        // never leak internals — log the real exception, return a generic message
        return new ApiError(Instant.now(), 500, "INTERNAL_ERROR",
                "Something went wrong on our side", req.getRequestURI());
    }
}

Every error the API can produce now looks like this:

{
  "timestamp": "2026-06-12T18:30:00Z",
  "status": 404,
  "error": "FRIEND_NOT_FOUND",
  "message": "No friend with id 42",
  "path": "/api/v1/expenses"
}

Map exceptions to status codes deliberately — each pairing is a sentence you should be able to say out loud:

ExceptionStatusThe sentence
FriendNotFoundException404You referenced a thing that does not exist
InvalidExpenseException400The request itself is wrong — fix it and retry
MethodArgumentNotValidException (module 05’s validation)400Same family — bad input
Anything unhandled500Our bug, not yours — and we tell you nothing else

The 500 case is a security rule, not a style choice: stack traces in responses hand attackers your library versions and table names.

Check The Concept

How This Shows Up At Work

  • The half-imported batch incident. A nightly job imports expenses, fails on row 400 of 1000, and the first 399 are committed because the @Transactional was on a method called via this.. Support tickets say “totals are wrong”; the on-call engineer who knows the proxy model finds it in minutes. The one who thinks the annotation “just works” burns a day.
  • The code review comment you’ll receive: “This existence check belongs in the service, not the controller — the upcoming bulk endpoint will bypass it.” Now you know why before your first review.
  • The interview question: “What does @Transactional actually do?” Most candidates say “makes it transactional.” You say: proxy, begin, invoke, commit-or-rollback, default rollback only on unchecked exceptions, and the three traps. That answer changes the interview’s temperature.
  • The client integration bug. A mobile team hardcodes against your error JSON. One endpoint returns Spring’s default error shape instead, their parser throws, and their crash becomes your ticket. One @RestControllerAdvice is the difference.

Build This

Where you are after module 05: splitease-api has Friend and Expense entities on PostgreSQL, repositories, and controllers that accept validated request records — but the controllers call repositories directly, no business rules exist beyond field shapes, and errors are whatever Spring defaults to. This module adds: LedgerService with the ported capstone math, transactions, and the global error handler.

  1. Create the exceptions. New package exception, two classes — type them from the Lesson: FriendNotFoundException, InvalidExpenseException.

  2. Create ApiError (record) and GlobalExceptionHandler (@RestControllerAdvice) from the Lesson, in a package error. Add the three handlers: 404, 400, and the generic 500.

  3. Create LedgerService and the Settlement record from the Lesson. Before typing the math, open your capstone’s Ledger.java next to it — port, don’t reinvent. Constructor injection only, as always.

  4. Refactor ExpenseController to go through the service — it should shrink:

@RestController
@RequestMapping("/api/v1/expenses")
public class ExpenseController {

    private final LedgerService ledger;

    public ExpenseController(LedgerService ledger) {
        this.ledger = ledger;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ExpenseResponse create(@Valid @RequestBody CreateExpenseRequest request) {
        return ExpenseResponse.from(ledger.createExpense(request));
    }
}
  1. Add a BalanceController with GET /api/v1/balances returning ledger.balances(). (settleUp() waits in the service — module 09 gives it an endpoint.)

  2. Run and prove it:

./mvnw spring-boot:run
curl -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{"name":"Asha"}'
curl -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{"name":"Rohit"}'
curl -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{"name":"Darshan"}'
curl -X POST http://localhost:8080/api/v1/expenses -H "Content-Type: application/json" -d '{"amountPaise":300000,"description":"dinner","payerId":1,"sharerIds":[1,2,3]}'
curl http://localhost:8080/api/v1/balances

Expected: {"Asha":200000,"Rohit":-100000,"Darshan":-100000} — and the values sum to zero, your capstone invariant, now live over HTTP. Then the clean failure:

curl -X POST http://localhost:8080/api/v1/expenses -H "Content-Type: application/json" -d '{"amountPaise":50000,"description":"chai","payerId":999,"sharerIds":[1]}'

Expected: a 404 with your exact ApiError shape — "error":"FRIEND_NOT_FOUND", "path":"/api/v1/expenses". No stack trace.

  1. Break it — the checked exception that commits. In createExpense, after expenses.save(...), add if (true) throw new IOException("receipt export died"); and throws IOException up through the controller. POST an expense → 500. Now curl /api/v1/balancesthe expense is in there anyway. The transaction committed through the explosion. Change the annotation to @Transactional(rollbackFor = Exception.class), repeat, and watch balances stay clean. Then remove the sabotage (keep rollbackFor — for money code it’s the right default).

  2. Break it — self-invocation. Add the importTrip method from the Lesson (no annotation) and a temporary POST /api/v1/expenses/import endpoint that accepts a JSON array. Inside createExpense, first line: System.out.println("tx active: " + TransactionSynchronizationManager.isActualTransactionActive());. POST a batch of three where the third has payerId: 999. Console prints tx active: false for every call — the proxy was bypassed — and curl /api/v1/balances shows the first two expenses survived the failure. Now put @Transactional on importTrip, repeat: tx active: true, and after the failure the balances are untouched. All-or-nothing. Delete the import endpoint and the print line when done.

Where to Practice

ResourceWhat to do thereHow long
spring.io/guidesDo the “Managing Transactions” guide — it deliberately breaks a booking halfway to show the rollback45 min
docs.spring.ioSpring Framework reference → Data Access → Declarative Transaction Management — read just the proxy explanation and the rollback rules section30 min
BaeldungSearch “Spring Transactional pitfalls” — free article; check their trap list against the three you reproduced25 min
BaeldungSearch “error handling for REST with Spring” — compare their advice-based approach with your handler20 min

Check Yourself

  1. Controller, service, repository — give each layer’s one-line job, and name one SplitEase rule that can only live in the service.
  2. What does the proxy around a @Transactional method do before your code runs, and what two things can it do after?
  3. By default, which exceptions trigger rollback and which commit? What’s the fix when everything must roll back?
  4. Why does this.createExpense() from inside the same class skip the transaction?
  5. Why is a half-saved expense worse than a cleanly failed request?
  6. Two reasons the SplitEase exceptions extend RuntimeException rather than Exception?
  7. Name the five fields of the ApiError shape and why path earns its place.
  8. Why 404 for an unknown payer but 400 for an empty sharer list?
Answers
  1. Controller parses HTTP in and shapes HTTP out; service decides every business rule; repository stores and fetches. “The payer must exist” can only live in the service — it requires a database lookup plus a decision, and it must hold for every caller, not just HTTP.
  2. Before: opens a connection and begins a transaction. After: commits if your method returned normally, rolls back if a runtime exception escaped.
  3. Unchecked (runtime) exceptions roll back; checked exceptions commit. Fix: @Transactional(rollbackFor = Exception.class).
  4. The transaction logic lives in the proxy that wraps the bean, and the proxy only intercepts calls arriving from outside the object. A this. call goes straight to the raw object.
  5. A failed request returns an error and changes nothing — the client retries. A half-saved expense silently corrupts every balance computed after it, and nobody is told.
  6. They roll back the transaction by default (checked ones would commit), and they propagate to the @RestControllerAdvice handler without throws declarations polluting every signature on the way up.
  7. timestamp, status, error, message, path. path tells you which endpoint produced the error when you’re reading logs or a client bug report — without it you’re guessing among every endpoint that can throw that error.
  8. 404 means “the thing you referenced does not exist” — friend 999 is a missing resource. 400 means “your request is malformed or breaks the rules — fix it and retry.” Deliberate mapping is what makes the API debuggable from the client side.

Explain it out loud: Explain to the empty chair what happens between a controller calling ledger.createExpense(...) and the row appearing in PostgreSQL — proxy, begin, your validation, save, commit — then explain the same call ending in FriendNotFoundException: rollback, the exception flying up to the advice, and the 404 JSON going out. If the proxy part is hand-wavy, reread the de-magic section.

Still Unclear?

Spring says @Transactional works via a proxy. Walk me through what the proxy
object actually is, what it looks like in a debugger, and exactly why a
this.method() call bypasses it. Use a tiny plain-Java example of wrapping an
object to add behavior - no Spring code, just the pattern.
Quiz me on transaction rollback rules: give me 6 scenarios (checked exception,
runtime exception, caught-and-swallowed exception, self-invocation, private
method, rollbackFor set) and for each make me predict commit or rollback
before you reveal the answer. Correct my reasoning, not just my answer.
I map FriendNotFoundException to 404 and InvalidExpenseException to 400 in my
expense-splitting API. Challenge my mapping: give me 5 tricky error cases
(expense referencing a deleted friend, negative amount, duplicate request,
database down, malformed JSON) and make me argue the right status code for
each. Push back when my reasoning is weak.

Why AI Can’t Do This For You

AI writes a flawless @Transactional service in seconds. But the importer that half-commits in production doesn’t announce “self-invocation on line 40” — it shows up as wrong totals three weeks later, and the fix requires someone who knows the transaction lives in a proxy your code path never touched. That someone reads the symptom and knows where to look. A prompt can’t, because the prompt doesn’t know your call graph.

The same goes for the error contract. AI will happily generate five endpoints with five error shapes, and each looks fine alone. Seeing that they must be one shape — because a real client parses them with one piece of code — is a system-level judgment about your API, your clients, and what leaks to attackers. That judgment is the job.

Module done? Add it to today’s tracker

Saves your progress on this device.