Prompt file imported from pbaletkeman/github-actions (
.github/prompts/initial/04-plan-quizEngine-springboot.prompt.md). Copyright stays with the author.
Java Spring Boot/JPA Quiz Engine for GH-200 Certification
System Architecture Overview
Directory Structure
quiz-engine/
├── src/
│ ├── main/
│ │ ├── java/com/quizengine/
│ │ │ ├── QuizEngineApplication.java # Spring Boot entry point
│ │ │ ├── entity/
│ │ │ │ ├── Question.java # JPA entity
│ │ │ │ ├── QuizSession.java # JPA entity
│ │ │ │ └── QuizResponse.java # JPA entity
│ │ │ ├── repository/
│ │ │ │ ├── QuestionRepository.java # Spring Data JPA
│ │ │ │ ├── SessionRepository.java # Spring Data JPA
│ │ │ │ └── ResponseRepository.java # Spring Data JPA
│ │ │ ├── service/
│ │ │ │ ├── QuizEngine.java # Core quiz logic
│ │ │ │ ├── QuizService.java # Business logic
│ │ │ │ ├── HistoryService.java # History queries
│ │ │ │ └── ImportService.java # Markdown import logic
│ │ │ ├── util/
│ │ │ │ ├── MarkdownParser.java # MD file parsing
│ │ │ │ ├── AnswerShuffler.java # Answer randomization
│ │ │ │ └── QuizUtils.java # Helpers
│ │ │ ├── cli/
│ │ │ │ ├── QuizCommand.java # Picocli @Command
│ │ │ │ ├── ImportCommand.java # Picocli @Command
│ │ │ │ ├── HistoryCommand.java # Picocli @Command
│ │ │ │ ├── ClearCommand.java # Picocli @Command
│ │ │ │ └── ConsoleFormatter.java # Pretty printing
│ │ │ ├── config/
│ │ │ │ └── QuizEngineConfig.java # Spring configuration
│ │ │ └── exception/
│ │ │ └── QuizEngineException.java # Custom exception
│ │ └── resources/
│ │ ├── application.yml # Spring Boot config
│ │ ├── application-h2.yml # H2 profile (testing)
│ │ └── schema.sql # SQLite schema
│ └── test/
│ └── java/com/quizengine/
│ ├── service/QuizEngineTest.java
│ ├── repository/QuestionRepositoryTest.java
│ └── util/AnswerShufflerTest.java
├── build.gradle.kts # Gradle build configuration
├── settings.gradle.kts # Gradle project settings
├── gradlew # Gradle wrapper (Unix/Mac)
├── gradlew.bat # Gradle wrapper (Windows)
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── README.md # Setup, usage docs
├── Dockerfile # Container image for production deployment
├── docker-compose.yml # Multi-container orchestration for dev/test
└── .gitignore
Docker & Containerization
Dockerfile (Production - Multi-stage)
# Build stage
FROM gradle:8-jdk17 as builder
WORKDIR /app
COPY . .
RUN gradle bootBuildImage -x test --no-daemon || gradle build -x test --no-daemon
# Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
# Create non-root user
RUN addgroup -g 1000 springuser && adduser -D -u 1000 -G springuser springuser
RUN chown -R springuser:springuser /app
USER springuser
# JVM optimizations for Spring Boot
ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -Dserver.port=8080"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD []
docker-compose.yml (Development)
version: '3.8'
services:
quiz-engine:
build: .
container_name: quiz-engine-dev
ports:
- "8080:8080"
volumes:
- .:/app
- gradle-cache:/root/.gradle
working_dir: /app
command: gradle bootRun --no-daemon
environment:
- GRADLE_OPTS=-Dorg.gradle.daemon=false
- SPRING_PROFILES_ACTIVE=dev
stdin_open: true
tty: true
quiz-engine-test:
build: .
container_name: quiz-engine-test
volumes:
- .:/app
- gradle-cache:/root/.gradle
working_dir: /app
command: gradle test jacocoTestCoverageVerification --no-daemon
environment:
- GRADLE_OPTS=-Dorg.gradle.daemon=false
- SPRING_PROFILES_ACTIVE=test
volumes:
gradle-cache:
Getting Started with Docker
Quick Start (5 steps):
-
Build the image:
docker build -t quiz-engine:latest . -
Run development mode:
docker-compose up quiz-engine -
Access Spring Boot app:
curl http://localhost:8080/api/quiz -
Run tests with JaCoCo:
docker-compose up quiz-engine-test -
Run in production mode:
docker run -p 8080:8080 quiz-engine:latest
Build & Push:
# Build multi-arch
docker buildx build --platform linux/amd64,linux/arm64 -t myregistry/quiz-engine:1.0 .
# Push to registry
docker push myregistry/quiz-engine:1.0
Container Configuration:
- Multi-stage build for optimized image size
- Spring Boot profiles for dev/test/prod
- JPA/Hibernate SQLite configuration inside container
- Port 8080 exposed for REST API
- Non-root user (springuser) for security
- Gradle cache volume for faster builds
- JaCoCo coverage verification
Database Schema (Managed by JPA/Spring)
Question Entity
@Entity
@Table(name = "questions", indexes = {
@Index(name = "idx_section", columnList = "section"),
@Index(name = "idx_difficulty", columnList = "difficulty"),
@Index(name = "idx_usage_cycle", columnList = "usage_cycle")
})
public class Question {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String questionText;
@Column(nullable = false)
private String optionA;
@Column(nullable = false)
private String optionB;
@Column(nullable = false)
private String optionC;
@Column(nullable = false)
private String optionD;
@Column
private String optionE;
@Column(nullable = false)
private String correctAnswer;
@Column
private String explanation;
@Column
private String section;
@Column
private String difficulty;
@Column
private String sourceFile;
@Column(nullable = false, columnDefinition = "INTEGER DEFAULT 1")
private Integer usageCycle = 1;
@Column(nullable = false, columnDefinition = "INTEGER DEFAULT 0")
private Integer timesUsed = 0;
@Column
private LocalDateTime lastUsedAt;
@CreationTimestamp
private LocalDateTime createdAt;
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
private List<QuizResponse> responses;
}
QuizSession Entity
@Entity
@Table(name = "quiz_sessions", indexes = {
@Index(name = "idx_started_date", columnList = "started_at")
})
public class QuizSession {
@Id
@Column(length = 36)
private String sessionId;
@Column(nullable = false)
@CreationTimestamp
private LocalDateTime startedAt;
@Column
private LocalDateTime endedAt;
@Column(nullable = false)
private Integer numQuestions;
@Column(columnDefinition = "INTEGER DEFAULT 0")
private Integer numCorrect = 0;
@Column(columnDefinition = "REAL DEFAULT 0.0")
private Double percentageCorrect = 0.0;
@Column
private Integer timeTakenSeconds;
@OneToMany(mappedBy = "session", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<QuizResponse> responses;
}
QuizResponse Entity
@Entity
@Table(name = "quiz_responses")
public class QuizResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "session_id", referencedColumnName = "sessionId")
private QuizSession session;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id", referencedColumnName = "id")
private Question question;
@Column(nullable = false)
private String userAnswer;
@Column(columnDefinition = "INTEGER DEFAULT 0")
private Integer isCorrect = 0;
@Column
private Integer timeTakenSeconds;
}
Implementation Plan
Phase 1: Spring Boot Setup & JPA Configuration
Timeline: 1.5-2 hours
Objective: Initialize Spring Boot project, configure JPA/Hibernate, create entities.
Tasks:
-
Generate Gradle Project:
- Run
gradle init --type java-application --dsl kotlin --test-framework junit-jupiter - Configure for Spring Boot 3.2.x with Maven Central repository
- Java version: 17+
- Run
-
Create
build.gradle.ktsConfiguration:plugins { java id("org.springframework.boot") version "3.2.3" id("io.spring.dependency-management") version "1.1.4" } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } repositories { mavenCentral() } dependencies { // Spring Boot Starters implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") // SQLite Driver implementation("org.xerial:sqlite-jdbc:3.44.0.0") // Hibernate Dialect for SQLite implementation("com.github.gwenn:sqlite-dialect:0.1.2") // Picocli for CLI implementation("info.picocli:picocli-spring-boot-starter:4.7.5") // Lombok (optional) compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") // JUnit 5 & Spring Test testImplementation("org.springframework.boot:spring-boot-starter-test") } tasks.test { useJUnitPlatform() } -
Create
application.yml:spring: application: name: quiz-engine datasource: url: jdbc:sqlite:quiz.db driver-class-name: org.sqlite.JDBC hikari: maximum-pool-size: 10 jpa: hibernate: ddl-auto: validate properties: hibernate.dialect: org.hibernate.community.dialect.SQLiteDialect hibernate.format_sql: true hibernate.use_sql_comments: true sql: init: mode: always data-locations: classpath:schema.sql logging: level: root: INFO com.quizengine: DEBUG -
Create Entity Models:
Question.javawith @Entity, @Table, indexes, relationshipsQuizSession.javawith @Entity, timestamps, relationship to responsesQuizResponse.javawith @Entity, foreign keys to session and question- Use Lombok
@Data,@Builderto reduce boilerplate
-
Create Spring Data JPA Repositories:
QuestionRepository extends JpaRepository<Question, Long>- Add custom query methods:
@Queryfor cycle-aware question selectionfindCurrentCycle()for MIN(usage_cycle)findByUsageCycleOrderByRandom()(with LIMIT)
SessionRepository extends JpaRepository<QuizSession, String>ResponseRepository extends JpaRepository<QuizResponse, Long>
-
Create
QuizEngineApplication.java:@SpringBootApplication public class QuizEngineApplication { public static void main(String[] args) { SpringApplication.run(QuizEngineApplication.class, args); } } -
Test JPA mappings:
./gradlew bootRun- Tables created automatically by Hibernate
- No errors on startup
Success Criteria:
- Spring Boot application starts cleanly
- SQLite database initialized with Hibernate
- All entities properly mapped
- Custom queries resolve correctly
- No lazy loading issues
Phase 2: Service Layer & Quiz Logic
Timeline: 2-3 hours
Objective: Implement business logic, quiz flow, cycle-aware question selection.
Tasks:
-
Create
service/QuizEngine.java:- Constructor: accepts SessionId, config, repositories
loadQuestions(int n)→ callsQuestionRepository.findByUsageCycle(getCurrentCycle()).limit(n)submitAnswer()→ verify, persist viaResponseRepository.save()finalize()→ mark questions used (markQuestionUsed()→ updates timesUsed)advanceQuestionsCycle()→ increment usage_cycle where timesUsed > 0- Transaction support:
@Transactional
-
Create
service/QuizService.java:- Wrapper around QuizEngine
startNewQuiz()→ generate sessionId, create QuizEnginegetQuizSession()→ retrieve from SessionRepository- Business logic orchestration
-
Create
service/HistoryService.java:getAllSessions()→SessionRepository.findAll(PageRequest.of())getSessionDetails(sessionId)→ fetch with responses loadedformatReview()→ group answers (incorrect first, then correct)
-
Create
service/ImportService.java:importQuestions(File)→ MarkdownParser + batch save@Transactionalfor batch operations- Handles UNIQUE constraint violations gracefully
-
Write Repositories with Custom Queries:
public interface QuestionRepository extends JpaRepository<Question, Long> { @Query("SELECT MIN(q.usageCycle) FROM Question q") Integer findCurrentCycle(); @Query(value = "SELECT * FROM questions WHERE usage_cycle = ? ORDER BY RANDOM() LIMIT ?", nativeQuery = true) List<Question> findByUsageCycleRandom(Integer cycle, Integer limit); @Modifying @Query("UPDATE Question SET timesUsed = timesUsed + 1, lastUsedAt = CURRENT_TIMESTAMP WHERE id = ?") void markQuestionUsed(Long id); } -
Test service layer:
- Unit tests with
@DataJpaTest - Mock repositories with Mockito
- Verify cycle-aware selection
- Verify score calculation
- Unit tests with
Success Criteria:
- QuizEngine orchestrates full flow
- Questions selected only from current cycle
- Usage stats updated correctly
- Cycle auto-advances
- Transactional integrity maintained
- All service tests passing
Phase 3: CLI Layer with Picocli + Spring
Timeline: 1.5-2 hours
Objective: Implement interactive CLI using Picocli + Spring integration.
Tasks:
-
Create CLI Commands (Picocli + Spring):
@Commandannotated classes that are also@Component@Autowiredto inject services- Subcommands for quiz, import, history, clear
-
Write
cli/QuizCommand.java:@Command(name = "quiz", description = "Take a quiz")- Interactive prompts for num_questions, seconds_per
- Call
QuizService.startNewQuiz() - Loop through questions with timing
- Display results + offer review
-
Write
cli/ImportCommand.java:@Command(name = "import")@Option(names = "--file")for file path@Option(names = "--dir")for directory- Call
ImportService.importQuestions() - Report import stats
-
Write
cli/HistoryCommand.java:@Command(name = "history")@Option(names = "--session-id"),--review,--export- Call
HistoryServicemethods - Format and display results
-
Write
cli/ClearCommand.java:@Command(name = "clear")@Option(names = "--questions"),--history,--confirm- Confirmation prompts
- Delete via repositories
-
Create
QuizEngineConfig.java:- Spring
@Configurationclass - Define
@Beanfor Picocli integration - Wire Picocli into Spring Boot
- Spring
-
Write
ConsoleFormatter.java:- Static methods for colored output
- Box drawing for questions
- Table formatting for results
- Progress bars for timer
-
Test CLI commands:
./gradlew bootRun --args='quiz'./gradlew bootRun --args='import --file questions.md'- All commands work interactively
Success Criteria:
- All CLI commands execute via Spring Boot
- Picocli integration seamless
- Tab completion works
- Error messages helpful
- Interactive flow smooth
Phase 4: Testing & Coverage Enforcement (JUnit 5 + JaCoCo)
Timeline: 2-3 hours
Objective: Achieve >90% unit and integration test coverage. JaCoCo must fail the Gradle build below 90%.
JaCoCo Configuration (build.gradle.kts):
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
java
jacoco
}
tasks.test {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
html.required.set(true)
xml.required.set(true)
}
}
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = "0.90".toBigDecimal() // fail build below 90%
}
}
rule {
element = "CLASS"
excludes = listOf(
"com.quizengine.QuizEngineApplication", // Spring Boot main
"com.quizengine.cli.*" // CLI wiring
)
limit { minimum = "0.90".toBigDecimal() }
}
}
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
Run coverage:
./gradlew test jacocoTestReport jacocoTestCoverageVerification
# HTML report: build/reports/jacoco/test/html/index.html
Tasks:
-
Write Repository Tests with
@DataJpaTest(target: >92% repository layer):@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @TestPropertySource(properties = "spring.datasource.url=jdbc:sqlite::memory:") class QuestionRepositoryTest { @Autowired private QuestionRepository questionRepository; @Test void saveAndFind_returnsQuestion() { Question q = Question.builder() .questionText("What is CI?") .optionA("Continuous Integration") .optionB("Code Import") .optionC("Compile") .optionD("Configure") .correctAnswer("A") .usageCycle(1) .build(); questionRepository.save(q); List<Question> questions = questionRepository.findByUsageCycle(1); assertThat(questions).hasSize(1); assertThat(questions.get(0).getQuestionText()).isEqualTo("What is CI?"); } @Test void getRandomQuestions_doesNotExposeCorrectAnswer() { // Verify the JPQL query omits correctAnswer projection questionRepository.save(buildSampleQuestion()); List<QuestionProjection> results = questionRepository.findRandomForQuiz(PageRequest.of(0, 1)); // QuestionProjection interface must not include correctAnswer getter assertThat(results.get(0)).doesNotHave( new Condition<>(p -> hasField(p, "correctAnswer"), "correctAnswer field")); } @Test void advanceCycle_whenAllQuestionsExhausted() { Question q = questionRepository.save(buildSampleQuestion()); questionRepository.markQuestionUsed(q.getId()); questionRepository.advanceCycleIfExhausted(); assertThat(questionRepository.getCurrentCycle()).isEqualTo(2); } } -
Write Integration Tests with
@SpringBootTest(target: >90% service layer):@SpringBootTest @TestPropertySource(locations = "classpath:application-test.properties") class QuizServiceIntegrationTest { @Autowired private QuizService quizService; @Autowired private QuestionRepository questionRepository; @BeforeEach void setUp() { questionRepository.deleteAll(); questionRepository.save(buildSampleQuestion()); questionRepository.save(buildSampleQuestion2()); } @Test void startQuiz_loadsCorrectNumberOfQuestions() { QuizSession session = quizService.startQuiz(2); assertThat(session.getQuestions()).hasSize(2); } @Test void submitAnswer_correctAnswer_incrementsScore() { QuizSession session = quizService.startQuiz(1); String sessionId = session.getSessionId(); SubmitResult result = quizService.submitAnswer(sessionId, 0, "A", 10); assertThat(result.isCorrect()).isTrue(); } @Test void finalizeQuiz_persistsSessionWithStats() { QuizSession session = quizService.startQuiz(2); quizService.submitAnswer(session.getSessionId(), 0, "A", 10); quizService.submitAnswer(session.getSessionId(), 1, "B", 10); QuizResult result = quizService.finalizeQuiz(session.getSessionId()); assertThat(result.getPercentageCorrect()).isEqualTo(100.0); assertThat(quizService.getSession(session.getSessionId())).isNotNull(); } @Test void questionsNeverRepeatWithinCycle() { // add 3 questions, take 3 quizzes of 1 question each questionRepository.save(buildSampleQuestion3()); Set<Long> seenIds = new HashSet<>(); for (int i = 0; i < 3; i++) { QuizSession s = quizService.startQuiz(1); Long id = s.getQuestions().get(0).getId(); assertThat(seenIds).doesNotContain(id); seenIds.add(id); quizService.finalizeQuiz(s.getSessionId()); } } } -
Write
test/resources/application-test.properties:spring.datasource.url=jdbc:sqlite::memory: spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false logging.level.root=WARN -
Write Service Unit Tests with Mockito (target: >90% business logic):
@ExtendWith(MockitoExtension.class) class QuizEngineUnitTest { @Mock private QuestionRepository questionRepository; @InjectMocks private QuizEngine quizEngine; @Test void loadQuestions_callsRepositoryForCurrentCycle() { when(questionRepository.getCurrentCycle()).thenReturn(1); when(questionRepository.findRandomForQuiz(any())).thenReturn(List.of()); quizEngine.loadQuestions(3); verify(questionRepository).findRandomForQuiz(any()); } @Test void checkAnswer_returnsTrueForCorrectAnswer() { boolean correct = quizEngine.checkAnswer("A", "A"); assertTrue(correct); } @Test void checkAnswer_returnsFalseForWrongAnswer() { assertFalse(quizEngine.checkAnswer("A", "B")); } } -
Coverage target summary:
| Layer | Test Style | Target |
|---|---|---|
| Repository | @DataJpaTest |
>92% |
| Service | @SpringBootTest |
>90% |
| Business Logic | Mockito unit tests | >92% |
| Utilities (Shuffler, Parser) | Plain JUnit 5 | >95% |
-
Implement Error Handling:
- Custom
QuizEngineException - Graceful handling of DB failures, validation errors
- Custom
-
Create Executable JAR and README:
./gradlew build→build/libs/quiz-engine-0.0.1-SNAPSHOT.jar- README must include testing section showing how to run coverage
Success Criteria:
./gradlew test jacocoTestCoverageVerificationpasses (enforces >90% coverage)- JaCoCo HTML report at
build/reports/jacoco/test/html/ - All tests passing with ≥90% coverage
- Executable JAR runs standalone
- README complete and clear
- No unhandled exceptions
- Cross-platform compatibility
Dependencies Summary
- Spring Boot 3.2 - Application framework
- Spring Data JPA - ORM abstraction
- Hibernate SQLite Dialect - SQLite support
- Picocli Spring Boot Starter - CLI framework
- Lombok (optional) - Boilerplate reduction
- JUnit 5, Mockito - Testing frameworks
Total JAR size: ~45MB (Spring Boot overhead)
Core Design Decisions
1. Spring Data JPA over Plain JDBC
- ORM: Simplified data access, less boilerplate
- Queries: Custom
@Queryfor complex queries - Lifecycle: Automatic session/transaction management
- Benefit: Productivity over raw control
2. Picocli + Spring Integration
- CLI: Modern, declarative command structure
- Spring: Full DI, service layer access
- Result: CLI commands are Spring beans
3. Transactional Integrity
@Transactionalon service methods- Batch operations (import) atomicity
- Quiz finalization as single transaction
4. Lazy vs Eager Loading
QuizResponse→ EAGER fetch relationships (needed in review)Question→ LAZY for performance (not always needed)- Explicit control via fetch strategy
5. Non-Repetition with JPA Queries
- Native SQL for cycle-aware RANDOM() selection (SQLite-specific)
- JPA
@Querywrapper for type safety - Hibernate manages parameter binding
CLI Operations & Examples
1. Take a Quiz
java -jar quiz-engine.jar quiz --questions 100
2. Import Questions
java -jar quiz-engine.jar import --file questions.md
java -jar quiz-engine.jar import --dir ./md/
3. View History
java -jar quiz-engine.jar history
java -jar quiz-engine.jar history --session-id <uuid> --review
java -jar quiz-engine.jar history --export json
4. Clear Data
java -jar quiz-engine.jar clear --questions --confirm
java -jar quiz-engine.jar clear --history --all --confirm
Success Criteria
Functional Requirements
- ✓ Spring Boot application boots cleanly
- ✓ JPA entities auto-create SQLite schema
- ✓ Load 100+ random questions from current cycle
- ✓ NEVER repeat question until all exhausted
- ✓ Answers randomized and tracked
- ✓ Session persisted with full integrity
- ✓ Import, history, clear operations work
- ✓ All CLI commands functional
Non-Functional Requirements
- ✓ Performance: Question loading <1 second (JPA query)
- ✓ Usability: Full workflow in <15 minutes
- ✓ Reliability: Transactional integrity, error handling
- ✓ Maintainability: Clean service layer, testable
- ✓ Compatibility: Java 17+, Windows/Mac/Linux
Implementation Notes
- Test-Driven: Write repository tests first, then services
- Lazy vs Eager: Review fetch strategies to avoid N+1 queries
- Batch Operations: Use
saveAll()for imports - Transaction Scope: Keep
@Transactionalblocks minimal - Profiles: Use
@Profilefor dev/test/prod configs - Future: Add REST API with
spring-boot-starter-web, persistence layer caching