Introduction
It’s time for something new. By now, I have enough experience building .NET APIs that I can pretty much do it with my eyes closed. So, I’m going to challenge myself by doing it a different way this time: with Java Spring Boot.
If you’re not a Java developer, you’re probably feeling some resistance right now because the online consensus is that Java is outdated and doesn’t offer a great Developer Experience. Yet, I’ve also read that some Java developers swear by it and wouldn’t want to use anything else. So, I’m curious to see how it really is — and I’m going to try it out.
I expect a large part to be similar to the traditional way .NET works: controllers, services, repositories, and a database. At least, if you follow the package-by-layer structure. Personally, I prefer package-by-feature (vertical slices), but since I want to experience how Spring Boot is used in the industry, I’m going with the package-by-layer approach for now.
I learn best by seeing how an expert uses something and relating it to what I already know. So, to get a first impression of how Spring Boot works, I first searched for some examples. I watched the video Spring Boot CRUD Tutorial: Building a Book Management Application.
Tip: speed up YouTube tutorial videos to learn faster and don’t hesitate to skip ahead if it feels familiar. I watched this video at 3x speed, which helped me quickly grasp how to create a CRUD API with Spring Boot.
After that, I read blog posts comparing Java technologies to .NET technologies:
- Getting Started with Spring Boot 3 for .NET Developers
- Building a Product Entity CRUD Application in Spring Boot for .NET Developers
In about fifteen minutes, I was up to speed. Time to put theory into practice…
What am I actually going to build? I came across an assignment to build a web app with an API where you can take a quiz with trivia questions.
Assignment
Develop a simple web application where users can answer trivia questions. The questions will be fetched via the Open Trivia Database API (OpenTDB API), which returns answers in JSON format.
Key points:
- The raw API response contains both the question and the correct answer, meaning a user with technical knowledge can easily find the correct answers.
- The application must be designed so this information isn’t easily accessible via browser developer tools.
- The interface should be simple and guide the user smoothly through the quiz.
Requirements:
- Use Java Spring Boot for backend implementation.
- Fetch trivia questions from the external Open Trivia Database API.
- Basic frontend to enable user interaction.
- Logic implementation to hide the correct answer until the user submits an answer.
- No database required.
- Hosted version is optional.
Time to get started.
Preparation
First, I’m treating myself to another JetBrains application: IntelliJ IDEA. I really like working with JetBrains IDEs. Once installed, I create a new project. I choose a new Java Spring Boot project with Maven and the Spring Web dependency. The project is named TriviJava (Trivia + Java). Very original, I know.
What do I actually need? I come up with a rough plan:
- Create a controller with two endpoints:
GET /api/v1/quiz/questions
POST /api/v1/quiz/answers
- Create a QuizService with the following functionalities:
- Fetch trivia questions from the external API
- Validate submitted answers
- Build a web app where users can start a quiz, answer questions, and check answers.
No answers should be sent to the client. That means the backend must validate the submitted answers. The external API
generates random questions, so to later validate if the submitted answers are correct, the backend must store the
correct answer per question. Since a database isn’t required, I keep it simple with a Map<UUID, UUID>
, where the key
is a self-generated questionId and the value a self-generated answerId. In this scenario, no other data needs to be
stored. Of course, you could store more in the Map if needed.
It all seems straightforward—just a standard GET request with DTOs, a controller, a service, a repository, and an ApiClient. But then I opened the OpenTDB website. The OpenTDB API uses its own conventions with tokens and response codes and has rate limiting of 1 request per 5 seconds. That was a lesson for me to stick to HTTP and REST standards. So it became a bit more work due to handling token management, different response codes, and strict rate limiting.
Example response:
{
"response_code": 0,
"results": [
{
"type": "multiple",
"difficulty": "medium",
"category": "Entertainment: Books",
"question": "According to The Hitchhiker'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"
]
}
]
}
As a user, you don’t want to get the same questions all the time. That’s why OpenTDB has a token system. You first request a token, then include it with your requests to get guaranteed unique questions until all questions (>20,000) have been served. At that point, you receive a specific response code and need to reset the token. After 6 hours of inactivity, the token becomes invalid, which also results in a certain response code requiring a new token. There’s also strict rate limiting (1 request per 5 seconds). The TriviaApiClient needs to handle all this properly.
API
I start with the controller. Two endpoints are needed.
@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);
}
}
Spring Boot has many annotations you can assign to classes or methods. For example, @RestController
indicates a
controller with REST endpoints. @RequestMapping
assigns a route to the entire class. @GetMapping
specifies a route
for a GET request, and there are similar annotations for other HTTP methods. You can also easily specify where the
endpoint variables come from—headers, body, or query params. Validation can also be added.
What I really like about Spring Boot is its Dependency Injection system implementing the IoC principle. In .NET, I was used to adding things manually to the DI container, but Spring Boot does it all again via annotations. Very convenient.
Here, there are two endpoints, both returning a ResponseEntity
. To fetch questions, an OpenTDB token is required. Each
user will have seen different questions, so it’s handy to keep track of which token belongs to which user. This is done
via an X-Session-Id
header. The frontend needs to send this to identify the user. The TokenRepository
links this to
an OpenTDB token.
Personally, I believe a controller should do only a few things: validate requests, map DTOs, call logic, and return responses. Other logic doesn’t belong here. That’s why I created both request and response DTOs. Each request DTO has methods to convert to domain models, which the service can use. After processing, results are converted back to response DTOs and sent back. This keeps the controller clean and separates logic well.
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);
}
}
For DTOs, I use records because of immutability and automatically generated methods for fields. I was surprised these exist (I’m used to them in .NET). From reading online reactions, I’d gotten a somewhat outdated impression of Java. But the more I worked with Java, the more I liked the language.
The logic to fetch questions and check answers lives in 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();
}
}
As mentioned, for fetching the questions, an existing token is used or a token is retrieved first, with expired or
invalid tokens being handled. The interaction with the OpenTDB API happens via the TriviaApiClient
.
What I noticed here is that Java has Optional types. I find that a nice feature. In C#, you can put a question mark
after your object type (e.g., string? Example
) to indicate nullability. That feels like it’s just glued on. An
Optional type with all kinds of handy methods, like orElseGet
, feels like a complete feature (although there is a
proposal for
C# to implement Discriminated Unions).
After fetching the questions, the answerId
of the correct answer is linked to the questionId
in the
questionRepository
(for now in a 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));
}
}
Checking the answers is done by comparing the answerId
for each given answer with the stored answerId
for the
corresponding questionId
in the QuestionRepository
. Here, I used ‘functional’ programming by mapping and filtering a
stream. I personally find this way of programming very clear and straightforward. The methods indicate what you do with
the data, as opposed to loops where everyone can give their own structure. I am a big fan of LINQ (method syntax) in C#.
It’s super cool that Java supports something similar.
The API is now functional. There are some possible improvements, such as configuring a global exception handler and adding rate limiting, but I won’t focus on those now. However, I have written unit tests and those worked flawlessly.
Web
To create a quiz, a simple web application is needed. Since this is a small assignment, it can be done fine for now with
pure HTML, CSS, and JS, without front-end frameworks. To keep it simple, I add
src/main/resources/static/index.html
to the project. Spring Boot then automatically makes this available at “{base_url}/path”. index.html
is a special name
that loads automatically, even without a path. So it’s reachable at http://localhost:8080
(if you have run the
project,
of course ;)).
When you load the page, you see a loading animation while the questions are being fetched. Then you see the first question where you can select an answer.
After answering all the questions, you can submit the results and review the answers.
Then you can start a new quiz. A unique X-Session-Id is stored in localstorage. This is sent to the API to generate unique questions.
Deployment
Of course, a project is never really finished if it hasn’t been deployed (if it can be). I’m also curious how dockerizing a Spring Boot application works. So I’m going to deploy the API as a docker container in my homelab.
During my search on how to do this, I found that Spring Boot supports starting Docker containers via a Maven dependency.
That reminds me of what Microsoft has recently been working on: .NET Aspire. That’s interesting to know. Also, I found
out that the Spring Boot application can be compiled to a native application with GraalVM. That’s a very nice feature (
similar to Microsoft’s AOT Compilation in .NET). Though I expect various complications there. So for now, I choose a
standard Docker image. The Apache Maven Wrapper even has a command to build an image. That’s ideal. Since I’m not
setting up an automated CI/CD pipeline in Github Actions, I’m writing a simple bash script deploy.sh
for now.
./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
Then I give run permissions to the script and run it: chmod +x ./deploy.sh && ./deploy.sh
.
Next, I still need to create a compose.yml file and use it in my homelab. I use a proxy manager, so no ports need to be open.
services:
trivijava:
image: ghcr.io/gwku/trivijava:latest
networks:
- proxy
environment:
SPRING_PROFILES_ACTIVE: "prod"
restart: unless-stopped
networks:
proxy:
external: true
And sure enough, a few minutes later the application is running at https://trivijava.gkloud.nl. Now I can lean back and play a quiz. Mission accomplished.
Reflection
What really pleasantly surprised me during this assignment is how easy and pleasant it was to build a Spring Boot API. As someone experienced with .NET (and computer science education experience with Java 8), I was a bit skeptical, partly due to the online atmosphere around Java. Often, Java is portrayed as outdated, slow, or cumbersome. But honestly? Those prejudices largely proved unfounded. I found it elegant, well documented, and thanks to Spring Boot, quite intuitive.
The annotation-driven approach, automatic dependency injection, the use of records, and the functional-style features like streams and optionals… it all felt much more modern than I expected. Most features are officially supported and built-in, unlike in .NET where important packages, such as MassTransit, MediatR en FluentAssertionssuddenly change their LICENSE and start charging money (recently happened).
The main lesson I take away here: don’t let yourself be too guided by the online consensus. Java is definitely not dead. It’s a mature, powerful language with a rich ecosystem — if you give it a chance, you’ll discover a lot of great stuff.
Even more, I found it so interesting that after building this simple API I started looking into what else is possible with Spring. During my search, I came across a video about Spring Boot End-to-End and advanced concepts like distributed messaging. By the way, that guy flies through his code. Impressive. Spring is much more than just a web framework. It’s a full-fledged enterprise toolkit that supports scalability, messaging, event-driven architectures, and much more.
And that makes you think… If I had such prejudices about Java — what other technologies or ideas have I unconsciously dismissed simply because the bubble I live in says otherwise?
Key insight: the tech world is full of echo chambers. And before you know it, you think something is “bad,” while you’ve never even tried it yourself. By being open to something new, I didn’t just learn a cool new tool, but also developed a broader perspective.
I look forward to doing more with Spring Boot. Not because I “have to,” but because it truly inspired me.
You can find the link to the repository at the top of the page.
Do you have feedback, questions, or comments? Or do you need someone to build an app or other software? Feel free to get in touch!