This capstone asks you to build a production-ready Java microservice: a small but complete REST-style service, a Cricket Scoreboard API, that manages match and player data and exposes operations to record events and query statistics. Unlike the earlier exercises, this project is deliberately end-to-end and production-minded, you will not merely make it work, but make it the way professional Java services are built: well-structured, dependency-managed, tested, logged, observable, and tuned to run reliably.
The project draws on the entire course. You will model the domain with modern Java (records, sealed types, pattern matching), apply clean OOP and the functional style, use collections and streams for queries, handle errors with exceptions and Optional, serve concurrent requests using the concurrency model (ideally virtual threads), read and write data with NIO.2, manage the build with Maven or Gradle, write JUnit and Mockito tests, add structured logging, and configure the JVM sensibly. It is the synthesis of everything from fundamentals through production concerns into one coherent, deployable application.
Learning Objectives
- Synthesise the full course, modern data modelling, OOP, streams, concurrency, I/O, testing, build tooling, logging, and tuning, into one coherent, deployable microservice.
- Design a clean layered architecture (domain model, service/business logic, repository/persistence, API/handler layer) with clear responsibilities and injected dependencies.
- Serve concurrent requests safely and scalably, using the concurrency model (virtual threads ideally) and thread-safe state, applying Module 5's guarantees.
- Build the project with Maven or Gradle, manage dependencies with correct scopes, and produce a runnable artifact via a reproducible, wrapper-driven build.
- Write a meaningful test suite with JUnit 5 and Mockito, unit-testing business logic with mocked dependencies and asserting behaviour, not implementation.
- Make the service observable and operable, structured leveled logging through SLF4J, graceful error handling, and sensible JVM configuration for its workload.
- Demonstrate production-readiness as a whole: robustness to bad input, clean shutdown, documented run instructions, and evidence the service behaves correctly under concurrent load.
Technical Requirements
- A layered structure: immutable domain types (records, a sealed event hierarchy), a service layer with business logic, a repository abstraction for storage, and an API/handler layer exposing operations.
- Core operations: record a match event (run, wicket, extra), fetch a match's current scorecard, list players, and compute statistics (totals, averages, top scorer) using streams.
- Thread-safe handling of concurrent requests: a concurrent collection or proper synchronization for shared state, and request handling on the concurrency model of your choice (virtual threads recommended).
- Robust input handling: validate inputs (records validating in compact constructors), reject bad data with clear errors, and return absence via Optional rather than null or crashes.
- Persistence with NIO.2: load seed data from and/or append events to a file using Files and try-with-resources with an explicit charset.
- A Maven or Gradle build with the wrapper, pinned dependency versions, correct scopes (test libraries in test scope), and a single command that compiles, tests, and packages.
- A JUnit 5 + Mockito test suite covering the service logic (with the repository mocked) and key edge cases, plus structured SLF4J logging at appropriate levels throughout.
Architecture & Design
The service is organised into four layers with strictly separated responsibilities. The domain layer holds immutable types, records for players and scorecards and a sealed interface for match events, so invalid data cannot exist and the closed event set enables exhaustive handling. The repository layer abstracts storage behind an interface (loading and saving via NIO.2), so the service depends on the abstraction, not a concrete file format, the seam that also makes it mockable in tests. The service layer holds the business logic, applying events to scorecards and computing statistics, and the API layer translates incoming requests into service calls and results back into responses.
This layering exists because separation of concerns is what makes a service maintainable, testable, and changeable: the domain is pure data, the repository can be swapped (file today, database tomorrow) without touching logic, the service is unit-testable in isolation with a mocked repository, and the API layer keeps transport concerns out of the business logic. Dependencies flow inward and are injected through constructors, so every layer can be tested independently, and the design directly reflects the principles threaded through the whole course, encapsulation, abstraction, testability, and clear boundaries, now assembled into a realistic whole.
// Domain + repository abstraction: the foundation the service is built on.
import java.util.*;
// --- Domain: modern, immutable, self-validating (records + sealed events) ---
public sealed interface ScoreEvent permits ScoreEvent.Run, ScoreEvent.Wicket, ScoreEvent.Extra {
record Run(int count) implements ScoreEvent {
public Run { if (count < 0 || count > 6) throw new IllegalArgumentException("runs 0-6"); }
}
record Wicket(String batter, String mode) implements ScoreEvent {}
record Extra(String type, int count) implements ScoreEvent {}
}
// An immutable scorecard snapshot; the service produces new snapshots from events.
record Scorecard(String matchId, int runs, int wickets, int extras) {
Scorecard apply(ScoreEvent e) {
return switch (e) { // exhaustive over the sealed type
case ScoreEvent.Run(int n) -> new Scorecard(matchId, runs + n, wickets, extras);
case ScoreEvent.Wicket(var b, var m) -> new Scorecard(matchId, runs, wickets + 1, extras);
case ScoreEvent.Extra(var t, int n) -> new Scorecard(matchId, runs + n, wickets, extras + n);
};
}
}
// --- Repository ABSTRACTION: the seam that decouples storage and enables mocking ---
interface ScoreRepository {
Optional<Scorecard> find(String matchId); // Optional: absence is explicit
void save(Scorecard card); // persisted via a NIO.2 impl
List<Scorecard> findAll();
}
Phase 1 — Domain, Repository, and Service
Phase 1 builds the core: the domain types, a repository implementation backed by NIO.2, and the service that holds the business logic. The domain is immutable and self-validating; the file-backed repository loads seed data and persists scorecards using Files with try-with-resources and an explicit charset; and the service, depending on the ScoreRepository interface (injected, not constructed), implements the operations: record an event by loading the current scorecard, applying the event, and saving the result, and compute statistics across matches with streams.
This phase establishes the testable heart of the service before any concurrency or transport concerns, which is the right order: get the logic correct and unit-testable in isolation first. Because the service depends on the repository abstraction, it can later be tested with a Mockito mock, and because the domain validates itself and absence is returned as Optional, whole classes of error are prevented at the foundation. Everything built in later phases, concurrency, the API, observability, wraps around this correct, well-encapsulated core.
// Service layer (injected repository) + a NIO.2-backed repository implementation.
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;
import org.slf4j.*;
class ScoreService {
private static final Logger log = LoggerFactory.getLogger(ScoreService.class);
private final ScoreRepository repo; // INJECTED -> testable, swappable
ScoreService(ScoreRepository repo) { this.repo = repo; }
// Record an event: load -> apply (immutably) -> save. Validation lives in the domain.
Scorecard record(String matchId, ScoreEvent event) {
Scorecard current = repo.find(matchId)
.orElse(new Scorecard(matchId, 0, 0, 0)); // new match if absent
Scorecard updated = current.apply(event);
repo.save(updated);
log.info("Recorded {} for match {} -> {}", event.getClass().getSimpleName(), matchId, updated);
return updated;
}
Optional<Scorecard> scorecard(String matchId) { return repo.find(matchId); }
// A statistic computed with streams over all matches.
OptionalInt highestTotal() {
return repo.findAll().stream().mapToInt(Scorecard::runs).max();
}
}
// File-backed repository using NIO.2 (one line per scorecard: matchId,runs,wickets,extras).
class FileScoreRepository implements ScoreRepository {
private final Path file;
FileScoreRepository(Path file) { this.file = file; }
@Override public Optional<Scorecard> find(String id) {
return findAll().stream().filter(c -> c.matchId().equals(id)).findFirst();
}
@Override public List<Scorecard> findAll() {
if (!Files.exists(file)) return List.of();
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
return lines.map(l -> l.split(","))
.map(f -> new Scorecard(f[0], Integer.parseInt(f[1]),
Integer.parseInt(f[2]), Integer.parseInt(f[3])))
.collect(Collectors.toList());
} catch (Exception e) { throw new RuntimeException("load failed", e); }
}
@Override public void save(Scorecard c) { /* upsert: rewrite the line for c.matchId() via NIO.2 */ }
}
Phase 2 — Concurrency and the API Layer
Phase 2 makes the service handle concurrent requests and exposes it through an API layer. Requests arrive concurrently, so shared state must be thread-safe: guard the repository's storage with a concurrent collection or appropriate synchronization (from Module 5), and serve each request on its own thread, ideally a virtual thread via newVirtualThreadPerTaskExecutor, so the service scales to many simultaneous clients with simple blocking code. The API layer parses each request into a domain operation, invokes the service, and turns the result, or a clear error, into a response.
This phase applies the concurrency model deliberately: the service core stays simple, while the API layer and an executor handle parallelism, and thread-safety is confined to the genuinely shared state. Virtual threads make the thread-per-request model both simple and scalable, exactly the modern approach from Module 5, and confining mutable shared state behind thread-safe structures prevents the races that lesson warned about. The result is a service that remains correct under concurrent load while keeping its request-handling code straightforward, the production reality of a microservice serving many clients at once.
// Thread-safe storage + virtual-thread-per-request handling + a simple API layer.
import java.util.*;
import java.util.concurrent.*;
import org.slf4j.*;
// A thread-safe in-memory repository (Module 5): concurrent map guards shared state.
class ConcurrentScoreRepository implements ScoreRepository {
private final ConcurrentHashMap<String, Scorecard> store = new ConcurrentHashMap<>();
@Override public Optional<Scorecard> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void save(Scorecard c) { store.put(c.matchId(), c); } // atomic per key
@Override public List<Scorecard> findAll() { return List.copyOf(store.values()); }
}
// A minimal API layer: parses a request, calls the service, returns a response/error.
class ScoreApi {
private static final Logger log = LoggerFactory.getLogger(ScoreApi.class);
private final ScoreService service;
private final ExecutorService requests = Executors.newVirtualThreadPerTaskExecutor();
ScoreApi(ScoreService service) { this.service = service; }
// Handle a request CONCURRENTLY on its own virtual thread; map errors to clear responses.
Future<String> handle(String matchId, ScoreEvent event) {
return requests.submit(() -> {
try {
Scorecard sc = service.record(matchId, event);
return "200 " + sc;
} catch (IllegalArgumentException bad) {
log.warn("Bad request for match {}: {}", matchId, bad.getMessage());
return "400 " + bad.getMessage(); // graceful client error
} catch (RuntimeException e) {
log.error("Failed handling match {}", matchId, e);
return "500 internal error";
}
});
}
void shutdown() { requests.shutdown(); } // clean shutdown of the request executor
}
Phase 3 — Build, Test, and Production-Readiness
Phase 3 makes the service genuinely production-ready: a proper build, a test suite, and operability. Set up Maven or Gradle with the wrapper, declaring dependencies (SLF4J and a backend, JUnit, Mockito) with correct scopes and pinned versions, so a single command compiles, tests, and packages a runnable artifact. Write JUnit 5 tests for the service logic with the repository mocked by Mockito, asserting behaviour and covering edge cases (invalid events, absent matches), and ensure structured SLF4J logging runs at appropriate levels throughout.
Finally, address operability: handle errors gracefully so bad input yields clear responses rather than crashes, shut the request executor down cleanly, document how to build and run the service, and choose sensible JVM settings for its workload (heap sized to the working set, the default collector unless measurement shows otherwise). This phase is what distinguishes a toy from a production service: it is built reproducibly, verified by tests, observable through logs, robust to bad input, and configured to run reliably, the synthesis of the tooling, testing, logging, and tuning modules applied to make the service truly deployable.
// JUnit 5 + Mockito test of the service logic with the repository MOCKED.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.Optional;
class ScoreServiceTest {
ScoreRepository repo;
ScoreService service;
@BeforeEach void setUp() {
repo = mock(ScoreRepository.class); // mocked dependency -> isolate the service
service = new ScoreService(repo);
}
@Test @DisplayName("recording a boundary adds runs to the scorecard")
void recordRunAddsRuns() {
when(repo.find("m1")).thenReturn(Optional.of(new Scorecard("m1", 10, 0, 0)));
Scorecard result = service.record("m1", new ScoreEvent.Run(4));
assertEquals(14, result.runs()); // behaviour: 10 + 4
verify(repo).save(result); // it persisted the update
}
@Test @DisplayName("recording on a new match starts from zero")
void recordOnNewMatch() {
when(repo.find("new")).thenReturn(Optional.empty()); // absent match
Scorecard result = service.record("new", new ScoreEvent.Wicket("Kohli", "lbw"));
assertEquals(1, result.wickets());
}
@Test @DisplayName("invalid run count is rejected by domain validation")
void invalidEventRejected() {
assertThrows(IllegalArgumentException.class, () -> new ScoreEvent.Run(7)); // 0-6 only
}
}
// Build & run (Maven), and a sensible JVM configuration for this workload:
// ./mvnw package -> compiles, runs THESE tests, builds the JAR
// java -Xms256m -Xmx256m -jar target/scoreboard-1.0.0.jar (heap sized to working set;
// default G1 unless measured otherwise)
Evaluation Rubric
- Architecture: clean separation into domain, repository, service, and API layers, with dependencies injected through constructors and the service depending on the repository abstraction.
- Correctness & robustness: operations produce correct results, the domain validates itself so invalid data cannot exist, absence is handled via Optional, and bad input yields clear errors rather than crashes.
- Concurrency: shared state is thread-safe (concurrent collections or proper synchronization) and requests are served concurrently (virtual threads recommended) with no races under load.
- Modern Java & functional style: records and a sealed event hierarchy with exhaustive pattern matching, and stream-based queries rather than manual loops.
- Build & tests: a reproducible Maven/Gradle build via the wrapper with pinned, correctly-scoped dependencies; a JUnit 5 + Mockito suite covering service logic and edge cases, asserting behaviour.
- Observability & operability: structured SLF4J logging at appropriate levels (no secrets), clean executor shutdown, sensible JVM settings, and documented build/run instructions.
- Production-readiness as a whole: the service builds, passes tests, runs reliably under concurrent load, degrades gracefully on bad input, and reads as professional, maintainable code.
Extension Challenges: Replace the file repository with an embedded database accessed via JDBC, keeping the same ScoreRepository interface to prove the abstraction holds, and add integration tests alongside the unit tests. Expose the API over real HTTP using the built-in com.sun.net.httpserver.HttpServer or a lightweight framework, and add JSON serialisation with a library declared in your build (using opens for reflection if you modularise). Add Java Flight Recorder configuration and capture a profile under load to identify any hotspot, then tune one JVM parameter and measure the effect. Finally, containerise the service and set -Xmx within the container's memory limit, demonstrating the deployment and tuning concerns from the final module end to end.