This lesson closes the module, and it settles three debts that have been waiting for whole modules.

The first was incurred in 09-06. There, to read the response of a book-metadata API, you wrote a JSON parser with indexOf and substring. It was described in so many words as a teaching stopgap to be resolved here. That code breaks with the first escape character, with the first nested object and with the first field the API decides to add.

The second comes from 03-09 and got worse in 11-03: BiblioTech's entities carry fifty lines of getters, equals, hashCode and toString written by hand. Code that contributes nothing, has to be maintained and is easy to get wrong.

The third is from 06-07: BiblioTech's logging is java.util.logging, chosen back then to avoid adding dependencies, with the limitation already pointed out there. And mvn dependency:tree has shown you three times that Spring Boot already brought you SLF4J and Logback without you asking.

All three have something in common, and it is what sets this lesson apart from the earlier ones in the module: they are libraries, not frameworks. Going back to 11-01, you call them. They do not invert control, they impose no architecture, and removing them is far cheaper than removing Spring. They are the toolbox, not the building.

By the end you will have mastered Jackson and rewritten MetadataClient; you will know Lombok with an honest assessment of its problems; you will have migrated BiblioTech to SLF4J with Logback configured; and you will have an overview of the libraries worth knowing and what each one is for.

Contents

  1. What JSON is and how it maps to Java
  2. Jackson: the ObjectMapper
  3. Why you create one and reuse it
  4. Serialising: from object to JSON
  5. Deserialising: from JSON to object
  6. Mapping POJOs and records
  7. Jackson's essential annotations
  8. @JsonIgnoreProperties: the indispensable one
  9. Generic types with TypeReference
  10. Modules: JavaTimeModule and dates
  11. The JsonNode tree for dynamic JSON
  12. Custom serialisers and deserialisers
  13. BiblioTech: rewriting MetadataClient
  14. Security: polymorphic deserialisation and gadgets
  15. Gson and JSON-B, mentioned
  16. Lombok: what it is and how it works
  17. Lombok's annotations
  18. @Builder and @RequiredArgsConstructor
  19. IDE set-up and lombok.config
  20. Lombok: the honest assessment
  21. @Data on JPA entities: a real bug
  22. records versus Lombok
  23. SLF4J: facade versus implementation
  24. Why you program against SLF4J
  25. The LoggerFactory.getLogger pattern
  26. Parameterised logging with {}
  27. Levels and how they map to java.util.logging
  28. Configuring Logback
  29. MDC: correlating requests
  30. Structured logging in JSON
  31. BiblioTech: the migration to SLF4J
  32. Log4j2, mentioned
  33. Final overview: other libraries worth knowing
  34. Common Mistakes and Tips
  35. Exercises

  1. What JSON is and how it maps to Java

JSON (JavaScript Object Notation) is the dominant data-interchange format. It is text, it is readable, and it has only six types.

{
  "isbn": "978-0000000001",
  "title": "Effective Java",
  "author": "Joshua Bloch",
  "publicationYear": 2018,
  "available": true,
  "categories": ["Java", "Best practices"],
  "publisher": {
    "name": "Addison-Wesley",
    "country": "USA"
  },
  "addedDate": "2026-01-15",
  "rating": null
}

How it maps to Java:

JSON Java Notes
object {...} Class, record, Map<String, Object> The normal case
array [...] List, Set, array
string "..." String, enum, LocalDate, UUID With conversion
number int, long, double, BigDecimal BigDecimal for money
true / false boolean, Boolean
null null, Optional.empty()

What JSON does not have, and what causes most of the trouble:

Missing in JSON Consequence
Date types They are represented as strings. The format has to be agreed on (ISO-8601, as in 10-05)
Integers versus decimals 1 can be read as an int or as a double; 0.1 + 0.2 is still not 0.3
Comments Fields cannot be documented
Cyclic references A graph with cycles causes infinite recursion when serialising
Declared types Nothing says whether {"name": "x"} is an Employee or a Publisher

That last row is the root of the security problem in section 14.

  1. Jackson: the ObjectMapper

Jackson is the standard JSON library in Java. You already have it: spring-boot-starter-web brings it, and mvn dependency:tree showed it to you in 11-05. To add it explicitly:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <!-- no version: Spring Boot's BOM manages it (11-05) -->
</dependency>

<!-- Support for java.time (section 10) -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Jackson has three API levels:

Level Main class When
Data binding ObjectMapper 95% of cases: object ↔ JSON
Tree JsonNode Dynamic JSON or of unknown structure
Streaming JsonParser, JsonGenerator Huge documents, maximum performance

  1. Why you create one and reuse it

Before the first example, a rule that avoids a real performance problem:

Create one ObjectMapper and reuse it. Never one per call.

An ObjectMapper is expensive to build: the first time it processes a type, it introspects it by reflection (10-03) —fields, getters, annotations— and caches the result. That cache is what makes the second serialisation of the same type extremely fast.

// WRONG: creates a mapper on every call. Throws the cache away every time.
public String toJson(Material material) {
    return new ObjectMapper().writeValueAsString(material);   // slow and wasteful
}

// RIGHT: a single one, shared
private static final ObjectMapper MAPPER = new ObjectMapper();

public String toJson(Material material) {
    return MAPPER.writeValueAsString(material);
}

And there is a detail that makes it possible: ObjectMapper is safe to use from several threads, as long as you do not reconfigure it after you start using it. A singleton is perfectly fine.

It is exactly the same reasoning you applied in 09-06 with HttpClient and in 10-07 with Pattern and DateTimeFormatter: objects that are expensive to create and safe under concurrency get built once.

With Spring, the container solves this:

@Service
public class CatalogExporter {

    private final ObjectMapper mapper;   // Spring injects its own, already configured

    public CatalogExporter(ObjectMapper mapper) {
        this.mapper = mapper;
    }
}

Spring Boot auto-configures an ObjectMapper (11-02) with the modules registered and a sensible configuration. Use it instead of creating your own.

  1. Serialising: from object to JSON

package com.nexussoftware.bibliotech.persistence;

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

public class JsonExporter {

    private final ObjectMapper mapper;

    public JsonExporter(ObjectMapper mapper) { this.mapper = mapper; }

    public String export(Material material) {
        try {
            return mapper.writeValueAsString(material);
        } catch (JsonProcessingException e) {
            // Module 6's layered strategy: wrap, preserve the cause
            throw new ExportException("Could not serialise " + material.getIsbn(), e);
        }
    }

    public String exportPretty(Material material) {
        try {
            return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(material);
        } catch (JsonProcessingException e) {
            throw new ExportException("Could not serialise", e);
        }
    }

    public void exportToFile(List<Material> catalogue, Path target) throws IOException {
        mapper.writeValue(target.toFile(), catalogue);      // without loading it all in memory
    }
}

Result of writeValueAsString:

{"isbn":"978-0000000001","title":"Effective Java","author":"Joshua Bloch","availableCopies":3}

And of writerWithDefaultPrettyPrinter:

{
  "isbn" : "978-0000000001",
  "title" : "Effective Java",
  "author" : "Joshua Bloch",
  "availableCopies" : 3
}

A tip: use the pretty format only for debugging or for files people are going to read. In an API it produces between 20% and 30% more bytes on every request.

The possible destinations:

Method Destination
writeValueAsString(o) String
writeValueAsBytes(o) byte[]
writeValue(File, o) File
writeValue(OutputStream, o) Stream (module 7)
writeValue(Writer, o) Writer

For collections or large files, writing straight to the stream avoids building a giant string in memory — what 10-07 called an unnecessary heap allocation.

  1. Deserialising: from JSON to object

public Material importMaterial(String json) {
    try {
        return mapper.readValue(json, Book.class);
    } catch (JsonProcessingException e) {
        throw new ImportException("Invalid material JSON", e);
    }
}

Possible sources:

Book fromString = mapper.readValue(json, Book.class);
Book fromFile   = mapper.readValue(Path.of("book.json").toFile(), Book.class);
Book fromStream = mapper.readValue(input, Book.class);
Book fromUrl    = mapper.readValue(new URL("https://..."), Book.class);

What Jackson needs to deserialise into an ordinary class:

  1. A no-argument constructor (or one annotated with @JsonCreator).
  2. Setters or accessible fields.

If they are missing:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `com.nexussoftware.bibliotech.domain.Book`
(no Creators, like default constructor, exist)

It is the same requirement JPA imposes (11-03), and for the same reason: instantiation by reflection.

  1. Mapping POJOs and records

With an ordinary class:

public class BookMetadata {

    private String isbn;
    private String title;
    private String author;
    private int publicationYear;

    public BookMetadata() { }   // required by Jackson

    // getters and setters...
}

With a record (04-07), and here comes good news:

public record BookMetadata(String isbn, String title, String author, int publicationYear) { }

Jackson 2.12 and above support records natively. No annotation is needed: it uses the canonical constructor and the accessors. And because the record is immutable, you get an object that cannot end up half-built.

records are ideal as DTOs (data transfer objects): they represent the shape of the JSON, they are immutable, they get equals and toString for free, and they carry no logic. In 11-03 we saw that they cannot be JPA entities; here they find their natural home.

One detail for deserialising records with nested ordinary classes or when parameter names are missing: the compiler has to preserve them. That is why 11-05 configured <parameters>true</parameters> in the maven-compiler-plugin. With Spring Boot it is already set.

Nesting:

public record BookMetadata(
        String isbn,
        String title,
        Author author,                  // nested object
        List<String> categories,        // array
        LocalDate publicationDate) {    // ISO date (section 10)

    public record Author(String name, String country) { }
}
{
  "isbn": "978-0000000001",
  "title": "Effective Java",
  "author": { "name": "Joshua Bloch", "country": "USA" },
  "categories": ["Java", "Best practices"],
  "publicationDate": "2018-01-06"
}

Jackson resolves nesting and collections recursively, with no configuration.

  1. Jackson's essential annotations

Annotation What it does Example
@JsonProperty("name") Changes the name in the JSON @JsonProperty("isbn_13")
@JsonIgnore Excludes the field Passwords, internal fields
@JsonInclude(NON_NULL) Omits nulls Reduces the size
@JsonFormat Format for dates and numbers pattern = "dd/MM/yyyy"
@JsonAlias({"a","b"}) Alternative names when reading Version compatibility
@JsonCreator Marks the deserialisation constructor Immutable objects
@JsonIgnoreProperties(ignoreUnknown=true) Ignores unknown fields Indispensable (section 8)
@JsonPropertyOrder Field order Readability
@JsonAnySetter / @JsonAnyGetter Dynamic fields in a Map Extensions
@JsonUnwrapped Flattens a nested object
@JsonSerialize / @JsonDeserialize Custom serialiser Section 12

A complete example, with a real BiblioTech case:

package com.nexussoftware.bibliotech.network;

import com.fasterxml.jackson.annotation.*;
import java.time.LocalDate;
import java.util.List;

@JsonIgnoreProperties(ignoreUnknown = true)      // indispensable: section 8
@JsonInclude(JsonInclude.Include.NON_NULL)       // do not serialise nulls
public record MetadataResponse(

        @JsonProperty("isbn_13")                 // the API uses snake_case
        String isbn,

        @JsonAlias({"title", "book_title"})      // the API changed the name in v2
        String title,

        @JsonProperty("authors")
        List<String> authors,

        @JsonProperty("publish_date")
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
        LocalDate publicationDate,

        @JsonProperty("number_of_pages")
        Integer pages) {
}

@JsonAlias deserves a note: it accepts several names when reading but always writes with the main name. It is the tool for surviving an API renaming a field without breaking compatibility with the old responses.

And @JsonIgnore has an obvious security use:

public class UserDto {
    private String email;

    @JsonIgnore
    private String passwordHash;     // must NEVER leave in a response
}

Although the safest practice is not to rely on an annotation, but for the outgoing DTO not to have that field at all. It is covered in 12-07.

  1. @JsonIgnoreProperties: the indispensable one

It deserves its own section because it is the annotation that prevents the most production incidents.

By default, if the JSON carries a field your class does not have, Jackson fails:

public record BookMetadata(String isbn, String title) { }
{
  "isbn": "978-0000000001",
  "title": "Effective Java",
  "language": "en"
}
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "language" (class BookMetadata), not marked as ignorable

Think about what that means: the day the external API adds a new field —something perfectly compatible from its point of view— your application stops working. And that API can deploy on a Tuesday morning without telling you.

The solution:

@JsonIgnoreProperties(ignoreUnknown = true)
public record BookMetadata(String isbn, String title) { }

Or globally:

mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Or in Spring Boot, which already does it for you:

spring:
  jackson:
    deserialization:
      fail-on-unknown-properties: false   # the default in Spring Boot

Rule: every DTO that receives data from an external system must carry @JsonIgnoreProperties(ignoreUnknown = true).

The professional nuance: for your own JSON —your internal configuration, a file you generate— it can make sense to fail on unknown fields, because an unexpected field points to a typo. The distinction is "do I control the sender?".

  1. Generic types with TypeReference

And here the type erasure of 10-01 gets cashed in.

// DOES NOT WORK the way you expect
List<BookMetadata> list = mapper.readValue(json, List.class);
// -> returns List<LinkedHashMap>, and blows up when used:
// ClassCastException: LinkedHashMap cannot be cast to BookMetadata

Why: because of type erasure, List<BookMetadata>.class does not exist. At run time there is only List.class, with no information about the parameter. Jackson cannot know what to build inside and creates generic maps.

The solution is TypeReference, and it uses exactly the trick you learned in 10-01:

List<BookMetadata> list = mapper.readValue(json,
        new TypeReference<List<BookMetadata>>() { });

Notice the braces { } at the end: they create an anonymous subclass (04-04) of TypeReference. And a superclass's generic information is preserved in the bytecode, so Jackson can read it by reflection with getGenericSuperclass(). It is the same trick you studied in 10-01 when discussing what survives erasure.

Common cases:

// List
List<Material> materials = mapper.readValue(json, new TypeReference<>() { });

// Map
Map<String, List<Loan>> byEmployee = mapper.readValue(json, new TypeReference<>() { });

// Your own generic: the Result<T> of 10-01
Result<Material> result = mapper.readValue(json, new TypeReference<Result<Material>>() { });

Since Java 10, the diamond operator works on anonymous subclasses, so new TypeReference<>() { } is enough when the type is inferred from the target.

An alternative with JavaType, useful when the type is decided at run time:

JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Material.class);
List<Material> materials = mapper.readValue(json, type);

  1. Modules: JavaTimeModule and dates

Jackson is extensible through modules. The indispensable one is the java.time module, because without it:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Java 8 date/time type `java.time.LocalDate` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);   // important!

Or, better, in a single line that registers every module on the classpath:

ObjectMapper mapper = JsonMapper.builder()
        .findAndAddModules()
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .build();

That second line, WRITE_DATES_AS_TIMESTAMPS, matters a lot. A comparison using an Instant:

// Enabled (Jackson's default): number of seconds since 1970
{"recordedAt": 1774000800.000000000}

// Disabled: ISO-8601, readable and interoperable
{"recordedAt": "2026-03-20T09:00:00Z"}

The second form is clearly better: any system in the world understands it, it is readable in a log and it is what 10-05 established as the standard for BiblioTech's CSV files. Always disable it.

With Spring Boot it already comes disabled, and you can verify it:

spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false
    time-zone: Europe/Madrid
    default-property-inclusion: non_null

Common modules:

Module Adds
jackson-datatype-jsr310 java.time
jackson-module-parameter-names Parameter names for constructors
jackson-datatype-jdk8 Optional
jackson-dataformat-yaml Reading and writing YAML with the same API
jackson-dataformat-csv CSV (section 33)
jackson-dataformat-xml XML

The Optional one is interesting: without it, an Optional<String> serialises as {"present":true}, which is not what you want. With it, it serialises as the value or as null. Even so, the general recommendation is not to use Optional in DTOs: use the type directly and let it be null in the JSON. Optional is meant for return values, not for fields.

And that jackson-dataformat-csv deserves a look right now, because it settles 07-07's debt in passing:

CsvMapper mapper = new CsvMapper();
CsvSchema schema = mapper.schemaFor(MaterialCsv.class).withHeader().withColumnSeparator(';');

// Writing
mapper.writer(schema).writeValue(file, materials);

// Reading
List<MaterialCsv> materials = mapper.readerFor(MaterialCsv.class)
        .with(schema).<MaterialCsv>readValues(file).readAll();

That seventy-line CsvReader you wrote by hand in 07-07 —with its edge cases of embedded quotes and line breaks that it never got round to covering— now fits in three lines, with the very library you already have.

  1. The JsonNode tree for dynamic JSON

When you do not know the structure in advance, or you only need one field of a huge response:

JsonNode root = mapper.readTree(json);

String title = root.path("title").asText();
int year     = root.path("publicationYear").asInt(0);           // 0 if missing
String land  = root.path("publisher").path("country").asText("Unknown");

// Walking an array
for (JsonNode category : root.path("categories")) {
    System.out.println(category.asText());
}

// Checking existence
if (root.has("rating") && !root.get("rating").isNull()) {
    double rating = root.get("rating").asDouble();
}

// Building JSON by hand
ObjectNode created = mapper.createObjectNode();
created.put("isbn", "978-0000000001");
created.put("title", "Effective Java");
created.putArray("categories").add("Java").add("Best practices");
String json = mapper.writeValueAsString(created);

An important detail: path() versus get().

Method If the field does not exist
get("x") Returns nullNullPointerException when chaining
path("x") Returns a "missing" node → it can be chained safely

Use path() unless you explicitly need to tell "it is absent" from "it is present and null".

When to use the tree versus objects:

JsonNode (tree) Classes or records
Known structure Yes
Variable structure Yes
Just one field of a huge JSON Yes
Type safety None Complete
Readability Low High
Refactorable by the IDE No Yes

Prefer classes or records whenever you can. The tree turns compile errors into run-time errors.

  1. Custom serialisers and deserialisers

For when a type needs special treatment. BiblioTech's case: the record Isbn from 11-03, which in JSON must be a plain string, not an object.

// Without a custom serialiser: {"isbn": {"value": "978-0000000001"}}
// With a custom serialiser:    {"isbn": "978-0000000001"}
package com.nexussoftware.bibliotech.persistence;

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import java.io.IOException;

public class IsbnSerialiser extends JsonSerializer<Isbn> {
    @Override
    public void serialize(Isbn isbn, JsonGenerator gen, SerializerProvider sp)
            throws IOException {
        gen.writeString(isbn.value());
    }
}

public class IsbnDeserialiser extends JsonDeserializer<Isbn> {
    @Override
    public Isbn deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
        String text = p.getText();
        try {
            return new Isbn(text);           // the record's validation kicks in here
        } catch (InvalidIsbnException e) {
            throw JsonMappingException.from(p, "Invalid ISBN: " + text, e);
        }
    }
}

Registration, in two ways:

// Option 1: on the field
public record MaterialDto(
        @JsonSerialize(using = IsbnSerialiser.class)
        @JsonDeserialize(using = IsbnDeserialiser.class)
        Isbn isbn,
        String title) { }
// Option 2: in a module, applied to ALL Isbn values
SimpleModule module = new SimpleModule("BiblioTech");
module.addSerializer(Isbn.class, new IsbnSerialiser());
module.addDeserializer(Isbn.class, new IsbnDeserialiser());
mapper.registerModule(module);

With Spring, it is registered as a bean and the auto-configuration picks it up:

@Bean
public Module biblioTechModule() {
    SimpleModule module = new SimpleModule("BiblioTech");
    module.addSerializer(Isbn.class, new IsbnSerialiser());
    module.addDeserializer(Isbn.class, new IsbnDeserialiser());
    return module;
}

Notice the parallel with 11-03: this is Jackson's equivalent of JPA's AttributeConverter. The same value object, two adapters for two technologies. The domain stays clean and each piece of infrastructure has its own translator.

  1. BiblioTech: rewriting MetadataClient

The moment promised in 09-06.

That was the stopgap:

// 09-06: the "teaching stopgap", acknowledged as such
public class MetadataClient {

    public Optional<String> extractTitle(String json) {
        int start = json.indexOf("\"title\":\"");
        if (start < 0) return Optional.empty();
        start += 9;
        int end = json.indexOf("\"", start);
        if (end < 0) return Optional.empty();
        return Optional.of(json.substring(start, end));
    }

    public Optional<String> extractAuthor(String json) {
        // ... the same thing, all over again
    }
}

Everything that is wrong with that code:

Problem Example that breaks it
It does not handle escapes "title":"Java: the \"definitive\" guide" → truncated title
It does not handle nesting {"edition":{"title":"another"}} → returns the wrong title
It depends on order and spacing "title" : "x" (with spaces) → finds nothing
It does not handle escaped Unicode é → comes out literally instead of é
It does not handle types Everything is a String; no numbers, booleans or dates
One method per field Eight fields, eight nearly identical methods
It does not detect invalid JSON Returns Optional.empty() as if the field were missing
No types No compiler checking at all

And this is the Jackson version:

package com.nexussoftware.bibliotech.network;

import com.fasterxml.jackson.annotation.*;
import java.time.LocalDate;
import java.util.List;

/** Response of the external metadata API. It is a DTO: it mirrors the JSON, not the domain. */
@JsonIgnoreProperties(ignoreUnknown = true)   // the API can add fields whenever it likes
public record MetadataResponse(

        @JsonProperty("isbn_13")     String isbn,
        @JsonAlias({"title"})        String title,
        @JsonProperty("authors")     List<Author> authors,
        @JsonProperty("publish_date") LocalDate publicationDate,
        @JsonProperty("number_of_pages") Integer pages,
        @JsonProperty("publishers")  List<String> publishers) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Author(String name, String key) { }

    /** Translates the external DTO into BiblioTech's domain model. */
    public BookMetadata toDomain() {
        String joinedAuthors = authors == null ? null
                : authors.stream().map(Author::name).collect(Collectors.joining(", "));
        String publisher = publishers == null || publishers.isEmpty()
                ? null : publishers.get(0);
        return new BookMetadata(joinedAuthors, publicationDate, publisher, pages);
    }
}
package com.nexussoftware.bibliotech.network;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.Optional;
import org.slf4j.*;
import org.springframework.stereotype.Component;

@Component
public class HttpMetadataGateway implements MetadataGateway {

    private static final Logger log = LoggerFactory.getLogger(HttpMetadataGateway.class);

    private final HttpClient http;          // a single one, injected (11-02)
    private final ObjectMapper mapper;      // a single one, injected
    private final String baseUrl;

    public HttpMetadataGateway(HttpClient http, ObjectMapper mapper,
                               @Value("${bibliotech.metadata.url}") String baseUrl) {
        this.http = http;
        this.mapper = mapper;
        this.baseUrl = baseUrl;
    }

    @Override
    public Optional<BookMetadata> findByIsbn(String isbn) {

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(baseUrl + "/isbn/" + isbn + ".json"))
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();

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

            if (response.statusCode() == 404) {
                log.debug("The API does not know ISBN {}", isbn);
                return Optional.empty();
            }
            if (response.statusCode() >= 500) {
                throw new GatewayUnavailableException(
                        "The API returned " + response.statusCode());
            }

            // ONE LINE. No indexOf, no substring, no uncovered edge cases.
            MetadataResponse dto = mapper.readValue(response.body(), MetadataResponse.class);

            log.info("Metadata obtained for {}: {}", isbn, dto.title());
            return Optional.of(dto.toDomain());

        } catch (JsonProcessingException e) {
            log.warn("Invalid JSON response for {}", isbn, e);
            throw new GatewayUnavailableException("Unreadable response from the API", e);
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new GatewayUnavailableException("Network error while querying " + isbn, e);
        }
    }
}

Before and after:

Aspect indexOf (09-06) Jackson
Lines of parsing ~40, one per field 1
Escapes and Unicode Breaks Correct
Nesting Breaks Correct
Types Everything a String LocalDate, Integer, List
New field in the API No effect (it does not read it) No effect (ignoreUnknown)
Renamed field Silently returns empty @JsonAlias covers it
Invalid JSON Indistinguishable from a missing field Explicit exception
Compiler checking None Complete
Testable without the network Hard Yes (11-06)

And a design decision worth underlining: MetadataResponse is a DTO, not a domain entity. It mirrors the shape of the external JSON —with its isbn_13 and its publishers— and has a toDomain() method that translates. That way, if the API changes its format, only the DTO changes; BiblioTech's domain never notices. It is the same boundary separation as in 11-06.

  1. Security: polymorphic deserialisation and gadgets

A serious warning that picks up directly from 07-05.

In 07-05, when discussing Java serialization, we warned that deserialising data from an untrusted source is dangerous. With JSON the risk is smaller, but it is not zero, and the mechanism is the same.

The problem shows up with polymorphic deserialisation: when you ask Jackson to decide which class to instantiate based on the content of the JSON itself.

// DANGEROUS with external data
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                             ObjectMapper.DefaultTyping.NON_FINAL);

With that enabled, the JSON can contain the name of the class to instantiate:

{"@class": "com.example.DangerousClass", "property": "value"}

An attacker who controls the JSON can name any class on the classpath. If one of those classes —a gadget— does something dangerous in its constructor, in a setter or in an initialisation method (opening a connection, loading code, running a command), you get code execution. Gadget chains are known in very common libraries, and that is why Jackson maintains a blocklist that has to be kept up to date.

The practical rules:

  1. Do not enable default typing for external data. Ever.
  2. If you need polymorphism, use @JsonTypeInfo with an explicit allowlist:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = BookDto.class,     name = "BOOK"),
    @JsonSubTypes.Type(value = MagazineDto.class, name = "MAGAZINE"),
    @JsonSubTypes.Type(value = DvdDto.class,      name = "DVD")
})
public sealed interface MaterialDto permits BookDto, MagazineDto, DvdDto { }

That way only three classes can be instantiated, the ones you have listed. And notice how sealed (10-06) reinforces the guarantee at compile time: nobody can add a subtype without touching this file.

  1. Keep Jackson up to date. It is exactly 11-01's argument: vulnerabilities get discovered in code that already existed.
  2. Validate after deserialising. The JSON parsing successfully does not mean the data is valid. Jakarta Bean Validation (11-02) comes right after.

Application security —input validation, size limits, authorisation— is covered in depth in 12-07. Here the rule is enough: do not enable dynamic typing with data you do not control.

An additional limit worth knowing about: JSON with very deep nesting can exhaust the stack (the StackOverflowError of 10-07). Jackson 2.15 and above limit document depth and size by default, but if you expose a public API it is worth reviewing those limits explicitly.

  1. Gson and JSON-B, mentioned

Library Who Notes
Jackson FasterXML The standard. Integrated into Spring Boot. More complete and faster
Gson Google Simpler, minimal API. Does not need a no-arg constructor (it uses Unsafe). Fewer annotations and less extensible
JSON-B Jakarta EE The specification's standard. Implementations: Yasson, Johnzon. Little used outside Jakarta EE
JSON-P Jakarta EE Low-level API (the equivalent of Jackson's tree and streaming)

Recommendation: use Jackson. It is already in your project, it is what Spring expects, it has the richest module ecosystem and it is what you will find in any Java code base.

Gson has a legitimate niche: small projects or Android, where its simplicity and small size count.

  1. Lombok: what it is and how it works

The second debt. Look at a typical BiblioTech entity:

public class Employee {

    private Long id;
    private String email;
    private String name;
    private String department;

    public Employee() { }

    public Employee(String email, String name, String department) {
        this.email = email;
        this.name = name;
        this.department = department;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee)) return false;
        Employee other = (Employee) o;
        return Objects.equals(email, other.email);
    }

    @Override
    public int hashCode() { return Objects.hash(email); }

    @Override
    public String toString() {
        return "Employee{email='" + email + "', name='" + name + "'}";
    }
}

Forty lines, of which four carry information: the four fields. The rest is mechanical noise.

Lombok generates all of that at compile time:

@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "email")
@ToString(of = {"email", "name"})
public class Employee {
    private Long id;
    private String email;
    private String name;
    private String department;
}

How it works: an annotation processor

And here 10-02 comes back. Lombok is an annotation processor (javax.annotation.processing): it hooks into the compiler, and during compilation it modifies the syntax tree to add the methods.

graph LR
    A["Employee.java<br/>with @Getter"] --> B["javac<br/>analysis phase"]
    B --> C["Lombok's<br/>processor"]
    C -->|"modifies the AST:<br/>adds getters,<br/>equals, hashCode"| D["javac<br/>generation"]
    D --> E["Employee.class<br/>WITH the methods"]

Important consequences of it acting at compile time:

  1. There is no run-time cost. The .class contains the methods as if you had written them. Zero reflection, zero proxies.
  2. You can see it with javap:
javap -p target/classes/com/nexussoftware/bibliotech/domain/Employee.class
public class com.nexussoftware.bibliotech.domain.Employee {
  private java.lang.Long id;
  private java.lang.String email;
  public java.lang.Long getId();
  public void setId(java.lang.Long);
  public java.lang.String getEmail();
  ...
}
  1. The IDE needs a plugin. The source code does not contain getEmail(), so without a plugin the editor flags it in red even though it compiles fine.
  2. It is an internal compiler API. Lombok uses non-standardised javac mechanisms, and that is why it sometimes breaks with a new Java version until an update is published. That is its real risk.

Dependency:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>   <!-- compile time only (11-05) -->
    <optional>true</optional>
</dependency>

provided is exactly right: Lombok is not needed at run time, so it does not get packaged.

  1. Lombok's annotations

Annotation Generates
@Getter / @Setter Getters and setters (on the class or on a field)
@ToString toString(), with of/exclude
@EqualsAndHashCode equals and hashCode, with of/exclude
@NoArgsConstructor Empty constructor
@AllArgsConstructor Constructor with every field
@RequiredArgsConstructor Constructor with the final and @NonNull fields
@Data @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
@Value Like @Data but immutable: everything final, no setters
@Builder The Builder pattern
@Slf4j private static final Logger log = LoggerFactory.getLogger(X.class)
@NonNull A null check at the start of the method
@SneakyThrows Throws checked exceptions without declaring them. Use with great caution
@Cleanup Automatic closing (nowadays covered by try-with-resources, 06-06)

Two deserve special attention.

@Slf4j saves the line that will appear in every class in section 25:

@Slf4j
@Service
public class LoanManager {
    public void lend(String isbn) {
        log.info("Lending {}", isbn);        // 'log' exists, generated by Lombok
    }
}

@SneakyThrows deserves a warning. It lets you throw a checked exception without declaring it in the signature:

@SneakyThrows
public String read(Path file) {
    return Files.readString(file);      // IOException not declared
}

It is a trick played on the type system: the caller cannot catch that IOException with a catch (IOException e) without the compiler complaining that it is never thrown. It breaks module 6's contract. Use it only in lambdas or in code where the exception is genuinely impossible; never to avoid thinking about error handling.

  1. @Builder and @RequiredArgsConstructor

@Builder

@Builder
@Getter
public class Loan {
    private final Material material;
    private final Employee employee;
    private final LocalDate loanDate;
    private final LocalDate dueDate;
    @Builder.Default
    private final LoanStatus status = LoanStatus.ACTIVE;
}
Loan loan = Loan.builder()
        .material(book)
        .employee(marta)
        .loanDate(LocalDate.now(clock))
        .dueDate(LocalDate.now(clock).plusDays(15))
        .build();

The advantage over a five-parameter constructor: the arguments are named. A new Loan(book, marta, today, today.plusDays(15)) with two dates of the same type in a row is an accident waiting to happen; with a builder, mixing them up is impossible.

Builder is a design pattern, and as such it is studied in depth in 12-02, alongside Factory, Repository, Singleton and the rest. Here all that matters is that Lombok generates it.

Watch out for @Builder.Default: without it, the fields' initial values are ignored and they end up null. It is one of the most frequent mistakes with Lombok.

@RequiredArgsConstructor

This is the one used most with Spring, and it fits perfectly with 11-02's constructor injection:

@Service
@RequiredArgsConstructor        // constructor with ALL the final fields
public class LoanManager {

    private final MaterialRepository materials;
    private final EmployeeRepository employees;
    private final LoanRepository loans;
    private final FineCalculator calculator;
    private final NoticeService notices;
    private final Clock clock;

    // Lombok generates the 6-parameter constructor
}

A fifteen-line constructor that contributed nothing disappears, and constructor injection is preserved with all its advantages (11-02): final fields, an always-valid object, testable with new.

But there is a real trade-off that 11-02 pointed out: a constructor with nine parameters screams "this class does too much". With @RequiredArgsConstructor, that signal becomes much less visible: adding a final field is one line. It is the same problem field injection had, attenuated but present. It is worth being aware of it and watching the number of dependencies anyway.

  1. IDE set-up and lombok.config

Lombok needs an IDE plugin because the source code does not contain the generated methods:

IDE Set-up
IntelliJ IDEA Lombok plugin (bundled since 2020.3) + enable annotation processing
Eclipse Run java -jar lombok.jar and point it at the installation
VS Code "Lombok Annotations Support" extension

And a lombok.config file in the project root:

# Stops the configuration lookup from going up into parent directories
config.stopBubbling = true

# Adds @lombok.Generated to generated code: JaCoCo EXCLUDES it from coverage (12-05)
lombok.addLombokGeneratedAnnotation = true

# Forbids @Data and @Value: they force you to be explicit (see section 21)
lombok.data.flagUsage = error
lombok.value.flagUsage = warning

# Forbids @SneakyThrows: it breaks module 6's exception contract
lombok.sneakyThrows.flagUsage = error

# Copies these annotations onto the generated constructor (useful with Spring and Jackson)
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

The addLombokGeneratedAnnotation line is especially valuable: without it, the generated getters count as uncovered code and artificially sink the coverage percentage, pushing the team into writing getter tests — precisely the antipattern that 11-04 and 11-06 warned about.

  1. Lombok: the honest assessment

Lombok is one of the most argued-about libraries in the Java ecosystem. It deserves a balanced assessment.

What it gives you:

Advantage Detail
Less boilerplate A 40-line entity comes down to 8
Fewer mistakes A hand-written equals can forget a field; the generated one cannot
Less noise when reading The four fields are visible at a glance
Zero run-time cost It is generated code, not reflection
@RequiredArgsConstructor Fits Spring perfectly

What problems it brings:

Problem Detail
Debugging You cannot set a breakpoint in a generated getter. Stack traces point at lines that do not exist in the source
IDE dependency Without the plugin, the editor flags errors where there are none. A new teammate loses half a morning
Hidden magic @Data generates five things; you have to know them to predict the behaviour
Internal compiler API Lombok uses non-standardised javac mechanisms. A new Java version can break it until an update is published
@Data on JPA entities A real source of bugs. Section 21
It enables bad designs Slapping @Data on everything produces anaemic objects: bags of data with no behaviour
Exit cost "Delombok-ing" a large project is a project in itself

That last point deserves a nuance: the delombok tool exists and generates the equivalent source code. So the exit is feasible, although it produces an enormous commit.

The reasonable position:

  • Lombok is useful, above all @RequiredArgsConstructor and @Slf4j, which are used daily and have no trade-offs.
  • It is not indispensable, and Java 16's records cover a good part of its use cases.
  • It is a team decision: either the whole project uses it or nobody does. Mixing produces inconsistency.
  • If you use it, restrict @Data and @Value with lombok.config, and forbid @SneakyThrows.
  • Never @Data on JPA entities.

  1. @Data on JPA entities: a real bug

This section exists because it is a concrete, frequent problem with serious consequences.

// WHAT NOT TO DO
@Entity
@Data                       // <- includes @EqualsAndHashCode with EVERY field
public class Material {

    @Id @GeneratedValue
    private Long id;

    private String isbn;
    private String title;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Loan> loans;
}

Three bugs, all of them real:

Bug 1: equals and hashCode that change

@Data generates equals and hashCode from every field, including the generated id.

Material book = new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
Set<Material> set = new HashSet<>();
set.add(book);                              // id = null -> hashCode X

repository.save(book);                      // JPA assigns id = 42 -> hashCode Y

assertThat(set.contains(book));             // FALSE! It is in the wrong bucket

It is exactly the problem explained in 05-06 when discussing HashSet: if an object's hashCode changes while it is inside a hash-based collection, the object gets lost. And with JPA, the id always changes: it is null before persisting and a number afterwards.

Bug 2: a toString that fires queries

@Data generates a toString that includes every field, including the lazy relations.

log.debug("Material: {}", material);
// -> toString() calls getLoans()
// -> the lazy collection gets initialised
// -> SELECT * FROM loans WHERE material_id = 42

The consequences: a harmless log statement causes one query per logged object —11-03's N+1 problem, triggered from the logging—, or a LazyInitializationException if the persistence context has already closed. And with bidirectional relations, infinite recursion: Material.toString() calls Loan.toString() which calls Material.toString().

Bug 3: setters on everything

@Data generates setters for every field, including the id. Changing the id of a managed entity is a reliable way to corrupt data.

The solution

@Entity
@Getter                                       // getters only
@Setter(AccessLevel.PROTECTED)                // restricted setters
@NoArgsConstructor(access = AccessLevel.PROTECTED)   // the one JPA requires
@ToString(of = { "id", "isbn", "title" })     // ONLY scalar fields
@EqualsAndHashCode(of = "isbn")               // ONLY the business key, immutable
public class Material {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String isbn;                      // business key: it never changes

    private String title;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Loan> loans = new ArrayList<>();
}

Rules for JPA entities with Lombok:

Rule Reason
Never @Data It drags in all three bugs
@EqualsAndHashCode(of = "businessKey") An immutable value, never the generated id
@ToString(of = {scalar fields}) Exclude every relation
Restricted or absent @Setter Prefer domain methods with validation
Never a setter for the id JPA manages it

And the cleanest alternative of all: writing equals and hashCode by hand in the entities, following the pattern Hibernate recommends. That is ten lines per entity, written once, and it eliminates this whole class of problems.

  1. records versus Lombok

Since Java 16, records (04-07) cover a good part of what Lombok offered:

// Lombok
@Value
public class BookMetadata {
    String author;
    LocalDate publicationDate;
    String publisher;
}

// Java 16+, no dependencies
public record BookMetadata(String author, LocalDate publicationDate, String publisher) { }

A comparison:

Feature record Lombok
Getters Yes (author(), without get) Yes (getAuthor())
equals / hashCode / toString Yes, for free With annotations
Immutability Mandatory With @Value
Canonical constructor Yes @AllArgsConstructor
Validation in the constructor Yes, compact form Manual
Mutability No Yes, with @Data
Builder No (verbose by hand) @Builder
Inheritance No (they are final) Yes
JPA entities No Yes
External dependency None Yes
IDE support Native Plugin
Debugging Normal Awkward

The practical recommendation:

Case Choice
DTOs, value objects, API responses record
Query results and projections record (11-03)
JPA entities Class + restricted Lombok, or by hand
Mutable classes with many fields Lombok
Injection constructor in Spring @RequiredArgsConstructor
Logger @Slf4j

In BiblioTech, Card, SessionSummary, BookMetadata, MetadataResponse and BiblioTechProperties are records; Material, Employee and Loan are classes with restricted Lombok, because they are JPA entities.

  1. SLF4J: facade versus implementation

The third debt, the one from 06-07.

There, java.util.logging was chosen to avoid adding dependencies, and the limitation was pointed out: the whole ecosystem uses SLF4J. Here it gets resolved.

The problem SLF4J solves is historical. Java has had four popular logging systems —java.util.logging, Log4j 1, Commons Logging, Log4j 2— and every library picked one. A project with twenty dependencies could end up with four different logging systems writing to four places, with four configurations.

SLF4J (Simple Logging Facade for Java) is a facade: an API you program against, without committing to any implementation.

graph TD
    A["Your BiblioTech code<br/>log.info(...)"] --> B["slf4j-api<br/>THE FACADE"]
    L1["Spring Framework"] --> B
    L2["Hibernate"] --> B
    L3["Jackson"] --> B

    B --> C{"Which binding is<br/>on the classpath?"}
    C --> D["logback-classic<br/>(the default in Spring Boot)"]
    C --> E["log4j-slf4j2-impl<br/>-> Log4j2"]
    C --> F["slf4j-jdk14<br/>-> java.util.logging"]
    C --> G["slf4j-simple<br/>-> stderr"]

    D --> H["Files, console,<br/>syslog, JSON..."]

The three pieces:

Piece What it is Artifact
API What you use: Logger, LoggerFactory slf4j-api
Binding The bridge from API to implementation logback-classic, log4j-slf4j2-impl
Implementation Whatever actually writes logback-core, log4j-core

And a fourth, very useful one: the bridges, which redirect to SLF4J the logging of libraries that use a different API:

Bridge Redirects
jul-to-slf4j java.util.logging → SLF4J
jcl-over-slf4j Commons Logging → SLF4J
log4j-over-slf4j Log4j 1 → SLF4J

With those bridges, all your application's logging ends up in one place with one configuration, even if your dependencies use different APIs. That is the facade's real value.

And you already have it: 11-02 showed that spring-boot-starter drags in spring-boot-starter-logging, which brings slf4j-api, logback-classic, logback-core and jul-to-slf4j.

  1. Why you program against SLF4J

Four concrete reasons:

  1. The application picks the implementation, not the library. If BiblioTech were a library used by others, forcing Logback on them would be an abuse. With SLF4J, each application decides.
  2. It can be changed without touching the code. Moving from Logback to Log4j2 is two lines in the pom.xml (the exclusions from 11-05).
  3. Everything gets unified. With the bridges, the logging of Spring, Hibernate, Jackson and your code comes out through the same channel with the same configuration.
  4. It is what the ecosystem expects. Every modern Java library that logs does so against SLF4J.

It is exactly the same principle as JPA versus Hibernate in 11-03: program against the specification, choose the implementation at deployment time.

  1. The LoggerFactory.getLogger pattern

package com.nexussoftware.bibliotech.service;

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

public class LoanManager {

    private static final Logger log = LoggerFactory.getLogger(LoanManager.class);

    public Loan lend(String isbn, String email) {
        log.debug("Loan request: isbn={}, employee={}", isbn, email);
        // ...
        log.info("Loan created: id={}, due={}", loan.getId(), loan.getDueDate());
        return loan;
    }
}

Every element of that declaration has its reason:

Element Why
private Nobody outside should use this class's logger
static One per class, not one per instance
final It does not change
getLogger(X.class) The logger's name is the class's fully qualified name

That last point is what allows levels to be configured per package:

logging.level.com.nexussoftware.bibliotech=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework=INFO

The logger com.nexussoftware.bibliotech.service.LoanManager inherits from the logger com.nexussoftware.bibliotech, which inherits from com.nexussoftware, which inherits from root. It is a hierarchy, and that is why a single line configures a whole subtree.

With Lombok, the line disappears:

@Slf4j
public class LoanManager { ... }

  1. Parameterised logging with {}

This is SLF4J's most underrated feature.

// WRONG: concatenation
log.debug("Loan " + loan.getId() + " of material " + material.getTitle()
          + " for " + employee.getName());

// RIGHT: parameterised
log.debug("Loan {} of material {} for {}",
          loan.getId(), material.getTitle(), employee.getName());

Why it matters: with concatenation, the string is ALWAYS built, even if the DEBUG level is disabled.

Trace the first case at INFO level:

  1. The three getters get called.
  2. A StringBuilder gets built.
  3. Six fragments get concatenated.
  4. A new String is produced on the heap.
  5. log.debug(string) gets called.
  6. The logger checks the level, sees that DEBUG is disabled and discards the string.

All that work, for nothing. In a method that runs a million times a day, it is pure heap garbage — exactly what 10-07 called unnecessary work.

With the parameterised form:

  1. The three getters get called (this does always happen).
  2. The template and the three arguments get passed.
  3. The logger checks the level. If it is disabled, it returns without building anything.

The string is only composed if the message is going to be emitted. It is called deferred evaluation.

An illustrative measurement, using 10-07's methodology (JMH, not a homemade microbenchmark):

Form DEBUG level enabled DEBUG level disabled
Concatenation ~180 ns/op, 320 B allocated ~150 ns/op, 320 B allocated
Parameterised {} ~190 ns/op, 336 B allocated ~3 ns/op, 0 B allocated

The right-hand column is the one that matters, because in production the DEBUG level is disabled: fifty times faster and zero allocations. Multiplied by the millions of log.debug calls in a real application, the difference is measurable in GC time.

Particular cases:

// Exceptions: ALWAYS as the LAST argument, WITHOUT {}
log.error("Could not lend {}", isbn, exception);   // logs the full stack trace

// WRONG: loses the stack trace, which is the only useful part
log.error("Could not lend " + isbn + ": " + exception.getMessage());

// If the argument is EXPENSIVE to compute, check the level
if (log.isDebugEnabled()) {
    log.debug("Full catalogue state: {}", catalog.fullDump());
}

That last case is the only situation where isDebugEnabled() contributes anything: deferred evaluation avoids building the string, but it does not avoid evaluating the arguments. If fullDump() walks ten thousand objects, it runs anyway.

You can also use a Supplier with SLF4J 2's fluent API:

log.atDebug().setMessage("Catalogue: {}")
   .addArgument(() -> catalog.fullDump())        // lambda: only evaluated if needed
   .log();

  1. Levels and how they map to java.util.logging

SLF4J When to use it java.util.logging (06-07)
ERROR A failure that prevents an operation completing and needs attention SEVERE
WARN Something anomalous, but processing continued WARNING
INFO Relevant business events INFO
DEBUG Detail for diagnosis FINE
TRACE Very fine detail: every iteration FINER / FINEST

java.util.logging also had CONFIG, which has no equivalent and maps to INFO.

Practical criteria, with BiblioTech examples:

// ERROR: action is needed. Somebody should get an alert.
log.error("Could not connect to the database after 3 attempts", exception);

// WARN: anomalous but recovered.
log.warn("The metadata API did not respond for {}; carrying on without enriching", isbn);

// INFO: business events. It must be readable in production without noise.
log.info("Loan {} created for {} due on {}", id, email, dueDate);

// DEBUG: for diagnosis. Disabled in production.
log.debug("Evaluating {} pending reservations with {} days ahead", total, days);

// TRACE: very fine. Almost never enabled.
log.trace("Reservation {} : requestDate={}, expires={}", id, request, expiry);

Two frequent mistakes:

  • Everything at INFO. The log becomes unreadable and nobody looks at it. INFO should be what an operator would want to see in production.
  • Using ERROR for things that are not. If an ISBN does not exist in the external API, that is DEBUG or WARN, not ERROR. If everything is an error, alerts stop meaning anything.

And an important one for module 6: do not log and rethrow.

// WRONG: the same exception will appear three times in the log
catch (IOException e) {
    log.error("Read error", e);
    throw new PersistenceException("Read error", e);
}

// RIGHT: either you log (because it is handled here) or you rethrow (and whoever handles it logs)
catch (IOException e) {
    throw new PersistenceException("Error reading " + file, e);
}

It is 06-07's layered strategy: log where you handle, not where you propagate.

  1. Configuring Logback

Logback is configured with src/main/resources/logback-spring.xml (the -spring variant allows Spring profiles to be used):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <property name="CONSOLE_PATTERN"
              value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n"/>
    <property name="FILE_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{40} [%X{requestId}] - %msg%n"/>

    <!-- APPENDER 1: console -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- APPENDER 2: file rolling by date AND size -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/bibliotech.log</file>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/bibliotech-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>      <!-- rolls over when it passes 50 MB -->
            <maxHistory>30</maxHistory>          <!-- keeps 30 days -->
            <totalSizeCap>2GB</totalSizeCap>     <!-- never more than 2 GB in total -->
        </rollingPolicy>
    </appender>

    <!-- APPENDER 3: asynchronous. Writing does not block the business thread -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="FILE"/>
        <queueSize>512</queueSize>
        <discardingThreshold>0</discardingThreshold>   <!-- do not discard messages -->
    </appender>

    <!-- LEVELS PER PACKAGE -->
    <logger name="com.nexussoftware.bibliotech" level="DEBUG"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>
    <logger name="org.hibernate.orm.jdbc.bind" level="TRACE"/>
    <logger name="org.springframework" level="INFO"/>

    <!-- SPRING PROFILES (11-02) -->
    <springProfile name="dev">
        <root level="DEBUG">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <springProfile name="prod">
        <root level="INFO">
            <appender-ref ref="ASYNC_FILE"/>
        </root>
        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>
    </springProfile>

</configuration>

Logback concepts:

Concept What it is
Appender The destination: console, file, syslog, socket
Encoder / pattern The message's format
Rolling policy When and how to roll the files over
Logger The level for a specific package or class
Root logger The default level, inherited by all
<springProfile> Conditional configuration per Spring profile

The most-used patterns:

Token Means
%d{...} Date and time
%level / %-5level Level, padded to 5 characters
%thread Thread name (module 8)
%logger{36} Logger name, abbreviated to 36 characters
%msg The message
%n Line break
%X{key} MDC value (section 29)
%ex The exception's stack trace

And for simple cases you do not even need the XML: Spring Boot configures it from application.yml:

logging:
  level:
    root: INFO
    com.nexussoftware.bibliotech: DEBUG
    org.hibernate.SQL: DEBUG
  file:
    name: logs/bibliotech.log
  logback:
    rollingpolicy:
      max-file-size: 50MB
      max-history: 30
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"

Always start with application.yml. Move to logback-spring.xml only when you need appenders or policies the properties do not cover.

About the asynchronous appender: writing to disk blocks the thread that logs. In a high-traffic application, wrapping the file appender in an AsyncAppender moves the writing to a separate thread. The trade-off is that, if the process dies abruptly, the queued messages are lost — hence discardingThreshold at 0 and a sensible queue size.

  1. MDC: correlating requests

A real problem: with fifty simultaneous requests (module 8), the log messages get interleaved. Which ones belong to Marta's request?

The MDC (Mapped Diagnostic Context) is a per-thread map whose values can be included in every log line.

import org.slf4j.MDC;

public class CorrelationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        String requestId = Optional
                .ofNullable(((HttpServletRequest) request).getHeader("X-Request-Id"))
                .orElse(UUID.randomUUID().toString());

        MDC.put("requestId", requestId);
        MDC.put("user", currentUser());

        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();   // ESSENTIAL: the thread goes back to the pool (module 8)
        }
    }
}

With %X{requestId} in the pattern, every line carries the identifier:

2026-03-20 10:15:23.451 INFO  [http-nio-8080-exec-3] c.n.b.s.LoanManager [a3f2-9b21] - Loan 4218 created
2026-03-20 10:15:23.502 DEBUG [http-nio-8080-exec-3] c.n.b.p.LoanRepository [a3f2-9b21] - Saving loan
2026-03-20 10:15:23.510 INFO  [http-nio-8080-exec-7] c.n.b.s.LoanManager [7c81-4e55] - Loan 4219 created

Now you can filter by a3f2-9b21 and see only Marta's request, among thousands of lines.

Two important warnings:

  1. MDC.clear() in a finally, always. The MDC lives in a ThreadLocal, and in a thread pool (module 8) the thread gets reused. If you do not clear it, the next request inherits the previous one's identifier — and that is not just confusing: if you store user data, it is a leak between requests. It is exactly the ThreadLocal leak that 10-07 described as one of the four classic patterns.
  2. The MDC is not propagated to other threads automatically. If you hand work off to an ExecutorService (08-05) or a CompletableFuture (08-07), the new thread has no MDC. You have to copy it by hand with MDC.getCopyOfContextMap() and MDC.setContextMap(...).

MDC is the basis of trace correlation in distributed systems, and it is developed alongside observability in 12-07.

  1. Structured logging in JSON

In a modern environment, logs are not read by a person in a file: they are ingested by a system (Elasticsearch, Loki, CloudWatch) that indexes them and lets you query them. For that, plain text is awkward: it has to be parsed with brittle regular expressions.

Structured logging emits each line as a JSON object:

{"@timestamp":"2026-03-20T10:15:23.451+01:00","level":"INFO","logger":"com.nexussoftware.bibliotech.service.LoanManager","thread":"http-nio-8080-exec-3","message":"Loan 4218 created","requestId":"a3f2-9b21","user":"[email protected]"}

Now you can query level:ERROR AND requestId:a3f2-9b21 without parsing anything. And the MDC values become queryable fields.

With Logback and logstash-logback-encoder:

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>requestId</includeMdcKeyName>
        <includeMdcKeyName>user</includeMdcKeyName>
    </encoder>
</appender>

Or, from Spring Boot 3.4 onwards, with no extra dependencies:

logging:
  structured:
    format:
      console: ecs      # Elastic Common Schema

Common practice: readable text in development, JSON in production, selected with <springProfile>.

  1. BiblioTech: the migration to SLF4J

Before, as it was left in 06-07:

import java.util.logging.Level;
import java.util.logging.Logger;

public class LoanManager {

    private static final Logger LOG = Logger.getLogger(LoanManager.class.getName());

    public Loan lend(String isbn, String email) {
        if (LOG.isLoggable(Level.FINE)) {                   // manual check
            LOG.fine("Lending " + isbn + " to " + email);   // concatenation
        }
        try {
            // ...
            LOG.info("Loan created: " + loan.getId());
            return loan;
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error lending " + isbn, e);      // awkward API
            throw e;
        }
    }
}

After:

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

public class LoanManager {

    private static final Logger log = LoggerFactory.getLogger(LoanManager.class);

    public Loan lend(String isbn, String email) {
        log.debug("Lending {} to {}", isbn, email);            // parameterised, deferred

        Loan loan = /* ... */;
        log.info("Loan created: id={}, due={}",
                 loan.getId(), loan.getDueDate());
        return loan;
    }
}

Notice that the try/catch disappeared: there is no more log-and-rethrow (section 27). Whoever handles the exception will log it. And isLoggable disappeared, because deferred evaluation makes it unnecessary.

The migration steps:

1. Dependencies. They are already there (spring-boot-starter). Add the bridge in case some old library uses java.util.logging:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jul-to-slf4j</artifactId>
</dependency>

2. Replace the imports in every class:

Before After
java.util.logging.Logger org.slf4j.Logger
Logger.getLogger(X.class.getName()) LoggerFactory.getLogger(X.class)
LOG.fine("a" + b) log.debug("a{}", b)
LOG.info(...) log.info(...)
LOG.warning(...) log.warn(...)
LOG.log(Level.SEVERE, msg, e) log.error(msg, e)
if (LOG.isLoggable(Level.FINE)) (delete)

3. Delete LogConfiguration. That class from 06-07 that configured java.util.logging in code disappears: its job is now done by application.yml and logback-spring.xml, configurable without recompiling.

4. Configure per environment, taking advantage of 11-02's profiles.

5. Verify that there are not two implementations on the classpath:

./mvnw dependency:tree | grep -E "slf4j|logback|log4j"

If two bindings show up, SLF4J warns at start-up:

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [.../logback-classic-1.5.6.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [.../slf4j-simple-2.0.13.jar!/org/slf4j/impl/StaticLoggerBinder.class]

It is fixed with an exclusion (11-05).

The balance sheet:

Aspect java.util.logging (06-07) SLF4J + Logback
Parameterisation No, concatenation {} with deferred evaluation
Performance with the level disabled Builds the string ~0 ns, 0 bytes
Exception API log(Level, msg, e) log.error(msg, e)
Configuration In code or limited .properties Full XML or application.yml
File rolling Limited By date, size, with compression and a total cap
Per-environment configuration No <springProfile>
MDC No Yes
Structured JSON No Yes
Unification with libraries No Yes, with bridges
Changing implementation Rewrite Two lines of POM

  1. Log4j2, mentioned

Log4j 2 is the main alternative to Logback. It is fast —its asynchronous mode with a disruptor performs excellently—, its Logger API is rich and it supports lambdas natively.

Swapping it in on Spring Boot:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

Two lines, and not one line of code changes. That is the practical demonstration of the facade's value.

And here it is worth recalling Log4Shell (11-01): the critical vulnerability of December 2021 affected Log4j 2, not Logback. Two professional observations:

  1. It does not make Log4j2 a bad choice. The vulnerability was fixed, the project reacted, and today it is a solid and widely used implementation.
  2. It does illustrate 11-01's argument: any dependency can have a critical vulnerability, the hard question is knowing what you have, and the ability to update fast depends on having Maven (11-05) and tests (11-04, 11-06).

  1. Final overview: other libraries worth knowing

Closing the ecosystem. You do not have to learn them now: you have to know they exist so you do not reinvent them.

Library What for When you will need it
Apache Commons Lang 3 String, Objects, Random, reflection and comparison utilities Less than before: String.isBlank(), Objects.requireNonNullElse and records cover a lot
Apache Commons IO Stream copying, file utilities Less than before: NIO.2's Files (07-06) covers nearly everything
Guava Immutable collections, Multimap, BiMap, cache, Preconditions When you need structures the JDK does not have
MapStruct Entity ↔ DTO mappers generated at compile time As soon as you have fifteen entities and their DTOs. A typed alternative to writing mappings by hand
Caffeine High-performance in-memory cache, with expiry and a maximum size The modern replacement for Guava's cache; integrated with Spring's @Cacheable
OpenCSV / Commons CSV Reading and writing CSV properly, with quotes, escapes and embedded breaks Settles 07-07's debt: your hand-written CsvReader did not cover the edge cases
Flyway / Liquibase Versioned schema migrations As soon as there is a database in production (11-03, 12-06)
springdoc-openapi OpenAPI/Swagger documentation generated from your controllers As soon as you expose a REST API (12-04)
Resilience4j Retries, circuit breakers, rate limiters, bulkheads When you call external services. It is your RetryProxy from 10-03, made production-grade
Micrometer Metrics: counters, timers, histograms Observability (12-07). Integrated with Actuator
Testcontainers Real services in Docker for tests Serious integration tests (11-06, 12-05)
WireMock A fake HTTP server for tests Testing HTTP clients without the real API
ArchUnit Checking architecture rules as tests "The domain must not import Spring", verified in the build
JMH Reliable microbenchmarks Measuring properly (10-07)

Three deserve a longer comment.

OpenCSV, because it closes the course's last outstanding debt:

// Your CsvReader from 07-07: 70 lines, not covering quotes or embedded breaks
// With OpenCSV:
try (var reader = new CsvToBeanBuilder<MaterialCsv>(new FileReader(file, UTF_8))
        .withType(MaterialCsv.class)
        .withSeparator(';')
        .build()) {
    List<MaterialCsv> materials = reader.parse();
}

And you already saw in section 10 that jackson-dataformat-csv does the same thing with the library you already have.

MapStruct, because it solves a problem that will show up in 12-04:

@Mapper(componentModel = "spring")
public interface MaterialMapper {
    MaterialDto toDto(Material material);
    List<MaterialDto> toDtos(List<Material> materials);
}

MapStruct generates the implementation at compile time —another annotation processor, like Lombok—, with checked types: if you add a field to the DTO and it does not exist in the entity, it does not compile. It is far superior to mapping by hand or by reflection.

ArchUnit, because it turns into an automated test what 11-05 achieved with Maven modules:

@Test
void theDomainDoesNotDependOnSpringOrJpa() {
    JavaClasses classes = new ClassFileImporter()
            .importPackages("com.nexussoftware.bibliotech");

    noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("org.springframework..", "jakarta.persistence..")
            .check(classes);
}

That test, run by JUnit on every build, stops anybody introducing a Spring annotation into the domain. It is architectural discipline turned into something verifiable.

  1. Common Mistakes and Tips

Mistake: creating an ObjectMapper on every call. It is expensive and throws the introspection cache away. Use one, or the one Spring injects.

Mistake: forgetting @JsonIgnoreProperties(ignoreUnknown = true). The day the external API adds a field, your application stops working.

Mistake: readValue(json, List.class). Because of type erasure it returns List<LinkedHashMap> and blows up when used. Use TypeReference.

Mistake: not registering JavaTimeModule or leaving WRITE_DATES_AS_TIMESTAMPS on. Either it fails, or it produces numbers instead of ISO-8601.

Mistake: enabling Jackson's default typing with external data. It is a code-execution vulnerability. Use an allowlist with @JsonTypeInfo.

Mistake: using get() instead of path() with JsonNode. NullPointerException when chaining.

Mistake: @Data on a JPA entity. Three bugs: a hashCode that changes, a toString that fires lazy queries and setters for the id.

Mistake: @Builder without @Builder.Default. The fields' initial values are silently ignored.

Mistake: @SneakyThrows for convenience. It breaks the exception contract and the caller cannot catch what is not declared.

Mistake: concatenating in logger calls. The string gets built even if the level is disabled.

Mistake: log.error("...", e.getMessage()). It loses the stack trace, which is the only genuinely useful part. The exception goes as the last argument, without {}.

Mistake: logging and rethrowing. The same exception appears three times in the log. Log where you handle.

Mistake: not clearing the MDC. In a thread pool, the next request inherits the previous one's data: confusion and data leakage.

Mistake: two logging implementations on the classpath. SLF4J warns at start-up and the behaviour is unpredictable. Use exclusions.

Mistake: everything at INFO level. The log becomes unreadable and nobody looks at it.

Tip: use records for DTOs. Immutable, with equals and toString for free, natively supported by Jackson, and with no dependencies.

Tip: keep the external DTO separate from the domain. MetadataResponse mirrors the API's JSON; BookMetadata is your domain. If the API changes, only the DTO changes.

Tip: from Lombok, @RequiredArgsConstructor and @Slf4j. Those are the ones that contribute without trade-offs. Restrict @Data with lombok.config.

Tip: enable lombok.addLombokGeneratedAnnotation. It stops generated getters from sinking the coverage percentage.

Tip: start configuring logging from application.yml. Move to XML only when you need appenders or rolling policies the properties do not cover.

Tip: readable text in development, JSON in production. With <springProfile>, without touching the code.

Tip: think about who is going to read the log. A useful message says what happened, with what data and what was done. "Error" is not a message.

Tip: before adding a library from the overview, go back over 11-01's criteria. Does the JDK already do it? Is it maintained? What does it drag in?

  1. Exercises

Exercise 1: the availability API client

Nexus Software wants to check book availability at the external supplier in real time. The API returns:

{
  "request_id": "req-8821",
  "generated_at": "2026-03-20T10:15:23Z",
  "items": [
    {
      "isbn_13": "978-0000000001",
      "book_title": "Effective Java",
      "stock": { "available": 3, "reserved": 1, "warehouse": "MAD-01" },
      "unit_price": 54.95,
      "currency": "EUR",
      "last_updated": "2026-03-19",
      "tags": ["java", "best-practices"],
      "discontinued": false
    }
  ],
  "warnings": []
}

Requirements:

  1. Model the response with Jackson records. It must survive the API adding new fields.
  2. The API sometimes returns "title" instead of "book_title" (different versions of the service). Handle it.
  3. unit_price must arrive as a BigDecimal, never as a double.
  4. Dates must be LocalDate and Instant as appropriate.
  5. Write a toDomain() method that produces a record MaterialAvailability(String isbn, String title, int available, BigDecimal price, LocalDate updated).
  6. Configure the necessary ObjectMapper, explaining why each option is there.
  7. Write a test (11-04, 11-06) that deserialises the JSON above, plus an unknown field, and verifies the conversion to the domain.

Exercise 2: fixing an entity with Lombok

This entity is in the project and causes two reported incidents: "sometimes a material disappears from the selection" and "the debug log takes 30 seconds and sometimes blows up".

@Entity
@Table(name = "materials")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Material {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String isbn;

    private String title;

    private int availableCopies = 1;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Loan> loans = new ArrayList<>();

    @ManyToMany(fetch = FetchType.LAZY)
    private Set<Category> categories = new HashSet<>();

    @Version
    private Long version;
}

You are asked to:

  1. Explain each of the two incidents, relating it to a specific annotation.
  2. Identify a third problem that has not shown its face yet.
  3. Identify a fourth problem, related to @Builder.
  4. Rewrite the entity fixing everything.
  5. Write a test that would have caught the first incident.
  6. Write the lombok.config that would stop this happening again.

Exercise 3: migrating the logging and correlating

ReturnManager has this logging, inherited from 06-07:

public class ReturnManager {

    private static final Logger LOG = Logger.getLogger(ReturnManager.class.getName());

    public ReturnResult returnItem(Long loanId) {

        LOG.info("Starting return of loan " + loanId);

        Loan loan = loans.findById(loanId).orElse(null);
        if (loan == null) {
            LOG.severe("Loan not found: " + loanId);
            throw new LoanNotFoundException(loanId);
        }

        if (LOG.isLoggable(Level.FINE)) {
            LOG.fine("Loan state: " + loan.toString()
                     + " with material " + loan.getMaterial().toString());
        }

        BigDecimal fine = calculator.calculate(loan);
        LOG.info("Fine calculated: " + fine);

        try {
            loan.returnItem(LocalDate.now(clock));
            loans.save(loan);
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error saving the return of " + loanId, e);
            throw new PersistenceException("Error returning " + loanId, e);
        }

        LOG.info("Return completed");
        return new ReturnResult(loan, fine, false);
    }
}

You are asked to:

  1. Identify seven logging problems in that code, beyond the API used.
  2. Rewrite it with SLF4J applying all the good practices.
  3. Add MDC correlation so that every line of a return shares an identifier, with the correct clean-up.
  4. Write the logback-spring.xml fragment with: a readable console in dev, a rolled JSON file in prod, the correlation identifier in both, and org.hibernate.SQL at DEBUG only in dev.
  5. Explain what this migration would have cost if the code had been programmed against SLF4J since 06-07.

Solutions

Solution 1

package com.nexussoftware.bibliotech.network;

import com.fasterxml.jackson.annotation.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;

/** (1) DTO of the external availability API. It mirrors the JSON, not the domain. */
@JsonIgnoreProperties(ignoreUnknown = true)          // (1) survives new fields
public record AvailabilityResponse(

        @JsonProperty("request_id")   String requestId,
        @JsonProperty("generated_at") Instant generatedAt,     // (4) absolute instant
        @JsonProperty("items")        List<Item> items,
        @JsonProperty("warnings")     List<String> warnings) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Item(

            @JsonProperty("isbn_13") String isbn,

            // (2) API v1 uses "book_title", v2 uses "title"
            @JsonProperty("book_title")
            @JsonAlias({"title"})
            String title,

            @JsonProperty("stock") Stock stock,

            // (3) BigDecimal, NEVER double for money (01-04, 11-03)
            @JsonProperty("unit_price") BigDecimal unitPrice,

            @JsonProperty("currency") String currency,

            // (4) business date with no time: LocalDate (10-05)
            @JsonProperty("last_updated") LocalDate lastUpdated,

            @JsonProperty("tags") List<String> tags,

            @JsonProperty("discontinued") boolean discontinued) {

        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Stock(
                @JsonProperty("available") int available,
                @JsonProperty("reserved")  int reserved,
                @JsonProperty("warehouse") String warehouse) { }

        // (5) translation into the domain
        public MaterialAvailability toDomain() {
            return new MaterialAvailability(
                    isbn,
                    title,
                    stock == null ? 0 : stock.available(),     // defensive: the JSON may omit it
                    unitPrice,
                    lastUpdated);
        }
    }

    /** (5) Every item, already translated into the domain. */
    public List<MaterialAvailability> toDomain() {
        return items == null ? List.of() : items.stream().map(Item::toDomain).toList();
    }
}
// (5) The DOMAIN record: our own names, with no trace of the external API
public record MaterialAvailability(
        String isbn,
        String title,
        int available,
        BigDecimal price,
        LocalDate updated) {

    public boolean inStock() { return available > 0; }
}

(6) ObjectMapper configuration:

@Configuration
public class JacksonConfiguration {

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()

                // java.time support: without this, LocalDate and Instant fail
                .addModule(new JavaTimeModule())

                // Dates in ISO-8601, not as a number of seconds.
                // Readable, interoperable and consistent with what 10-05 decided.
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)

                // Redundant with @JsonIgnoreProperties, but it protects
                // any DTO where somebody forgets the annotation.
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)

                // JSON decimals are read as BigDecimal, not as double.
                // Essential for amounts: it avoids 54.949999999999996.
                .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)

                // Do not serialise null fields: smaller responses.
                .serializationInclusion(JsonInclude.Include.NON_NULL)

                .build();
    }
}

That USE_BIG_DECIMAL_FOR_FLOATS option is what really guarantees point 3: without it, Jackson reads 54.95 as a double internally and can introduce a representation error before converting to BigDecimal.

(7) The test:

class AvailabilityResponseTest {

    private final ObjectMapper mapper = new JacksonConfiguration().objectMapper();

    @Test
    @DisplayName("deserialises the response and translates it to the domain, ignoring unknown fields")
    void deserialisesAndTranslates() throws Exception {
        String json = """
                {
                  "request_id": "req-8821",
                  "generated_at": "2026-03-20T10:15:23Z",
                  "server_region": "eu-west-1",
                  "items": [
                    {
                      "isbn_13": "978-0000000001",
                      "book_title": "Effective Java",
                      "stock": { "available": 3, "reserved": 1, "warehouse": "MAD-01" },
                      "unit_price": 54.95,
                      "currency": "EUR",
                      "last_updated": "2026-03-19",
                      "tags": ["java", "best-practices"],
                      "discontinued": false,
                      "promo_code": "SPRING26"
                    }
                  ],
                  "warnings": []
                }
                """;   // text block from 10-06

        AvailabilityResponse response =
                mapper.readValue(json, AvailabilityResponse.class);

        // The unknown fields (server_region, promo_code) break nothing
        assertThat(response.requestId()).isEqualTo("req-8821");
        assertThat(response.generatedAt()).isEqualTo(Instant.parse("2026-03-20T10:15:23Z"));
        assertThat(response.warnings()).isEmpty();

        assertThat(response.toDomain()).singleElement().satisfies(a -> {
            assertThat(a.isbn()).isEqualTo("978-0000000001");
            assertThat(a.title()).isEqualTo("Effective Java");
            assertThat(a.available()).isEqualTo(3);
            assertThat(a.price()).isEqualByComparingTo("54.95");    // exact, no error
            assertThat(a.updated()).isEqualTo(LocalDate.of(2026, 3, 19));
            assertThat(a.inStock()).isTrue();
        });
    }

    @Test
    @DisplayName("also accepts the old name of the title field")
    void acceptsTheLegacyAlias() throws Exception {
        String jsonV2 = """
                {"items":[{"isbn_13":"978-0000000002","title":"Design Patterns",
                           "stock":{"available":2},"unit_price":49.00,
                           "last_updated":"2026-03-18"}]}
                """;

        var response = mapper.readValue(jsonV2, AvailabilityResponse.class);

        assertThat(response.toDomain()).singleElement()
                .extracting(MaterialAvailability::title)
                .isEqualTo("Design Patterns");
    }

    @Test
    @DisplayName("the price is read as an exact BigDecimal, not as a double")
    void exactPrice() throws Exception {
        String json = """
                {"items":[{"isbn_13":"978-0000000003","book_title":"Refactoring",
                           "stock":{"available":1},"unit_price":0.1,
                           "last_updated":"2026-03-20"}]}
                """;

        var a = mapper.readValue(json, AvailabilityResponse.class).toDomain().get(0);

        // With a double it would be 0.1000000000000000055511151231257827
        assertThat(a.price()).isEqualByComparingTo(new BigDecimal("0.1"));
        assertThat(a.price().toPlainString()).isEqualTo("0.1");
    }
}

The first test deliberately includes two fields the DTO does not know about (server_region and promo_code), which is exactly section 8's scenario: the API adds fields and the application carries on working.

Solution 2

(1) The two incidents.

Incident A: "sometimes a material disappears from the selection". It is caused by @Data, which generates @EqualsAndHashCode from every field, including the auto-generated id.

Material book = Material.builder().isbn("978-0000000001").title("Effective Java").build();
Set<Material> selection = new HashSet<>();
selection.add(book);                     // id = null -> hashCode H1 -> bucket A

repository.save(book);                   // JPA assigns id = 42 -> hashCode H2

selection.contains(book);                // looks in bucket B -> FALSE

The object is still inside the HashSet, but in the wrong bucket. It is exactly the problem described in 05-06: if the hashCode changes while the object is in a hash collection, it gets lost. And with JPA it always changes, because the id is null before persisting.

Incident B: "the debug log takes 30 seconds and sometimes blows up". It is caused by @Data's toString, which includes every field, including the two lazy relations.

log.debug("Material: {}", material);
  1. toString() accesses loansSELECT * FROM loans WHERE material_id = ?
  2. toString() accesses categoriesSELECT ... FROM material_category JOIN categories ...
  3. Each Loan has its own toString that calls getMaterial().toString()infinite recursion if Loan also carries @Data.

Hence the 30 seconds —11-03's N+1 problem triggered from a log statement— and the "sometimes blows up": either a StackOverflowError from the recursion, or a LazyInitializationException (11-03) if the context has already closed.

(2) The third problem, not yet visible: @Data generates setters for EVERY field, including id and version.

material.setId(99L);        // corrupts the entity's identity
material.setVersion(0L);    // breaks 11-03's optimistic locking

An accidental setVersion(0L) would make Hibernate believe the row is at its initial version, and a lost update would go unnoticed. setAvailableCopies(-5) is possible too: there is no validation whatsoever.

(3) The fourth problem: @Builder without @Builder.Default.

private int availableCopies = 1;
private List<Loan> loans = new ArrayList<>();
private Set<Category> categories = new HashSet<>();

When building with the builder, those initialisations are ignored:

Material m = Material.builder().isbn("978-0000000001").build();
m.getAvailableCopies();         // 0, not 1
m.getLoans();                   // null, not an empty list -> NullPointerException

And that null from getLoans() will cause a NullPointerException the first time somebody adds a loan, somewhere apparently unrelated.

(4) The corrected entity:

package com.nexussoftware.bibliotech.domain;

import jakarta.persistence.*;
import java.util.*;
import lombok.*;

@Entity
@Table(name = "materials")
@Getter                                                   // public read only
@Setter(AccessLevel.PROTECTED)                            // setters restricted to JPA/subclasses
@NoArgsConstructor(access = AccessLevel.PROTECTED)        // the one JPA requires (11-03)
@ToString(of = { "id", "isbn", "title", "availableCopies" })   // ONLY scalars
@EqualsAndHashCode(of = "isbn")                           // IMMUTABLE business key
public class Material {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 20, updatable = false)
    private String isbn;                                  // updatable=false: it never changes

    @Column(nullable = false, length = 200)
    private String title;

    @Column(name = "available_copies", nullable = false)
    private int availableCopies;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Loan> loans = new ArrayList<>();

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "material_category",
               joinColumns = @JoinColumn(name = "material_id"),
               inverseJoinColumns = @JoinColumn(name = "category_id"))
    private Set<Category> categories = new HashSet<>();

    @Version
    private Long version;

    /** Public constructor with validation: replaces @Builder and @AllArgsConstructor. */
    public Material(String isbn, String title, int availableCopies) {
        this.isbn = Objects.requireNonNull(isbn, "The ISBN is mandatory");
        this.title = Objects.requireNonNull(title, "The title is mandatory");
        if (availableCopies < 0) {
            throw new IllegalArgumentException("Copies cannot be negative");
        }
        this.availableCopies = availableCopies;
    }

    // DOMAIN methods with validation, instead of open setters
    public void lendOneCopy() {
        if (availableCopies <= 0) throw new NoCopiesAvailableException(isbn);
        availableCopies--;
    }

    public void returnOneCopy() { availableCopies++; }

    /** Defensive copy: nobody modifies the collection from outside (11-03 §13). */
    public List<Loan> getLoans() { return List.copyOf(loans); }
}

The changes and their reasons:

Before Now Fixes
@Data @Getter + @Setter(PROTECTED) Incidents A, B and problem 3
equals/hashCode from everything @EqualsAndHashCode(of = "isbn") Incident A
toString with everything @ToString(of = {scalars}) Incident B
Public setters for id and version protected setters Problem 3
@Builder without defaults Constructor with validation Problem 4
No validation requireNonNull and checks Invalid data
Exposed collections Defensive copy External modification

Note: @Builder has been removed rather than fixed with @Builder.Default. For an entity with three mandatory fields, a validated constructor is clearer and safer. Builder pays off when there are many optional parameters, and that pattern is studied in 12-02.

(5) The test that would have caught incident A:

@Test
@DisplayName("a Material remains findable in a HashSet after receiving its id")
void stableIdentityAfterPersisting() {

    Material book = new Material("978-0000000001", "Effective Java", 3);
    Set<Material> selection = new HashSet<>();
    selection.add(book);

    // Simulates what JPA does when persisting: assigning the generated id
    ReflectionTestUtils.setField(book, "id", 42L);

    // With @Data this FAILS: the hashCode changed and the object is in another bucket
    assertThat(selection).contains(book);
    assertThat(selection.contains(book)).isTrue();
}

@Test
@DisplayName("toString does not touch the lazy relations")
void toStringWithoutRelations() {
    Material book = new Material("978-0000000001", "Effective Java", 3);

    assertThat(book.toString())
            .contains("978-0000000001")
            .contains("Effective Java")
            .doesNotContain("loans")          // it would have fired the query
            .doesNotContain("categories");
}

And an integration version that would catch incident B against the database:

@DataJpaTest
class MaterialToStringIT {

    @Autowired private TestEntityManager em;

    @Test
    @DisplayName("toString does not cause LazyInitializationException outside the session")
    void toStringIsSafeOutsideTheSession() {
        Material book = em.persistAndFlush(new Material("978-0000000001", "Effective Java", 3));
        em.clear();                                   // simulates the context closing

        Material detached = em.getEntityManager()
                .getReference(Material.class, book.getId());

        assertThatCode(detached::toString).doesNotThrowAnyException();
    }
}

(6) The preventive lombok.config:

config.stopBubbling = true

# @Data is the root of both incidents: forbidden across the project
lombok.data.flagUsage = error

# @Value is immutable but incompatible with JPA: warn
lombok.value.flagUsage = warning

# @AllArgsConstructor allows entities to be built without validation
lombok.allArgsConstructor.flagUsage = warning

# @SneakyThrows breaks module 6's exception contract
lombok.sneakyThrows.flagUsage = error

# Generated methods do not count towards coverage (12-05)
lombok.addLombokGeneratedAnnotation = true

With lombok.data.flagUsage = error, any @Data somebody introduces in the future does not compile. The rule stops depending on everyone remembering the code review.

Solution 3

(1) The seven problems:

# Problem Consequence
1 Concatenation in every call The string gets built even if the level is disabled: useless work and garbage
2 Explicit entity toString() in the FINE call With lazy relations, it fires queries or throws LazyInitializationException (exercise 2)
3 Logging and rethrowing in the catch The same exception will appear two or three times in the log
4 severe for "not found" It is not a system error: it is a business case. It pollutes the alerts
5 INFO for everything "Starting", "Fine calculated", "Return completed": three lines per operation in production
6 Messages with no context "Return completed" does not say which loan. Useless under concurrent traffic
7 Manual isLoggable Unnecessary with parameterisation, and it adds noise

An eighth, extra one: catch (Exception e) catches too much, including business exceptions that should not be treated as persistence failures. It is a module 6 problem, not a logging one, but it gets fixed along the way.

(2) and (3) Version rewritten with SLF4J and MDC:

package com.nexussoftware.bibliotech.service;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import org.slf4j.*;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor          // Lombok: constructor injection (11-02)
public class ReturnManager {

    private static final Logger log = LoggerFactory.getLogger(ReturnManager.class);
    // (or simply @Slf4j on the class)

    private final LoanRepository loans;
    private final FineCalculator calculator;
    private final Clock clock;

    @Transactional
    public ReturnResult returnItem(Long loanId) {

        // (3) MDC: correlation of every line of this return
        MDC.put("operation", "return");
        MDC.put("loanId", String.valueOf(loanId));
        MDC.put("operationId", UUID.randomUUID().toString());

        try {
            // (5) DEBUG, not INFO: the start of an operation is of no interest in production
            log.debug("Starting return");

            Loan loan = loans.findById(loanId)
                    .orElseThrow(() -> {
                        // (4) DEBUG, not ERROR: it is a business case, not a system failure
                        log.debug("Loan not found");
                        return new LoanNotFoundException(loanId);
                    });

            // (1)(2)(7) parameterised, no entity toString(), no isLoggable
            log.debug("Loan located: isbn={}, dueDate={}, status={}",
                    loan.getMaterial().getIsbn(),         // SCALAR fields only
                    loan.getDueDate(),
                    loan.getStatus());

            BigDecimal fine = calculator.calculate(loan);

            loan.returnItem(LocalDate.now(clock));
            loans.save(loan);
            // (3) no catch: the persistence exception rises and whoever handles it logs it

            // (5)(6) ONE INFO line per operation, with ALL the relevant context
            log.info("Return completed: isbn={}, employee={}, fine={} EUR, daysLate={}",
                    loan.getMaterial().getIsbn(),
                    loan.getEmployee().getEmail(),
                    fine,
                    loan.daysLate(LocalDate.now(clock)));

            if (fine.compareTo(BigDecimal.ZERO) > 0) {
                log.warn("Return with a fine: isbn={}, amount={} EUR",
                        loan.getMaterial().getIsbn(), fine);
            }

            return new ReturnResult(loan, fine, false);

        } finally {
            MDC.clear();   // (3) ESSENTIAL: the thread goes back to the pool (10-07)
        }
    }
}

Decisions worth highlighting:

  • A single INFO line per operation, with all the relevant data, instead of three generic ones. It is what an operator would want to see in production.
  • WARN for the fine: it is something worth being able to query without enabling DEBUG, but it is not an error.
  • The messages do not carry the loanId because it is already in the MDC and appears on every line.
  • No catch: if save fails, the exception rises. Whoever handles it —a global handler (12-04)— will log it once, with its full stack trace.
  • MDC.clear() in finally: without it, the next request that reuses the thread would inherit this one's loanId.

(4) logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- ============ DEVELOPMENT: readable console ============ -->
    <springProfile name="dev">

        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{30}) [%X{operationId:-no-id}] - %msg%n</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>

        <logger name="com.nexussoftware.bibliotech" level="DEBUG"/>
        <logger name="org.hibernate.SQL" level="DEBUG"/>            <!-- SQL only in dev -->
        <logger name="org.hibernate.orm.jdbc.bind" level="TRACE"/>  <!-- parameters -->
        <logger name="org.springframework" level="INFO"/>

        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <!-- ============ PRODUCTION: rolled JSON file ============ -->
    <springProfile name="prod">

        <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>logs/bibliotech.json</file>

            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>operationId</includeMdcKeyName>
                <includeMdcKeyName>loanId</includeMdcKeyName>
                <includeMdcKeyName>operation</includeMdcKeyName>
                <customFields>{"application":"bibliotech","environment":"prod"}</customFields>
            </encoder>

            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <fileNamePattern>logs/bibliotech-%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
                <maxFileSize>100MB</maxFileSize>
                <maxHistory>30</maxHistory>
                <totalSizeCap>5GB</totalSizeCap>
            </rollingPolicy>
        </appender>

        <!-- Asynchronous: writing to disk does not block the business thread -->
        <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
            <appender-ref ref="JSON_FILE"/>
            <queueSize>1024</queueSize>
            <discardingThreshold>0</discardingThreshold>
        </appender>

        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>       <!-- NEVER SQL in production -->
        <logger name="org.springframework" level="WARN"/>

        <root level="INFO">
            <appender-ref ref="ASYNC"/>
        </root>
    </springProfile>

</configuration>

Details worth underlining:

  • %X{operationId:-no-id}: the :- supplies a default value if the key is not in the MDC (during start-up, for example). Without it, it would show up empty.
  • org.hibernate.SQL at OFF in production: logging every SQL statement in production degrades performance and can dump sensitive data into the log.
  • totalSizeCap: it guarantees the logs never fill the disk, which is a classic cause of a service going down.
  • JSON in production with the MDC values as indexable fields, which is what lets you query operation:return AND level:WARN in the log system.

(5) What it would have cost with SLF4J since 06-07:

Practically nothing. Specifically:

Task With java.util.logging (real) With SLF4J from the start
Changing imports 40+ files None
Rewriting concatenations Every call None
Adapting log(Level.X, ...) Every error call None
Removing isLoggable All of them None
Deleting LogConfiguration Yes It would not exist
Configuring Logback The same The same
Adding MDC The same The same

The migration would have been adding two dependencies and one XML file, without touching a single line of Java code.

And that is exactly the facade's lesson, and the reason it is recommended from the start: programming against a stable, neutral API makes changing the implementation cost two lines of pom.xml. It is the same principle as JPA versus Hibernate (11-03) and as the MetadataGateway interface versus HttpClient (11-06): isolate what can change behind something that does not.

The 06-07 decision —using java.util.logging to avoid adding dependencies— was reasonable in its teaching context, because back then there was neither Maven nor a convenient way to manage dependencies. In a real project, SLF4J from the very first line.

Conclusion

Module 11 ends, and the three debts are settled.

Jackson has replaced module 9's stopgap. You know JSON and how it maps to Java's types —including what JSON does not have and what causes most of the trouble—, you have mastered the ObjectMapper with the rule of creating it once and reusing it, for the same reason as 09-06's HttpClient and 10-07's Pattern: expensive to build, safe under concurrency. You serialise and deserialise POJOs and records —natively supported and perfect as DTOs—, and you know the essential annotations, with @JsonIgnoreProperties(ignoreUnknown = true) as the one that prevents the most incidents: without it, the day the external API adds a field, your application stops working.

You know how to resolve generics with TypeReference, understanding why it is needed —10-01's type erasure— and how the anonymous-subclass trick works, whose generic information does survive in the bytecode. You register the JavaTimeModule and disable WRITE_DATES_AS_TIMESTAMPS to get the ISO-8601 that 10-05 established as the standard. You handle the JsonNode tree for dynamic content —with path() instead of get()— and you write custom serialisers for value objects such as Isbn, which are Jackson's equivalent of JPA's AttributeConverter: the same domain object, two adapters for two pieces of infrastructure.

And you have rewritten MetadataClient: forty lines of indexOf that broke with the first escape, the first nesting or the first unexpected space, replaced by one line and a typed DTO that survives the API changing. With the correct separation between the external DTO and the domain, and with the security note that picks up 07-05: do not enable dynamic typing with data you do not control, because deserialisation gadgets are code execution, and if you need polymorphism, use @JsonTypeInfo with an allowlist reinforced by sealed.

Lombok has eliminated the boilerplate, and you genuinely understand it: it is an annotation processor from 10-02, it acts at compile time, it costs nothing at run time and you can see it with javap. You know its annotations, with @RequiredArgsConstructor fitting perfectly into 11-02's constructor injection and @Slf4j saving a line in every class. And you have the honest assessment: what it gives you, what it costs —debugging, IDE dependency, internal compiler API, exit cost— and above all the concrete problem that causes real bugs: @Data on JPA entities, with its three consequences dissected: a hashCode that changes on persisting and makes objects vanish from a HashSet; a toString that triggers lazy relations and causes N+1, LazyInitializationException or infinite recursion; and setters for the id and the version. With the rules for avoiding it and a lombok.config that turns the discipline into something the compiler verifies. And you know when a record is the better option, which is nearly always for DTOs and value objects.

SLF4J and Logback have replaced java.util.logging. You understand the facade versus the implementation and its four reasons, with the bridges that unify into one channel the logging of Spring, Hibernate, Jackson and your code. You know the LoggerFactory.getLogger(X.class) pattern and why every modifier is there, and above all parameterised logging with {}, whose advantage is not cosmetic but measurable: the string is not built if the level is disabled, fifty times faster and zero allocations in the case that happens in production. With the particular cases: the exception as the last argument without {}, and isDebugEnabled only when the argument is expensive to compute.

You handle the levels and how they map to java.util.logging, with the criteria for what goes in each and the two rules that improve a log the most: INFO is what an operator would want to see, and do not log and rethrow. You configure Logback with appenders, rolling by date and size with a total cap, asynchronous writing and <springProfile> to get readable text in development and structured JSON in production without touching code. And you know the MDC for correlating requests, with its two warnings: MDC.clear() in a finally, always, because in a thread pool not clearing it is both confusion and data leakage between requests —10-07's ThreadLocal leak—, and that it is not propagated automatically to other threads.

And you have the ecosystem's final overview: Commons, Guava, MapStruct, Caffeine, OpenCSV —which settles the last debt in passing, 07-07's hand-written CSV—, Flyway, springdoc-openapi, Resilience4j —which is your RetryProxy from 10-03 made production-grade—, Micrometer, Testcontainers, WireMock, ArchUnit and JMH. Not to learn them now, but so as not to reinvent them.


BiblioTech, as module 11 closes, is a different project.

It is a reproducible Maven project, with its wrapper, its commented POM, its declared and auditable dependencies, its phases and its plugins. It builds with ./mvnw clean verify on any machine in the world that has Java 17, and changing the version of a vulnerable dependency is one line and one command.

Its container is Spring: the hundred-and-fifty-line SimpleContainer is gone, replaced by an ApplicationContext with constructor injection, stereotypes, life cycle, scopes, dev and prod profiles, typed and validated properties that prevent start-up with invalid configuration, and declarative aspects where there used to be three hand-applied proxies.

Its persistence is JPA over Hibernate and H2: mapped entities, relations with referential integrity, ACID transactions, controlled lazy loading, parameterised JPQL queries, optimistic locking with @Version for the contested copy, and Spring Data repositories that are interfaces with no implementation. The CSV files are gone.

It has tests: forty-one unit tests that run in under a second, plus the integration ones; 10-05's Clock finally cashed in with Clock.fixed; parameterised tests covering twelve fine cases in six lines; and with Mockito, the error paths that in production happen on the worst day —the database failing, the API down, the concurrency conflict with its retry—, plus MetadataClient tested without touching the network.

Its JSON is Jackson, its boilerplate is generated by Lombok in a restricted and deliberate way, and its logging is SLF4J with Logback, parameterised, correlated with MDC and configured per environment.

And it is not an application yet.

It is a set of excellent pieces that do not yet make a product. It has no project structure designed to grow: everything lives in one module. It does not consciously apply the patterns that make a system maintainable — they have kept appearing (Dependency Injection, Proxy, Repository, Factory, Builder, Singleton) and each time they have been named and postponed. It has no interface a Nexus Software employee could use it through: no decent console and no web API. It has no measure of its own quality. It is not deployed anywhere. And it has no authentication, no authorisation, no observability and no schema evolution plan.

Module 12 turns the pieces into a product. You will set the project up properly, with modules that enforce the architectural boundaries. You will study the design patterns you have been running into all module without quite naming them. You will build the console application and the web application with Spring Boot and REST. You will measure quality with coverage and serious integration tests. You will deploy with containers and versioned migrations. And you will add security, observability and an evolution strategy.

You have learned to use the tools. Now you are going to build something with them.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved