Career OS

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.

LayerWhat you haveBuilt 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/balances03, 06, 07
PersistenceFriend, Expense, User entities on PostgreSQL via Spring Data JPA04
BoundaryRequest/response records + Bean Validation; entities never leave the service layer05
Business coreLedgerService — balance math and settle-up ported from the CLI, @Transactional06
ErrorsException hierarchy + @RestControllerAdvice → one ApiError JSON shape everywhere06
SecurityJWT issue + validate, everything locked except auth and health07
TestsUnit tests on the money math, a @WebMvcTest slice, one full integration path08

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:

DecisionThe callWhy
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 GroupAn expense belongs to exactly one group’s ledger
Where does the math change?LedgerService methods gain a groupId parameter and filter by itThe algorithm is untouched — only its input set shrinks
URL shape?Nested: /api/v1/groups/{groupId}/expensesThe resource genuinely belongs to the group — nesting says so (module 03)
Business rules?Payer AND all sharers must be members of the groupNew 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

  1. Group entity (id, name, @ManyToMany Set<Friend> members) + GroupRepository.
  2. GroupController: POST /api/v1/groups (create, validated name), GET /api/v1/groups/{id}, POST /api/v1/groups/{id}/members (add a friend by id).
  3. Add @ManyToOne Group group to Expense. Yes, this breaks the old flat /api/v1/expensesmigrate it: expense creation moves to POST /api/v1/groups/{groupId}/expenses. Deleting the old route and updating its tests is the realistic part of this exercise.
  4. LedgerService.balances(groupId) and settle(groupId) filter expenses by group; membership rules enforced before any write, inside the transaction.
  5. 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 long end to end; the display string 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 a POST — 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 ApiError shape with the right status — no stack traces, ever
  • Money is paise as long everywhere; display strings only at the boundary
  • Expense lists are paginated; no endpoint returns an unbounded collection
  • ./mvnw test is 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

  1. Trace one POST /api/v1/groups/1/expenses through every layer — name what each layer does and does not do.
  2. Why does the membership check (payer in group?) have to run inside the @Transactional method rather than in the controller?
  3. What stops a malicious client from POSTing {"id": 999, "balancePaise": 0} and overwriting data?
  4. Your pagination uses LIMIT/OFFSET. What user-visible oddity appears when new expenses arrive between page 1 and page 2?
  5. The settle algorithm divides 100000 paise among 3 sharers. Where do the leftover paise go, and where is that decision coded?
  6. A teammate adds a GET /api/v1/groups/{id}/export that returns all expenses unbounded “because exports need everything.” What do you say in review?
  7. Which three annotations in this codebase are proxies in disguise, and what does each proxy add?
  8. Why can your unit tests for the settle math run without Spring, and what design choice from module 01 made that possible?
Answers
  1. 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.
  2. 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.
  3. The request body deserializes into a record that simply has no id or balance fields — mass assignment dies at the boundary. That is the module 05 argument for DTOs in one sentence.
  4. 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.
  5. 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.
  6. 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.
  7. @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.”
  8. LedgerService takes 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

Saves your progress on this device.