Beginner

Building an Application Using Java SE

This course teaches how to build a true end-to-end Java application, using industry standard tools, libraries, and practices. The objective is not to learn a particular framework, but to...

Level: Beginner to intermediate


Table of Contents

  1. Course Overview
  2. Configuring a Java application (Module 2)
  1. Calling an external web API (Module 3)
  1. Storing data in a database
  1. Creation of a REST API (Module 5)
  1. Towards production
  1. Complete project architecture
  2. Full Source Code — Configuration Files
  1. Complete source code — Course-info-repository module
  1. Complete source code — Module course-info-cli
  1. Complete source code — Module course-info-server
  1. Sample data — sander-mak.json

1. Course Overview

This course teaches how to build a true end-to-end Java application, using industry standard tools, libraries, and practices. The objective is not to learn a particular framework, but to stay as close as possible to “core” Java while using essential additional libraries.

Topics covered

ThemeTechnology
Build ToolMaven
Language FeaturesJava 17 (records, text blocks, switch expressions, etc.)
Unit TestingJUnit 5
HTTP callsJava HttpClient (since Java 11)
JSON BindingJackson
Relational databaseH2 + JDBC
REST APIJAX-RS / Jersey 3

Target audience

This course is aimed at beginner to intermediate Java developers who already know the basics of the language (collections, streams, exception handling, OOP) but have not yet worked on a real application codebase.

Recommended prerequisite courses:

  • Java SE 17: The Big Picture
  • Java SE 17 Fundamentals
  • Object-oriented Programming in Java SE 17

2. Configuring a Java application

Module duration: 44m 42s

2.1 Introduction and prerequisites

This course builds a complete Java application from scratch. We spend the majority of time in the IDE actually coding. Here is what is assumed:

  • Knowledge of Java fundamentals (OOP, collections, streams, exceptions)
  • Prior experience with an IDE (not necessarily IntelliJ)
  • No prior knowledge of Maven, JUnit, HttpClient, JDBC, JAX-RS is required

The end goal is to be comfortable understanding and contributing to large, real-world Java codebases.

2.2 Tools used

ToolRecommended versionURL
JDKJava 17https://adoptium.net
Maven3.8.xhttps://maven.apache.org
SDKMAN (optional)https://sdkman.io

Verifying the installation from the terminal:

java --version
# openjdk 17.x.x ...

mvn --version
# Apache Maven 3.8.x ...

SDKMAN is a convenient alternative for installing and switching between different versions of Java or Maven:

# Installation SDKMAN
curl -s "https://get.sdkman.io" | bash

# Installation Java 17 via SDKMAN
sdk install java 17.x.x-tem

# Installation Maven
sdk install maven 3.8.6

2.3 What we will build

The project is called Course Info and consists of three components:

┌─────────────────────────────────────────────────────────────────┐
│                       Système Course Info                        │
│                                                                 │
│  ┌───────────────┐    stocke    ┌──────────────┐               │
│  │ course-info   │ ──────────►  │   Base de    │               │
│  │ -cli          │              │   données H2 │               │
│  │ (CLI tool)    │              │  (courses.db)│               │
│  └───────────────┘              └──────┬───────┘               │
│        │                               │ lit                    │
│        │ appel API                     │                        │
│        ▼                               ▼                        │
│  ┌───────────────┐              ┌──────────────┐               │
│  │  Pluralsight  │              │ course-info  │               │
│  │  API (HTTP)   │              │ -server      │               │
│  └───────────────┘              │ (REST API)   │               │
│                                 └──────────────┘               │
│                                        │                        │
│                                        ▼                        │
│                                 GET /courses → JSON             │
│                                 POST /courses/{id}/notes        │
└─────────────────────────────────────────────────────────────────┘

Key Features:

  1. CLI tool: calls the Pluralsight API, parses the JSON, stores the courses in the database
  2. REST server: expose courses stored in JSON via GET /courses
  3. Notes: allows you to add notes to a course via POST /courses/{id}/notes

A course in the system has the following structure:

{
  "id": "b6e31e25-ed0b-4bd1-8d1a-4854f63a268c",
  "name": "What's New in Java 15",
  "length": 68,
  "url": "https://app.pluralsight.com/library/courses/java-15-whats-new",
  "notes": null
}

Choice not to use a framework: Frameworks like Spring Boot, Jakarta EE, Quarkus or Micronaut are valid but impose specific architecture and practices. This course teaches Java and not a framework, in order to understand the foundations before tackling these layers of abstraction.

2.4 Setting up a Maven project

Maven is chosen as the build tool for several reasons:

  • Separation of build responsibility from IDE
  • Standardized and universally recognized project structure (convention over configuration)
  • Compiling and running automatic tests
  • Dependency Management (download from Maven Central)
  • Build reproducible on all environments

Maven directory structure

course-info/
├── pom.xml                  ← Configuration Maven
└── src/
    ├── main/
    │   └── java/            ← Code de production
    │       └── com/pluralsight/courseinfo/cli/
    └── test/
        └── java/            ← Code de test
            └── com/pluralsight/courseinfo/cli/

Configuring maven.compiler.release vs source/target

The --release flag (available since Java 9) is preferred to the source and target properties because it ensures that the code does not refer to APIs that do not exist in the target version:

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>17</maven.compiler.release>
</properties>

You must also configure a recent version of the Maven Compiler Plugin (≥ 3.6) so that the release flag is recognized:

<build>
    <plugins>
        <plugin>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.10.1</version>
        </plugin>
    </plugins>
</build>

Essential Maven Commands

# Nettoyer les artéfacts de compilation
mvn clean

# Compiler, tester, et packager
mvn clean verify

# Exécuter seulement les tests
mvn test

Note: On first launch, Maven downloads plugins and dependencies to a local cache (~/.m2/repository). Subsequent launches reuse this cache.

2.5 Compiling and running the first class

Package naming convention in a large Java project:

com.pluralsight.courseinfo.cli
│── com.pluralsight   → GroupId Maven (identifie l'entreprise/organisation)
│── courseinfo        → ArtifactId Maven (nom du projet)
└── cli               → Composant (CLI, repository, server, etc.)

First class — application entry point:

package com.pluralsight.courseinfo.cli;

public class CourseRetriever {

    public static void main(String... args) {
        if (args.length == 0) {
            System.out.println("Please provide an author name as first argument.");
            return;
        }
        try {
            retrieveCourses(args[0]);
        } catch (Exception e) {
            System.out.println("Unexpected error: " + e.getMessage());
            e.printStackTrace();
        }
    }

    private static void retrieveCourses(String authorId) {
        System.out.println("Retrieving courses for author: " + authorId);
    }
}

Important points:

  • The signature main(String... args) uses Java varargs, equivalent to main(String[] args)
  • A high-level exception handler is introduced from the start to catch any unhandled exceptions in lower layers
  • IntelliJ allows configuring program arguments via Run → Edit Configurations → Program arguments

2.6 Introduction of a first dependency (SLF4J)

Why use a logging library?

System.out.println is insufficient for a real application because:

  • No log levels (DEBUG, INFO, WARN, ERROR)
  • No configuration without recompiling
  • No possibility of directing logs to different destinations (file, centralized system)

SLF4J: Simple Logging Facade for Java

The Java logging ecosystem is fragmented (Log4j, Logback, JDK logging). SLF4J is a facade that abstracts the underlying implementation. This allows:

  • Write code against a single API (SLF4J API)
  • To change the implementation without modifying the application code
Code applicatif → SLF4J API → [slf4j-simple | Logback | Log4j | JDK logging]

Checking for a Maven dependency

To find Maven coordinates for a library: https://search.maven.org Search for slf4j → select org.slf4j:slf4j-api

Added SLF4J dependencies in pom.xml

<dependencies>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.36</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-simple</artifactId>
        <version>1.7.36</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>
  • slf4j-api: the logging API that the code uses directly
  • slf4j-simple: simple logging implementation (scope runtime because we do not code against it)

Usage in code

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CourseRetriever {
    private static Logger LOG = LoggerFactory.getLogger(CourseRetriever.class);

    public static void main(String... args) {
        LOG.info("CourseRetriever starting");
        if (args.length == 0) {
            LOG.warn("Please provide an author name as first argument.");
            return;
        }
        // ...
    }
}

SLF4J parameterized messages avoid unnecessary string concatenation:

// À éviter
LOG.info("Retrieving courses for author '" + authorId + "'");

// Recommandé — la string n'est construite que si le niveau de log est actif
LOG.info("Retrieving courses for author '{}'", authorId);

Log levels:

MethodUsage
LOG.trace(...)Very detailed information (development only)
LOG.debug(...)Debug Info
LOG.info(...)Informative progress messages
LOG.warn(...)Non-blocking warnings
LOG.error(...)Important errors

2.7 Module 2 Summary

  • Creating a Java Maven project from scratch in IntelliJ
  • Correct configuration of Java 17 via maven.compiler.release
  • Understanding standard Maven directory structure
  • Introduction of essential Maven commands (mvn clean, mvn verify)
  • Adding an external dependency via Maven Central
  • Using SLF4J for logging instead of System.out.println

3. Calling an external web API

Module duration: 46m 54s

3.1 Overview

This module extends the CLI so that it can make HTTP calls to the Pluralsight API which returns courses for a given author. Concepts covered:

  • Java HttpClient (standard API since Java 11) for HTTP calls
  • Java Records (Java 16+) to model data returned by the API
  • Jackson to deserialize JSON into Java objects
  • JUnit 5 to write the first unit tests

3.2 Using the Java HttpClient

Separation of concerns: introduction of CourseRetrievalService

Rather than writing the HTTP code directly in CourseRetriever, we create a dedicated class CourseRetrievalService in the service package:

com.pluralsight.courseinfo.cli
├── CourseRetriever.java          ← Point d'entrée
└── service/
    ├── CourseRetrievalService.java  ← Logique d'appel HTTP
    ├── CourseStorageService.java    ← Logique de stockage
    └── PluralsightCourse.java       ← Modèle de données (record)

Java API Javadoc

To explore the Java 17 API:

HttpClient base code

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class CourseRetrievalService {

    private static final String PS_URI =
        "https://app.pluralsight.com/profile/data/author/%s/all-content";

    private static final HttpClient CLIENT = HttpClient.newHttpClient();

    public String getCoursesFor(String authorId) {
        HttpRequest request = HttpRequest
            .newBuilder(URI.create(PS_URI.formatted(authorId)))
            .GET()
            .build();

        try {
            HttpResponse<String> response =
                CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            return response.body();
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException("Could not call Pluralsight API", e);
        }
    }
}

Important points:

  • URI.create(PS_URI.formatted(authorId)) uses String.formatted() (Java 15+) instead of String.format()
  • HttpResponse.BodyHandlers.ofString() indicates that the response body should be treated as a String
  • The HttpClient instance is declared static final because it is thread-safe and reusable

3.3 Improvement of HttpClient

Pattern Builder to configure the client

private static final HttpClient CLIENT = HttpClient
    .newBuilder()
    .followRedirects(HttpClient.Redirect.ALWAYS)  // Suivre les redirections HTTP
    .build();

Managing HTTP status codes with a switch expression

Java 14 introduced switch expressions, a form of switch that returns a value:

public List<PluralsightCourse> getCoursesFor(String authorId) {
    HttpRequest request = HttpRequest
        .newBuilder(URI.create(PS_URI.formatted(authorId)))
        .GET()
        .build();

    try {
        HttpResponse<String> response =
            CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

        return switch (response.statusCode()) {
            case 200 -> toPluralsightCourses(response);
            case 404 -> List.of();
            default -> throw new RuntimeException(
                "Pluralsight API call failed with status code " + response.statusCode()
            );
        };
    } catch (IOException | InterruptedException e) {
        throw new RuntimeException("Could not call Pluralsight API", e);
    }
}

The switch expression is more concise than a chained if-else and allows the different cases to be clearly expressed.

3.4 Introduction of a Java Record

What is a Java Record?

Records (introduced in Java 16) are a type of class designed to model immutable data. They automatically generate:

  • A constructor with all parameters
  • Access methods (getters) for each component
  • equals(), hashCode(), toString()

Syntax of a record:

public record PluralsightCourse(String id, String title, String duration,
                                 String contentUrl, boolean isRetired) {
}

This is equivalent to a classic Java class with all these elements defined, but in a single line.

Access to record components

Unlike JavaBeans, a record’s access methods have the same name as the component (without a get prefix):

PluralsightCourse course = new PluralsightCourse("id", "Title", "01:30:00", "/url", false);
String title = course.title();       // "Title" — pas getTitlte()
boolean retired = course.isRetired(); // false

Adding a behavior method to a record

A record can contain normal methods. Here, we convert the duration (string) into minutes (long):

@JsonIgnoreProperties(ignoreUnknown = true)
public record PluralsightCourse(String id, String title, String duration,
                                 String contentUrl, boolean isRetired) {

    public long durationInMinutes() {
        return Duration.between(
            LocalTime.MIN,
            LocalTime.parse(duration())
        ).toMinutes();
    }
}

The java.time API:

  • LocalTime.parse(duration()) parses the string "01:30:00.123" into LocalTime
  • Duration.between(LocalTime.MIN, ...) calculates the duration since midnight
  • .toMinutes() converts to integer minutes

@JsonIgnoreProperties(ignoreUnknown = true)

The Jackson annotation indicates that properties present in the JSON but absent in the record should be ignored (rather than causing an error).

3.5 JSON binding with Jackson

Why Jackson?

There is no API in the Java Standard Library to map JSON to objects. Jackson is the most used library in the Java ecosystem for this task.

Added Jackson dependencies in pom.xml

<properties>
    <jackson.version>2.13.3</jackson.version>
</properties>

<dependencies>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>${jackson.version}</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-annotations</artifactId>
        <version>${jackson.version}</version>
    </dependency>
</dependencies>
  • jackson-databind: the JSON mapping engine ↔ Java objects
  • jackson-annotations: annotations (@JsonIgnoreProperties`, etc.)
  • jackson-core: transitive dependency of jackson-databind, automatically downloaded by Maven

Important: Never depend on a transitive dependency in your own code. If we use jackson-core directly, we must explicitly add it as a dependency.

Using ObjectMapper

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

private List<PluralsightCourse> toPluralsightCourses(HttpResponse<String> response)
        throws JsonProcessingException {
    JavaType returnType = OBJECT_MAPPER.getTypeFactory()
        .constructCollectionType(List.class, PluralsightCourse.class);
    return OBJECT_MAPPER.readValue(response.body(), returnType);
}
  • ObjectMapper is thread-safe and shareable → declared static final
  • JavaType is needed for generic type List<PluralsightCourse> due to type erasure in Java (type erasure)

Exploring Jackson source code in IntelliJ

IntelliJ offers a Download Sources button that downloads the source code of the libraries from Maven Central. This allows you to:

  • Read the Javadoc directly in the IDE
  • Navigating Libraries Implementation
  • Understand how APIs work

3.6 Course filtering

Once the PluralsightCourse list is available, you can filter the withdrawn courses with the Stream API:

With a lambda

List<PluralsightCourse> activeCourses = courses.stream()
    .filter(course -> !course.isRetired())
    .toList();

With a method reference and Predicate.not

import static java.util.function.Predicate.not;

List<PluralsightCourse> activeCourses = courses.stream()
    .filter(not(PluralsightCourse::isRetired))
    .toList();
  • PluralsightCourse::isRetired is a method reference that acts like a Predicate<PluralsightCourse>
  • Predicate.not(...) reverses this predicate
  • .toList() (Java 16+) creates an unmodifiable list from the stream

3.7 Writing the first unit tests (JUnit)

Why write tests?

  • Verify that the code does what it should do
  • Tests remain even if implementation changes
  • Allows you to refactor with confidence

Added JUnit 5 to pom.xml

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.8.2</version>
    <scope>test</scope>
</dependency>

The test scope means that this dependency is only available in the test code and is not included in the final JAR.

You must also configure the Maven Surefire Plugin for Maven to correctly execute JUnit 5 tests:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
</plugin>

Without this configuration, mvn test will return 0 tests executed because the default version of the plugin does not support JUnit 5.

First unit test

We test the durationInMinutes() method of the PluralsightCourse record:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class PluralsightCourseTest {

    @Test
    void durationInMinutes() {
        PluralsightCourse course =
            new PluralsightCourse("id", "Test course", "01:08:54.9613330", "url", false);
        assertEquals(68, course.durationInMinutes());
    }
}

JUnit 5 conventions:

  • @Test marks a method as test
  • Test method is package-private (no public)
  • assertEquals(expected, actual) — argument order is important for error messages

3.8 Setting up the unit test

Rather than duplicating the test for each case, we use @ParameterizedTest with @CsvSource:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class PluralsightCourseTest {

    @ParameterizedTest
    @CsvSource(textBlock = """
            01:08:54.9613330, 68
            00:05:37,          5
            00:00:00.0,        0
            """)
    void durationInMinutes(String input, long expected) {
        PluralsightCourse course =
            new PluralsightCourse("id", "Test course", input, "url", false);
        assertEquals(expected, course.durationInMinutes());
    }
}

Important points:

  • @ParameterizedTest replaces @Test and indicates that the test will be run multiple times
  • @CsvSource provides input data in CSV form
  • Text block (Java 15): a multiline text with """...""" which preserves the formatting
  • JUnit 5 automatically converts CSV strings to long for the expected parameter
  • The name of each test displayed matches the CSV line, making it easier to identify failures

3.9 Module 3 Summary

  • Using the standard Java HttpClient to call an external HTTP API
  • Managing HTTP status codes with a switch expression
  • Data modeling with an immutable Java Record
  • Parsing JSON with Jackson (ObjectMapper, @JsonIgnoreProperties)
  • Filtering a collection with the Stream API and Predicate.not
  • Writing unit tests with JUnit 5 (@Test, @ParameterizedTest, @CsvSource)
  • Using Java text blocks for multiline test data
  • Configuring Maven Surefire Plugin for running JUnit 5 tests

4. Storing data in a database

Module duration: 36m 21s

4.1 Overview

This module adds data persistence to a relational database. We introduce:

  • Maven modularization (multi-module project)
  • The Repository Pattern to abstract data access
  • The H2 database (embedded, written in Java)
  • The standard JDBC API for interacting with the database

4.2 Refactoring to multiple Maven modules

Why multiple Maven modules?

As the application grows, it is useful to separate related parts into separate Maven modules. Advantages:

  • Each module produces its own .jar
  • Dependencies between modules are declared explicitly in the POM
  • Avoids a “spaghetti” code base where everything depends on everything
  • Sharing configuration via parent POM

Multi-module structure

course-info/                    ← Parent POM
├── pom.xml                     ← POM parent (packaging = pom)
├── course-info-repository/     ← Module partagé (domain + JDBC)
│   ├── pom.xml
│   └── src/
├── course-info-cli/            ← Module CLI
│   ├── pom.xml
│   └── src/
└── course-info-server/         ← Module serveur REST
    ├── pom.xml
    └── src/

Parent POM — module declaration

<project>
    <groupId>com.pluralsight</groupId>
    <artifactId>course-info</artifactId>
    <packaging>pom</packaging>    ← Important : pas de code Java dans le parent
    <version>1.0-SNAPSHOT</version>

    <modules>
        <module>course-info-repository</module>
        <module>course-info-cli</module>
        <module>course-info-server</module>
    </modules>
    <!-- ... -->
</project>

Dependencies between modules

The course-info-cli module declares a dependency on course-info-repository:

<dependency>
    <groupId>com.pluralsight</groupId>
    <artifactId>course-info-repository</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

Maven builds modules in the correct order automatically.

4.3 Introduction of the Repository abstraction

The Pattern Repository

The goal is to introduce an abstraction (interface) that hides the details of the persistence implementation. The advantages:

  • Consumer code is written in terms of domain objects, not SQL
  • We can change the underlying database without affecting consumers
  • The code is more testable (we can pass an in-memory implementation in the tests)

Object of domain Race

Before creating the CourseRepository interface, we create a Course record representing a course in our system (separate from PluralsightCourse which is linked to the external API):

package com.pluralsight.courseinfo.domain;

import java.util.Optional;

public record Course(String id, String name, long length, String url, Optional<String> notes) {

    // Bloc de validation compact du constructeur
    public Course {
        filled(id);
        filled(name);
        filled(url);
        notes.ifPresent(Course::filled);
    }

    private static void filled(String s) {
        if (s == null || s.isBlank()) {
            throw new IllegalArgumentException("No value present!");
        }
    }
}

Important points:

  • The compact constructor (public Course { ... }) is specific to records
  • It allows adding validation without re-declaring all parameters
  • Optional<String> notes: notes are optional. We use Optional to make explicit the possible absence of value

Interface CourseRepository

package com.pluralsight.courseinfo.repository;

import com.pluralsight.courseinfo.domain.Course;
import java.util.List;

public interface CourseRepository {

    void saveCourse(Course course);

    List<Course> getAllCourses();

    void addNotes(String id, String notes);

    // Factory method statique pour créer une instance du repository
    static CourseRepository openCourseRepository(String databaseFile) {
        return new CourseJdbcRepository(databaseFile);
    }
}

The static factory method openCourseRepository allows consumers to obtain an instance without knowing the concrete implementation class CourseJdbcRepository.

4.4 Configuring the Repository with H2 and JDBC

H2 Database

H2 is an embedded SQL database, written in Java:

  • No separate server installation (Postgres, SQL Server, etc.)
  • Included as a library in the application (Maven dependency)
  • Storing data in a file on disk
  • AUTO_SERVER mode: allows several processes (CLI + server) to access the same database

JDBC (Java Database Connectivity)

JDBC is a standard Java library API for interacting with SQL databases:

  • Standardized: identical code regardless of the base vendor
  • Almost all databases provide a JDBC driver
  • High level libraries (Hibernate, Jooq) rely on JDBC

Adding H2 to the POM of the repository module

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.1.214</version>
</dependency>

Database initialization script

CREATE TABLE IF NOT EXISTS COURSES(
    ID      VARCHAR PRIMARY KEY NOT NULL,
    NAME    VARCHAR NOT NULL,
    LENGTH  INT     NOT NULL,
    URL     VARCHAR NOT NULL,
    NOTES   VARCHAR
);

This db_init.sql file is referenced in the JDBC URL with the parameter INIT=RUNSCRIPT FROM './db_init.sql', which ensures that the table is created if it does not already exist.

4.5 Implementation of the JDBC Repository

The CourseJdbcRepository class is package-private (no public) because consumers use the CourseRepository interface. The implementation is a detail.

H2 login URL

private static final String H2_DATABASE_URL =
    "jdbc:h2:file:%s;AUTO_SERVER=TRUE;INIT=RUNSCRIPT FROM './db_init.sql'";
  • file:%s: storage on disk, the %s is replaced by the file path
  • AUTO_SERVER=TRUE: mode which allows several concurrent connections
  • INIT=RUNSCRIPT: executes the initialization SQL script at startup

SQL queries with text blocks

private static final String INSERT_COURSE = """
        MERGE INTO Courses (id, name, length, url)
         VALUES (?, ?, ?, ?)
        """;

private static final String ADD_NOTES = """
        UPDATE Courses SET notes = ?
         WHERE id = ?
        """;
  • MERGE INTO offers upsert behavior: creates the course if it does not exist, updates it if it exists (based on primary key id)
  • The ? are placeholders of PreparedStatement

Importance of PreparedStatements

PreparedStatements (as opposed to simple Statements) are essential for security:

  • Protect against SQL injections (SQL injection)
  • Allow the database engine to cache the execution plan

Implementation of saveCourse

@Override
public void saveCourse(Course course) {
    executeStatement(INSERT_COURSE, statement -> {
        statement.setString(1, course.id());
        statement.setString(2, course.name());
        statement.setLong(3, course.length());
        statement.setString(4, course.url());
        statement.execute();
    }, "Failed to insert " + course);
}

Utility method to execute a statement

private void executeStatement(String sql, PreparedStatementConfigurer configurer, String errorMsg) {
    try (Connection connection = dataSource.getConnection()) {  // try-with-resources
        PreparedStatement statement = connection.prepareStatement(sql);
        configurer.configure(statement);
        statement.execute();
    } catch (SQLException e) {
        throw new RepositoryException(errorMsg, e);
    }
}

@FunctionalInterface
interface PreparedStatementConfigurer {
    void configure(PreparedStatement statement) throws SQLException;
}

Try-with-resources

JDBC connection implements AutoCloseable. We use try-with-resources to guarantee that the connection is always closed, even in the event of an exception:

try (Connection connection = dataSource.getConnection()) {
    // La connexion est automatiquement fermée à la fin du bloc,
    // qu'il y ait une exception ou non.
}

RepositoryException — domain exception

We do not propagate SQLException directly because this would expose the SQL implementation details to consumers of the interface:

package com.pluralsight.courseinfo.repository;

import java.sql.SQLException;

public class RepositoryException extends RuntimeException {
    public RepositoryException(String message, SQLException e) {
        super(message, e);
    }
}

By using a RuntimeException, we do not force consumers to have a try-catch around each call to the repository.

Implementation of getAllCourses

@Override
public List<Course> getAllCourses() {
    try (Connection connection = dataSource.getConnection()) {
        Statement statement = connection.createStatement();
        ResultSet resultSet = statement.executeQuery("SELECT * FROM COURSES");

        List<Course> courses = new ArrayList<>();
        while (resultSet.next()) {
            Course course = new Course(
                resultSet.getString(1),  // id
                resultSet.getString(2),  // name
                resultSet.getLong(3),    // length
                resultSet.getString(4),  // url
                Optional.ofNullable(resultSet.getString(5))  // notes
            );
            courses.add(course);
        }
        return Collections.unmodifiableList(courses);
    } catch (SQLException e) {
        throw new RepositoryException("Failed to retrieve courses", e);
    }
}

4.6 Using the Repository from the CLI

CourseStorageService

package com.pluralsight.courseinfo.cli.service;

import com.pluralsight.courseinfo.domain.Course;
import com.pluralsight.courseinfo.repository.CourseRepository;

import java.util.List;
import java.util.Optional;

public class CourseStorageService {
    private static final String PS_BASE_URL = "https://app.pluralsight.com";

    private final CourseRepository courseRepository;

    public CourseStorageService(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }

    public void storePluralsightCourses(List<PluralsightCourse> psCourses) {
        for (PluralsightCourse psCourse : psCourses) {
            Course course = new Course(
                psCourse.id(),
                psCourse.title(),
                psCourse.durationInMinutes(),
                PS_BASE_URL + psCourse.contentUrl(),
                Optional.empty()  // Pas de notes lors du stockage initial
            );
            courseRepository.saveCourse(course);
        }
    }
}

CourseRetriever updated

private static void retrieveCourses(String authorId) {
    LOG.info("Retrieving courses for author '{}'", authorId);
    CourseRetrievalService courseRetrievalService = new CourseRetrievalService();
    CourseRepository courseRepository =
        CourseRepository.openCourseRepository("./courses.db");
    CourseStorageService courseStorageService =
        new CourseStorageService(courseRepository);

    List<PluralsightCourse> coursesToStore = courseRetrievalService
        .getCoursesFor(authorId)
        .stream()
        .filter(not(PluralsightCourse::isRetired))
        .toList();

    LOG.info("Retrieved {} courses: {}", coursesToStore.size(), coursesToStore);
    courseStorageService.storePluralsightCourses(coursesToStore);
    LOG.info("Courses successfully stored");
}

Testing CourseStorageService

To test CourseStorageService without a real database, we use an in-memory implementation of the CourseRepository interface (stub):

class CourseStorageServiceTest {

    @Test
    void storePluralsightCourses() {
        CourseRepository repository = new InMemoryCourseRepository();
        CourseStorageService courseStorageService = new CourseStorageService(repository);

        PluralsightCourse ps1 = new PluralsightCourse(
            "1", "Title 1", "01:40:00.123", "/url-1", false
        );
        courseStorageService.storePluralsightCourses(List.of(ps1));

        Course expected = new Course(
            "1", "Title 1", 100, "https://app.pluralsight.com/url-1", Optional.empty()
        );
        assertEquals(List.of(expected), repository.getAllCourses());
    }

    // Implémentation en mémoire de CourseRepository pour les tests
    static class InMemoryCourseRepository implements CourseRepository {
        private final List<Course> courses = new ArrayList<>();

        @Override public void saveCourse(Course course) { courses.add(course); }
        @Override public List<Course> getAllCourses() { return courses; }
        @Override public void addNotes(String id, String notes) {
            throw new UnsupportedOperationException();
        }
    }
}

This illustrates how the Repository Pattern improves testability: one can test service logic without starting an actual database.

4.7 Summary of Module 4

  • Maven modularization: transformation of a single-module project into multi-module with parent POM
  • Repository Pattern: abstraction of the persistence layer behind an interface
  • Record Course: immutable domain object with validation in the compact constructor
  • H2: embedded SQL database, convenient for development and testing
  • JDBC: Standard API to interact with any SQL database
  • PreparedStatement: protection against SQL injections and better performance
  • Try-with-resources: automatic management of connection closure
  • RepositoryException: encapsulation of technical exceptions behind a domain exception

5. Creating a REST API

Module duration: 28m 45s

5.1 Overview

This module adds an HTTP server which exposes the courses stored in the database via a REST API. Technologies used:

  • JAX-RS: Jakarta EE specification for creating REST APIs in Java
  • Jersey 3: JAX-RS reference implementation
  • Grizzly: lightweight HTTP server integrated into Jersey

JAX-RS is an annotations-oriented API: we configure endpoints declaratively by annotating Java methods.

5.2 Creating a JAX-RS Resource

A JAX-RS resource is a Java class that describes and implements a REST endpoint.

JAX-RS dependencies in pom.xml of the server module

<dependency>
    <groupId>jakarta.ws.rs</groupId>
    <artifactId>jakarta.ws.rs-api</artifactId>
    <version>3.1.0</version>
</dependency>

Annotation @Path

@Path("courses")
public class CourseResource {
    // Tous les endpoints de cette classe seront sous /courses
}

Endpoint GET /courses

@GET
@Produces(MediaType.APPLICATION_JSON)
public Stream<Course> getCourses() {
    return courseRepository.getAllCourses()
        .stream()
        .sorted(comparing(Course::id));
}
  • @GET: HTTP GET method
  • @Produces: returned content type (application/json)
  • Sorting on Course::id guarantees a stable order in the responses

Injection via constructor

The CourseResource receives the CourseRepository from its constructor:

public class CourseResource {
    private final CourseRepository courseRepository;

    public CourseResource(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }
    // ...
}

This is an approach close to dependency injection but without a framework. We instantiate and wire the objects manually in the CourseServer class.

5.3 Exposing the resource via Jersey (HTTP)

Jersey dependencies in pom.xml

<properties>
    <jersey.version>3.0.4</jersey.version>
</properties>

<dependencies>
    <!-- Coeur de Jersey -->
    <dependency>
        <groupId>org.glassfish.jersey.core</groupId>
        <artifactId>jersey-server</artifactId>
        <version>${jersey.version}</version>
    </dependency>
    <!-- Serveur HTTP Grizzly pour Jersey -->
    <dependency>
        <groupId>org.glassfish.jersey.containers</groupId>
        <artifactId>jersey-container-grizzly2-http</artifactId>
        <version>${jersey.version}</version>
    </dependency>
    <!-- Framework d'injection HK2 (requis à runtime) -->
    <dependency>
        <groupId>org.glassfish.jersey.inject</groupId>
        <artifactId>jersey-hk2</artifactId>
        <version>${jersey.version}</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

CourseServer — server entry point

public class CourseServer {

    private static final Logger LOG = LoggerFactory.getLogger(CourseServer.class);
    private static final String BASE_URI = "http://localhost:8080/";

    public static void main(String... args) {
        String databaseFilename = loadDatabaseFilename();
        LOG.info("Starting HTTP server with database {}", databaseFilename);

        CourseRepository courseRepository =
            CourseRepository.openCourseRepository("./courses.db");

        ResourceConfig config = new ResourceConfig()
            .register(new CourseResource(courseRepository));

        GrizzlyHttpServerFactory.createHttpServer(URI.create(BASE_URI), config);
    }
    // ...
}

ResourceConfig is the Jersey entry point for registering JAX-RS resources. We pass the new CourseResource(courseRepository) instance directly instead of letting Jersey instantiate it (which would require an injection framework).

5.4 Returning JSON with JAX-RS and Jersey

Problem: MessageBodyWriter not found

When changing the return type from String to Stream<Course> with @Produces(APPLICATION_JSON), Jersey generates an error:

MessageBodyWriter not found for media type=application/json

Jersey cannot serialize a Java object to JSON without an additional module.

Solution: jersey-media-json-jackson

<dependency>
    <groupId>org.glassfish.jersey.media</groupId>
    <artifactId>jersey-media-json-jackson</artifactId>
    <version>${jersey.version}</version>
    <scope>runtime</scope>
</dependency>

This module integrates Jackson into Jersey for JSON serialization/deserialization. It is in runtime scope because we do not write code that references it directly.

Additional Jackson dependency for Java 8+ types

To get Jackson to properly serialize Optional<String>:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jdk8</artifactId>
    <version>${jackson.version}</version>
    <scope>runtime</scope>
</dependency>

5.5 Added storage of notes in the Repository

Modification of the Race record

We add Optional<String> notes as the 5th component:

public record Course(String id, String name, long length, String url, Optional<String> notes) {
    public Course {
        filled(id);
        filled(name);
        filled(url);
        notes.ifPresent(Course::filled);  // Validation si notes présentes
    }
    // ...
}

This change is not backwards compatible and causes compilation errors in:

  1. CourseStorageService → add Optional.empty() to instantiation of Course
  2. CourseStorageServiceTest → update Course constructs in tests
  3. CourseJdbcRepository.getAllCourses() → read the 5th notes column from the ResultSet

addNotes method in CourseJdbcRepository

@Override
public void addNotes(String id, String notes) {
    executeStatement(ADD_NOTES, statement -> {
        statement.setString(1, notes);
        statement.setString(2, id);
    }, "Failed to add notes to " + id);
}

5.6 Adding notes via REST API

Endpoint POST /courses/{id}/notes

@POST
@Path("/{id}/notes")
@Consumes(MediaType.TEXT_PLAIN)
public void addNotes(@PathParam("id") String id, String notes) {
    courseRepository.addNotes(id, notes);
}
  • @POST: HTTP POST method
  • @Path("/{id}/notes"): relative path with placeholder {id}
  • @Consumes(TEXT_PLAIN): request body is plain text
  • @PathParam("id"): binds the placeholder {id} to the parameter String id
  • Return void → HTTP response 204 No Content by default

Full endpoint URL

POST http://localhost:8080/courses/{courseId}/notes
Content-Type: text/plain

Voici mes notes sur ce cours.

Error handling in getCourses

@GET
@Produces(MediaType.APPLICATION_JSON)
public Stream<Course> getCourses() {
    try {
        return courseRepository
            .getAllCourses()
            .stream()
            .sorted(comparing(Course::id));
    } catch (RepositoryException e) {
        LOG.error("Could not retrieve courses from the database", e);
        throw new NotFoundException();  // HTTP 404
    }
}

5.7 Module 5 Summary

  • JAX-RS: Annotation-based API to create REST APIs
  • Jersey 3: JAX-RS implementation with integrated Grizzly HTTP server
  • ResourceConfig: saving JAX-RS resources in Jersey
  • @GET, @POST, @Path, @PathParam, @Produces, @Consumes: essential JAX-RS annotations
  • jersey-media-json-jackson: Jackson integration for JSON serialization in Jersey
  • Optional<String> in the Course record: explicit representation of optional values
  • Annotations enable a declarative programming style, very common in Java frameworks

6. Towards production

Module duration: 28m 9s

6.1 Unification of application logging

Problem: Two logging systems coexist

When the server starts, we observe two different log formats:

  1. SLF4J (our code) → a single line
  2. JDK logging (Internal Jersey) → two lines with different format

Jersey internally uses the java.util.logging API (JDK logging), different from SLF4J.

Solution: jul-to-slf4j bridge

Library jul-to-slf4j redirects all JDK logging API calls to SLF4J:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jul-to-slf4j</artifactId>
    <version>${slf4j.version}</version>
</dependency>

Activation of the bridge in CourseServer

The bridge must be installed before any log, using a static block:

static {
    LogManager.getLogManager().reset();   // Supprime les handlers JDK logging par défaut
    SLF4JBridgeHandler.install();         // Installe le bridge vers SLF4J
}

The static block executes when loading the class, ensuring that the bridge is active before any logging is used.

Result

All application logs (our code + Jersey) now use the same SLF4J format.

6.2 External configuration of the application

Why outsource configuration?

We do not want to hardcode values which:

  • Change between environments (dev, test, prod)
  • Should be controlled by the installer, not the developer
  • Would require recompilation to change

Possible approaches

ApproachUsage
Environment VariablesSimple, universal
Command line argumentsAlready used in the CLI tool
Configuration filesMore user-friendly for many settings

Here we use the Java properties files.

Creation of the server.properties file

Location: src/main/resources/server.properties (will be included in the JAR)

course-info.database=./courses.db

Loading into CourseServer

private static String loadDatabaseFilename() {
    try (InputStream propertiesStream =
             CourseServer.class.getResourceAsStream("/server.properties")) {
        Properties properties = new Properties();
        properties.load(propertiesStream);
        return properties.getProperty("course-info.database");
    } catch (IOException e) {
        throw new IllegalStateException("Could not load database filename");
    }
}
  • getResourceAsStream("/server.properties") loads the file from the classpath (absolute path with /)
  • Properties.load() parses the properties file
  • getProperty("course-info.database") gets the value

6.3 Maven dependency management (dependencyManagement)

Problem: Duplicating versions

SLF4J and Jackson versions are duplicated in multiple POM files. If you want to update them, you have to modify several files.

Solution 1: Properties in parent POM

Move version properties to parent POM:

<!-- Dans le pom.xml parent -->
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>17</maven.compiler.release>
    <slf4j.version>1.7.36</slf4j.version>
    <jackson.version>2.13.3</jackson.version>
</properties>

All submodules inherit these properties and can refer to them with ${slf4j.version}.

Solution 2: dependencyManagement section

The dependencyManagement section in the parent POM allows dependency versions and scopes to be declared only once. The submodules declare the dependency without version:

<!-- Dans le pom.xml parent -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>${slf4j.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.8.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>${jackson.version}</version>
        </dependency>
        <!-- ... -->
    </dependencies>
</dependencyManagement>

In the submodules, we declare the dependency without version:

<!-- Dans course-info-cli/pom.xml -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <!-- Pas de version : héritée du parent via dependencyManagement -->
</dependency>

Benefit: To update SLF4J, change slf4j.version in a single file.

6.4 Creating a standalone executable JAR

Problem

The .jar produced by Maven only contains our code. To run the application, you would need to provide all dependencies on the classpath:

# Ingérable en production
java -cp target/course-info-server.jar:lib/slf4j-api.jar:lib/jersey-server.jar:... \
     com.pluralsight.courseinfo.server.CourseServer

Solution: Maven Shade Plugin

The Maven Shade Plugin creates a fat JAR (also called uber JAR) that contains our code and all dependencies in a single file:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.3.0</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <transformers>
                            <!-- Définit la Main-Class dans le manifeste MANIFEST.MF -->
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>com.pluralsight.courseinfo.server.CourseServer</mainClass>
                            </transformer>
                            <!-- Fusionne les fichiers META-INF/services (nécessaire pour Jersey) -->
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Build and run

# Construire le fat JAR
mvn clean package

# Exécuter le serveur — une seule commande
java -jar target/course-info-server-1.0-SNAPSHOT.jar

The ServicesResourceTransformer is necessary because Jersey uses Java’s SPI (Service Provider Interface) mechanism to find its implementations, via files in META-INF/services/. This transformer correctly merges these files from the different JARs.

6.5 Next steps for the project

Ideas for extending the course-info system:

Functional improvements:

  • Add a new course source (other than Pluralsight) in the CLI tool
  • Add an endpoint DELETE /courses/{id} (requires repository method + @DELETE in CourseResource)
  • Add update endpoint (PUT /courses/{id})
  • Create a course via REST API (POST /courses)

Technical improvements:

  • Replace H2 with PostgreSQL (JDBC driver available, schema management with Flyway or Liquibase)
  • Introduce integration tests for the repository layer with a real H2 database
  • Set up a CI/CD pipeline (GitHub Actions for example) which launches the tests automatically
  • Create a Docker image to distribute the application
  • Add pagination to the GET /courses endpoint

6.6 Next steps for the learner

Practical: The best way to progress is to code. Extend the course-info project or create a new application.

Java frameworks to explore:

  • Spring Framework / Spring Boot: the most popular Java framework, learning path available on Pluralsight
  • Jakarta EE Web Profile: Java Enterprise, preview available with Jakarta EE Web Profile: The Big Picture
  • Quarkus / Micronaut: modern cloud and microservices-oriented frameworks

Specific areas for further investigation:

  • Maven → Maven Fundamentals (Pluralsight)
  • Java HttpClient → Java Fundamentals: HttpClient (Pluralsight)
  • Java Records → Java SE 17 Advanced Language Features (Pluralsight)
  • JUnit 5 → Java SE 17 Unit Testing with JUnit (Pluralsight)
  • JDBC → Java Core Libraries: JDBC 4 (Pluralsight)
  • JAX-RS / Jersey → Jersey 3 Fundamentals (Pluralsight)

7. Complete project architecture

File structure

course-info/
├── pom.xml                                         ← POM parent (multi-module)
├── db_init.sql                                     ← Script SQL d'initialisation
├── sander-mak.json                                 ← Données JSON de test
│
├── course-info-repository/                         ← Module partagé (domain + JDBC)
│   ├── pom.xml
│   └── src/
│       ├── main/java/com/pluralsight/courseinfo/
│       │   ├── domain/
│       │   │   └── Course.java                    ← Record domaine
│       │   └── repository/
│       │       ├── CourseRepository.java           ← Interface
│       │       ├── CourseJdbcRepository.java       ← Implémentation JDBC (package-private)
│       │       └── RepositoryException.java        ← Exception domaine
│       └── test/java/com/pluralsight/courseinfo/
│           └── domain/
│               └── CourseTest.java
│
├── course-info-cli/                                ← Module CLI tool
│   ├── pom.xml
│   └── src/
│       ├── main/java/com/pluralsight/courseinfo/cli/
│       │   ├── CourseRetriever.java                ← Main class
│       │   └── service/
│       │       ├── CourseRetrievalService.java     ← Appels API HTTP
│       │       ├── CourseStorageService.java       ← Stockage via Repository
│       │       └── PluralsightCourse.java          ← Record (données API Pluralsight)
│       └── test/java/com/pluralsight/courseinfo/cli/service/
│           ├── CourseStorageServiceTest.java
│           └── PluralsightCourseTest.java
│
└── course-info-server/                             ← Module serveur REST
    ├── pom.xml
    └── src/
        └── main/
            ├── java/com/pluralsight/courseinfo/server/
            │   ├── CourseServer.java               ← Main class (Grizzly + Jersey)
            │   └── CourseResource.java             ← Resource JAX-RS
            └── resources/
                └── server.properties               ← Configuration externe

Module dependency graph

course-info-cli ──────────────────────────────────► course-info-repository
       │                                                       │
       │  (utilise CourseRepository, Course)                   │  (définit Course,
       │                                                       │   CourseRepository,
       │                                                       │   CourseJdbcRepository)
course-info-server ────────────────────────────────►           │
       │                                                       │
       │  (utilise CourseRepository, Course)                   ▼
       │                                               H2 Database (JDBC)
       ▼
JAX-RS / Jersey (REST API HTTP)

CLI tool execution flow

main(args) → retrieveCourses(authorId)
    │
    ├─► CourseRetrievalService.getCoursesFor(authorId)
    │       │
    │       ├─► HttpClient.send(request)  →  API Pluralsight
    │       ├─► ObjectMapper.readValue()  →  List<PluralsightCourse>
    │       └─► return List<PluralsightCourse>
    │
    └─► CourseStorageService.storePluralsightCourses(courses)
            │
            └─► CourseRepository.saveCourse(course)
                    │
                    └─► JDBC MERGE INTO Courses ...  →  H2 (courses.db)

REST server execution flow

main() → CourseServer.main()
    │
    ├─► loadDatabaseFilename()  (depuis server.properties)
    ├─► CourseRepository.openCourseRepository("./courses.db")
    ├─► new CourseResource(courseRepository)
    ├─► ResourceConfig.register(courseResource)
    └─► GrizzlyHttpServerFactory.createHttpServer(BASE_URI, config)
            │
            ▼
    GET /courses
        │
        └─► CourseResource.getCourses()
                │
                └─► courseRepository.getAllCourses()  →  H2  →  List<Course>
                    stream().sorted(comparing(Course::id)).return JSON

    POST /courses/{id}/notes  (body: text plain)
        │
        └─► CourseResource.addNotes(id, notes)
                │
                └─► courseRepository.addNotes(id, notes)  →  H2 UPDATE

8. Full Source Code — Configuration Files

8.1 parent pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.pluralsight</groupId>
    <artifactId>course-info</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <modules>
        <module>course-info-repository</module>
        <module>course-info-cli</module>
        <module>course-info-server</module>
    </modules>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
        <slf4j.version>1.7.36</slf4j.version>
        <jackson.version>2.13.3</jackson.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-api</artifactId>
                <version>${slf4j.version}</version>
            </dependency>
            <dependency>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-simple</artifactId>
                <version>${slf4j.version}</version>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter</artifactId>
                <version>5.8.2</version>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>${jackson.version}</version>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-annotations</artifactId>
                <version>${jackson.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.10.1</version>
            </plugin>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

</project>

8.2 pom.xml — course-info-repository

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.pluralsight</groupId>
        <artifactId>course-info</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>com.pluralsight</groupId>
    <artifactId>course-info-repository</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>
    <name>Course Info Storage</name>

    <dependencies>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>2.1.214</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
        </dependency>
    </dependencies>

</project>

8.3 pom.xml — course-info-cli

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>course-info</artifactId>
        <groupId>com.pluralsight</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>course-info-cli</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.pluralsight</groupId>
            <artifactId>course-info-repository</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
        </dependency>
    </dependencies>

</project>

8.4 pom.xml — course-info-server

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.pluralsight</groupId>
        <artifactId>course-info</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>com.pluralsight</groupId>
    <artifactId>course-info-server</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>
    <name>Course Info API server</name>

    <properties>
        <jersey.version>3.0.4</jersey.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.pluralsight</groupId>
            <artifactId>course-info-repository</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>jul-to-slf4j</artifactId>
            <version>${slf4j.version}</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jdk8</artifactId>
            <version>${jackson.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.ws.rs</groupId>
            <artifactId>jakarta.ws.rs-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.core</groupId>
            <artifactId>jersey-server</artifactId>
            <version>${jersey.version}</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.inject</groupId>
            <artifactId>jersey-hk2</artifactId>
            <version>${jersey.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-json-jackson</artifactId>
            <version>${jersey.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-grizzly2-http</artifactId>
            <version>${jersey.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.3.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>com.pluralsight.courseinfo.server.CourseServer</mainClass>
                                </transformer>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

9. Full source code — Course info repository module

9.1 Course.java (domain)

package com.pluralsight.courseinfo.domain;

import java.util.Optional;

public record Course(String id, String name, long length, String url, Optional<String> notes) {

    // Constructeur compact : validation des champs obligatoires
    public Course {
        filled(id);
        filled(name);
        filled(url);
        notes.ifPresent(Course::filled);
    }

    private static void filled(String s) {
        if (s == null || s.isBlank()) {
            throw new IllegalArgumentException("No value present!");
        }
    }
}

Explanations:

  • record: Java 16+ immutable data class. Automatically generate constructor, getters, equals, hashCode, toString
  • Compact constructor (public Course { ... }): special form without re-declaring parameters, executed after component assignment
  • Optional<String> notes: explicit representation of a potentially absent value. Preferable to null to force the consumer code to handle the absent case
  • notes.ifPresent(Course::filled): calls filled only if notes are present

9.2 CourseRepository.java (interface)

package com.pluralsight.courseinfo.repository;

import com.pluralsight.courseinfo.domain.Course;
import java.util.List;

public interface CourseRepository {

    void saveCourse(Course course);

    List<Course> getAllCourses();

    void addNotes(String id, String notes);

    // Factory method statique — masque l'implémentation concrète
    static CourseRepository openCourseRepository(String databaseFile) {
        return new CourseJdbcRepository(databaseFile);
    }
}

Explanations:

  • Pure interface: defines the contract without implementation details
  • The static factory method openCourseRepository allows consumers to obtain an instance without knowing CourseJdbcRepository
  • CourseJdbcRepository is package-private (no public) → external consumers cannot instantiate it directly

9.3 CourseJdbcRepository.java (JDBC implementation)

package com.pluralsight.courseinfo.repository;

import com.pluralsight.courseinfo.domain.Course;
import org.h2.jdbcx.JdbcDataSource;

import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

class CourseJdbcRepository implements CourseRepository {

    private static final String H2_DATABASE_URL =
            "jdbc:h2:file:%s;AUTO_SERVER=TRUE;INIT=RUNSCRIPT FROM './db_init.sql'";

    // SQL avec text blocks Java 15+
    private static final String INSERT_COURSE = """
            MERGE INTO Courses (id, name, length, url)
             VALUES (?, ?, ?, ?)
            """;

    private static final String ADD_NOTES = """
            UPDATE Courses SET notes = ?
             WHERE id = ?
            """;

    private final DataSource dataSource;

    CourseJdbcRepository(String databaseFile) {
        JdbcDataSource jdbcDataSource = new JdbcDataSource();
        jdbcDataSource.setURL(H2_DATABASE_URL.formatted(databaseFile));
        this.dataSource = jdbcDataSource;
    }

    @Override
    public List<Course> getAllCourses() {
        try (Connection connection = dataSource.getConnection()) {
            Statement statement = connection.createStatement();
            ResultSet resultSet = statement.executeQuery("SELECT * FROM COURSES");

            List<Course> courses = new ArrayList<>();
            while (resultSet.next()) {
                Course course = new Course(
                        resultSet.getString(1),   // id
                        resultSet.getString(2),   // name
                        resultSet.getLong(3),     // length
                        resultSet.getString(4),   // url
                        Optional.ofNullable(resultSet.getString(5))  // notes
                );
                courses.add(course);
            }
            return Collections.unmodifiableList(courses);
        } catch (SQLException e) {
            throw new RepositoryException("Failed to retrieve courses", e);
        }
    }

    @Override
    public void saveCourse(Course course) {
        executeStatement(INSERT_COURSE, statement -> {
            statement.setString(1, course.id());
            statement.setString(2, course.name());
            statement.setLong(3, course.length());
            statement.setString(4, course.url());
            statement.execute();
        }, "Failed to insert " + course);
    }

    @Override
    public void addNotes(String id, String notes) {
        executeStatement(ADD_NOTES, statement -> {
            statement.setString(1, notes);
            statement.setString(2, id);
        }, "Failed to add notes to " + id);
    }

    private void executeStatement(String sql,
                                   PreparedStatementConfigurer configurer,
                                   String errorMsg) {
        try (Connection connection = dataSource.getConnection()) {
            PreparedStatement statement = connection.prepareStatement(sql);
            configurer.configure(statement);
            statement.execute();
        } catch (SQLException e) {
            throw new RepositoryException(errorMsg, e);
        }
    }

    // Interface fonctionnelle personnalisée pour configurer un PreparedStatement
    @FunctionalInterface
    interface PreparedStatementConfigurer {
        void configure(PreparedStatement statement) throws SQLException;
    }
}

Key explanations:

  • Package-private class (class without public): implementation encapsulation
  • MERGE INTO: upsert behavior — create if absent, update if present (based on primary key id)
  • PreparedStatement with ?: prevent SQL injections. Values are never interpolated in SQL query
  • Try-with-resources: try (Connection conn = ...) guarantees the connection is closed even in the event of an exception
  • @FunctionalInterface PreparedStatementConfigurer: custom functional interface allowing you to pass PreparedStatement configuration code as lambda
  • Collections.unmodifiableList(): returns an unmodifiable list to protect internal state

9.4 RepositoryException.java

package com.pluralsight.courseinfo.repository;

import java.sql.SQLException;

public class RepositoryException extends RuntimeException {
    public RepositoryException(String message, SQLException e) {
        super(message, e);
    }
}

Explanations:

  • Inherits from RuntimeException: consumers are not required to catch it (unchecked exception)
  • Encapsulate SQLException: SQL detail is not exposed in the signature of the CourseRepository interface
  • Original exception is kept as cause (exception chaining) for debugging

9.5 db_init.sql

CREATE TABLE IF NOT EXISTS COURSES(
    ID      VARCHAR PRIMARY KEY NOT NULL,
    NAME    VARCHAR             NOT NULL,
    LENGTH  INT                 NOT NULL,
    URL     VARCHAR             NOT NULL,
    NOTES   VARCHAR
);

Explanations:

  • IF NOT EXISTS: idempotent — can be executed multiple times without error
  • NOTES VARCHAR without NOT NULL: the column can contain NULL (optional notes)
  • This script is run automatically via the INIT=RUNSCRIPT FROM './db_init.sql' parameter in the JDBC H2 URL

9.6 CourseTest.java

package com.pluralsight.courseinfo.domain;

import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class CourseTest {

    @Test
    void rejectNullComponents() {
        assertThrows(IllegalArgumentException.class, () ->
                new Course(null, null, 1, null, Optional.empty()));
    }

    @Test
    void rejectBlankNotes() {
        assertThrows(IllegalArgumentException.class, () ->
                new Course("1", "title", 1, "url", Optional.of("")));
    }
}

Explanations:

  • assertThrows(ExceptionClass, () -> ...): checks that an exception is thrown
  • These tests validate the compact constructor of the Course record
  • Optional.of(""): notes present but empty — must be rejected by validation

10. Complete source code — Course info cli module

10.1 CourseRetriever.java (main)

package com.pluralsight.courseinfo.cli;

import com.pluralsight.courseinfo.cli.service.CourseRetrievalService;
import com.pluralsight.courseinfo.cli.service.CourseStorageService;
import com.pluralsight.courseinfo.cli.service.PluralsightCourse;
import com.pluralsight.courseinfo.repository.CourseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

import static java.util.function.Predicate.not;

public class CourseRetriever {

    private static Logger LOG = LoggerFactory.getLogger(CourseRetriever.class);

    public static void main(String... args) {
        LOG.info("CourseRetriever starting");
        if (args.length == 0) {
            LOG.warn("Please provide an author name as first argument.");
            return;
        }

        try {
            retrieveCourses(args[0]);
        } catch (Exception e) {
            LOG.error("Unexpected error", e);
        }
    }

    private static void retrieveCourses(String authorId) {
        LOG.info("Retrieving courses for author '{}'", authorId);

        CourseRetrievalService courseRetrievalService = new CourseRetrievalService();
        CourseRepository courseRepository =
                CourseRepository.openCourseRepository("./courses.db");
        CourseStorageService courseStorageService =
                new CourseStorageService(courseRepository);

        List<PluralsightCourse> coursesToStore = courseRetrievalService
                .getCoursesFor(authorId)
                .stream()
                .filter(not(PluralsightCourse::isRetired))
                .toList();

        LOG.info("Retrieved the following {} courses {}",
                coursesToStore.size(), coursesToStore);
        courseStorageService.storePluralsightCourses(coursesToStore);
        LOG.info("Courses successfully stored");
    }
}

Explanations:

  • main(String... args): Java varargs, equivalent to main(String[] args)
  • High-level exception handler (try-catch Exception): centralizes the handling of unexpected errors
  • not(PluralsightCourse::isRetired): Predicate.not with method reference (static import)
  • .toList(): Java 16, creates an unmodifiable list from a stream

10.2 CourseRetrievalService.java

package com.pluralsight.courseinfo.cli.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;

public class CourseRetrievalService {

    // URL de l'API Pluralsight — %s sera remplacé par l'authorId
    // Fallback local (pour les cas où l'API n'est pas accessible) :
    // private static final String PS_URI =
    //   "https://raw.githubusercontent.com/sandermak-ps/course-info-java-17/master/sander-mak.json";
    private static final String PS_URI =
            "https://app.pluralsight.com/profile/data/author/%s/all-content";

    // HttpClient partagé, thread-safe, configuré une seule fois
    private static final HttpClient CLIENT = HttpClient
            .newBuilder()
            .followRedirects(HttpClient.Redirect.ALWAYS)  // Suivre les redirections
            .build();

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public List<PluralsightCourse> getCoursesFor(String authorId) {
        HttpRequest request = HttpRequest
                .newBuilder(URI.create(PS_URI.formatted(authorId)))
                .GET()
                .build();

        try {
            HttpResponse<String> response =
                    CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

            // Switch expression — retourne une valeur selon le code de statut HTTP
            return switch (response.statusCode()) {
                case 200 -> toPluralsightCourses(response);
                case 404 -> List.of();
                default -> throw new RuntimeException(
                        "Pluralsight API call failed with status code "
                                + response.statusCode());
            };
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException("Could not call Pluralsight API", e);
        }
    }

    private List<PluralsightCourse> toPluralsightCourses(HttpResponse<String> response)
            throws JsonProcessingException {
        // JavaType est nécessaire à cause de l'effacement de type (type erasure)
        // pour le type générique List<PluralsightCourse>
        JavaType returnType = OBJECT_MAPPER.getTypeFactory()
                .constructCollectionType(List.class, PluralsightCourse.class);
        return OBJECT_MAPPER.readValue(response.body(), returnType);
    }
}

Explanations:

  • HttpClient.Redirect.ALWAYS: automatically follow HTTP redirects (301, 302, etc.)
  • Switch expression (Java 14+): switch (expr) { case x -> value; ... } returns a value
  • catch (IOException | InterruptedException e): multi-catch with |
  • JavaType + constructCollectionType: bypassing Java type erasure to deserialize a List<PluralsightCourse> with Jackson

10.3 PluralsightCourse.java (record)

package com.pluralsight.courseinfo.cli.service;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import java.time.Duration;
import java.time.LocalTime;

// Ignorer les propriétés JSON qui ne correspondent à aucun composant du record
@JsonIgnoreProperties(ignoreUnknown = true)
public record PluralsightCourse(
        String id,
        String title,
        String duration,       // Format: "HH:mm:ss.SSSSSSS" (ex: "01:08:54.9613330")
        String contentUrl,
        boolean isRetired) {

    public long durationInMinutes() {
        // LocalTime.parse() parse la durée comme une heure (HH:mm:ss)
        // Duration.between(LocalTime.MIN, ...) calcule la durée depuis minuit
        return Duration.between(
                LocalTime.MIN,
                LocalTime.parse(duration())
        ).toMinutes();
    }
}

Explanations:

  • Java Record (Java 16): immutable data class. Components are declared in parentheses
  • @JsonIgnoreProperties(ignoreUnknown = true): Jackson ignores unmapped JSON fields (robustness in the face of API evolution)
  • duration(): access method automatically generated by the record (no get prefix)
  • LocalTime.parse(): parse a string like "01:08:54.9613330" into a LocalTime object
  • LocalTime.MIN: represents 00:00:00 — used as a starting point to calculate the duration

10.4 CourseStorageService.java

package com.pluralsight.courseinfo.cli.service;

import com.pluralsight.courseinfo.domain.Course;
import com.pluralsight.courseinfo.repository.CourseRepository;

import java.util.List;
import java.util.Optional;

public class CourseStorageService {

    private static final String PS_BASE_URL = "https://app.pluralsight.com";

    private final CourseRepository courseRepository;

    public CourseStorageService(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }

    public void storePluralsightCourses(List<PluralsightCourse> psCourses) {
        for (PluralsightCourse psCourse : psCourses) {
            Course course = new Course(
                    psCourse.id(),
                    psCourse.title(),
                    psCourse.durationInMinutes(),
                    PS_BASE_URL + psCourse.contentUrl(),  // URL partielle → URL complète
                    Optional.empty()                       // Pas de notes à l'insertion
            );
            courseRepository.saveCourse(course);
        }
    }
}

Explanations:

  • Translate PluralsightCourse (external API DTO) to Course (domain object)
  • PS_BASE_URL + psCourse.contentUrl(): the Pluralsight API returns relative paths (ex: /library/courses/...), we prefix them with the base domain
  • Optional.empty(): notes are missing on initial insertion

10.5 CourseStorageServiceTest.java

package com.pluralsight.courseinfo.cli.service;

import com.pluralsight.courseinfo.domain.Course;
import com.pluralsight.courseinfo.repository.CourseRepository;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

class CourseStorageServiceTest {

    @Test
    void storePluralsightCourses() {
        // Utilisation d'un repository en mémoire (stub) — pas de vraie base de données
        CourseRepository repository = new InMemoryCourseRepository();
        CourseStorageService courseStorageService = new CourseStorageService(repository);

        PluralsightCourse ps1 = new PluralsightCourse(
                "1", "Title 1", "01:40:00.123", "/url-1", false);
        courseStorageService.storePluralsightCourses(List.of(ps1));

        Course expected = new Course(
                "1", "Title 1", 100,
                "https://app.pluralsight.com/url-1",
                Optional.empty());
        assertEquals(List.of(expected), repository.getAllCourses());
    }

    // Stub — implémentation en mémoire de CourseRepository pour les tests
    static class InMemoryCourseRepository implements CourseRepository {

        private final List<Course> courses = new ArrayList<>();

        @Override
        public void saveCourse(Course course) {
            courses.add(course);
        }

        @Override
        public List<Course> getAllCourses() {
            return courses;
        }

        @Override
        public void addNotes(String id, String notes) {
            throw new UnsupportedOperationException();
        }
    }
}

Explanations:

  • End-to-end testing of the service without real database
  • InMemoryCourseRepository implements CourseRepository by storing courses in memory (stub)
  • The duration "01:40:00.123" must be converted to 100 minutes (1h40)
  • URL "/url-1" must be prefixed with "https://app.pluralsight.com""https://app.pluralsight.com/url-1"
  • Illustrates how the CourseRepository interface improves testability

10.6 PluralsightCourseTest.java

package com.pluralsight.courseinfo.cli.service;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

class PluralsightCourseTest {

    @ParameterizedTest
    @CsvSource(textBlock = """
            01:08:54.9613330, 68
            00:05:37,          5
            00:00:00.0,        0
            """)
    void durationInMinutes(String input, long expected) {
        PluralsightCourse course =
                new PluralsightCourse("id", "Test course", input, "url", false);
        assertEquals(expected, course.durationInMinutes());
    }
}

Explanations:

  • @ParameterizedTest: JUnit 5 runs the test once per line of the CSV
  • @CsvSource(textBlock = """..."""): Java 15+ text block for multiline data
  • JUnit 5 automatically converts CSV values to parameter types (String, long)
  • Three test cases: normal duration with milliseconds, short duration, zero duration
  • The name of each test run in the results matches the CSV line

11. Full source code — Course info server module

11.1 CourseServer.java (main)

package com.pluralsight.courseinfo.server;

import com.pluralsight.courseinfo.repository.CourseRepository;
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;
import org.glassfish.jersey.server.ResourceConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Properties;
import java.util.logging.LogManager;

public class CourseServer {

    // Bloc statique : exécuté au chargement de la classe, avant main()
    // Redirige le JDK logging (java.util.logging) vers SLF4J
    static {
        LogManager.getLogManager().reset();  // Supprime les handlers JDK logging par défaut
        SLF4JBridgeHandler.install();        // Installe le bridge JUL → SLF4J
    }

    private static final Logger LOG = LoggerFactory.getLogger(CourseServer.class);
    private static final String BASE_URI = "http://localhost:8080/";

    public static void main(String... args) {
        String databaseFilename = loadDatabaseFilename();
        LOG.info("Starting HTTP server with database {}", databaseFilename);

        CourseRepository courseRepository =
                CourseRepository.openCourseRepository("./courses.db");

        // Configuration Jersey : enregistrement de la resource JAX-RS
        ResourceConfig config = new ResourceConfig()
                .register(new CourseResource(courseRepository));

        // Démarrage du serveur HTTP Grizzly
        GrizzlyHttpServerFactory.createHttpServer(URI.create(BASE_URI), config);
    }

    private static String loadDatabaseFilename() {
        try (InputStream propertiesStream =
                     CourseServer.class.getResourceAsStream("/server.properties")) {
            Properties properties = new Properties();
            properties.load(propertiesStream);
            return properties.getProperty("course-info.database");
        } catch (IOException e) {
            throw new IllegalStateException("Could not load database filename");
        }
    }
}

Explanations:

  • Static block (static { ... }): executed only once when the class is first loaded, before main()
  • SLF4JBridgeHandler: bridge of the jul-to-slf4j library which redirects all calls to JDK logging (used by Jersey) to SLF4J
  • LogManager.getLogManager().reset(): removes the default console handler from JDK logging to avoid duplicates
  • ResourceConfig.register(new CourseResource(...)): we instantiate CourseResource manually (rather than letting Jersey do it via reflection) to pass the CourseRepository by constructor
  • getResourceAsStream("/server.properties"): load the file from the classpath (the initial / indicates the root of the classpath)

11.2 CourseResource.java (JAX-RS)

package com.pluralsight.courseinfo.server;

import com.pluralsight.courseinfo.domain.Course;
import com.pluralsight.courseinfo.repository.CourseRepository;
import com.pluralsight.courseinfo.repository.RepositoryException;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.stream.Stream;

import static java.util.Comparator.comparing;

@Path("courses")                            // Tous les endpoints sont sous /courses
public class CourseResource {

    private static final Logger LOG = LoggerFactory.getLogger(CourseResource.class);

    private CourseRepository courseRepository;

    public CourseResource(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)   // Produit du JSON
    public Stream<Course> getCourses() {
        try {
            return courseRepository
                    .getAllCourses()
                    .stream()
                    .sorted(comparing(Course::id));  // Tri stable par ID
        } catch (RepositoryException e) {
            LOG.error("Could not retrieve courses from the database", e);
            throw new NotFoundException();  // HTTP 404
        }
    }

    @POST
    @Path("/{id}/notes")                    // Chemin : /courses/{id}/notes
    @Consumes(MediaType.TEXT_PLAIN)         // Corps de la requête : texte brut
    public void addNotes(@PathParam("id") String id, String notes) {
        courseRepository.addNotes(id, notes);
        // Retour void → HTTP 204 No Content
    }
}

Explanations of JAX-RS annotations:

AnnotationRole
@Path("courses")Prefix the path of all endpoints in the class
@GETResponds to HTTP GET requests
@POSTResponds to HTTP POST requests
@Produces(APPLICATION_JSON)The response body is JSON
@Consumes(TEXT_PLAIN)The expected request body is plain text
@Path("/{id}/notes")Relative path with dynamic placeholder {id}
@PathParam("id")Binds the placeholder {id} to the Java parameter String id
  • Stream<Course> in return: Jersey (via jackson) serializes the stream into a JSON array
  • NotFoundException(): JAX-RS exception which automatically generates an HTTP 404 response

11.3 server.properties

course-info.database=./courses.db

This file is placed in src/main/resources/ and will be included in the JAR during the Maven build. It is loaded from the classpath via getResourceAsStream("/server.properties").


12. Example data — sander mak.json

File sander-mak.json contains Pluralsight API sample data for author sander-mak. It is used as a local fallback if the Pluralsight API is not accessible.

Structure of a course object in this JSON:

{
  "title": "What's New in Java 15",
  "id": "b6e31e25-ed0b-4bd1-8d1a-4854f63a268c",
  "status": "published",
  "level": "Intermediate",
  "duration": "01:08:54.9613330",
  "displayDate": "2020-10-28T00:00:00+00:00",
  "contentRating": {
    "averageRating": 4.94444,
    "numberOfRaters": 36
  },
  "authors": [
    {
      "handle": "sander-mak",
      "firstName": "Sander",
      "lastName": "Mak"
    }
  ],
  "type": "course",
  "contentUrl": "/library/courses/java-15-whats-new",
  "isRetired": false,
  "isNew": false
}

Matching record PluralsightCourse:

JSON fieldRecord componentJava type
ididString
titletitleString
durationdurationString (converted to minutes via durationInMinutes())
contentUrlcontentUrlString (prefixed with https://app.pluralsight.com)
isRetiredisRetiredboolean
All other fieldsIgnored by @JsonIgnoreProperties(ignoreUnknown = true)


Search Terms

application · java · se · backend · architecture · full-stack · web · pom.xml · maven · repository · record · source · dependencies · dependency · jackson · added · api · database · jax-rs · jdbc · logging · method · server · cli

Interested in this course?

Contact us to book it or get a custom training plan for your team.