Terug naar projects
06 jun 2025
15 min

TriviJava: Een Quiz App bouwen met Java

Een eenvoudige quizapplicatie bouwen met Java Spring Boot, waarbij trivia-vragen van een externe API worden opgehaald en antwoorden veilig worden verwerkt zonder database.

Inleiding

Het is tijd voor iets nieuws. Inmiddels heb ik genoeg ervaring met het bouwen van .NET API’s dat ik het zo uit mijn mouw kan schudden. Daarom ga ik mezelf uitdagen door het eens een keer op een andere manier te doen: met Java Sprint Boot. Waarschijnlijk komt er nu een heleboel weerstand in je omhoog als je geen Java-developer bent, omdat online de consensus is dat Java outdated is en geen goede Developer Experience biedt. Toch heb ik ook gelezen dat sommige Java Developers juist zeggen dat ze niets anders willen. Daarom ben ik benieuwd hoe het nu daadwerkelijk is en dus ga ik het uitproberen.

Ik verwacht dat een groot deel overeenkomt met de traditionele manier waarop .NET het doet: controllers, services, repositories en een database. Tenminste, als je de package by layer structuur volgt. Zelf geef ik de voorkeur aan de package by feature structure (vertical slices), maar omdat ik wil ervaren hoe Sprint Boot in het bedrijfsleven wordt gebruikt, doe ik het nu op de package by layer manier.

Persoonlijk leer ik het snelst als ik zie hoe een deskundige het gebruikt en het kan relateren aan wat ik al weet. Dus om een eerste indruk te krijgen van hoe Sprint Boot in elkaar zit, ging ik eerst maar eens op zoek naar wat voorbeelden. Daarbij heb ik de video Spring Boot CRUD Tutorial: Building a Book Management Application bekeken.

Tip: versnel de video snelheid van YouTube tutorials om sneller te leren en spoel ook gerust door als het je al bekend voorkomt. Deze video bekeek ik op snelheid 3x, waardoor ik snel doorkreeg hoe een CRUD API gemaakt kan worden met Sprint Boot.

Daarna heb ik blog artikelen gelezen waarin de auteur de Java-technologieën vergelijkt met .NET-technologieën.

Zodoende was ik in een kwartiertje op de hoogte. Tijd om de proef op de som te stellen…

Wat ga ik eigenlijk bouwen? Ik kwam een opdracht tegen om een web app met API te bouwen waarin je een quiz kunt doen met Trivia vragen.

Opdracht

Ontwikkel een eenvoudige webapplicatie waarmee gebruikers trivia-vragen kunnen beantwoorden. De vragen worden opgehaald via de Open Trivia Database API (OpenTDB API), die antwoorden in JSON-formaat retourneert.

Belangrijke aandachtspunten:

  • De ruwe API-respons bevat zowel de vraag als het correcte antwoord, wat betekent dat een gebruiker met technische kennis gemakkelijk de juiste antwoorden kan achterhalen.
  • De applicatie moet zodanig worden opgezet dat deze informatie niet eenvoudig te achterhalen is via de browserontwikkeltools.
  • De interface moet simpel zijn en de gebruiker gemakkelijk door de quiz leiden.

Vereisten:

  • Gebruik van Java Sprint Boot voor de backend-implementatie.
  • Ophalen van trivia-vragen van de externe Open Trivia Database API.
  • Basis frontend om interactie met de gebruiker mogelijk te maken.
  • Implementatie van logica om het correcte antwoord te verbergen totdat de gebruiker antwoordt.
  • Een database is niet vereist
  • Gehoste versie is optioneel

Hoog tijd om aan de slag te gaan.

Voorbereiding

Eerst ga ik mezelf trakteren op nóg een JetBrains applicatie: IntelliJ IDEA. De IDE’s van Jetbrains vind ik heerlijk werken. Nadat deze geïnstalleerd is maak ik een nieuw project aan. Ik kies voor een nieuw Java Sprint Boot project met Maven en met de Spring Web dependency. Het project krijgt de naam TriviJava (Trivia + Java). Erg origineel, I know.

Wat heb ik eigenlijk allemaal nodig? Ik bedenk een ruw stappenplan:

  1. Maak een controller met twee endpoints:
    • GET /api/v1/quiz/questions
    • POST /api/v1/quiz/answers
  2. Maak een QuizService met de volgende functionaliteiten:
    • Ophalen van Trivia vragen bij de externe API
    • Controleren van gegeven antwoorden
  3. Bouw een web app waarop de gebruiker een quiz kan starten, de vragen kan beantwoorden en antwoorden kan controleren.

Er mogen geen antwoorden naar de client worden gestuurd. Dat betekent dat de backend de gegeven antwoorden zal moeten valideren. De externe API genereert random vragen. Dus om later te kunnen valideren of de gegeven antwoorden correct zijn, moet in de backend worden opgeslagen wat het juiste antwoord is op de vraag. Aangezien een database geen vereiste is, houd ik het voor nu simpelweg bij een Map<UUID, UUID> waarbij de key een zelfgegenereerd questionId en de value een zelfgegenereerd answerId is. Het is in dit scenario niet nodig om andere dingen op te slaan. Anders zou je natuurlijk ook andere data in de Map op kunnen slaan.

Het lijkt allemaal makkelijk te doen, gewoon een standaard GET request met DTOs, een controller, een service, een repository en een ApiClient. Maar toen opende ik de website van OpenTDB. Daar bleek dat de OpenTDB API een geheel eigen conventie gebruikt met tokens en response_codes en een rate limiting heeft van 1 request per 5 seconden. Dat was meteen weer een les voor mij om me aan de HTTP en REST standaarden te houden. Enfin, het werd dus iets meer werk doordat moest worden omgegaan met token management, de verschillende response codes en sterke rate limiting.

Een voorbeeld response:

{
  "response_code": 0,
  "results": [
    {
      "type": "multiple",
      "difficulty": "medium",
      "category": "Entertainment: Books",
      "question": "According to The Hitchhiker&#039;s Guide to the Galaxy book, the answer to life, the universe and everything else is...",
      "correct_answer": "42",
      "incorrect_answers": [
        "Loving everyone around you",
        "Chocolate",
        "Death"
      ]
    }
  ]
}

Je wilt als gebruiker natuurlijk niet steeds dezelfde vragen krijgen. Daarom heeft OpenTDB een tokensysteem opgezet. Je kunt eerst een token ophalen door een request te doen. Vervolgens kun je deze token steeds meesturen en krijg je gegarandeerd unieke vragen, totdat alle vragen (>20.000) aan de beurt geweest zijn. Op dat moment krijg je een bepaalde response code en kun je de token weer resetten. Na 6 uur inactiviteit, wordt de token ongeldig. Dan kan het dus zijn dat je ook weer een bepaalde response code terugkrijgt, waarna je een nieuwe token moet ophalen. Ook geldt een zware rate limiting van 1 request per 5 seconden. De TriviaApiClient moet hier dus allemaal netjes mee omgaan.

API

Ik begin eerst maar met de controller. Er zijn dus twee endpoints nodig.

@RestController()
@RequestMapping("api/v1/quiz")
public class QuizController {

    private final QuestionService questionService;

    public QuizController(QuestionService questionService) {
        this.questionService = questionService;
    }

    @GetMapping("questions")
    public ResponseEntity<ListQuestionResponse> listQuestions(
            @RequestHeader("X-Session-Id") @NotEmpty String sessionId,
            @RequestParam(defaultValue = "10") @Min(1) @Max(50) int amount
    ) {
        var questions = questionService.getQuestions(sessionId, amount);
        var response = ListQuestionResponse.fromDomain(sessionId, questions);
        return ResponseEntity.ok(response);
    }

    @PostMapping("answers")
    public ResponseEntity<CheckAnswersResponse> checkAnswers(@RequestBody CheckAnswersRequest request) {
        var answeredQuestions = request.toDomain();
        var results = questionService.checkAnswers(answeredQuestions);
        var response = CheckAnswersResponse.fromDomain(results);
        return ResponseEntity.ok(response);
    }
}

Sprint Boot heeft allerlei annotaties die je kunt toewijzen aan bijvoorbeeld een class of method. Zo wordt @RestController gebruikt om aan te geven dat het een controller is met REST endpoints. @RequestMapping kan worden gebruikt om een route toe te wijzen aan de gehele class. Met @GetMapping kan een route worden aangegeven waarnaar een GET request kan worden gedaan. Hetzelfde geldt voor andere HTTP methoden. Ook zijn er allerlei gemakkelijke notaties om aan te geven waar de endpoint ‘variabelen’ vandaan moet halen, zoals een header, body of query param. Validatie kan ook aangegeven worden.

Wat ik ook heel mooi vind aan Sprint Boot is het Dependency Injection systeem om het IoC-principe toe te passen. In .NET was ik gewend dit zelf te moeten toevoegen aan de DI-container, maar Sprint Boot doet dat wederom met annotaties. Heel convenient.

In dit geval zijn er dus twee endpoints, die beiden een ResponseEntity returnen. Om vragen op te halen is een OpenTDB token nodig. Elke gebruiker zal al andere vragen hebben gehad, dus is het wel zo handig om bij te houden welke token bij de gebruiker hoort. Dit is gedaan door middel van een X-Session-Id. De front-end zal deze mee moeten sturen om de gebruiker te identificeren. In de TokenRepository wordt deze gekoppeld aan een OpenTDB token.

Persoonlijk vind ik dat een controller maar weinig dingen mag doen: request validatie, DTO mapping, logica aansturen en response met resultaat terugsturen. Andere logica hoort daar niet thuis. Daarom heb ik zowel request als response DTOs gemaakt. Elke request DTO heeft methoden om zich om te zetten naar domain models. Deze kunnen naar een service worden gestuurd om iets mee te doen. Nadat dit is gebeurd, wordt het resultaat omgezet in een response DTO en weer teruggestuurd. Op deze manier blijft de controller overzichtelijk en is de logica goed gescheiden.

public record CheckAnswersRequest(@NotEmpty List<Answer> answers) {
    record Answer(UUID questionId, UUID answerId) {}

    public List<AnsweredQuestion> toDomain() {
        return answers.stream()
                .map(a -> new AnsweredQuestion(a.questionId(), a.answerId()))
                .toList();
    }

    public static CheckAnswersRequest fromDomain(List<AnsweredQuestion> answeredQuestions) {
        var answers = answeredQuestions.stream()
                .map(q -> new Answer(q.questionId(), q.answerId()))
                .toList();
        return new CheckAnswersRequest(answers);
    }
}

Voor DTOs gebruik ik records, vanwege immutability en automatisch gegenereerde methoden voor de fields. Ik was verbaasd toen ik zag dat deze bestonden (in .NET ben ik ze namelijk gewend). Door allerlei reacties online te lezen, had ik namelijk nogal een outdated indruk gekregen van Java. Maar hoe meer ik bezig was met Java, hoe mooier ik de taal begon te vinden.

De logica voor het verkrijgen van de vragen en checken van de antwoorden bevindt zich in de QuestionService.

@Service
public class QuestionService {

    private static final Logger logger = LoggerFactory.getLogger(QuestionService.class);
    private final QuestionRepository questionRepository;
    private final TokenRepository tokenRepository;
    private final TriviaApiClient triviaClient;

    public QuestionService(QuestionRepository questionRepository, TokenRepository tokenRepository, TriviaApiClient triviaClient) {
        this.questionRepository = questionRepository;
        this.tokenRepository = tokenRepository;
        this.triviaClient = triviaClient;
    }

    public List<Question> getQuestions(String sessionId, int amount) {
        var token = tokenRepository.findToken(sessionId)
                .orElseGet(() -> {
                    var newToken = triviaClient.requestNewToken();
                    tokenRepository.saveToken(sessionId, newToken);
                    return newToken;
                });

        try {
            return getQuestionsAndSaveCorrectAnswer(token, amount);
        } catch (TokenNotFoundTriviaApiException ex) {
            logger.warn("Token not found. Requesting new token...");
            var newToken = triviaClient.requestNewToken();
            tokenRepository.saveToken(sessionId, newToken);
            return getQuestionsAndSaveCorrectAnswer(newToken, amount);
        } catch (TokenExhaustedTriviaApiException ex) {
            logger.info("Token exhausted. Resetting token...");
            var resetToken = triviaClient.resetToken(token);
            return getQuestionsAndSaveCorrectAnswer(resetToken, amount);
        }
    }

    public List<Question> getQuestionsAndSaveCorrectAnswer(String token, int amount) {
        var questions = triviaClient.fetchQuestions(token, amount);
        questions.forEach(question -> questionRepository.storeCorrectAnswer(question.id(), question.correctAnswer().id()));
        return questions;
    }

    public List<AnswerResult> checkAnswers(List<AnsweredQuestion> answeredQuestions) {
        return answeredQuestions.stream()
                .map(q -> {
                    var correctId = questionRepository.getCorrectAnswer(q.questionId());
                    return correctId.map(uuid -> new AnswerResult(q.questionId(), q.answerId(), uuid)).orElse(null);
                })
                .filter(Objects::nonNull)
                .toList();
    }
}

Zoals gezegd wordt voor het ophalen van de vragen een bestaande token gebruikt of eerst een token opgehaald, waarbij een verlopen token of invalide token wordt afgevangen. De interactie met de OpenTDB API gebeurt via de TriviaApiClient. Wat ik hier opmerkte, is dat Java Optional types heeft. Dat vind ik een mooie feature. Bij C# kun je een vraagteken achter je object type zetten (bijv. string? Example) om nullability aan te geven. Dat voelt alsof het er maar even als lijm is bij geplakt. Een Optional type met allerlei handige methoden erbij, zoals orElseGet voelt als een complete feature (al is er een proposal voor C# om Discriminated Unions te implementeren).

Na het ophalen van de vragen wordt de answerId van het correcte antwoord gekoppeld aan de questionId in de questionRepository (voor nu in een Map).

@Repository
public class QuestionRepositoryImpl implements QuestionRepository {

    private final Map<UUID, UUID> correctAnswersByQuestionId = new HashMap<>();

    @Override
    public void storeCorrectAnswer(UUID questionId, UUID correctAnswerId) {
        correctAnswersByQuestionId.put(questionId, correctAnswerId);
    }

    @Override
    public Optional<UUID> getCorrectAnswer(UUID questionId) {
        return Optional.ofNullable(correctAnswersByQuestionId.get(questionId));
    }

}

Het checken van de antwoorden wordt gedaan door voor elk gegeven antwoord de answerId te vergelijken met de opgeslagen answerId bij de betreffende questionId in de QuestionRepository. Daarbij heb ik gebruik gemaakt van ‘functional’ programmeren, door een stream te mappen en filteren. Deze manier van programmeren vind ik persoonlijk heel erg overzichtelijk en duidelijk. De methodes geven aan wat je met de data doet, in tegenstelling tot loops waar iedereen zijn eigen structuur aan kan geven. Ik ben dan ook een groot fan van LINQ (method syntax) in C#. Supertof dat Java iets vergelijkbaars ondersteunt.

De API functioneert nu. Er is een aantal verbeteringen mogelijk, zoals een global exception handler configureren en rate limiting toevoegen, maar daar ga ik me nu even niet mee bezig houden. Wel heb ik unit tests geschreven en ook dat werkte vlekkeloos.

Web

Om een quiz te maken, is een eenvoudige webapplicatie nodig. Aangezien dit een kleine opdracht is, kan het voor nu prima met pure HTML, CSS en JS, zonder front-end frameworks. Om het simpel te houden voeg ik src/main/resources/static/index.html toe aan het project. Sprint Boot maakt deze dan automatisch beschikbaar op “{base_url}/path”. index.html is een speciale naam, die automatisch wordt geladen, ook zonder path. Dus deze is bereikbaar op http://localhost:8080 (als je het project hebt runnen natuurlijk ;)).

Als je de pagina laadt, krijg je een laadanimatie te zien terwijl de vragen worden opgehaald. Daarna zie je de eerste vraag waarop je een antwoord kunt selecteren. eerste vraag

Als je alle vragen hebt beantwoord, kun je de resultaten insturen en de antwoorden bekijken.

resultaat

Daarna kun je een nieuwe quiz starten. Er wordt een unieke X-Session-Id opgeslagen in localstorage. Deze wordt meegegeven aan de API om unieke vragen te genereren.

Deployment

Natuurlijk is een project niet echt af, als er geen deployment heeft plaatsgevonden waar dat kan. Ik ben ook benieuwd hoe het dockerizen van een Sprint Boot applicatie werkt. Daarom ga ik de API als docker container deployen in mijn homelab.

Tijdens mijn zoektocht naar hoe dit moest, kwam ik tegen dat Sprint Boot via een Maven dependency support biedt voor het starten van Docker containers. Dat lijkt op waar Microsoft recent mee bezig is: .NET Aspire. Dat is interessant om te weten. Verder ben ik te weten gekomen dat de Sprint Boot applicatie kan worden compiled naar een native applicatie, met GraalVM. Dat is een heel mooie feature (vergelijkbaar met AOT Compilation van Microsoft bij .NET). Wel verwacht ik daarbij allerlei complicaties. Dus daarom kies ik nu voor een standaard Docker image. De Apache Maven Wrapper heeft zelfs een command om een image te builden. Dat is ideaal. Aangezien ik geen automated CI/CD pipeline op ga zetten in Github Actions, schrijf ik voor nu even een bash script deploy.sh.

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/gwku/trivijava
echo $GITHUB_TOKEN | docker login ghcr.io -u gwku --password-stdin
docker push ghcr.io/gwku/trivijava:latest

Daarna geef ik run permissions aan het script en run ik deze: chmod +x ./deploy.sh && ./deploy.sh.

Dan rest mij nog om een compose.yml file te maken en deze in mijn homelab te gebruiken. Ik maak gebruik van een proxy manager, dus er hoeven geen ports open te staan.

services:
  trivijava:
    image: ghcr.io/gwku/trivijava:latest
    networks:
      - proxy
    environment:
      SPRING_PROFILES_ACTIVE: "prod"
    restart: unless-stopped

networks:
  proxy:
    external: true

En ja hoor, eventjes later draait de applicatie op https://trivijava.gkloud.nl. Nou kan ik heerlijk achterover leunen en een quiz spelen. Missie geslaagd.

Reflectie

Wat me tijdens deze opdracht echt positief heeft verrast, is hoe makkelijk en prettig het was om een Spring Boot API te bouwen. Als iemand met ervaring in .NET (en informatica-opleiding-ervaring met Java 8) was ik een beetje sceptisch, mede door de online sfeer rondom Java. Vaak wordt Java neergezet als verouderd, log of omslachtig. Maar eerlijk? Die vooroordelen bleken grotendeels ongegrond. Ik vond het juist elegant, goed gedocumenteerd en dankzij Spring Boot ook behoorlijk intuïtief.

De annotatie-gedreven aanpak, de automatische dependency injection, het gebruik van records, en de functional-style features zoals streams en optionals… het voelde allemaal veel moderner dan ik had verwacht. De meeste features zijn officieel ondersteund en ingebouwd, in tegenstelling tot .NET, waar belangrijke packages, zoals MassTransit, MediatR en FluentAssertions zomaar hun LICENSE veranderen en geld gaan vragen (recent gebeurd).

De belangrijkste les die ik hieruit trek: laat je niet te veel leiden door de online consensus. Java is zeker niet dood. Het is juist een volwassen, krachtige taal met een rijk ecosysteem — als je het een kans geeft, zul je merken dat er veel moois in zit.

Sterker nog, ik vond het zó interessant, dat ik na het bouwen van deze eenvoudige API ben gaan kijken naar wat er allemaal meer mogelijk is met Spring. Tijdens mijn zoektocht kwam ik een video tegen over Sprint Boot End-to-End en geavanceerde concepten zoals distributed messaging. By the way, de presentator vliegt daar door de code. Indrukwekkend. Spring is veel meer dan alleen een web framework. Het is een volwaardige enterprise toolkit die schaalbaarheid, messaging, event-driven architecturen en nog veel meer ondersteunt.

En dat zet je aan het denken… Als ik dit soort vooroordelen had over Java — wat voor andere technologieën of ideeën heb ik dan nog onbewust afgeschreven, simpelweg omdat de bubbel waarin ik leef iets anders roept?

Belangrijkste inzicht: de techwereld zit vol met echo chambers. En voor je het weet denk je dat iets “slecht” is, terwijl je het zelf nooit hebt geprobeerd. Door open te staan voor iets nieuws, heb ik niet alleen een leuke nieuwe tool geleerd, maar ook een bredere blik ontwikkeld.

Ik kijk ernaar uit om meer met Spring Boot te doen. Niet omdat het “moet”, maar omdat het me echt heeft geïnspireerd.


De link naar de repository vind je bovenaan de pagina.

Heb je feedback of vragen over dit project? Of ben je op zoek naar hulp bij softwareontwikkeling? Neem gerust contact op