Building a Quiz Management REST API With Spring Boot
A backend-first approach to building a quiz platform using Spring Boot, PostgreSQL, and REST APIs — including quiz creation, question banks, session management, and score tracking.
title: "Building a Quiz Management REST API With Spring Boot" date: "2024-03-01" description: "A backend-first approach to building a quiz platform using Spring Boot, PostgreSQL, and REST APIs — including quiz creation, question banks, session management, and score tracking." tags: ["java", "spring", "backend", "postgresql"] published: true image: ""
Most of the time when someone builds a "quiz app," they start with a frontend and bolt on an API later. I did the opposite — and deliberately. This project was a backend-only exercise: no HTML, no JavaScript, no templates. Just REST endpoints, a PostgreSQL database, and Postman.
The goal was to get comfortable with Spring Boot's ecosystem — JPA, Bean Validation, transaction management — without the distraction of making it look nice.
Why Backend-First?
There's a tendency in student projects to have the UI drive architectural decisions. You end up with endpoints that match exactly what one particular screen needs, which usually means you've tightly coupled your API to a frontend that may change completely.
Designing the API first forces you to think in terms of resources and operations, not screens and buttons. What are the entities? What operations do users need to perform? What are the consistency requirements? These are the right questions to answer before writing a single @GetMapping.
It also meant I could move fast. Testing a REST API with Postman is significantly faster than building a UI just to test whether data is being saved correctly.
Domain Model
The core entities and their relationships:
I added orderIndex on Question after realising that database insertion order is not the same as display order and I didn't want to sort by ID.
API Design
The endpoints followed a conventional REST structure. Here's the full surface area:
| Method | Path | Description |
|---|---|---|
POST | /api/quizzes | Create a new quiz |
GET | /api/quizzes | List all published quizzes |
GET | /api/quizzes/{id} | Get quiz by ID |
PUT | /api/quizzes/{id} | Update quiz metadata |
DELETE | /api/quizzes/{id} | Delete quiz |
POST | /api/quizzes/{id}/questions | Add a question to a quiz |
PUT | /api/questions/{id} | Update a question |
DELETE | /api/questions/{id} | Remove a question |
POST | /api/sessions | Start a quiz session |
POST | /api/sessions/{id}/submit | Submit answers for a session |
GET | /api/sessions/{id}/result | Get score for a completed session |
GET | /api/quizzes/{id}/leaderboard | Top scores for a quiz |
The controller layer stayed thin — request parsing, validation, delegation to the service, response mapping:
I used separate DTO classes for requests and responses rather than exposing JPA entities directly. This is a habit I'd recommend from the start — it gives you control over what gets serialized and prevents Jackson from triggering lazy-load exceptions at inconvenient moments.
JPA Gotchas
Two issues burned me.
The N+1 problem. When fetching a list of quizzes and accessing each quiz's question count, JPA was firing one query per quiz. The fix was a @Query with a JOIN FETCH or using @EntityGraph:
This collapsed the N+1 into a single JOIN query. The tradeoff is that for quizzes with many questions, you're loading everything eagerly — acceptable here because quizzes aren't enormous.
Bidirectional relationship management. With @OneToMany / @ManyToOne bidirectional relationships, you have to maintain both sides of the association in code. I added helper methods on the parent entity:
If you forget to call question.setQuiz(this) when adding, the foreign key column stays null and the insert fails with a confusing constraint violation error.
Scoring Logic
When a session is submitted, the service iterates over the submitted answers, checks each against the correct options, and accumulates points:
One deliberate design choice: scoring only awards full points per question, not partial. For a multiple-correct-answer question, you get the points only if your selected set exactly matches the correct set. I considered partial scoring but it adds complexity without much value for this use case.
Testing the API
All testing was manual via Postman, which I organised into a collection with environment variables for the base URL and session IDs. A typical test flow:
POST /api/quizzes— create quiz, capture the returnedidPOST /api/quizzes/{id}/questions— add 5–10 questions with optionsPUT /api/quizzes/{id}— set status toPUBLISHEDPOST /api/sessions— start a session, capturesessionIdPOST /api/sessions/{sessionId}/submit— submit answersGET /api/sessions/{sessionId}/result— verify score
The whole collection ran in about 30 seconds once I set up the environment variables. I added a few negative test cases too — submitting to a non-existent session, creating a quiz with a blank title — to verify that Bean Validation and the exception handler were returning proper 400/404 responses.
Writing a Postman collection for your own API is underrated. It forces you to use your own API as a client would, and you quickly find endpoints that are awkward to call or return unhelpful error messages.
Reflections
Building backend-first gave me a much cleaner domain model than I would have had otherwise. The entity relationships felt natural because I thought about them in terms of the data, not in terms of form submissions.
The thing I underestimated was error handling. A @ControllerAdvice global exception handler is not optional — it's what separates an API that's usable from one that leaks stack traces to clients. I added one early and it made the development experience significantly better:
If I were building this for production, I'd add Spring Security for authentication, rate limiting on the session endpoints, and proper integration tests with @SpringBootTest. But as an exercise in designing a REST API from scratch, it did exactly what I needed it to do.