Back to blog
javaspringbackendpostgresql

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.

TS
Tharun Sai Putta
March 1, 2024
7 min read

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:

Quiz (1) ──── (many) Question (1) ──── (many) Option
  │
  └──── (many) QuizSession (1) ──── (1) Score
@Entity
@Table(name = "quizzes")
public class Quiz {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @NotBlank(message = "Quiz title is required")
    @Size(max = 200)
    private String title;
 
    private String description;
 
    @Enumerated(EnumType.STRING)
    private QuizStatus status = QuizStatus.DRAFT; // DRAFT, PUBLISHED, ARCHIVED
 
    @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Question> questions = new ArrayList<>();
 
    @Column(name = "time_limit_minutes")
    private Integer timeLimitMinutes;
 
    @CreationTimestamp
    private LocalDateTime createdAt;
}
 
@Entity
@Table(name = "questions")
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "quiz_id", nullable = false)
    private Quiz quiz;
 
    @NotBlank
    @Column(columnDefinition = "TEXT")
    private String questionText;
 
    @Enumerated(EnumType.STRING)
    private QuestionType type = QuestionType.SINGLE_CHOICE;
 
    @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Option> options = new ArrayList<>();
 
    private Integer points = 1;
    private Integer orderIndex;
}

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:

MethodPathDescription
POST/api/quizzesCreate a new quiz
GET/api/quizzesList 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}/questionsAdd a question to a quiz
PUT/api/questions/{id}Update a question
DELETE/api/questions/{id}Remove a question
POST/api/sessionsStart a quiz session
POST/api/sessions/{id}/submitSubmit answers for a session
GET/api/sessions/{id}/resultGet score for a completed session
GET/api/quizzes/{id}/leaderboardTop scores for a quiz

The controller layer stayed thin — request parsing, validation, delegation to the service, response mapping:

@RestController
@RequestMapping("/api/quizzes")
@Validated
public class QuizController {
 
    private final QuizService quizService;
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public QuizResponseDto createQuiz(@Valid @RequestBody CreateQuizRequest request) {
        return quizService.createQuiz(request);
    }
 
    @PostMapping("/{quizId}/questions")
    @ResponseStatus(HttpStatus.CREATED)
    public QuestionResponseDto addQuestion(
            @PathVariable Long quizId,
            @Valid @RequestBody CreateQuestionRequest request) {
        return quizService.addQuestion(quizId, request);
    }
 
    @GetMapping("/{id}")
    public QuizResponseDto getQuiz(@PathVariable Long id) {
        return quizService.getQuizById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Quiz not found: " + id));
    }
}

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:

@Repository
public interface QuizRepository extends JpaRepository<Quiz, Long> {
 
    @Query("SELECT q FROM Quiz q LEFT JOIN FETCH q.questions WHERE q.status = :status")
    List<Quiz> findByStatusWithQuestions(@Param("status") QuizStatus status);
}

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:

// Inside the Quiz entity
public void addQuestion(Question question) {
    questions.add(question);
    question.setQuiz(this);
}
 
public void removeQuestion(Question question) {
    questions.remove(question);
    question.setQuiz(null);
}

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:

@Transactional
public ScoreResult submitSession(Long sessionId, List<AnswerSubmission> answers) {
    QuizSession session = sessionRepository.findById(sessionId)
        .orElseThrow(() -> new ResourceNotFoundException("Session not found"));
 
    if (session.getStatus() != SessionStatus.IN_PROGRESS) {
        throw new IllegalStateException("Session already completed");
    }
 
    int totalPoints = 0;
    int earnedPoints = 0;
 
    for (Question question : session.getQuiz().getQuestions()) {
        totalPoints += question.getPoints();
 
        Set<Long> correctOptionIds = question.getOptions().stream()
            .filter(Option::isCorrect)
            .map(Option::getId)
            .collect(Collectors.toSet());
 
        Set<Long> submittedOptionIds = answers.stream()
            .filter(a -> a.getQuestionId().equals(question.getId()))
            .flatMap(a -> a.getSelectedOptionIds().stream())
            .collect(Collectors.toSet());
 
        if (correctOptionIds.equals(submittedOptionIds)) {
            earnedPoints += question.getPoints();
        }
    }
 
    double percentage = totalPoints > 0 ? (earnedPoints * 100.0 / totalPoints) : 0;
 
    Score score = new Score(session, earnedPoints, totalPoints, percentage);
    scoreRepository.save(score);
 
    session.setStatus(SessionStatus.COMPLETED);
    session.setCompletedAt(LocalDateTime.now());
    sessionRepository.save(session);
 
    return new ScoreResult(earnedPoints, totalPoints, percentage);
}

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:

  1. POST /api/quizzes — create quiz, capture the returned id
  2. POST /api/quizzes/{id}/questions — add 5–10 questions with options
  3. PUT /api/quizzes/{id} — set status to PUBLISHED
  4. POST /api/sessions — start a session, capture sessionId
  5. POST /api/sessions/{sessionId}/submit — submit answers
  6. GET /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:

@ControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_ERROR", message));
    }
}

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.

TS

Tharun Sai Putta

Product Engineer @ Protectt.ai

Building Android security SDKs, IDE plugins, and cross-platform tooling. IIITDM Kancheepuram CSE alumnus.