Status: ✅ Complete (with Bug Fixes & Groq Migration) Goal: Build a chat-style interview page where users answer questions one at a time, powered by HTMX.
- What Was Built
- HTMX — What & Why
- Architecture: Full Page vs Fragment
- Interview Flow
- Thymeleaf Fragments
- Service Layer Additions
- Challenges Faced & How We Fixed Them
- Groq API Migration
- Testing Strategy
- Interview Q&A
- interview.html — Chat-style page with progress bar, answered question history, and current question
- fragments/question-card.html — Thymeleaf fragment returned by HTMX for partial page updates
- 3 new controller endpoints —
/play,/answer,/complete - 3 new service methods —
getCurrentQuestion,getAnsweredQuestions,submitAnswer - Chat CSS — Bubbles, animations, progress bar, scrollable history
- 4 new unit tests — testing the new service methods
- Groq API migration — Switched from Google Gemini to Groq (OpenAI-compatible format)
HTMX lets you add dynamic behavior to HTML using attributes instead of writing JavaScript. It makes HTTP requests and swaps HTML fragments directly into the DOM.
<!-- Normal form: full page reload -->
<form action="/submit" method="post">...</form>
<!-- HTMX form: partial update, no reload -->
<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML">...</form>| HTMX | React/Angular/Vue |
|---|---|
| No build step, no npm | Requires npm, webpack, babel |
| Server returns HTML | Server returns JSON, client renders |
| Works with Thymeleaf | Needs separate frontend project |
| 14KB library (CDN) | 100KB+ framework |
| Perfect for MPA apps | Better for SPA apps |
| Attribute | Purpose | Example |
|---|---|---|
hx-post |
Make POST request on submit | hx-post="/interviews/1/answer" |
hx-target |
Which element to update | hx-target="#question-area" |
hx-swap |
How to update the target | hx-swap="innerHTML" |
Traditional (full page reload):
User clicks → Browser sends request → Server returns FULL HTML page → Browser replaces everything
HTMX (partial update):
User clicks → HTMX sends AJAX request → Server returns HTML FRAGMENT → HTMX replaces ONLY the target div
- User types answer in textarea
- Clicks "Submit Answer" → HTMX sends
POST /interviews/{id}/answer - Controller saves answer, gets next question
- Returns only the question card fragment (not the full page)
- HTMX replaces
#question-areawith the new card - User sees the next question — no page flicker, no reload
Create Session → Begin Interview → [AI generates questions]
↓
/play page renders
↓
Show Question 1 → User answers → Submit via HTMX
↓
Show Question 2 → User answers → Submit via HTMX
↓
... repeat for all questions ...
↓
Show "All Done" card → Complete Interview → Session Detail page
A reusable, partial piece of HTML. Instead of returning a full page, the controller can return just a fragment.
// Full page render:
return "interview";
// Fragment render (for HTMX):
return "fragments/question-card :: questionCard";<!-- Define fragment (no parameters — uses model attributes directly) -->
<div th:fragment="questionCard" class="question-active-card">
<p th:text="${question.questionText}">...</p>
<form th:hx-post="@{/interviews/{id}/answer(id=${interviewSession.id})}">
...
</form>
</div>
<!-- Use fragment in another template -->
<div th:replace="~{fragments/question-card :: questionCard}"></div>Important: Fragments returned from controllers access model attributes directly via
${}. Do NOT use parameterized fragments liketh:fragment="name(param)"when returning from controllers.
public Question getCurrentQuestion(Long sessionId) {
return session.getQuestions().stream()
.filter(q -> !q.isAnswered())
.sorted((a, b) -> Integer.compare(a.getOrderIndex(), b.getOrderIndex()))
.findFirst()
.orElse(null); // null = all done
}Stream API: filter → sort → findFirst. Functional programming in Java.
public Question submitAnswer(Long sessionId, Long questionId, String answer) {
question.setUserAnswer(answer);
question.setAnswered(true);
sessionRepository.save(session);
return question;
}Idempotency guard: Throws IllegalStateException if already answered.
Symptom: Every page that displayed session data (session list, session detail, interview play) crashed with a 500 error.
Root Cause: In Thymeleaf, session is a reserved word that refers to the HttpSession object. Our controller was doing:
// ❌ BROKEN — "session" is reserved in Thymeleaf
model.addAttribute("session", interviewSession);And templates were iterating:
<!-- ❌ BROKEN — "session" as loop variable conflicts with HttpSession -->
<a th:each="session : ${sessions}">...</a>The Fix:
// ✅ FIXED — use a non-reserved name
model.addAttribute("interviewSession", session);<!-- ✅ FIXED — renamed loop variable to "s" -->
<a th:each="s : ${sessions}">...</a>Files fixed: InterviewController.java, session-detail.html, sessions.html, interview.html, question-card.html
Lesson Learned: Thymeleaf reserves several variable names: session, param, application, request. Never use them as model attribute names or loop variables. This is poorly documented and easy to miss.
Symptom: After renaming session → interviewSession, some pages still crashed.
Root Cause: The find-and-replace for ${session. did NOT catch ${!session. (with the ! negation operator):
<!-- ❌ This was MISSED by find-and-replace -->
<div th:if="${!session.questions.isEmpty()}">The Fix: A second, more thorough pass to catch !session. patterns:
<!-- ✅ Fixed -->
<div th:if="${!interviewSession.questions.isEmpty()}">Lesson Learned: When doing bulk find-and-replace in templates, always verify with a comprehensive search for ALL patterns of the old variable name, including negation (!), method chains, and embedded references. Use grep or Select-String to scan all templates after the rename.
Symptom: Template files were correct on disk, but the server still threw errors referencing old session variable names.
Root Cause: Spring Boot's mvnw spring-boot:run copies resources to target/classes/. When templates are edited directly (not through Maven), the target/classes directory still has the old versions.
The Fix: Use mvnw clean spring-boot:run instead of just mvnw spring-boot:run:
# ❌ May serve stale templates
.\mvnw.cmd spring-boot:run
# ✅ Deletes target/ first, forces fresh copy
.\mvnw.cmd clean spring-boot:runLesson Learned: Always use clean when debugging template issues. The target/classes cache can mask your fixes and waste hours of debugging.
Symptom: Accessing session.questions in Thymeleaf templates sometimes failed.
Root Cause: JPA's @OneToMany uses LAZY loading by default. The questions collection is not loaded from the database until accessed. If the Hibernate session is closed (after the @Transactional method returns), accessing questions in the template throws LazyInitializationException.
The Fix: Since an interview has only 5-10 questions (small collection), we switched to EAGER loading:
// ❌ Default: LAZY — fails in templates
@OneToMany(mappedBy = "session", cascade = CascadeType.ALL, orphanRemoval = true)
// ✅ Fixed: EAGER — always loads questions with the session
@OneToMany(mappedBy = "session", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)Lesson Learned: For small collections that are always needed (e.g., questions in a session), EAGER fetch is simpler and avoids the Open Session In View anti-pattern. For large or optional collections, use JOIN FETCH in queries instead.
Symptom: Clicking "Begin Interview" showed: 429 Too Many Requests: "You exceeded your current quota"
Root Cause: Google Gemini's free tier has strict rate limits (requests per minute/day). After several test runs, the quota was exhausted.
The Fix: Migrated from Gemini to Groq (see Groq API Migration section below).
Lesson Learned: Always have a fallback AI provider. Groq's free tier is more generous and uses the industry-standard OpenAI format, making it easy to switch between providers.
We switched from Google Gemini to Groq — a fast AI inference platform that uses the OpenAI-compatible chat completions API format.
| Feature | Gemini | Groq |
|---|---|---|
| API Format | Proprietary (contents/parts) | OpenAI-compatible (model/messages) |
| Auth | Query param (?key=) |
Bearer token (industry standard) |
| Free Tier | Strict limits, exhausted quickly | More generous limits |
| Speed | ~2-3s response | ~0.5-1s response (very fast) |
| Model | gemini-2.0-flash |
llama-3.3-70b-versatile |
| File | What Changed |
|---|---|
application.yml |
URL → api.groq.com, env var → GROQ_API_KEY, added model |
GeminiProperties.java |
Added model field to Api inner class |
GeminiRequest.java |
Rewrote for OpenAI format: {model, messages} |
GeminiResponse.java |
Rewrote for OpenAI format: {choices: [{message: {content}}]} |
GeminiApiClient.java |
Bearer auth header instead of query param key |
// ❌ OLD (Gemini proprietary format)
{
"contents": [
{ "parts": [{ "text": "your prompt" }] }
]
}
// ✅ NEW (OpenAI-compatible format — works with Groq, OpenAI, etc.)
{
"model": "llama-3.3-70b-versatile",
"messages": [
{ "role": "user", "content": "your prompt" }
]
}// ❌ OLD (Gemini)
{
"candidates": [{ "content": { "parts": [{ "text": "response" }] } }]
}
// ✅ NEW (OpenAI-compatible)
{
"choices": [{ "message": { "role": "assistant", "content": "response" } }]
}// ❌ OLD (Gemini — API key as query param)
String url = properties.getUrl() + "?key=" + properties.getKey();
restClient.post().uri(url).body(request).retrieve();
// ✅ NEW (Groq — Bearer token in header)
restClient.post()
.uri(properties.getUrl())
.header("Authorization", "Bearer " + properties.getKey())
.body(request)
.retrieve();$env:GROQ_API_KEY="your-groq-api-key-here"
.\mvnw.cmd clean spring-boot:run4 new tests added for Phase 4 methods:
getCurrentQuestion→ returns first unanswered questiongetCurrentQuestion→ returns null when all answeredsubmitAnswer→ marks question answered and stores textsubmitAnswer→ throws if already answered
Total: 19 tests passing (14 service + 4 question gen + 1 context).
Note: The Groq API migration did NOT break any tests because tests mock
GeminiApiClient.generateContent()— they never hit the actual API. This validates our architecture decision to separate the HTTP client from the business logic.
A: HTMX is a 14KB JavaScript library that adds AJAX behavior through HTML attributes like hx-post and hx-target. I chose it because it works seamlessly with Spring MVC + Thymeleaf — the server returns HTML fragments instead of JSON, so there's no need for a separate frontend framework. It gave us a smooth, no-reload interview experience without the complexity of React.
A: When the user submits an answer, HTMX sends a POST request to /interviews/{id}/answer. The controller processes the answer, gets the next question, and returns a Thymeleaf fragment (just the question card HTML, not a full page). HTMX takes this fragment and swaps it into the #question-area div, so only that section of the page updates.
A: A fragment is a named, reusable piece of HTML defined with th:fragment="name". Instead of returning an entire page, a controller can return a specific fragment using the syntax "templateName :: fragmentName". This is essential for HTMX because it expects HTML snippets, not full pages.
A: I don't store a "current question index" in the database. Instead, getCurrentQuestion() uses Java Streams to filter the session's questions for the first one where answered == false, sorted by orderIndex. This is derived state — always consistent, no sync issues.
A: WebSockets are bidirectional and great for real-time push notifications (chat apps, stock tickers). For our use case — user submits → server responds — the standard HTTP request-response model is simpler. HTMX gives us the smooth UX of WebSockets with the simplicity of regular HTTP.
A: The biggest challenge was the Thymeleaf reserved word session causing 500 errors across all pages. The error message was TemplateInputException which was misleading — it looked like a template parsing error, not a variable naming conflict. I debugged it by:
- Enabling
server.error.include-stacktrace: alwaysandinclude-message: alwaysinapplication.yml - Reading the full stack trace in the browser, which showed the exact SpEL expression and line number
- Discovering that
sessionis reserved in Thymeleaf (it refers toHttpSession) - Renaming all occurrences to
interviewSessionacross the controller and all templates
A: Google Gemini's free tier quota got exhausted during development. Groq was chosen because: (1) it has a generous free tier, (2) it uses the OpenAI-compatible API format (industry standard), making it easy to switch between providers, and (3) it's extremely fast. The migration only required changing 5 files — the DTOs, the API client, and the config.
A: LAZY loading (default for @OneToMany) only loads related entities when they're explicitly accessed. This saves memory but can cause LazyInitializationException if the Hibernate session is closed. EAGER loading fetches related entities immediately with the parent. I used EAGER for questions because: (a) there are only 5-10 questions per session, and (b) every page that shows a session also shows its questions.
A: Because of separation of concerns. The tests mock GeminiApiClient.generateContent() which returns a plain String. The tests never construct GeminiRequest or parse GeminiResponse — that's inside the client. So when I changed the request/response format for Groq, the test contract (String → String) stayed the same. This proves why separating HTTP concerns from business logic matters.
Previous: Phase 3 — AI Integration | Next: Phase 5 — Answer Evaluation & Scoring