Capstone — The SplitEase API
Eight modules ago this project was a Scanner loop in a terminal. Right now it is a secured, validated, tested REST API — but it is not finished. Finished means groups, the settle-up endpoint that was the whole point, pagination that survives real data, and a README that lets a stranger run it. This module is where you stop adding layers and ship the thing.
The Goal
By the end of this module you can:
- Design a new feature (groups) into an existing system without breaking what works — the actual day-job skill
- Expose the settle-up algorithm from the Core Java capstone as a REST endpoint
- Paginate a list endpoint properly and explain why unbounded lists are a production incident waiting
- Ship to a Definition of Done: every endpoint secured, validated, tested, documented
- Demo the full API with a curl walkthrough you can narrate in an interview
What Exists After Module 08
Take inventory before you build. If any row is missing, go back — this module stands on all of them.
| Layer | What you have | Built in |
|---|---|---|
| Endpoints | /api/v1/auth/register, /api/v1/auth/login, /api/v1/friends (POST/GET/GET by id), /api/v1/expenses (POST/GET), /api/v1/balances | 03, 06, 07 |
| Persistence | Friend, Expense, User entities on PostgreSQL via Spring Data JPA | 04 |
| Boundary | Request/response records + Bean Validation; entities never leave the service layer | 05 |
| Business core | LedgerService — balance math and settle-up ported from the CLI, @Transactional | 06 |
| Errors | Exception hierarchy + @RestControllerAdvice → one ApiError JSON shape everywhere | 06 |
| Security | JWT issue + validate, everything locked except auth and health | 07 |
| Tests | Unit tests on the money math, a @WebMvcTest slice, one full integration path | 08 |
Feature 1 — Groups
Design first, exactly like the CLI capstone taught you
Right now every friend and expense lives in one global pot. Real life is the Goa trip and the flat and the office lunch crew — separate ledgers, separate balances. The feature is groups, and the design questions come before any code:
| Decision | The call | Why |
|---|---|---|
| What is a Group? | @Entity with id, name, members (@ManyToMany to Friend) | Same modeling move as Expense↔sharers in module 04 |
| Where do expenses live? | Expense gains a @ManyToOne Group | An expense belongs to exactly one group’s ledger |
| Where does the math change? | LedgerService methods gain a groupId parameter and filter by it | The algorithm is untouched — only its input set shrinks |
| URL shape? | Nested: /api/v1/groups/{groupId}/expenses | The resource genuinely belongs to the group — nesting says so (module 03) |
| Business rules? | Payer AND all sharers must be members of the group | New exception: FriendNotInGroupException → 422, into the advice from module 06 |
flowchart TD
G["Group name plus members"] -->|has many| E["Expense amount payer sharers"]
G -->|many to many| F["Friend"]
E -->|paid by one| F
E -->|split among many| F
Build it
Groupentity (id,name,@ManyToMany Set<Friend> members) +GroupRepository.GroupController:POST /api/v1/groups(create, validated name),GET /api/v1/groups/{id},POST /api/v1/groups/{id}/members(add a friend by id).- Add
@ManyToOne Group grouptoExpense. Yes, this breaks the old flat/api/v1/expenses— migrate it: expense creation moves toPOST /api/v1/groups/{groupId}/expenses. Deleting the old route and updating its tests is the realistic part of this exercise. LedgerService.balances(groupId)andsettle(groupId)filter expenses by group; membership rules enforced before any write, inside the transaction.- Re-run
./mvnw test. The module 08 tests that just broke are not an annoyance — they are the safety net doing its job. Fix them to speak group-language.
Feature 2 — The Settle Endpoint
The minimal-payments algorithm you implemented in the CLI capstone finally gets its REST debut:
GET /api/v1/groups/{groupId}/settle
[
{"from": "Darshan", "to": "Asha", "paise": 100000, "display": "Rs 1000.00"},
{"from": "Rohit", "to": "Asha", "paise": 50000, "display": "Rs 500.00"}
]
Three rules, all of them old friends:
- The algorithm lives in
LedgerService— the controller only translates. If you are tempted to compute anything in the controller, reread module 06. - Money stays paise as
longend to end; thedisplaystring is formatted at the boundary, never stored. - It is a
GET— settling computes who should pay whom, it does not move money. Idempotent, cacheable, safe. (Recording an actual settlement payment would be aPOST— add it as a stretch goal.)
The response DTO is a record, obviously: record SettlementResponse(String from, String to, long paise, String display) {}.
Feature 3 — Pagination
A group three years old has 4,000 expenses. GET .../expenses returning all of them is how mobile apps freeze and DBs cry — week 8 called this the first production pattern. Spring Data makes the fix nearly free:
@GetMapping
public Page<ExpenseResponse> list(
@PathVariable Long groupId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return ledgerService.expenses(groupId, pageable);
}
ExpenseRepository gains Page<Expense> findByGroupId(Long groupId, Pageable pageable) — a derived query (module 04) that paginates in SQL with LIMIT/OFFSET, not in memory. Try ?page=0&size=5, then ?page=1&size=5, and read the totalElements/totalPages metadata Spring wraps around your content.
Feature 4 — Request IDs
One filter, ten lines, and every log line of a request shares an id — week 8’s MDC pattern. Add the RequestIdFilter, set X-Request-Id on responses, and put %X{requestId} in your log pattern. When a future bug report says “creating an expense failed around 9pm,” you will grep one id instead of reading an evening of logs. Do it now while the codebase is small enough to feel the benefit immediately.
The Definition of Done
Ship means all of these, checked honestly:
- Every endpoint requires a JWT except
/api/v1/auth/**and/actuator/health - Every request body is a validated record — no entity crosses the boundary in either direction
- Every business rule failure returns the
ApiErrorshape with the right status — no stack traces, ever - Money is paise as
longeverywhere;displaystrings only at the boundary - Expense lists are paginated; no endpoint returns an unbounded collection
-
./mvnw testis green: money-math unit tests, controller slice, one full register→settle integration path - README: what it is, how to run it (
./mvnw spring-boot:run+ DB setup), the full endpoint table, key design decisions (paise-not-double, DTOs-not-entities, settle-is-GET) - The curl walkthrough below runs top to bottom against a fresh start
The Full Walkthrough
Narrate this out loud as you run it — this is your interview demo:
# 1. Who am I
curl -X POST localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"darshan@example.com","password":"Sprint@12"}'
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"darshan@example.com","password":"Sprint@12"}' | jq -r .token)
# 2. The cast
curl -X POST localhost:8080/api/v1/friends -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Asha"}'
# ...repeat for Rohit and Darshan (ids 1, 2, 3)
# 3. The trip
curl -X POST localhost:8080/api/v1/groups -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Goa"}'
curl -X POST localhost:8080/api/v1/groups/1/members -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"friendId":1}'
# ...add friends 2 and 3
# 4. The money — 3000 rupees dinner, Asha paid, split three ways
curl -X POST localhost:8080/api/v1/groups/1/expenses -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"dinner","paise":300000,"paidById":1,"sharerIds":[1,2,3]}'
# 5. The answer
curl localhost:8080/api/v1/groups/1/balances -H "Authorization: Bearer $TOKEN"
curl localhost:8080/api/v1/groups/1/settle -H "Authorization: Bearer $TOKEN"
# → Rohit pays Asha Rs 1000.00, Darshan pays Asha Rs 1000.00
# 6. The proofs
curl localhost:8080/api/v1/groups/1/expenses -H "Authorization: Bearer $TOKEN" # 401 without the header
curl -X POST localhost:8080/api/v1/groups/1/expenses -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"description":"cab","paise":-500}' # 400, validation
curl "localhost:8080/api/v1/groups/1/expenses?page=0&size=5" -H "Authorization: Bearer $TOKEN" # paginated
What Month 3 Does With This
This API is the artifact the rest of the sprint packages:
- The Docker track puts it in Docker (the capstone containerizes exactly this app) and the Cloud track deploys it.
- Week 10 wraps it in CI/CD — your module 08 tests are what the pipeline runs on every push.
- Week 12 turns it into resume bullets — “Built a REST API handling expense splitting with paise-precision money math, JWT auth with token expiry, paginated endpoints, and a tested settle-up algorithm” is not a claim, it is a description of this repo.
Check The Concept
Check Yourself
- Trace one
POST /api/v1/groups/1/expensesthrough every layer — name what each layer does and does not do. - Why does the membership check (payer in group?) have to run inside the
@Transactionalmethod rather than in the controller? - What stops a malicious client from POSTing
{"id": 999, "balancePaise": 0}and overwriting data? - Your pagination uses LIMIT/OFFSET. What user-visible oddity appears when new expenses arrive between page 1 and page 2?
- The settle algorithm divides 100000 paise among 3 sharers. Where do the leftover paise go, and where is that decision coded?
- A teammate adds a
GET /api/v1/groups/{id}/exportthat returns all expenses unbounded “because exports need everything.” What do you say in review? - Which three annotations in this codebase are proxies in disguise, and what does each proxy add?
- Why can your unit tests for the settle math run without Spring, and what design choice from module 01 made that possible?
Answers
- Filter chain authenticates the JWT; DispatcherServlet routes; controller deserializes +
@Valid-checks the record and translates to a service call; service enforces membership rules and runs the math inside a transaction; repository emits SQL; the entity result is mapped back to a response record; Jackson serializes; 201 goes out. No layer does another layer’s job. - The check and the write must be atomic. Checked in the controller, a concurrent membership removal could land between check and save — the transaction makes check-plus-write one unit against one consistent view.
- The request body deserializes into a record that simply has no
idor balance fields — mass assignment dies at the boundary. That is the module 05 argument for DTOs in one sentence. - Page drift: an expense inserted at the top shifts everything down, so page 2 can repeat the last item of page 1. Cursor pagination is the fix; knowing the trade-off is the interview answer.
- The remainder goes to the payer’s share — decided in
LedgerService, in the same method that computes the split, with a test pinning it down. A money rule lives in exactly one place. - Exports need everything eventually, not in one response. Offer pagination, a streamed download, or an async job — and point at the group with 4,000 expenses that would OOM the export.
@Transactional(begin/commit/rollback around the call),@RestController-mapped methods via the DispatcherServlet machinery (HTTP translation), and the repository interface (Spring Data generates the implementation). All three are “your code wrapped in generated code.”LedgerServicetakes its repository through its constructor, so the test hands it an in-memory fake. Constructor injection — banned field@Autowired— was never about style; it was about this exact moment.
Explain it out loud: Give the five-minute demo: run the curl walkthrough and narrate what happens inside the app at each call — which filter, which layer, which SQL. If you can do that without notes, you are interview-ready on this project.
Why AI Can’t Do This For You
AI can generate every file in this project — controller, service, entity, test — each one plausibly correct in isolation. What it cannot do is hold the system in its head: that the settle response’s display string must never touch the entity, that the membership check belongs inside the transaction, that six failing tests after the groups refactor are good news. Those are system-level judgments, and you just spent nine modules earning them.
That is also the honest pitch for your interviews: not “I wrote this code” but “I can defend every design decision in it.” The first claim is worthless in 2026. The second is the job.
Module done? Add it to today’s tracker