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
@Transactionaldown to what it really is: a proxy doing begin → your method → commit-or-rollback - Reproduce the three classic
@Transactionaltraps 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.
| Layer | Its one job | What it must NOT do |
|---|---|---|
| Controller | Parse HTTP in, shape HTTP out | Decide anything about money |
| Service | Decide — every business rule lives here | Know that HTTP exists |
| Repository | Store and fetch | Validate, calculate, decide |
The controller parses. The repository stores. The service decides. For SplitEase, “decides” means a concrete rule set:
| Rule | Why it’s a business rule, not a field check |
|---|---|
| The payer must be a real, existing friend | Bean 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 real | An expense shared by nobody corrupts every balance downstream |
| Splits derive from the amount — the server computes shares | If 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 zero | This 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 Ledger | LedgerService | What actually changed |
|---|---|---|
Set<Friend> friends | FriendRepository friends | Storage: memory → PostgreSQL |
List<Expense> expenses | ExpenseRepository expenses | Storage again |
| Balance math over expenses | Identical math | Nothing |
| Greedy settle-up algorithm | Identical algorithm | Nothing |
| Throws on unknown friend | Throws on unknown friend | Only 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.
| Trap | Symptom in production | Fix |
|---|---|---|
| Self-invocation | Partial batch saved despite an exception | Annotate the public entry-point method |
| Checked exception | Error returned, data committed anyway | rollbackFor = Exception.class |
| Private method | No 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:
| Exception | Status | The sentence |
|---|---|---|
FriendNotFoundException | 404 | You referenced a thing that does not exist |
InvalidExpenseException | 400 | The request itself is wrong — fix it and retry |
MethodArgumentNotValidException (module 05’s validation) | 400 | Same family — bad input |
| Anything unhandled | 500 | Our 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
@Transactionalwas on a method called viathis.. 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
@Transactionalactually 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
@RestControllerAdviceis 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.
-
Create the exceptions. New package
exception, two classes — type them from the Lesson:FriendNotFoundException,InvalidExpenseException. -
Create
ApiError(record) andGlobalExceptionHandler(@RestControllerAdvice) from the Lesson, in a packageerror. Add the three handlers: 404, 400, and the generic 500. -
Create
LedgerServiceand theSettlementrecord from the Lesson. Before typing the math, open your capstone’sLedger.javanext to it — port, don’t reinvent. Constructor injection only, as always. -
Refactor
ExpenseControllerto 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));
}
}
-
Add a
BalanceControllerwithGET /api/v1/balancesreturningledger.balances(). (settleUp()waits in the service — module 09 gives it an endpoint.) -
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.
-
Break it — the checked exception that commits. In
createExpense, afterexpenses.save(...), addif (true) throw new IOException("receipt export died");andthrows IOExceptionup through the controller. POST an expense → 500. Nowcurl /api/v1/balances— the 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 (keeprollbackFor— for money code it’s the right default). -
Break it — self-invocation. Add the
importTripmethod from the Lesson (no annotation) and a temporaryPOST /api/v1/expenses/importendpoint that accepts a JSON array. InsidecreateExpense, first line:System.out.println("tx active: " + TransactionSynchronizationManager.isActualTransactionActive());. POST a batch of three where the third haspayerId: 999. Console printstx active: falsefor every call — the proxy was bypassed — andcurl /api/v1/balancesshows the first two expenses survived the failure. Now put@TransactionalonimportTrip, 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
| Resource | What to do there | How long |
|---|---|---|
| spring.io/guides | Do the “Managing Transactions” guide — it deliberately breaks a booking halfway to show the rollback | 45 min |
| docs.spring.io | Spring Framework reference → Data Access → Declarative Transaction Management — read just the proxy explanation and the rollback rules section | 30 min |
| Baeldung | Search “Spring Transactional pitfalls” — free article; check their trap list against the three you reproduced | 25 min |
| Baeldung | Search “error handling for REST with Spring” — compare their advice-based approach with your handler | 20 min |
Check Yourself
- Controller, service, repository — give each layer’s one-line job, and name one SplitEase rule that can only live in the service.
- What does the proxy around a
@Transactionalmethod do before your code runs, and what two things can it do after? - By default, which exceptions trigger rollback and which commit? What’s the fix when everything must roll back?
- Why does
this.createExpense()from inside the same class skip the transaction? - Why is a half-saved expense worse than a cleanly failed request?
- Two reasons the SplitEase exceptions extend
RuntimeExceptionrather thanException? - Name the five fields of the
ApiErrorshape and whypathearns its place. - Why 404 for an unknown payer but 400 for an empty sharer list?
Answers
- 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.
- Before: opens a connection and begins a transaction. After: commits if your method returned normally, rolls back if a runtime exception escaped.
- Unchecked (runtime) exceptions roll back; checked exceptions commit. Fix:
@Transactional(rollbackFor = Exception.class). - 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. - 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.
- They roll back the transaction by default (checked ones would commit), and they propagate to the
@RestControllerAdvicehandler withoutthrowsdeclarations polluting every signature on the way up. timestamp,status,error,message,path.pathtells 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.- 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