100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Java Mastery
60 minintermediate

Capstone Project: Production-Ready Java Microservice

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.

Analogy🏏Cricket
🏏 Think of it like cricket: this project is your first full international match after a season of nets and practice games. In the nets you drilled each skill, batting, bowling, fielding, in isolation; in this match every skill must come together under real conditions, with you making the in-game decisions rather than following a coach's step-by-step script. Just as a real match tests whether your separately-honed skills cohere into a complete performance, this project tests whether your separately-learned techniques, OOP, collections, streams, exceptions, functional style, cohere into a working application. Just as a captain shapes the game with their own judgement rather than a fixed plan, you shape this catalog with your own design choices. The insight is that genuine capability shows only when the full toolkit is assembled, under your own direction, into something complete.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: a well-run team organisation has clear, separate departments, the players themselves (the data, each a properly-prepared individual), the team management that selects, arranges, and reports on them (the query layer), and the match-day operations that put it all into action (the driver). Just as players are validated and registered before they can be part of the squad, products are validated before entering the catalog. Just as management never lets outsiders rummage through the squad directly but answers questions about it through proper channels, the Catalog keeps its storage private and answers via query methods. Just as match-day operations simply coordinate the departments rather than doing their jobs, the driver wires the pieces together. The insight is that separating the data, the logic that queries it, and the coordination that runs it keeps a system as orderly as a well-managed team.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: before a team plays internationally, you first ensure the fundamentals are flawless, the players are properly registered and fit (validated domain data), the records office reliably stores and retrieves their information (repository), and the coaching staff's core decision-making is sound and can be evaluated on its own (service logic), all before worrying about crowds, broadcasts, or match-day pressure. Just as you would not expose a team to international scrutiny before its fundamentals are solid and independently proven, you build and test the service's core logic before adding concurrency and transport. Just as the coaching staff work through the records office rather than depending on a specific filing system, the service depends on the repository interface, keeping it swappable and testable. The insight is that getting the validated data, reliable storage, and sound core logic right first, in isolation, is the foundation everything else safely builds upon.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: an international fixture must serve a huge crowd simultaneously, many spectators, media, and officials all making requests at once, so the operation assigns staff to handle each request in parallel rather than forming one endless queue (virtual-thread-per-request handling), while the one genuinely shared resource, the official scorebook, is updated through a strict protocol so two updates never collide (thread-safe shared state). Just as the crowd-facing staff handle thousands of requests concurrently while the single official record is protected by a careful update rule, the API layer serves many requests in parallel while shared state is guarded against races. Just as you would not bottleneck the whole stadium through one clerk, you do not serialise all requests through one thread. The insight is that scaling to many simultaneous clients means handling requests in parallel while rigorously protecting the few truly shared resources, which is exactly the concurrent-service design.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: a player is not ready for international duty just because they can bat, they must pass fitness tests and selection criteria (the build and test suite that must pass before shipping), be prepared for every match scenario including setbacks (graceful error handling), have their performance continuously recorded for analysis (structured logging), and arrive with a clear, repeatable preparation routine and conditions suited to the match (documented build/run and sensible JVM tuning). Just as selection gates ensure only a fully-prepared player takes the field, a build that compiles, tests, and packages ensures only verified code ships. Just as a complete professional handles a collapse without falling apart and leaves a full performance record, the service handles bad input without crashing and logs its behaviour. The insight is that production-readiness, like international readiness, is the full package of verification, robustness, observability, and disciplined preparation, not mere raw capability, and assembling that package is what this final phase delivers.
java
// 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.

Submit your capstone project

Checking submission status…
Final Exam unlocks when all 35 lessons are complete (35 left)
Lesson 35 of 35
0% complete