Jakarta EE Fieldbook Junior Java developer — Bologna · Milano · Tirana
0% 0
checking
Chapter 01

How to read this course

Forty-one chapters, one running application, and a job market to answer. This page tells you what the boxes mean, which chapter answers which line of that advert, and in what order to walk it.

Every idea is told twice

Once in the words a colleague would use in a review, and once in the words you would use to explain it to someone who has never seen a computer. Both are true. The second one is what you actually remember under pressure.

Whenever a concept is genuinely hard — the persistence context, a database index, a rebase — you will find a sand-coloured box like this one:

In plain words

Imagine a library. The books on the shelves are the database. When you want to work on some books you take them to a table — that table is the persistence context. While the books are on your table the librarian watches what you scribble on them, and when you leave, every change you made gets copied back onto the shelf copies. Take a book away from the table and try to read a chapter that was never brought over, and you get nothing — that is a LazyInitializationException.

And when there is a real trap — something that compiles, passes a quick test, and breaks in production — it is marked as a warning:

The trap

A green test suite is evidence, not proof. Chapter 20 shows a test in this very repository that passes for the wrong reason, and how to make it fail honestly.

There are four other kinds of box. A green example is a thing you can copy and run. A table is a lookup you will come back to. A figure is a picture of a mechanism — if you can redraw it, you understand it. And every chapter ends with Golden rules, which are collected automatically into the Cheat sheet chapter at the end.

Examplea green box always means: you can run this

Everything in this course is about one real application — a university enrollment API. You can have it running in two commands:

terminalfrom the project root
# 1. build and run every test — no server, no database needed
mvn verify

# 2. package the WAR and hot-deploy it into WildFly
mvn package

When the badge at the top right of this page turns live, the interactive panels in the later chapters are firing real HTTP requests against your own machine.

The advert, line by line

This course was assembled around one junior backend job description. Every requirement in it has a chapter, and here is the map.

Read the left column as the recruiter wrote it. The right column is where you go to be able to answer it in an interview — and, more importantly, on the first Monday.

6 months / 1 year of backend development experience
the whole course · build the project, break it, fix it
Knowledge of Java
ch. 04 Java laws · 05 Collections · 06 Threads
The Spring Boot framework: REST APIs, basic configuration, dependency management
ch. 16 Spring Boot · 17 Spring REST & data · 18 Rosetta stone
Familiarity with at least one relational database (MySQL, PostgreSQL, Oracle, SQL Server)
ch. 07 SQL · 08 Persistence · 09 Lifecycle
Basic knowledge of version control (Git)
ch. 19 Git
Ability to work in a team, curiosity, problem-solving attitude
ch. 22 Agile · 23 Debugging
Attention to detail and the will to grow in a technical environment
ch. 20 Testing · 23 Debugging · every Golden rules block
Good knowledge of the English language
ch. 24 English for developers

And the four lines the advert files under nice to have — which is where a junior candidate actually wins, because most applicants skip them:

Knowledge of continuous integration processes (Jenkins or similar)
ch. 21 CI and Jenkins
Experience, even academic, implementing REST APIs
ch. 12 REST · 13 The boundary · 15 Security
Notions of Agile, or work in structured teams
ch. 22 Agile and the team

That is one advert. Chapter 36 The market is what happened when thirty of them were read side by side — live junior listings in Bologna, Modena, Milano and Tirana — ranked by how often each requirement appears, with the pay, the contract types, and this same map extended to everything the survey turned up. Chapter 38 The interview is the hundred and forty-eight questions those employers actually ask, and chapter 37 The application is the step in between, where most applications quietly die.

Six chapters exist only because that survey found them: 28 Maven and 29 Migrations, the build and the schema; 30 Modern Java, because the adverts say Java 17 and your course said Java 8; 31 Design and SOLID and 32 The front end; and 34 Reading code with 35 Working with AI, which are about your first month rather than your first interview.

Why a Jakarta EE project for a Spring Boot job

Because the hard parts are identical. Dependency injection, transaction boundaries, the JPA persistence context, HTTP status codes, connection pools, lazy loading, optimistic locking — Spring Boot renames the annotations and hides the server, but the mechanisms underneath are the same specifications.

Learning them here and then reading chapter 18 Rosetta stone gives you something most juniors do not have: you can say why @Transactional behaves the way it does, not merely that it exists. That is the difference between having used a framework and understanding one.

A route through it

Forty-one chapters is six to eight focused weeks. Reading is not the work — the work is the terminal next to the browser.

WeekChaptersWhat you should be able to do at the end of it
101–06Explain what a container is, and defend equals/hashCode, immutability and thread-safety in an interview.
207–11Write a join with a GROUP BY by hand, and explain what a transaction and a persistence context each guarantee.
312–15Design a REST resource, choose the right status code for six failures, and validate input at the correct layer.
416–18Build the same endpoint in Spring Boot, and translate any annotation in this project into its Spring equivalent.
519–24Work like a colleague: branch, test, pipeline, review, sprint, and say all of it in English.
625–29Run it, profile it and deploy it — then own the two things underneath: the build, and the schema.
730–35Answer the rest of the advert — modern Java, SOLID, the front end, containers — and learn to work inside code you did not write.
836–41Read the market, write the application, drill the interview bank, and score above 30 on the self-test without notes.
step 1Read the chapter once, without stopping at what you do not follow.
step 2Open the file it names in src/main/java and read the real code.
step 3Break it deliberately — scripts/break.sh does this on purpose.
step 4Fix it, and write the Golden rule of that chapter from memory.
In plain words

You do not learn to ride a bicycle by reading about balance. You learn by falling over in a safe place. This project is the safe place: it has a seeding script, so you can wreck the data, delete the database, and get it all back with one command. Break things on purpose. That is the whole method.

Which chapter would you open if an interviewer asked "what happens if two people take the last seat at the same time?"

Chapter 11 Transactions for the boundary, and chapter 07 SQL for the row lock underneath it. The short answer is that the course row is read with FOR NO KEY UPDATE, so the second request waits at the database until the first one commits and then sees the true seat count. Chapter 03 The Trace shows that exact statement in a real log.

You have two hours before an interview for the job in the advert. What do you read?

Chapter 18 Rosetta stone (so every Spring annotation has a mechanism behind it), the Cheat sheet chapter, the P1 questions in 38 The interview, and then take the Self-test. Two hours is not enough to learn the material — it is enough to organise what you already know into sentences.

Golden rules

Four habits that decide whether any of the next forty chapters stick.

  1. Read the source file the chapter names.Every claim here points at a real path in src/main/java. A concept you have only read about is a concept you can be talked out of; one you have seen in a file is one you can defend.
  2. Break it on purpose, before it breaks you by accident.scripts/break.sh introduces real, documented faults. Recognising an error message once, in calm conditions, is worth more than reading ten explanations of it.
  3. Say the plain-language version out loud.If you cannot explain the persistence context without using the words “persistence context”, you have memorised a phrase rather than a mechanism. The sand-coloured boxes exist for exactly this test.
  4. The nice-to-haves are where a junior wins.Every applicant claims Java. Far fewer can talk about a Jenkins pipeline, a rebase, a liveness probe or why their own Enrollment has no setter. Chapters 19–21 and 31–33 are short, and chapter 36 counts how rarely other applicants have them.
Chapter 02

What you are actually running

Before any annotation makes sense, one idea has to land: your code is not a program. It is a deployment — a slice handed to a server that already exists.

Jakarta EE is a set of contracts

Jakarta EE is not a library you call. It is a specification — a list of promises an application server makes to your code.

In plain words

Imagine you are a chef hired by a restaurant that already exists. You do not build the kitchen, buy the ovens, or hire the waiters — all of that is there before you arrive. You bring recipes. The restaurant reads them and does the cooking, the serving and the washing up.

Jakarta EE is the restaurant. Your WAR file is the recipes. That is why there is no main() anywhere in this project: the restaurant opens itself.

You write a class, mark it with an annotation, and something else takes responsibility for creating it, wrapping it in a transaction, exposing it over HTTP, or handing it a database connection. That "something else" is the container. In this project it is WildFly.

This is the inversion that confuses everyone coming from Spring Boot or plain Java: there is no main() in this codebase. Search for one — you won't find it. The server has the main(). Your code is cargo.

SpecificationWhat it gives youWhere you see it here
JPAObjects ↔ database tablesdomain/model, repository
CDICreates and wires your objects@Inject, @ApplicationScoped
JTATransaction boundaries@Transactional
JAX-RSHTTP endpointsapi/rest
Bean ValidationConstraint checking@NotNull, @StudentNumber

The WAR, and the word provided

Your build produces a WAR — a Web Application Archive. Not a runnable jar. Look at what the build declares:

pom.xmlthe two lines that define everything
<packaging>war</packaging>

<dependency>
    <groupId>jakarta.platform</groupId>
    <artifactId>jakarta.jakartaee-api</artifactId>
    <scope>provided</scope>   <!-- ← the whole model, in one word -->
</dependency>

provided means: compile against this, but do not package it. The runtime supplies it. So the WAR contains your classes and no implementation of JPA, CDI, JTA or JAX-RS. Every annotation in the project is an empty marker until WildFly wires it up.

That is why this application's WAR has no WEB-INF/lib directory at all — not one jar sits beside your classes. A Spring Boot fat jar doing the same job is 30–50 MB, because it embeds the server. Neither is wrong — they are opposite answers to "who owns the runtime?"

WILDFLY 41 — THE CONTAINER (provided) SPECIFICATION IMPLEMENTATIONS Hibernate Weld (CDI) Narayana RESTEasy Undertow enrollment.war — NO WEB-INF/lib — EVERYTHING YOU WROTE api/ service/ repository/ domain/ No framework code ships inside. The annotations are hollow until the layer above binds them.
Everything in teal is supplied by WildFly at deploy time. Everything in bordeaux is yours. Remove the container and nothing in the lower box can run.

The four layers, and the rule between them

Requests descend; nothing skips a level. Each layer is allowed to know only about the one directly beneath it.

LayerJobForbidden
api/HTTP in, JSON outBusiness rules
service/Rules, transactions, eventsKnowing about HTTP
repository/QueriesBusiness rules
domain/State + invariantsKnowing about anything above
api/ @Path @Valid Response service/ @Transactional @Observes repository/ JPQL · Criteria · em() domain/ @Entity invariants enums a domain class importing api/ = one layer wearing four folder names Each layer may know only the one directly beneath it.
The annotations on the left tell you which layer you are in faster than the folder name does. A @Transactional in api/ or a @Path in service/ is the architecture leaking.
Why it matters

The service layer never imports a JAX-RS type. That is what lets the same EnrollmentService be driven by a REST call, a scheduled job, or a test — none of which involve HTTP. When a layer starts importing from above, the architecture has quietly become a single layer wearing four folder names.

Golden rules

Four sentences that decide how you read every later chapter.

  1. provided means the container owns the runtime.Your WAR ships without a single jar in it because Hibernate, Weld, Narayana and RESTEasy already live in WildFly. Change server, not code, and the same WAR runs on a different set of implementations.
  2. An annotation does nothing until something reads it.There is no new anywhere in the service layer. Outside a container every annotation in this project is inert metadata — which is why a unit test has to construct its objects by hand.
  3. Requests descend; nothing climbs.service/ never imports a JAX-RS type. The day it does, four folder names are describing one layer, and no compiler will tell you.
  4. The package a class lives in is a statement about what it may know.domain/ knows nothing above it, which is what lets the same rules be driven by a REST call, a scheduled job, or a test with no server at all.
Chapter 03

One request, all the way down

This is a real trace, captured from this application enrolling student 100003 into CS101. Every line below came out of the server log. Step through it.

The replay

Press play. The left pane is the log; the right pane explains the step you are on; the diagram lights up the layer currently executing.

In plain words

One click in a browser feels instant. Inside, it is more like a parcel travelling along a conveyor belt: a label is stuck on it so it can be traced (the correlation id), it is weighed and checked (validation), the warehouse is asked eight separate questions about it (the SQL), and only then is it stamped and sent back.

The player below replays that belt one station at a time — using log lines a real server actually printed.

POST /api/enrollments · correlation TRACE-DEMO-1
CorrelationIdFilter EnrollmentResource EnrollmentService @Transactional Repositories PostgreSQL

Ready

Press Play to send the request.

Now send it for real

The replay above is a recording. This one actually hits your server.

Each attempt generates a fresh correlation ID. Copy it, then grep the WildFly log for that string and you will find the whole request — filter, interceptor, every query, commit.

Live request

Not connected to the API. Open this page at localhost:8280/enrollment/tutorial.html to enable live requests.

This writes to your database

A successful call creates a real enrollment row. Repeat it and the second attempt returns 409 DUPLICATE_RESOURCE — which is the rule engine working, not a failure. That is also why api-tour.sh only scores 41/41 against a freshly seeded database.

Eight queries, eight business rules

The service fired eight SQL statements. Each one exists because a rule demanded it — the mapping is one to one.

#JavaRule
1studentRepository.findByIdDoes the student exist and may they enrol?
2findByIdWithPessimisticLockDoes the course exist, is the window open?
3findByStudentAndCourseAlready enrolled?
4countOccupiedSeatsIs the course full?
5course.getPrerequisites()What are the prerequisites?
6student.getEnrollments()Have they passed them?
7–8save() + flush()Write the row
Look again at 5 and 6

Nobody wrote those queries. They are plain getter calls — course.getPrerequisites() — that silently fired SQL because the collections are lazy. This is the single most important mechanism in JPA, and Chapter 3 is about what happens when it fires at the wrong moment.

Golden rules

What the trace teaches, independent of this particular endpoint.

  1. Read the SQL log, not the Java, to find out what happened.Two of the eight statements were never written by anyone — getPrerequisites() and getEnrollments() are plain getters that fired queries. The Java is not a reliable description of the database traffic.
  2. Every query should be attributable to a rule.Eight statements, eight checks, one to one. A query you cannot attach to a business rule is either dead work or a rule nobody wrote down.
  3. Validate everything before mutating anything.All the checks run before the insert, so a rejected enrollment leaves no partial state behind and needs no rollback to clean it up.
  4. One request, one id, one grep.CorrelationIdFilter puts an id in the MDC and every later log line carries it. Without that you are searching a shared log by timestamp and hoping.
Chapter 04

The contracts no compiler checks

Java's type system verifies shapes. It does not verify promises. Every collection, every framework, every equals you write rests on rules nothing enforces — and breaking one rarely throws. The object simply stops being found.

The equals / hashCode contract

Five clauses for equals, one for hashCode, and only the last one is regularly broken by people who know all six.

In plain words

Picture a cloakroom with a hundred numbered pegs. hashCode decides which peg your coat hangs on. equals is how the attendant checks a coat is really yours.

If two identical coats get different pegs, the attendant looks on peg 12, your coat is on peg 87, and you are told you never had a coat. That is precisely what happens when you override equals and forget hashCode — the object goes into the set and can never be found again.

Object.equals is specified as an equivalence relation. Any override must keep it one:

ClauseMeaningHow it usually breaks
reflexivex.equals(x) is trueAlmost never — but a field-by-field comparison with a NaN field breaks it
symmetricx.equals(y) == y.equals(x)A subclass that adds a field to the comparison; a class that accepts a String as equal to itself
transitivex=y and y=z implies x=zTwo sibling subclasses that each ignore the other's extra field
consistentRepeated calls agree, if nothing changedComparing on a field that a setter can change
null-hostilex.equals(null) is false, never throwsHandled for free by instanceof, which is false for null

Then the one that costs people entire afternoons:

The law

If two objects are equal, their hash codes must be equal. The converse is not required — two unequal objects may share a hash code, and every real hash function does this. The implication runs one way only, and every bug in this area is someone assuming it runs both ways.

A hash-based collection uses hashCode to pick a bucket and equals to search inside it. If the hash disagrees with equality, the collection looks in the wrong bucket, finds nothing, and reports — perfectly calmly — that the object is not there.

The disappearing entity

A Student goes into a HashSet before it is saved, so its id is still null. Then it is persisted and the database assigns id 42. Pick which hashCode the entity uses, then step through it.

A fresh, empty set. Press 1 to begin.

 

Nothing throws. The set still reports size() == 1. The object is in there, in a bucket no lookup will ever visit again — the closest thing Java has to losing something down the back of a sofa.

Which is exactly why BaseEntity in this project does the thing that looks wrong:

domain/model/BaseEntity.javaconstant on purpose
@Override
public boolean equals(Object other) {
    if (this == other) return true;
    if (!(other instanceof BaseEntity)) return false;   // instanceof, not getClass()
    BaseEntity that = (BaseEntity) other;
    return this.getId() != null && this.getId().equals(that.getId());
}

@Override
public int hashCode() {
    return getClass().hashCode();   // constant for the object's whole life
}
  • instanceof, not getClass(). Hibernate hands you proxies for lazy associations — generated subclasses. getClass() on a proxy returns something like Student$HibernateProxy$xY7, so a getClass() == other.getClass() test fails against a real Student holding the very same row.
  • getId(), not id. Reading the field directly on an uninitialised proxy returns null. The getter triggers initialisation. Every method on an entity should go through getters for this reason.
  • A null id equals nothing — including another null id. Two unsaved students are not the same student.
  • The hash is constant. Bad distribution — every Student shares one bucket — but correctness first. A hash that changes while the object sits in a set is not a performance problem, it is a data-loss problem.
Sharp edge worth seeing

Read that equals once more. It accepts any BaseEntity, so student42.equals(course42) is true. In practice a Set never mixes entity types, which is why this pattern is everywhere — but the stricter form compares Hibernate.getClass(this) with Hibernate.getClass(other), which unwraps the proxy before comparing. Try writing it and running the repository tests.

And the law that trails behind it: compareTo should be consistent with equals. When it is not, a TreeSet and a HashSet disagree about what counts as a duplicate. The canonical example ships with the JDK: new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false — different scale — while compareTo returns 0. Put both into a HashSet and you get two elements; into a TreeSet, one.

Identity is not equality

== asks whether two references point at the same object. For values, that is almost never the question you meant.

Java makes the trap comfortable by caching. Small boxed integers and string literals are shared, so == appears to work — right up to the value where it stops.

Identity lab

Two variables, the same literal value, compared both ways. Drag through the boundary.

 

The Integer cache is specified, not incidental: Integer.valueOf must return the same instance for every value in −128 … 127. Autoboxing calls valueOf, so == on boxed integers is true inside that window and false outside it. Nothing about your code changes at 128 — only whether two references happen to land on the same object.

ExpressionResultWhy
"cs" == "cs"trueBoth are the same interned literal in the constant pool
"cs" == new String("cs")falsenew is a promise of a distinct object; it always allocates
s.intern() == "cs"trueintern() asks the pool for the canonical instance
0.1 + 0.2 == 0.3falseBinary floating point cannot represent 0.1; the sum is 0.30000000000000004
Double.NaN == Double.NaNfalseSpecified by IEEE 754. Use Double.isNaN(x)
Objects.equals(a, b)safeNull-tolerant on both sides — the default form to reach for
Why it matters here

Email is an @Embeddable value object precisely so that two addresses holding the same text are the same thing, and Email.of() lower-cases at construction so that "Mario@unicam.it" and "mario@unicam.it" cannot become two rows. Normalise once, at the boundary, inside the type. Every comparison after that is free.

The floating-point row is not a curiosity. It is why money is never a double: use BigDecimal with an explicit scale, or store integer cents. A rounding error compounds silently across a million rows and then turns up in an audit.

Immutability, and what final does not give you

final freezes the reference. It says nothing whatsoever about the object on the other end of it.

the distinction, in three linesfinal ≠ immutable
private final List<String> codes = new ArrayList<>();
codes.add("CS101");         // legal — the list is mutable
codes = new ArrayList<>();   // illegal — the reference is not

An immutable class is one whose observable state cannot change after construction. Getting there takes four things, and people usually stop after two:

  1. No setters, and every field final.The obvious half. Necessary, and nowhere near sufficient.
  2. The class cannot be subclassed.Make it final, or give it only private constructors plus a static factory — the shape Email uses. A subclass can otherwise add mutable state and override methods.
  3. Defensive copies in and out.A constructor that stores the caller's List leaves the caller holding a live reference into your internals. Copy on the way in; copy or wrap on the way out.
  4. No this escapes during construction.Registering a listener or starting a thread from inside a constructor publishes a half-built object to somebody else.

The project's answer for outbound state is worth reading closely, because it stops one step short of a copy:

domain/model/Course.javaa view, not a copy
public Set<Course> getPrerequisites() {
    return Collections.unmodifiableSet(prerequisites);
}

unmodifiableSet returns a view. The caller cannot add through it — but the underlying set is still live, so later internal changes are visible through a view the caller already holds. That is the intended behaviour here (the entity stays the single writer, via addPrerequisite), and it is a genuinely different guarantee from Set.copyOf(prerequisites), which snapshots. Know which one you wrote.

ConstructCaller can mutate?Sees later internal changes?
return prerequisitesyes — disasteryes
Collections.unmodifiableSet(x)no (throws)yes — it is a live view
Set.copyOf(x)no (throws)no — snapshot taken at call time
new LinkedHashSet<>(x)yes, but only their copyno

A record gives you clauses 1 and 2 for free, plus equals, hashCode and toString derived from the components. It does not give you 3: a record holding a List is only shallowly immutable, and that list is as mutable as ever. Records also cannot be JPA entities — an entity needs a no-arg constructor and non-final fields — which is why the DTOs in this project could be records and the entities could not.

null, Optional, and failing at the right line

A NullPointerException is never thrown where the bug is. It is thrown wherever the null was finally dereferenced — frames away from whoever produced it.

Two tools narrow that gap, and each has a place it belongs.

repository/AbstractJpaRepository.javaboth, doing different jobs
public T save(T entity) {
    Objects.requireNonNull(entity, "entity must not be null");   // fail here, not later
    ...
}

public Optional<T> findById(Long id) {
    return Optional.ofNullable(entityManager.find(entityClass, id));
}
  • requireNonNull asserts a precondition. It converts "something will explode somewhere downstream" into "this caller passed null, on this line". Put it at every boundary you do not control.
  • Optional is a statement in the type system. em.find returns null for a missing row; wrapping it makes "might not be there" part of the signature, so the compiler forces the caller to decide.

The rules about where Optional does not belong matter just as much:

UseVerdictReason
Optional<T> return typeyesWhat it was designed for: an absent result the caller must handle
Optional field in an entitynoNot Serializable; JPA cannot map it; one extra object per row
Optional parameternoThe caller now has three cases: value, empty, and a null Optional. Overload instead
opt.get()noThrows NoSuchElementException with no context. Use orElseThrow(supplier)
Optional<List<T>>noAn empty list already means "nothing". Two ways to say nothing is one too many

Watch how the service turns absence into an HTTP status without a single if:

service/EnrollmentService.javaabsence becomes 404, once
Student student = studentRepository.findById(cmd.getStudentId())
        .orElseThrow(() -> new ResourceNotFoundException("Student", cmd.getStudentId()));

The exception carries the type and the id; an ExceptionMapper turns it into 404 with a problem document. One decision, expressed once, in the only layer that knows what "missing" means.

Generics: invariant, erased, bounded

Generic types exist for the compiler. At runtime they are gone — and three separate design decisions follow from that single fact.

Erasure means List<String> and List<Integer> are the same class. You cannot write new T[], cannot say T.class, cannot test x instanceof List<String>. Which is why the repository base class carries a constructor parameter that looks redundant and is not:

repository/AbstractJpaRepository.javaerasure, paid for in one field
public abstract class AbstractJpaRepository<T extends BaseEntity> {

    private final Class<T> entityClass;      // because T.class does not exist

    protected AbstractJpaRepository(Class<T> entityClass) {
        this.entityClass = Objects.requireNonNull(entityClass);
    }

    public Optional<T> findById(Long id) {
        return Optional.ofNullable(entityManager.find(entityClass, id));
    }
}

<T extends BaseEntity> is a bounded type parameter: it is what lets this class call entity.isNew() and entity.getId(). With a bare <T> the only methods available would be Object's.

Then the asymmetry that surprises everyone: arrays are covariant and checked at runtime; generics are invariant and checked at compile time.

two ways to be wrongand when you find out
Object[] arr = new String[1];
arr[0] = 42;                          // compiles. ArrayStoreException at runtime.

List<Object> l = new ArrayList<String>();  // does not compile. Good.

Invariance is the safer design, but it makes some perfectly reasonable code impossible — hence wildcards, and the mnemonic worth memorising:

PECS

Producer extends, Consumer super. If the parameter produces values you will read, write ? extends T. If it consumes values you will write into it, write ? super T. If it does both, use a plain T and accept the lost flexibility. Collections.copy(List<? super T> dest, List<? extends T> src) is the entire rule in one signature.

The same idea drives Page.map in this codebase: it takes a Function<T, R> and returns a Page<R>, which is how the REST layer converts a page of entities into a page of DTOs without the repository ever hearing about DTOs.

Exceptions: a statement about who can recover

Checked or unchecked is not a style preference. It answers one question — can the caller plausibly do something about this?

KindExtendsMeansExample
ErrorErrorThe JVM is in trouble. Do not catchOutOfMemoryError
checkedExceptionExpected, recoverable, outside your controlIOException
uncheckedRuntimeExceptionA bug, or a business outcome you modelIllegalStateException

Every business exception in this project is unchecked, and that is a deliberate, load-bearing decision:

exception/BusinessException.javaone word doing three jobs
public abstract class BusinessException extends RuntimeException { ... }
  1. It rolls back.JTA rolls back automatically on unchecked exceptions only. A checked CourseFullException would let the transaction commit — the seat taken, the rule “enforced”, the data wrong.
  2. It does not pollute signatures.enroll() can violate eight rules. Eight throws clauses on every method up the stack is how people learn to write catch (Exception e) {}.
  3. It reaches a mapper.JAX-RS ExceptionMappers catch it at the edge and produce one problem document. Nothing in between needs a try.

Four more laws that hold everywhere:

  • Never swallow. An empty catch block converts a fault into a mystery. If you genuinely want to ignore it, log at debug and say why in a comment.
  • Never return from a finally. It discards any exception in flight, silently. Same for throwing from finally.
  • Use try-with-resources. It closes in reverse order and attaches close-failures as suppressed exceptions instead of replacing the real one — which a hand-written finally { r.close(); } does.
  • Exceptions are not control flow. Constructing one walks the stack. Throwing one per loop iteration is a performance problem you will find with a profiler and never by reading.

And the wrapping law: when you catch and rethrow, pass the cause — throw new BusinessRuleViolationException(msg, e). Dropping it deletes the only stack trace that pointed at the real line.

Construction order, and why the container avoids constructors

A constructor runs against a half-initialised object. Anything it calls that a subclass can override runs before that subclass exists.

the classicprints null
class Base {
    Base() { describe(); }                    // calls the override…
    void describe() { }
}
class Child extends Base {
    private final Integer credits = 5;     // …before this line has run
    @Override void describe() { System.out.println(credits); }
}
new Child();   // prints: null

Initialisation order is fixed and worth being able to recite: static fields and static blocks in textual order, at first active use of the class; then, per instance, the superclass constructor, then instance fields and initialiser blocks in textual order, then the constructor body. The final field really is null during the superclass constructor. final does not mean "already assigned".

This is also why CDI and JPA both insist on a no-argument constructor, and why the real setup work lives elsewhere:

RequirementImposed byBecause
no-arg constructorJPA, CDIBoth instantiate reflectively, before any field value exists
non-final class & methodsCDI, JPABoth generate proxy subclasses; a final method cannot be intercepted
@PostConstructCDIInjection happens after construction, so an injected field is null in the constructor
The one that bites

Reading an @Injected field from a constructor gives you null, every time, with no warning. Field injection runs after the object exists. Either move the work into @PostConstruct, or use constructor injection — where CDI passes the dependencies as arguments and the field can be final.

Golden rules

Language-level laws. They hold in every Java program you will ever write, container or not.

  1. Equal objects must have equal hash codes. The reverse is not required.The implication runs one way. A collection picks the bucket by hash and searches it by equals, so a hash that disagrees with equality does not throw — it silently stops finding things.
  2. Nothing in a hash-based collection may change while it is in there.Mutating a field that hashCode reads moves the object's correct bucket without moving the object. It is now unreachable and still counted by size().
  3. == asks “the same object”, which is almost never the question.The Integer cache makes it work up to 127 and fail at 128; string literals make it work until one string is built at runtime. Both make the bug arrive late.
  4. final freezes the reference, not the object.A final List field is fully mutable. Immutability needs no setters, no subclassing, defensive copies both ways, and no this escaping the constructor.
  5. Optional is a return type.Not a field, not a parameter, never .get(). It exists to make “might be absent” visible in a signature so the compiler can force the caller to decide.
  6. Generic type arguments do not exist at runtime.Erasure is why AbstractJpaRepository must be handed a Class<T>, why there is no new T[], and why generics are invariant while arrays are not.
  7. Checked versus unchecked answers “can the caller recover?”Here it also decides whether JTA rolls back. Business exceptions extend RuntimeException so a violated rule cannot commit half its work.
  8. An injected field is null inside the constructor.Construction happens first, injection second. Setup work belongs in @PostConstruct — or use constructor injection and make the field final.
Chapter 05

Collections, and the pipelines over them

Choosing LinkedHashSet over HashSet in one field of Course changes the order of the JSON your API returns. Data structures are not an implementation detail; they are part of the contract.

Three contracts, and choosing an implementation

The interface says what is promised. The class says what it costs — and, crucially, what order you get.

InterfacePromisesSays nothing about
ListOrder by index, duplicates allowedCost of get(i) or add
SetNo duplicates, by equalsIteration order
MapOne value per key, by equalsIteration order, null tolerance
Queue / DequeAn insertion and removal endWhether it blocks or bounds

Then the implementations, and the reasons to pick one:

ClassOrderLookupReach for it when
ArrayListinsertionO(1) by indexAlmost always. Contiguous memory, cache-friendly
LinkedListinsertionO(n)Essentially never. A Deque you want is ArrayDeque
HashSet / HashMapnone guaranteedO(1) averageMembership and lookup, order irrelevant
LinkedHashSet / …MapinsertionO(1) averageLookup and a reproducible order
TreeSet / TreeMapsortedO(log n)Range queries, "first after X", sorted iteration
EnumSet / EnumMapenum declarationO(1), bitsetKeys are an enum. Dramatically smaller and faster

Two of those choices are load-bearing in this codebase, and neither is an accident:

domain/model/Course.java & EnrollmentStatus.javaorder, and bitsets
private Set<Course> prerequisites = new LinkedHashSet<>();   // reproducible JSON order

private final Set<EnrollmentStatus> allowed = EnumSet.of(COMPLETED, WITHDRAWN);
  • LinkedHashSet makes output deterministic. CourseMapper streams the prerequisites into a JSON array. With a plain HashSet the array order would depend on hash codes — stable within a run, and free to change when a course code changes or the JDK is upgraded. Tests that assert on that array would then fail for no reason a diff can explain.
  • EnumSet is a long, not a set. For an enum with ≤64 constants it is a single 64-bit word with one bit per constant. Membership is a bit test. The state machine in EnrollmentStatus is a handful of bitmasks.
The interface-in-the-signature rule

Declare parameters and return types as List, Set, Map; choose the concrete class only at new. Anything else freezes an implementation decision into every caller. The one honest exception is when the guarantee is the point — a method that promises sorted output may well return SortedSet, because that is a contract, not an implementation.

Inside a HashMap

An array of buckets, an index derived from the hash, and a rule for what happens when too many keys land in the same place.

In plain words

A HashMap is a row of numbered drawers. hashCode tells you which drawer, and then equals checks the few things inside that one drawer. You never search the whole room, which is why it is fast.

And if every key happens to land in the same drawer, the map quietly becomes a very slow list. That is what a bad hashCode costs you: not a wrong answer, just a hundred times the work.

Three lines of arithmetic explain almost everything about hash-map behaviour, including why a bad hashCode degrades a map into a list:

java.util.HashMapthe essential arithmetic
h = key.hashCode();
h = h ^ (h >>> 16);        // "spread": mix high bits down into the low ones
index = (table.length - 1) & h;   // cheap modulo — table.length is a power of two

The spread step exists because the index only uses the low bits. Without it, any hashCode that varies only in its high bits — very common for hand-written ones — would collide on every key.

Bucket visualiser

Eight buckets. Add course codes and watch where they land, then switch to the constant hash BaseEntity uses.

Add a key to begin.

 

MechanismTriggerEffect
Resizesize > capacity × 0.75Table doubles; every key is rehashed. Size the map up front if you know n
Treeify≥ 8 entries in one bucket (and table ≥ 64)The chain becomes a red-black tree: O(n) lookup degrades only to O(log n)
CollisionTwo keys, same indexBoth kept in the bucket; equals decides which is which

Treeification was added as a defence against hash-flooding — an attacker sending thousands of request parameters engineered to collide, turning every map operation quadratic. It is a safety net, not a licence: a constant hashCode still puts everything in one bucket, and Student is not Comparable, so that bucket cannot even become a tree.

Iterating while modifying

Java's collections are fail-fast: they try to detect concurrent modification and throw, rather than return quietly wrong answers.

three attempts at the same removalonly two work
for (Enrollment e : enrollments) {
    if (e.isWithdrawn()) enrollments.remove(e);   // ConcurrentModificationException
}

Iterator<Enrollment> it = enrollments.iterator();
while (it.hasNext()) {
    if (it.next().isWithdrawn()) it.remove();    // correct — the iterator knows
}

enrollments.removeIf(Enrollment::isWithdrawn);   // correct, and says what it means

The mechanism is a modCount field: every structural change increments it, and every next() checks that it still matches what the iterator saw when it was created. Two consequences worth internalising:

  • The name is misleading. It fires on single-threaded modification too — that is the common case, and the one above.
  • It is a best effort, not a guarantee. The javadoc says so explicitly. Never write a catch (ConcurrentModificationException) and treat it as a control-flow signal; it is a bug detector.
The JPA version of this trap

Removing an Enrollment from course.getEnrollments() while iterating is worse than a CME, because with a lazy association the iteration may itself trigger a query, and with orphanRemoval the removal triggers a DELETE at flush. This is exactly why Course exposes an unmodifiable view and keeps mutation inside methods on the entity: there is one code path that can change the collection, and it is auditable.

Streams are lazy, one-shot, and element-at-a-time

A stream pipeline does nothing until a terminal operation asks for a result — and then it pulls one element through the whole chain before touching the next.

That is not an optimisation detail. It changes how many times your lambdas run, and it is the difference between a limit(2) that reads two rows and one that reads a million.

Pipeline stepper

Six enrollments through filter → map → limit(2) → collect. Step it and count how many elements each stage actually sees.

Press Step to pull the first element into the pipeline.

0source reads
0filter calls
0map calls
0collected

 

The source was never fully read: limit short-circuited, and elements after the second match were never even fetched. Now the rules that follow from that model:

LawConsequence
Intermediate operations are lazyA pipeline with no terminal operation runs zero lambdas. A .map() whose result you discard did nothing at all
A stream is consumed onceA second terminal operation throws IllegalStateException. Streams are not collections; do not store them in fields
Lambdas must be non-interferingModifying the source collection during the pipeline is undefined — usually a CME
Lambdas should be statelessA lambda that mutates an outer variable is a race in a parallel stream and unreadable in a sequential one
peek is for debuggingIt may be skipped entirely when the pipeline can prove the element is unneeded
api/mapper/CourseMapper.javaa real pipeline from this project
response.setPrerequisiteCodes(course.getPrerequisites().stream()
        .map(Course::getCode)
        .sorted()
        .collect(Collectors.toList()));

Three things are worth noticing in four lines. sorted() is a stateful operation: it must buffer every element before it can emit the first, so it defeats short-circuiting downstream. Course::getCode is a method reference, which is a lambda whose body is a single call — same semantics, less noise. And the whole thing runs against a lazily loaded association, which is why the fetch plan matters: if prerequisites was not fetch-joined, the .stream() either fires another SELECT or throws, depending on whether the transaction is still open.

When not to use a stream

A stream over a handful of elements inside a hot loop is slower than the loop, allocates more, and produces stack traces nobody enjoys reading. Use streams where they read better than the loop — mapping, filtering, grouping — and a plain for where you need an index, an early return, or a checked exception. Parallel streams need a further, higher bar: a large workload, no shared state, and no involvement from the container — parallelStream() uses the common ForkJoinPool, which carries no transaction and no security context.

Collectors, and the boxing tax

Two habits that cost measurable performance and are invisible in a code review unless you know to look.

domain/model/Professor.javathe primitive stream
return courses.stream().mapToInt(Course::getCredits).sum();   // IntStream — no boxing

Stream<Integer> allocates an Integer per element and dereferences a pointer per operation. IntStream, LongStream and DoubleStream carry primitives. For sums, averages and counts over a large collection the difference is not subtle — and mapToInt(...).sum() also cannot return null, which reduce(Integer::sum) effectively can.

The collectors worth knowing by name, because they replace whole paragraphs of loop:

CollectorProducesWatch out for
toList()a mutable ArrayListJava 16+ stream.toList() returns an unmodifiable list — a different contract
toMap(k, v)a HashMapThrows on a duplicate key. Pass a merge function unless duplicates are impossible
groupingBy(f)Map<K, List<T>>The classic "enrollments per student" in one line
partitioningBy(p)Map<Boolean, List<T>>Both keys always present, even when one list is empty
joining(", ")a StringThe right way to build a delimited string from a stream

And the trap that reaches into the database: a groupingBy over entities is a report you computed in the JVM after loading every row. If the answer is a count, a SELECT ... GROUP BY in the repository does it in the database, returns one row, and does not depend on how many enrollments exist. Streams are excellent at transforming data you already have and a poor substitute for not fetching it.

Golden rules

Data-structure choices leak into your API, your SQL and your tests.

  1. HashSet guarantees no iteration order at all.If the collection ends up in a JSON array or an assertion, use LinkedHashSet. Depending on hash order is depending on an accident that is free to change between JDK versions.
  2. A bad hashCode turns a map into a list.The index comes from the low bits of a spread hash. A constant hash puts every key in one bucket — and treeification cannot save a key type that is not Comparable.
  3. Structurally modify a collection only through its iterator or removeIf.Fail-fast detection is single-threaded too, it is explicitly best-effort, and with a JPA association the same line can trigger a query and a DELETE.
  4. No terminal operation, no work.Intermediate operations are lazy and a stream is single-use. Elements are pulled one at a time through the whole chain, which is why limit can stop the source early — and why sorted cannot.
  5. Declare the interface, instantiate the class.List, Set, Map in every signature. Naming ArrayList in a parameter type freezes an implementation choice into every caller for no gain.
  6. Grouping in Java is a query you decided not to write.groupingBy over entities means every row was loaded first. If the result is an aggregate, GROUP BY in the repository returns one row instead of a million.
Chapter 06

One instance, many threads

Nothing in this codebase creates a thread, and every line of it runs concurrently. The container supplies the threads; you inherit every consequence. This chapter is the one whose bugs never reproduce on your laptop.

Where the threads come from

Undertow keeps a pool of worker threads. Each arriving request is handed to one of them and rides it all the way down and back.

That single sentence has two halves, and both matter. A request is one thread, which is why ThreadLocal-based tooling works at all: the correlation ID lives in the MDC, a per-thread map, and every log line the request produces can read it back. The beans are not per-thread, which is why any field you add to a service is shared by every request in flight.

UNDERTOW WORKER POOL worker-1 req A worker-2 req B worker-3 req C EnrollmentService @ApplicationScoped — ONE instance @PersistenceContext em a proxy → per-transaction EM a field here is shared by A, B and C the shared object is a proxy; the real EM is per transaction Three threads, one bean. Statelessness is not a style choice here — it is the safety property.
The only reason a shared EntityManager field is safe is that it is not an EntityManager — it is a proxy that resolves, per call, to the one bound to the current JTA transaction.
ScopeInstancesMay hold state?
@ApplicationScopedone, foreverOnly immutable or properly synchronised state
@RequestScopedone per requestYes — one thread ever touches it
@Dependentone per injection pointInherits the lifetime of whatever injected it
@Singleton (EJB)one, container-lockedYes — the container serialises access by default

The three problems, not one

"Thread safety" is shorthand for three independent failures. A fix for one is not a fix for the others.

In plain words

Two people share one bank account. At the same moment, both look at the balance: €100. Each of them takes out €80. Both saw a balance that allowed it, nobody broke a rule — and the bank is €60 short.

No amount of careful thinking inside either person fixes that. The only thing that fixes it is the bank refusing to let them both look at once. That is the whole subject of this chapter, and it is why a lock lives in the database rather than in your Java code.

  1. Atomicity.count++ is three operations: read, add, write. Another thread can land between any two of them, and one of the increments is simply lost.
  2. Visibility.A write by one thread is not guaranteed to be seen by another, ever. Each core has its own caches and the compiler may keep a value in a register. Without a synchronisation point there is no promise the second thread ever observes the new value.
  3. Ordering.The compiler, the JIT and the CPU may reorder instructions as long as a single thread cannot tell. Another thread absolutely can tell.

The demo below is the first of them. Two requests both call an endpoint that reads a counter, adds one, and writes it back — the shape of a mutable field on an @ApplicationScoped bean.

Lost update simulator

Two threads, six increments each, on a shared int. Correct answer: 12.

Press Run. Each step shows which thread executed which part of count++.

0counter
0increments done
0lost

 

Run the plain version a few times. The result varies — and that is the entire difficulty of this subject. A test that passes is not evidence of correctness; it is evidence that this particular interleaving did not happen this particular time.

happens-before, the only guarantee you get

The Java Memory Model does not promise that writes become visible. It promises something narrower and much more useful: an ordering between specific pairs of actions.

If action A happens-before action B, then everything A did is visible to B. Absent such an edge, the JVM owes you nothing. There are only a handful of ways to create one, and memorising the list is genuinely worth it:

EdgeAB
program orderan earlier statementa later one, in the same thread
monitorunlock of a monitora later lock of the same monitor
volatilewrite to a volatile fielda later read of that field
thread starteverything before t.start()everything inside the new thread
thread joineverything in the threadeverything after t.join() returns
final fieldthe constructor finishingany read through a correctly published reference
What volatile does and does not do

volatile guarantees visibility and ordering. It does not give atomicity: volatile int count; count++ is still a lost-update bug, because the read and the write are still two actions with a gap between them. Use volatile for a flag that one thread writes and others read; use AtomicInteger or a lock when a value is read and then written based on what was read.

The last row of that table is why immutability is the strongest concurrency tool in the language. An object whose fields are all final and assigned in the constructor is safely published by the JVM: any thread that gets a reference sees fully constructed state, with no synchronisation at the reader at all. That is a guarantee Email, Clock, Instant, String and every value object you write get for free.

NO EDGE — no promise at all T1: ready = true; T2: while (!ready) { } T2 may spin forever — it can cache `ready` in a register EDGE — volatile write / read T1: volatile ready = true; T2: while (!ready) { } the read sees the write, and everything before it AND THE PART PEOPLE MISS T1: config = loadConfig(); ready = true; // two ordinary writes T2: if (ready) use(config); // config may still be null A volatile write to `ready` also publishes everything written before it. That is the ordering half.
Visibility and ordering are one mechanism. A volatile write is a fence: it makes every preceding write visible to any thread that reads the volatile afterwards.

The rule for enterprise code

In a container the correct answer is almost never a lock. It is to have no shared mutable state to lock.

Look at what every service in this project holds: injected collaborators, assigned once at injection, never reassigned. No counters, no caches, no "last result", no SimpleDateFormat. Every value that varies per request arrives as a method parameter and lives on the stack — and the stack is private to a thread by construction.

the shape of every service hereand the one line that would break it
@ApplicationScoped
public class EnrollmentService {

    @Inject private EnrollmentRepository enrollmentRepository;   // set once, then read-only
    @Inject private Clock clock;

    // private int enrollmentsToday = 0;   ← ONE instance. ALL requests. A race.

    @Transactional
    public Enrollment enroll(EnrollCommand cmd) {
        Student student = ...;    // local variable = stack = thread-private
    }
}

Some types are unsafe in ways that are easy to miss, because they look like harmless utilities:

Do not shareWhyUse instead
SimpleDateFormatMutable internal calendar stateDateTimeFormatter (immutable)
HashMapConcurrent resize can corrupt itConcurrentHashMap
EntityManager (real)Specified as not thread-safe@PersistenceContext proxy
RandomContended seedThreadLocalRandom
a StringBuilder fieldUnsynchronised by designa local variable
Never new Thread() in a container

A thread you create yourself is invisible to the server: it carries no transaction, no security context, no MDC, no classloader guarantees, and nothing will ever shut it down cleanly on redeploy. If you need concurrency, inject a ManagedExecutorService — the Jakarta Concurrency spec exists exactly so that container context propagates onto the worker. The same rule is why the nightly sweep in this project is an @Schedule method on a @Singleton rather than a timer thread someone started at boot.

When state genuinely must be shared, the ladder is short and worth climbing in order: an immutable object, then an atomic, then a concurrent collection, then a lock, and only then your own synchronized protocol. Each step down is more powerful and easier to get wrong.

Where the real contention actually is

In an application like this, the interesting race is not between two Java threads. It is between two database transactions.

Java-level synchronisation cannot help you there, and this is the point most people meet late. synchronized guards one JVM. The moment a second server joins the cluster — or the same server restarts mid-flight — the lock protects nothing, because the two competing readers were never in the same process.

RaceScopeCorrect tool
Two requests mutating a bean fieldone JVMDon't have the field
Two requests taking the last seatthe databasePESSIMISTIC_WRITE — FOR UPDATE
Two admins editing one coursethe database, over minutes@Version — optimistic, 409
Two servers running the same nightly jobthe clusterA scheduler with leader election, or a DB lock row

The chapters on Persistence and Transactions take those apart. What belongs here is the boundary itself: Java's memory model ends at the JVM. Correctness across processes is a database problem, and the database has its own, stronger vocabulary for it — one that survives a restart.

Golden rules

Five sentences that decide whether your application survives its first busy afternoon.

  1. A request is one thread; a bean is not one request.Undertow hands each request a worker thread, but @ApplicationScoped means one bean instance for all of them. Every field you add is shared by every request in flight.
  2. Thread safety is three problems, and a fix for one is not a fix for the others.Atomicity (count++ is three operations), visibility (a write may never be seen), ordering (instructions may be reordered). volatile solves the last two and not the first.
  3. Without a happens-before edge, the JVM promises nothing.The edges are program order, monitor lock/unlock, volatile write/read, thread start and join, and constructor completion for final fields. If none of those connects your two actions, the second may see stale data forever.
  4. Immutable objects are safely published for free.All-final fields assigned in the constructor mean any thread holding the reference sees fully built state with no synchronisation. This is why value objects and DateTimeFormatter are shareable and SimpleDateFormat is not.
  5. Never create your own thread inside the container.It carries no transaction, no security context, no MDC, and nothing shuts it down on redeploy. Inject a ManagedExecutorService, or make it a scheduled @Singleton and let the container own the timer.
  6. synchronized guards one JVM; a seat is guarded by the database.The moment a second node exists, an in-process lock protects nothing. Cross-process correctness is FOR UPDATE, @Version, or a unique constraint — things that survive a restart.
Chapter 07

The relational model, and the language over it

The advert asks for familiarity with at least one relational database. They are all the same idea with different spellings — and the idea is older, simpler and more durable than any framework on top of it.

Tables, rows, and the rules a spreadsheet does not have

A relational database is a set of tables. A table is a set of rows. A row is a fixed list of typed values. That is the entire data model — everything else is a rule about which rows are allowed to exist.

In plain words

Think of a school register. One page lists the pupils, one page lists the classes, and a third page records who sits in which class. Nobody writes a pupil's full name on the third page — they write the pupil's number. If you get a new surname you change it in one place, on the pupil page, and the whole register is right again.

That is the entire relational idea: write each fact exactly once, and point at it from everywhere else. A spreadsheet lets you type anything anywhere. A database refuses: a pupil number that does not exist is rejected, and two pupils cannot share a number.

This project has five tables. Read the diagram as: a line means “this column holds the id of a row over there”, and the crow's foot marks the many end.

professors PK id staff_number UQ last_name department courses PK id code, academic_year UQ capacity semester enrollment_opens_at FK professor_id course_prerequisites FK course_id FK prerequisite_id both point back at courses enrollments PK id FK student_id UQ FK course_id UQ status grade, with_honours unique pair — no double enrollment students PK id student_number UQ email UQ last_name idx status idx 1 : N N : N 1 : N 1 : N Student and Course are many-to-many — and the link carries its own facts (status, grade, date). The moment a link has facts of its own, it stops being a join table and becomes a table.
course_prerequisites is a pure join table — two foreign keys and nothing else. enrollments started life as the same shape and grew a status, a grade and a timestamp, which is exactly why it is an entity in the Java model and the other one is not.

Keys: the four promises

A key is a constraint, and a constraint is a promise the database will enforce even when your application code forgets to.

ConstraintThe promiseIn this schema
PRIMARY KEYThis column identifies the row. Never null, never duplicated, never reused.id, on all five tables
FOREIGN KEYThis value exists as a primary key over there. You cannot orphan a row.enrollments.course_id → courses.id
UNIQUENo two rows share this value — or this combination of values.(student_id, course_id)
NOT NULL / CHECKA value must be present, or must fall in a range.capacity, credits, status

The most important line in the whole schema is a two-column unique constraint:

domain/model/Enrollment.javathe last line of defence
@UniqueConstraint(
    name = "uk_enrollments_student_course",
    columnNames = {"student_id", "course_id"}
)

The service already checks for a duplicate before inserting. So why also constrain it? Because between the check and the insert there is a gap, and another transaction can slip into that gap. Only the database sees every transaction at once, so only the database can make the rule airtight.

The trap

A rule enforced only in application code is a rule that holds until the day two requests arrive in the same millisecond — or until someone writes a second service, a batch import, or a manual INSERT during an incident. Any rule that must never be violated needs a real constraint. The application check exists to give a friendly 409; the constraint exists to make the friendliness optional.

Notice also what a primary key is not: it is not a business identifier. student_number is what the university writes on the card, and it is unique — but the primary key is a meaningless generated id. Business identifiers change (people are re-registered, codes are reissued); a surrogate key never does. Both exist here, on purpose.

In plain words

Your primary key is like the number the hospital gives you at birth: meaningless, permanent, and never given to anyone else. Your business key is like your username: it means something, it is unique today, and one day you might change it. Never build the plumbing of a system on something that can change.

SELECT, and the order the clauses actually run in

You write a query top to bottom. The database executes it in a completely different order, and knowing that order explains nearly every “why can't I use my alias there” error.

HOW YOU WRITE IT HOW IT RUNS 1 SELECT name, count(*) 2 FROM enrollments e 3 JOIN students s ON ... 4 WHERE e.status = 'ACTIVE' 5 GROUP BY s.last_name 6 HAVING count(*) > 2 7 ORDER BY count(*) DESC 8 LIMIT 10 1 FROM pick the rows 2 JOIN attach matching rows 3 WHERE throw rows away 4 GROUP BY fold into buckets 5 HAVING throw buckets away 6 SELECT now compute columns 7 ORDER BY sort what is left 8 LIMIT keep the first n SELECT runs sixth. That is why WHERE cannot see an alias you defined in SELECT — and why ORDER BY can.
Two rules fall straight out of this order. WHERE filters rows and runs before grouping; HAVING filters groups and runs after. And an alias created in SELECT is invisible to WHERE, because WHERE already finished.
Example — against this project's own tablespsql -h localhost -p 5433 -U enrollment
SQLevery open course in 2025, with seats left
SELECT  c.code,
        c.title,
        c.capacity,
        c.capacity - COUNT(e.id) AS seats_left
FROM    courses c
LEFT JOIN enrollments e
        ON e.course_id = c.id
       AND e.status IN ('ACTIVE', 'FAILED')   -- only these occupy a seat
WHERE   c.academic_year = 2025
  AND   NOW() BETWEEN c.enrollment_opens_at AND c.enrollment_closes_at
GROUP BY c.id, c.code, c.title, c.capacity
HAVING  c.capacity - COUNT(e.id) > 0
ORDER BY seats_left ASC;

That single query is the SQL behind GET /api/courses/open. Two details worth pausing on: the seat filter sits in the ON clause, not in WHERE — put it in WHERE and a course with zero enrollments disappears entirely, because its one NULL row gets filtered out and the LEFT JOIN silently becomes an inner join.

The trap

COUNT(*) counts rows, including the all-NULL row a LEFT JOIN invents when there is no match — so an empty course would report 1. COUNT(e.id) counts non-null values and correctly reports 0. One character, one off-by-one bug in every capacity report.

Joins, drawn as rows rather than circles

The overlapping-circles picture is where most people learn joins, and it is where most people go wrong: a join does not intersect sets, it matches and multiplies rows.

COURSES 1 CS101 2 CS201 3 CS401 ENROLLMENTS course 1 · Rossi course 1 · Bianchi course 2 · Ferrari INNER JOIN — 3 rows CS101 · Rossi CS101 · Bianchi CS201 · Ferrari CS101 appears twice. Three course rows in, three result rows out — but not the same three. CS401 has vanished. LEFT JOIN — 4 rows CS101 · Rossi CS101 · Bianchi CS201 · Ferrari CS401 · NULL Every left row survives. Where there was no match, the right-hand columns are filled with NULL. This is the row COUNT(*) would wrongly count as 1.
Say it as a sentence and you will not misuse it again: an inner join keeps rows that matched; a left join keeps every row from the left table and pads the misses with NULL. RIGHT JOIN is the same thing with the tables swapped, and almost nobody writes it. FULL OUTER JOIN keeps unmatched rows from both sides.
You wantJoinWatch out for
Only students who are enrolled in somethingINNER JOINStudents enrolled twice appear twice — use DISTINCT or group.
Every course, with a count that may be zeroLEFT JOINFilter the right table in ON, never in WHERE.
Courses with no enrollments at allLEFT JOIN … WHERE e.id IS NULLThe classic “anti-join”. NOT EXISTS is usually clearer and faster.
Every combination of two tablesCROSS JOINRows multiply. A forgotten ON is an accidental cross join and the usual cause of a query that never returns.
Why does moving e.status = 'ACTIVE' from ON to WHERE break a LEFT JOIN?

Because the padded row a LEFT JOIN invents has e.status = NULL, and NULL = 'ACTIVE' is not true — it is unknown, which WHERE discards. Every unmatched left row is thrown away, and the outer join quietly degrades into an inner join. Conditions on the outer table belong in WHERE; conditions on the optional table belong in ON.

Indexes, and why the same query is 200× slower

Without an index the database reads every row and checks it. With one, it walks a tree. The difference is not a percentage, it is an order of magnitude.

In plain words

An index is the index at the back of a book. Want every page mentioning “Ferrari”? Without the index you turn every page. With it you jump to F, read three page numbers, and turn to exactly three pages.

And it has the same cost as a real book index: it takes up extra paper, and if you add a page, someone has to update the index too. That is why you do not index every column — every index makes writes slower.

< Marchetti | ≥ < Ferrari | ≥ < Ricci | ≥ Bianchi → row 7 Ferrari → row 3 Ricci → row 12 Zanetti → row 41 Finding “Ferrari” in 50,000 students: 3 hops down the tree, not 50,000 comparisons.
This is the shape behind @Index(name = "idx_students_last_name", columnList = "last_name") in Student.java. A B-tree stays balanced as rows are inserted, so lookup cost grows with the logarithm of the table size, not the size.

Four rules cover most of what a junior needs to know about indexing:

  • Index what you filter and join on, not what you display. Every foreign key used in a join wants an index; this schema indexes enrollments.student_id and enrollments.course_id for exactly that reason.
  • A composite index is ordered, and the order matters. (course_id, status) serves a filter on course_id alone, and one on both — but not one on status alone. Think of a phone book sorted by surname then first name.
  • Wrapping the column in a function disables the index. WHERE lower(email) = ? cannot use a plain index on email; you need an index on the expression itself.
  • Every index is paid for on write. Six indexes means six trees to update per insert. Indexes are not free storage, they are a read/write trade.
Example — ask the database what it plans to dothe single most useful SQL command to learn
SQLPostgreSQL
EXPLAIN ANALYZE
SELECT * FROM students WHERE last_name = 'Ferrari';

-- Index Scan using idx_students_last_name  (actual time=0.021..0.024 rows=1)
--   Index Cond: (last_name = 'Ferrari'::text)
--
-- Drop the index and the same query says:
-- Seq Scan on students  (actual time=0.008..3.812 rows=1)
--   Filter: (last_name = 'Ferrari'::text)
--   Rows Removed by Filter: 49999

Seq Scan means it read the whole table. Index Scan means it used the tree. On a query that runs a thousand times a minute, that is the entire difference between a healthy service and an incident. In MySQL and Oracle the command is EXPLAIN; in SQL Server it is the estimated execution plan — same idea, different spelling.

ACID, and the four ways concurrency lies to you

A transaction is a group of statements that either all happen or none do. That is one letter of four, and the other three are the ones that get asked about in interviews.

LetterPromiseWhat it stops
AtomicityAll statements commit, or none do.Money leaving one account and never arriving at the other.
ConsistencyConstraints hold before and after.An enrollment pointing at a course that does not exist.
IsolationConcurrent transactions do not see each other's half-finished work.Two students taking the same last seat.
DurabilityOnce committed, it survives a power cut.Losing yesterday's enrollments to a crash.

Isolation is not a switch, it is a dial — and every step up costs concurrency. The anomalies each level permits are worth memorising, because this is the standard follow-up question after “what is ACID”:

LevelDirty readNon-repeatable readPhantom
READ UNCOMMITTEDpossiblepossiblepossible
READ COMMITTEDpossiblepossible
REPEATABLE READpossible
SERIALIZABLE
In plain words

You are counting the biscuits in a jar while your brother is eating them.

Dirty read — you count a biscuit he is holding but has not decided to eat yet; he puts it back and your count was never real. Non-repeatable read — you count twice and get a different number for the same biscuit. Phantom — you count all the chocolate ones, he adds a new chocolate one, you count again and a biscuit appeared from nowhere.

The strictest setting fixes all three by making him wait outside the kitchen until you finish. It is correct, and it is slow, which is exactly the trade-off.

PostgreSQL and Oracle default to READ COMMITTED; MySQL's InnoDB defaults to REPEATABLE READ. This project does not raise the level. Instead it takes a targeted row lock exactly where the race is:

repository/CourseRepository.javaa real lock, on one row, for the length of one transaction
// LockModeType.PESSIMISTIC_WRITE becomes, on PostgreSQL:
SELECT ... FROM courses WHERE id = ? FOR NO KEY UPDATE;

// Request B now waits here until request A commits or rolls back,
// so B can never read a seat count that A is about to change.

That is the mechanism chapter 11 Transactions builds on, and it is the honest answer to “how do you stop two people booking the last seat”: not with a Java synchronized block, which only guards one server, but with a lock held by the one component every server shares.

Four databases, one language, small differences

The advert lists MySQL, PostgreSQL, Oracle and SQL Server as interchangeable. For 90% of the SQL you will write, they are. Here is the 10%.

TaskPostgreSQLMySQLOracleSQL Server
Generated idSERIAL / SEQUENCEAUTO_INCREMENTSEQUENCEIDENTITY
First 10 rowsLIMIT 10LIMIT 10FETCH FIRST 10 ROWSTOP 10
Join stringsa || bCONCAT(a,b)a || ba + b
NowNOW()NOW()SYSDATEGETDATE()
BooleanBOOLEANTINYINT(1)NUMBER(1)BIT
Default isolationREAD COMMITTEDREPEATABLE READREAD COMMITTEDREAD COMMITTED
String comparecase-sensitiveusually case-insensitivecase-sensitivecollation-dependent

This is precisely the difference JPA exists to absorb. The project declares one line of configuration and Hibernate emits the right dialect:

META-INF/persistence.xmlone property, four vendors
<property name="hibernate.dialect"
          value="org.hibernate.dialect.PostgreSQLDialect"/>

<!-- Swap that value for MySQLDialect, OracleDialect or SQLServerDialect
     and the same @Entity classes produce different SQL. That portability
     is real, and it is most of the reason JPA exists at all. -->
The trap in the interview

“Which databases have you used?” is rarely a question about products. It is a question about whether you know what a transaction, an index and a join are. Answering “PostgreSQL, and I know the isolation levels and how to read an EXPLAIN” is a far stronger answer than listing four names.

The tests in this project run on H2, but production runs on PostgreSQL. What could that hide?

Anything dialect-specific: a native query using PostgreSQL syntax, a FOR NO KEY UPDATE that H2 implements differently, a case-sensitivity difference in a LIKE, or a sequence allocation behaving unlike the real one. It is the standard trade — H2 gives you a test suite that runs in seconds with no infrastructure, at the cost of not being the real database. The professional answer is to keep the fast H2 suite and run a smaller set of tests against a real PostgreSQL in CI, which is what Testcontainers is for (chapter 21).

Golden rules

What survives from this chapter into every project you will ever work on.

  1. A rule that must never break needs a database constraint.The application check gives a friendly 409; the UNIQUE constraint is what actually makes double enrollment impossible, because only the database sees every transaction at once.
  2. SELECT runs sixth, not first.FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. That order explains why WHERE cannot see a SELECT alias, and why HAVING filters groups while WHERE filters rows.
  3. Filter the optional table in ON, never in WHERE.A condition on the right-hand table in WHERE discards the NULL-padded rows, silently turning a LEFT JOIN into an inner join and losing every unmatched row.
  4. Index what you filter and join on — and pay for it on every write.A composite index is usable left-to-right only, and wrapping a column in a function disables it entirely. EXPLAIN is how you find out, rather than guess.
  5. Isolation is a dial, and the row lock is usually the better tool.Raising the level costs concurrency everywhere. A SELECT ... FOR UPDATE on the one contended row costs nothing anywhere else, and is what this project uses to protect the last seat.
  6. The four vendors differ in spelling, not in concepts.Sequences, LIMIT versus TOP, string concatenation and default isolation. Learn the concepts once; the dialect is a lookup table — and Hibernate hides most of it behind one property.
Chapter 08

JPA and the persistence context

Objects in memory, rows in a database, and a cache in between that explains almost every surprise you will hit.

Entities and the shared base class

Every table in this schema has an id, a version, and timestamps. Rather than repeat them, they live in one place:

domain/model/BaseEntity.javaJava: inheritance for mapping reuse
@MappedSuperclass
public abstract class BaseEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "app_id_generator")
    private Long id;

    @Version                          // optimistic locking — see below
    private long version;

    @Column(name = "created_at", updatable = false)
    private Instant createdAt;
}

@MappedSuperclass is pure code reuse. It is not an entity, has no table of its own, and cannot be queried. Its fields are copied into each subclass's table at mapping time. Contrast that with real JPA inheritance (@Inheritance), where the parent is queryable and the database layout changes.

The Java bit: why equals() is hard here

Two objects representing the same row must be equal. But before persist(), id is null — so an id-based equals makes every new object equal to every other new object, and a HashSet of unsaved entities collapses to one element. This is a genuine, famous JPA trap, and it is why BaseEntity handles it deliberately rather than letting the IDE generate it.

All four relationships, in one class

Course was written to contain every association type you will meet:

domain/model/Course.javathe four shapes
// MANY courses → ONE professor. The owning side: this table holds the FK.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "professor_id")
private Professor professor;

// SELF-referencing MANY-to-MANY: a course requires other courses.
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "course_prerequisites",
    joinColumns        = @JoinColumn(name = "course_id"),
    inverseJoinColumns = @JoinColumn(name = "prerequisite_id"))
private Set<Course> prerequisites = new LinkedHashSet<>();

// INVERSE side. mappedBy = "this is mirrored by a field over there".
@OneToMany(mappedBy = "course", fetch = FetchType.LAZY)
private Set<Enrollment> enrollments = new LinkedHashSet<>();

Owning versus inverse is the concept to hold onto. The owning side is whichever one has the foreign-key column; only changes to the owning side are written. Setting the inverse side alone updates your object graph and saves nothing to the database — a bug that looks like "JPA is ignoring me".

Enrollment is not a plain join table: it carries a grade, a status and timestamps. When a relationship has its own data it stops being @ManyToMany and becomes an association entity — two @ManyToOnes pointing outward.

Professor email: @Embeddable Course holds professor_id Enrollment holds student_id + course_id Student aggregate root 1 : N @OneToMany(mappedBy) Enrollment is the owning side of both @ManyToOne links: it carries grade + status, so it is an entity, not a @ManyToMany join table. Student side declares cascade = ALL and orphanRemoval = true @ManyToMany to itself course_prerequisites BaseEntity @MappedSuperclass id · version · timestamps no table of its own
Bordeaux marks the side that holds the foreign key — the only side JPA writes. Every association here is LAZY, which is what makes Chapter 3's boundary matter.

Lazy loading, and the exception it causes

Every association above is LAZY. Accessing one runs a query — but only while the persistence context is still open.

In plain words

Back to the library from chapter 1. Lazy loading means the librarian only fetches a book at the moment you actually open it — which is efficient while you are still sitting at the table.

The moment you leave, the table is cleared. Reach for a book you never opened while you were there and there is nothing to reach for. LazyInitializationException is the sound of grabbing at an empty table after the librarian has gone home.

The persistence context is a cache of managed entities that lives for the duration of the transaction. Inside it, a lazy getter transparently fires SQL. Once the transaction commits, entities become detached, and the same getter throws.

PERSISTENCE CONTEXT NEW no id, no row MANAGED tracked · lazy getters fire SQL DETACHED a plain Java object REMOVED persist() commit clear() merge() remove() find() · JPQL · Criteria — results arrive already managed lazy getter here throws nothing tracks it; setters do nothing Only inside the box does changing a field become an UPDATE. Outside it, an entity is just an object.
Four states, five operations. Almost every JPA surprise is an entity being used in a state you assumed it was not in.

Here is that boundary, drawn against the request from Chapter 2:

time PERSISTENCE CONTEXT OPEN entities managed · lazy getters fire SQL getPrerequisites() → works @Transactional in COMMIT DETACHED the mapper runs here getPrerequisites() → throws filter service CourseMapper
The identical line of code succeeds on the left of the commit and throws on the right. Nothing about the entity changed — only whether a session is still open behind it.

Run the experiment

This project's query fetch-joins prerequisites so the mapper can read them. Toggle that one clause and watch the endpoint flip:

repository/CourseRepository.javacomplete fetch plan
"SELECT DISTINCT c FROM Course c "
  + "JOIN FETCH c.professor "
  + "LEFT JOIN FETCH c.prerequisites "
  + "WHERE c.id = :id"
GET /api/courses/55
200 OK

prerequisiteCodes: ["CS101","CS301"]

CourseRepositoryIT
PASSES

6 tests, 0 failures.

The rule to remember

A fetch plan is a contract between a query and every consumer of its result. CourseMapper touches getPrerequisites(), so the fetch join is part of that mapper's requirements — even though no compiler, type or interface records the connection. Add a consumer that reads one more association and the query must change with it.

N+1, the performance bug you will meet

Load 50 courses in a loop, touch getProfessor() on each, and JPA issues 1 query for the list plus 50 more — one per course. Nothing looks wrong in the Java. The fix is to declare the fetch plan up front:

In plain words

You need the phone numbers of thirty people. You can ask for the whole list once, or you can ring the office thirty separate times and ask for one number each.

Both give the correct answer. One takes a second, the other takes a minute — and nothing in your Java code looks different between the two. That is why N+1 is so easy to write and so hard to notice without looking at the SQL log.

the difference1 query vs 51
// N+1: one SELECT for courses, then one per professor
"SELECT c FROM Course c"

// One SELECT, joined
"SELECT c FROM Course c JOIN FETCH c.professor"
SELECT c FROM Course c then touch getProfessor() in a loop 1 list query + 50 lazy loads 51 round trips ... JOIN FETCH c.professor the fetch plan is declared up front one statement, joined 1 round trip
The Java looks identical in both cases — a loop over a list, calling a getter. Only the log tells them apart, which is the entire reason this project logs SQL at DEBUG.

You detect it by reading the SQL log, which is exactly why this project sets org.hibernate.SQL to DEBUG. If a single request emits a suspiciously round number of near-identical queries, that is N+1.

Two kinds of locking

Both appear in this codebase, for different problems.

OptimisticPessimistic
@Version on BaseEntity. Assumes no conflict; on write, checks the version still matches. If not → OptimisticLockException → HTTP 409. LockModeType.PESSIMISTIC_WRITE. Takes a real database row lock immediately. Others wait.
Cheap. Right for "two admins edited the same course". Expensive. Right for the seat-counting race — read count, decide, insert.

You saw the pessimistic one in the trace: query 2 ended in FOR NO KEY UPDATE. That clause is the lock, and it is why two students cannot both take the last seat.

Golden rules

The five that account for most of the surprises in JPA.

  1. Lazy means later — and only while the session is still open.The identical getter succeeds before the commit and throws after it. Nothing about the entity changed; only whether a persistence context is still behind it.
  2. A fetch plan is a contract between a query and every consumer of its result.CourseMapper reads getPrerequisites(), so LEFT JOIN FETCH is part of that mapper's requirements. No type, interface or compiler records that link — only a test can.
  3. Only the owning side is written.The owning side is whichever one holds the foreign-key column. Setting the mappedBy side alone updates your object graph and saves nothing, which reads as “JPA is ignoring me”.
  4. A suspiciously round number of near-identical queries is N+1.1 + 50, never 51 different statements. You find it by reading the log, which is why org.hibernate.SQL sits at DEBUG in this project.
  5. Optimistic for edits, pessimistic for counting seats.@Version says “tell me if someone beat me to it”. FOR NO KEY UPDATE says “nobody else moves until I commit”. Only the second can protect a read-decide-insert sequence.
Chapter 09

Four states an object can be in

You never call update(). You change a field and it appears in the database. That is not magic — it is one specific state, and the previous chapter's LazyInitializationException is what happens the moment you leave it.

Transient, managed, detached, removed

The same Enrollment object behaves completely differently depending on which of four states it is in — and nothing in its type says which.

In plain words

A document can be in four places. A draft on your desk nobody knows about (transient). Open in a shared editor where every keystroke is being recorded (managed). A copy you emailed yourself and are now editing offline (detached). And one in the bin, waiting for the cleaner (removed).

Almost every surprise in JPA is really the same question wearing a disguise: which of those four is this object in right now?

StateKnown to the context?In the database?Changes tracked?
transientnonono — it is a plain Java object
managedyeson flushyes — dirty checking is on
detachedno (any more)yesno — edits go nowhere
removedyes, scheduled for DELETEuntil flushn/a

The persistence context is the thing doing the knowing. It is a map from primary key to entity instance, created when the transaction begins and thrown away when it commits. Everything below is an operation on that map.

State explorer

Start with new Enrollment(...) and apply operations. Illegal moves are greyed out — click one to see exactly how it fails.

Current state: transient — a plain object the provider has never seen.

what the provider doesno SQL yet
// nothing has touched the database

The one that explains the mapper

commit closes the context, and every entity it held becomes detached in one step. That is the whole story of LazyInitializationException: the object is still in your hand, still has its id, and has lost the only thing that could load anything more.

Dirty checking, and when the SQL actually runs

The provider took a snapshot of every field when it loaded the row. At flush time it compares, and writes only what differs.

service/CourseService.java — the shape of itno save() anywhere
@Transactional
public Course changeCapacity(Long id, int capacity) {
    Course course = courseRepository.findById(id)          // now MANAGED
            .orElseThrow(() -> new ResourceNotFoundException("Course", id));
    course.changeCapacity(capacity);                        // just a field write
    return course;                                          // UPDATE happens at commit
}

No repository call is needed for the update, and adding one changes nothing. That surprises people twice: first that it works, then that save() is redundant rather than required.

Flush is the moment pending changes become SQL. It happens at three points, and the middle one is the one people forget:

  1. At commit.Always. This is the one you rely on.
  2. Before a query that might be affected.The default FlushModeType.AUTO flushes before running a JPQL query whose result could depend on unwritten changes — otherwise your own query would not see your own work.
  3. When you call em.flush().Occasionally necessary: to get a generated id early, or to force a constraint violation to surface at a line you control rather than at commit.
Two costs of dirty checking

The snapshot is per entity, so loading 50,000 rows to change one of them means 50,000 comparisons at flush — the reason bulk updates go around the persistence context entirely (see the Scheduled chapter). And an entity is dirty if any field differs, including one you "changed" back: setting a field to a new value and then to the original produces no UPDATE, because dirty checking compares values, not calls.

persist versus merge, precisely

The single most common JPA bug is calling merge and ignoring what it returns.

persist(e)merge(e)
Intended fora transient entitya detached entity
Returnsvoida different, managed instance
Argument afterwardsbecomes managedstays detached
Given an id already setthrows or inserts, dependingSELECTs, then copies state onto the result
Costone INSERTa SELECT first, unless already in the context
repository/AbstractJpaRepository.javawhy save() returns a value
public T save(T entity) {
    if (entity.isNew()) {
        entityManager.persist(entity);
        return entity;              // same instance — persist mutates it
    }
    return entityManager.merge(entity);   // DIFFERENT instance — use this one
}

Write repository.save(course); and discard the result and your changes may vanish without a single error. The signature is trying to tell you: it returns something because the something it returns is not what you gave it.

The rest of the operations, compactly:

CallDoesGotcha
find(C.class, id)Context first, then databaseReturns null when missing — wrap in Optional
getReference(C.class, id)Returns a proxy without queryingThrows EntityNotFoundException on first real use, not here
remove(e)Schedules a DELETEOnly accepts a managed entity — merge a detached one first
detach(e)Evicts one entityPending changes to it are silently dropped
clear()Evicts everythingWhat CourseRepositoryIT uses to make a test honest
refresh(e)Overwrites the object from the databaseDiscards your unsaved changes — that is the point

Cascade: which operations travel down the association

A cascade says "whatever you do to the parent, do to the children too". Choosing ALL because it sounds convenient is how rows disappear.

This project makes two different choices for two structurally similar associations, and the contrast is the lesson:

Student.java vs Professor.javasame shape, opposite decision
// Student — an enrollment cannot outlive its student
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL,
           orphanRemoval = true, fetch = FetchType.LAZY)
private Set<Enrollment> enrollments = new LinkedHashSet<>();

// Professor — a course must NOT vanish when a professor leaves
@OneToMany(mappedBy = "professor", fetch = FetchType.LAZY)
private Set<Course> courses = new LinkedHashSet<>();

The test is ownership, not convenience: does the child have any meaning without this parent? An enrollment is a fact about one student and is meaningless without them — it is part of the aggregate. A course outlives whoever teaches it — a different aggregate that happens to be pointed at.

Cascade explorer

Pick a cascade configuration, then an operation on the parent, and see what reaches the child.

Choose an operation above.

resulting SQL 
// pick an operation
CascadePropagatesUse when
PERSISTSaving the parent saves new childrenAlmost always safe for owned children
MERGEReattaching the parent reattaches childrenDetached graphs coming back from a UI
REMOVEDeleting the parent deletes childrenOnly for true composition. Never on @ManyToMany
REFRESH / DETACHThe obvious analoguesRare
orphanRemovalRemoving from the collection deletes the rowThe child is a part, not a reference

orphanRemoval is not a cascade — it is stronger. CascadeType.REMOVE fires when you delete the parent; orphanRemoval fires when you merely take the child out of the collection. One line — student.getEnrollments().remove(e) — becomes a DELETE at flush. That is the correct behaviour for a part, and catastrophic for a reference.

Cascade REMOVE on a @ManyToMany

Course.prerequisites is a self-referencing @ManyToMany with no cascade, and that is deliberate. A REMOVE cascade there would mean deleting one course deletes its prerequisites — and theirs, recursively. A many-to-many association records a relationship between independent things; ownership is exactly what it does not express.

The first-level cache, and the one that is switched off

The persistence context is itself a cache, and it is not optional. The second-level cache is, and here it is off.

Inside one transaction, find(Course.class, 55L) twice issues one SELECT. The second call finds the entity in the context and returns the same instance — reference-identical, guaranteed by the spec. That guarantee is why entity equals by id is safe within a transaction, and why it stops being enough across two.

CacheScopeHereRisk
First-level (L1)one persistence contextAlways on, not disableableMemory, if you load huge result sets
Second-level (L2)the whole EntityManagerFactoryshared-cache-mode = NONEStale data across nodes; needs invalidation
Query cacheresult-set ids per queryOffInvalidated by any write to the tables involved
META-INF/persistence.xmla teaching decision
<shared-cache-mode>NONE</shared-cache-mode>

With L2 off, every query you read in the SQL log corresponds to a real round trip — nothing is quietly served from memory, so the log tells the truth. That is worth more while learning than the throughput a cache would buy. In production the setting is ENABLE_SELECTIVE plus @Cacheable on the entities that are read constantly and written rarely — reference data, not enrollments.

The rule for L2

Cache entities that are read far more often than written, and whose staleness for a few seconds is harmless. Never cache something a business rule reads to make a decision — the seat-counting query in this application must see the true current row, which is exactly why it is a FOR UPDATE read and not a cached one.

Golden rules

The state an entity is in explains more bugs than any other single fact about it.

  1. Only a managed entity writes itself to the database.Change a field on a managed entity and the UPDATE appears at commit; change the identical field on a detached one and absolutely nothing happens, with no error.
  2. Commit detaches everything at once.The persistence context closes with the transaction. Every entity that was managed becomes detached in the same instant — which is the entire explanation for LazyInitializationException in the mapper.
  3. merge returns a different object.persist mutates its argument; merge copies state onto a managed instance and hands it back, leaving your argument detached. Ignoring the return value is the most common JPA bug there is.
  4. Cascade follows ownership, not convenience.Ask whether the child means anything without this parent. An enrollment cannot outlive its student (ALL + orphanRemoval); a course outlives its professor (no cascade at all).
  5. orphanRemoval deletes on removal from the collection.Stronger than CascadeType.REMOVE, which only fires when the parent is deleted. list.remove(child) becomes a DELETE at the next flush.
  6. Within one transaction, the same row is the same instance.The first-level cache is not optional and is guaranteed by the spec. It is also why loading fifty thousand rows to modify one is expensive: every one of them is snapshotted for dirty checking.
Chapter 10

CDI — who builds your objects

You never call new on a service in this codebase. Something else does, and that indirection buys you interceptors, scopes and events.

Scopes decide lifetime

An annotation on the class tells the container how many instances to keep and how long they live. This project uses four:

In plain words

You never build your own objects here. You write down what you need, and something else puts it in your hand — like a surgeon who does not go and buy a scalpel, but holds out a hand and one appears.

A scope is simply how long the tray keeps that instrument: one for the whole hospital, one per operation, or a fresh one for every patient. Choosing wrongly is how one patient's instrument ends up being used on the next.

ScopeUsesLifetime
@ApplicationScoped17One per application. Services, repositories, mappers.
@RequestScoped5One per HTTP request. Anything holding per-request state.
@Singleton4One, eagerly created, no proxy.
@Dependent4Lives and dies with whoever injected it.
REQUEST 1 REQUEST 2 @ApplicationScoped @Singleton (EJB) @RequestScoped @Dependent one instance, shared by both requests at once — keep it stateless one instance, eager, container write-lock — callers queue new instance new instance per injection per injection deploy undeploy A field on a bar that spans both requests is shared state. That is the whole rule.
Two beans can both be “one instance” and still behave completely differently: CDI's @ApplicationScoped is entered concurrently, the EJB @Singleton serialises its callers.
The classic bug

@ApplicationScoped beans are shared by every concurrent request. Give one a mutable field and you have built a race condition that only appears under load. Services here are stateless for exactly this reason — all state travels in method parameters.

Interceptors — the same idea as a decorator

Because the container constructs your beans, it can wrap them. @Loggable is a custom binding; anything annotated with it gets timed:

common/logging/LoggingInterceptor.javaAOP without a framework
@Loggable
@Interceptor
public class LoggingInterceptor {

    @AroundInvoke
    public Object logInvocation(InvocationContext ctx) throws Exception {
        // ... log entry, start timer
        Object result = ctx.proceed();     // ← continue the chain
        // ... log exit and elapsed time
        return result;
    }
}

Those -> EnrollmentService.enroll and <- completed in 162ms lines in the trace came from here, not from the service. ctx.proceed() is the hinge: everything before it runs on the way in, everything after on the way out.

@Loggable — log entry, start timer @Loggable — log exit, “completed in 162ms” @Transactional — begin, open persistence context @Transactional — commit, detach everything enroll() — the only code you wrote call return in ▼ ▲ out Top edge = before ctx.proceed(). Bottom edge = after it. Same class, same method, two halves.
An interceptor is not “code that runs first” — it is code that runs on both sides of yours. That is why a commit can happen after your method has already returned its result.

This is precisely how @Transactional works. It is an interceptor too — begin before proceed(), commit or roll back after. Understanding one explains the other.

Events, and the phase that matters

The service fires one event. Two observers receive it — and the difference between them is a single clause:

service/EnrollmentNotificationListener.javasame event, different timing
// Runs IMMEDIATELY, inside the transaction. If it throws, everything rolls back.
public void auditEnrollmentCreated(@Observes EnrollmentCreatedEvent e) { ... }

// Runs only AFTER a successful commit.
public void onEnrollmentCreated(
        @Observes(during = TransactionPhase.AFTER_SUCCESS) EnrollmentCreatedEvent e) { ... }

In the trace you can see the commit happen between them:

server.logthe 6 ms that separates them
14:34:40,710  [AUDIT] enrollment.created id=202   ← inside the transaction
14:34:40,710  <- enroll completed in 162ms       ← COMMIT happens here
14:34:40,716  [NOTIFICATION] To matteo...        ← after the commit
Why you care

Never send an email inside a transaction. The transaction can still roll back after your observer runs, and then you have emailed a student about an enrollment that does not exist. AFTER_SUCCESS is the guarantee that the row really landed.

Golden rules

Objects the container builds behave differently from ones you build.

  1. @ApplicationScoped plus a mutable field is a race condition.One instance is shared by every concurrent request. The 17 application-scoped beans here are stateless on purpose — all state travels in method parameters.
  2. ctx.proceed() is the hinge.Everything before it runs on the way in, everything after on the way out. That one idea explains @Loggable, @Transactional and @RolesAllowed at once.
  3. An observer inside the transaction can veto it; one after it cannot.A plain @Observes that throws rolls the enrollment back. AFTER_SUCCESS runs only once the row is committed — and by then nothing it does can be undone.
  4. Never do anything irreversible inside a transaction.Emails, payments, calls to other systems. The transaction can still roll back after your observer ran; the email cannot.
Chapter 11

JTA — where the boundary is

One annotation decides what commits together, what rolls back, and how long entities stay alive.

@Transactional wraps the method

Put it on a method and the container begins a transaction before the body runs and commits when it returns. The whole method is atomic: all eight of those queries either land together or not at all.

In plain words

A transaction is the word and. “Take the seat and write the enrollment.” If the second half fails, the first half must un-happen.

@Transactional draws a box around the method. Either everything inside the box happened, or none of it did. There is no half.

It also defines the persistence-context lifetime — which is why the detachment boundary from Chapter 3 sits exactly at the closing brace of enroll().

Rollback rules surprise everyone

By default, Jakarta EE rolls back on unchecked exceptions only.

ThrownDefault behaviour
RuntimeExceptionRolls back
ErrorRolls back
Checked ExceptionCommits anyway
@Transactional enroll() RuntimeException or Error checked Exception (extends Exception) ROLLBACK no row was written COMMIT half the work is now real Every business exception in this project extends RuntimeException, so the lower path never runs here — by choice, not by luck. to change it: @Transactional(rollbackOn = MyCheckedException.class)
The lower path is the one that surprises people: the method threw, the caller saw a failure, and the rows it had already written are committed anyway.

That last row is not a bug — it comes from a distinction the specification draws between a checked exception (an expected business outcome, e.g. "insufficient funds") and an unchecked one (something broke). It rarely matches intuition, which is why every business exception in this project extends RuntimeException. If you need different behaviour: @Transactional(rollbackOn = ...).

The self-invocation trap

This is the one that silently costs people days.

In plain words

The annotation is not really on your method. It is on a doorman standing outside your office. Anyone who comes in through the door gets checked.

But when you shout to yourself across your own desk, you never go past the doorman — so nothing is checked, no transaction is started, and nobody tells you. It fails completely silently, which is exactly what makes it a trap rather than a bug.

Interceptors work through a proxy. The container hands callers a wrapper, and the wrapper applies the annotation before delegating to your real object. So a call from outside is intercepted. A call from inside the same object goes straight to this — bypassing the proxy entirely.

Resource PROXY begins TX DataSeeder seed() @Transactional TX open this.method() NO TX The annotation is still there. It simply never runs.
An internal this.method() call never leaves the object, so the proxy never sees it and the annotation has no effect. DataSeeder is structured specifically to avoid this.

The symptom is brutal: the annotation is right there in the source, so the code looks correct, but no transaction is ever started. The fix is to move the annotated method into a different bean and inject it.

Golden rules

Where the boundary is, and what it takes with it.

  1. The transaction ends at the closing brace, and takes the persistence context with it.Every entity the method returns is detached from that instant. The mapper always runs on the far side of that line.
  2. Unchecked rolls back, checked commits.Counter-intuitive, and specified. It is why every business exception here extends RuntimeException — override it with @Transactional(rollbackOn = ...) only deliberately.
  3. this.method() is never intercepted.The call never leaves the object, so the proxy never sees it and the annotation silently does nothing. The fix is another bean, not another annotation.
  4. One service method is one unit of work.The boundary belongs to the layer that owns the rules. A resource that opens a transaction has taken the service's job; a repository that opens one has taken it twice.
Chapter 12

JAX-RS and the error contract

How little a resource class should do, and why every failure in this API has the same shape.

A resource is a thin translator

api/rest/EnrollmentResource.javanote what is absent
@Path("/enrollments")
@Consumes(MediaType.APPLICATION_JSON)
public class EnrollmentResource {

    @POST
    public Response enroll(@Valid @NotNull EnrollRequest request) {
        // validate → delegate → map → status code. No rules live here.
    }
}

No business logic, no SQL, no try/catch. @Valid makes the container validate before your method is entered, and failures are handled centrally. A resource that grows an if about eligibility has taken work that belongs to the service.

In plain words

A REST resource is a receptionist. They take your request in the language of the outside world — JSON over HTTP — find the right person inside the building, and carry the answer back out.

A receptionist who starts deciding company policy is a receptionist doing the wrong job. That is exactly what a business rule written inside a controller is, and it is the first thing a reviewer will point at.

DTOs exist to break a coupling

Entities are never returned directly. If they were, a JPA mapping change would silently alter your public API, lazy associations would serialise unpredictably, and every internal field would be exposed. The mapper is the seam — and, as Chapter 3 showed, also where fetch plans get enforced.

Exception mappers: errors handled once

Instead of try/catch in fifty endpoints, each exception type is mapped to a status in one place:

ExceptionStatus
ResourceNotFoundException404
InvalidRequestException400
ConstraintViolationException400
BusinessRuleViolationException409
DuplicateResourceException409
OptimisticLockException409
Exception (catch-all)500
InvalidRequestException ConstraintViolationException 400 ResourceNotFoundException 404 BusinessRuleViolation · Duplicate OptimisticLockException 409 any other Exception 500 @Provider ExceptionMapper<T> one per type, zero in the endpoints problem+json type title status errorCode correlationId Same document shape for every failure, chosen in exactly one place per exception type. A try/catch in a resource is a second opinion about a status code nobody asked for.
Read it right to left when designing: decide the shape the client gets, then decide which exceptions land on which status. The endpoints never take part in that decision.

The 409s are worth noticing. 409 Conflict means "your request was well-formed, but the current state of the server forbids it" — a full course, a suspended student, a closed window. That is a different failure from 400 Bad Request, which means the request itself was malformed. Getting this distinction right is most of designing a decent API.

RFC 7807 and correlation IDs

Every error returns the same document shape — a published standard, not a house style:

a real 500 from this serverapplication/problem+json
{
  "type": "https://api.unicam.it/problems/internal-error",
  "title": "Internal Server Error",
  "status": 500,
  "errorCode": "INTERNAL_ERROR",
  "instance": "/courses/55",
  "correlationId": "BREAK-2"
}

That correlationId is the operational payoff. CorrelationIdFilter assigns an ID per request and puts it in the MDC — a per-thread map the logging system reads. The log pattern includes %X{correlationId}, so every line that request produced carries it.

A user reports an error and quotes the ID; you grep one string and get that request's entire life — filter, service, all eight queries, the exception. Without it, you are searching a shared log by timestamp and hoping.

Console — provoke every status code

Reading a table of status codes teaches you less than making the server produce them. Each button below is a real request.

The read-only ones are safe to repeat. The two marked writes change data — that is deliberate, because the second click is what demonstrates the duplicate rule.

Live API console

Not connected to the API. These run only when the page is served by WildFly at localhost:8280.

What to watch for

Compare a 400 with a 409. The 400 lists every invalid field at once — Bean Validation collects them all rather than failing on the first. The 409 carries an errorCode like PREREQUISITES_NOT_MET, because the request was perfectly well-formed and the domain refused it. Same JSON shape, completely different meaning.

Golden rules

The API contract, stated as rules rather than as endpoints.

  1. A resource translates; it never decides.Validate, delegate, map, choose a status code. An if about eligibility in a JAX-RS method is a rule the scheduled job will never run.
  2. 400 means the request is malformed. 409 means the request is fine and the world says no.Full course, suspended student, closed window, duplicate enrollment — all 409. Getting that split right is most of designing a decent API.
  3. Handle each failure once, in a mapper.Fifty try/catch blocks are fifty chances to disagree about a status code. One ExceptionMapper per exception type, and the endpoints stay empty of error handling.
  4. Never return an entity.Without a DTO, a mapping change becomes an API change, lazy associations serialise unpredictably, and every internal field is public by default.
  5. Errors carry an id, not a stack trace.The client gets a correlationId in a problem+json body; the server log gets the detail. A stack trace in a response hands out your library versions for free.
Chapter 13

Where JSON becomes objects

Between the socket and your service sits a layer that parses bytes, picks a status, checks constraints and refuses malformed input. Most of it is declarative, which makes it easy to use and easy to misunderstand.

Content negotiation, and what the annotations really say

@Produces and @Consumes are not documentation. They participate in dispatch, and they can make a method unreachable.

api/rest/CourseResource.javafive annotations, five different jobs
@Path("/courses")
@RequestScoped
@Produces(MediaType.APPLICATION_JSON)   // what this resource can write
@Consumes(MediaType.APPLICATION_JSON)   // what it will accept
public class CourseResource {

    @POST
    public Response create(@Valid @NotNull CreateCourseRequest request) { ... }
}

The container matches a request to a method in a fixed order, and each step has a status code it produces on failure. Knowing the order turns three confusing responses into obvious ones:

StepComparesFailure
1. PathURI against @Path templates404
2. MethodHTTP verb against @GET/@POST/…405 Method Not Allowed
3. ConsumesContent-Type against @Consumes415 Unsupported Media Type
4. ProducesAccept against @Produces406 Not Acceptable
The 415 that looks like a bug

A perfectly valid JSON body sent without a Content-Type: application/json header is rejected with 415 before your method exists. Nothing in your code ran, so nothing in your code can log it. The same applies to curl -d, which defaults to application/x-www-form-urlencoded — the single most common cause of "my endpoint does not work" in a first REST project.

Path templates are matched by specificity, not by declaration order — literal segments beat template variables, so /courses/open wins over /courses/{id} regardless of which is written first in the file. That is a spec rule, and relying on it is fine; relying on file order is not.

JSON-B: the rules that decide your wire format

Nobody in this project writes a parser. A DTO's field names, types and getters are the JSON contract.

JSON Binding maps by convention, and the conventions are worth knowing because breaking one silently changes your API:

JavaJSONNote
getStudentNumber()"studentNumber"Derived from the getter, not the field
isActive()"active"Boolean getters drop the is
LocalDate"2026-03-01"ISO-8601 by default
Instant"2026-03-01T10:00:00Z"Always UTC — the reason to store Instant
null fieldomittedJSON-B skips nulls by default; JSON-P and Jackson differ
enum"ACTIVE"By name(). Renaming a constant is a breaking API change

And the requirement that explains why the request DTOs look unlike every other class in the codebase:

api/dto/request/CreateStudentRequest.javamutable on purpose
/** Required by JSON-B for deserialisation. */
public CreateStudentRequest() { }

Deserialisation needs a no-arg constructor and setters — somewhere to put the values as they are parsed. The domain model is immutable and the DTOs are not, and that is the correct trade: mutability is confined to a class whose entire life is one request, and which no business rule ever reads.

Four reasons never to serialise an entity

Mass assignment — a caller sends {"id":1,"version":99} and sets fields you never exposed. Coupling — the schema becomes the public contract, so a column rename breaks clients. Shape — creation needs no id, the response must have one; one class cannot honestly be both. Lazy loading — serialisation walks associations and either fires a cascade of queries or throws once the transaction closed. The mapper layer exists to hold all four problems in one place.

Validation runs twice, deliberately

The same constraint appears on the DTO and on the entity. That is two lines of defence with two different audiences, not copy-paste.

the same rule, twicedifferent trigger, different response
// DTO — runs at the HTTP boundary because of @Valid on the parameter
@NotBlank(message = "email is required")
@Email(message = "email must be a valid address")
private String email;                      // → 400 with per-field messages

// Entity — runs at @PrePersist, on every path including jobs and imports
@NotBlank(message = "{email.notblank}")
@jakarta.validation.constraints.Email
private String value;                      // → ConstraintViolationException, rollback

The boundary check gives a human a useful 400. The entity check protects the database from paths that never touch HTTP — the seeder, the nightly job, a future import. Delete either one and something real stops being guarded.

  • @Valid is what triggers it. A DTO covered in constraints and no @Valid on the parameter is validated by nobody. The annotations are inert metadata — the same law as everywhere else in this book.
  • @Valid cascades. On a field, it validates the nested object too. Without it, constraints inside a nested DTO are never checked.
  • Order is not guaranteed. All constraints on a bean run and all violations come back together — which is why a 400 here lists every bad field at once instead of making the caller fix them one at a time.
  • Groups let one class have two contracts. @NotNull(groups = Update.class) plus @Validate(groups = ...) expresses "required when updating, optional when creating" without a second DTO.

Where a constraint cannot be expressed with the built-ins, you write one. This project has exactly one, and it is instructive because the interesting half is not the regex:

domain/validation/StudentNumber.javaan annotation plus a validator
@Constraint(validatedBy = StudentNumberValidator.class)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
public @interface StudentNumber {
    String message() default "must be six digits";
    Class<?>[] groups() default {};              // required by the spec
    Class<? extends Payload>[] payload() default {};
}

Those three members are mandatory — a constraint annotation without message, groups and payload fails at deploy time. And the validator itself is a CDI bean, so it can @Inject: a uniqueness check that queries a repository is a perfectly legitimate custom constraint, and a much better place for the rule than the fourth line of a service method.

Choosing the status code

Most API design arguments are really one question asked repeatedly: whose fault is it, and can retrying help?

Status decision tree

Answer as if you were the resource method. The tree ends on the code this project would return.

Loading…

CodeMeansShould the client retry?
200 / 201 / 204Done. 201 carries a Location; 204 carries no body
400The request is malformed — missing field, bad enumNot without changing it
404The resource does not existNo
409Well-formed, but the current state forbids itMaybe later — the state may change
422Syntactically fine, semantically invalidOften used where this project uses 400
500Your fault. A bug escapedYes, and someone should be paged

The 400/409 line is the one that decides whether an API feels well designed. Malformed means the message could never be valid: a missing studentNumber, a date that is not a date. Conflict means the message was perfect and the world said no: the course is full, the student is suspended, the enrollment window closed. A client can fix the first by changing the request and can only fix the second by waiting or doing something else — which is precisely the distinction the status code exists to communicate.

Safe, idempotent, and why it matters more than it sounds

These are not adjectives. They are guarantees that proxies, browsers and retry logic act on without asking you.

In plain words

Safe means looking does not change anything: refreshing a page never books you a ticket. Idempotent means doing it twice is the same as doing it once — a switch labelled OFF is idempotent, a switch labelled TOGGLE is not.

That single distinction is why your browser happily retries a GET after a dropped connection, and warns you before resending a POST.

MethodSafeIdempotentMeaning
GETyesyesNo side effects at all. Cacheable, prefetchable
PUTnoyesSend the whole resource. Twice = once
DELETEnoyesSecond call is a no-op, still 204
POSTnonoCreates something new each time
PATCHnonoA partial change; only idempotent if you make it so

The practical consequence: a client that times out on a PUT can safely retry, and a client that times out on POST /api/enrollments cannot — it may have enrolled the student already. That is a real problem with a standard answer: a caller-supplied idempotency key, stored with a unique constraint, so the second attempt collides and returns the first result instead of a second enrollment. This project does not implement one, and the unique constraint on (student, course) quietly does the same job for this particular operation.

Optimistic locking, on the wire

@Version already detects "someone else changed this row". The HTTP expression of the same idea is ETag plus If-Match: the response carries the version as an entity tag, the client sends it back on the update, and a mismatch is a 412 Precondition Failed instead of a lost update. Same mechanism, one layer up — and the reason a 409 from OptimisticLockExceptionMapper is an honest translation of a database event into an HTTP one.

Golden rules

The boundary is a translation layer. Every rule here is about not letting the two sides leak into each other.

  1. Dispatch fails before your method runs.Path, then verb, then Content-Type, then Accept — producing 404, 405, 415, 406 in that order. A 415 from a valid JSON body means the header was missing, and nothing you wrote ever executed.
  2. The getter name is the JSON field name.JSON-B derives the wire format from accessors, serialises enums by name() and omits nulls. Renaming a getter or an enum constant is a breaking API change with no compiler error anywhere.
  3. Never accept or return an entity.It permits mass assignment, couples the schema to the contract, cannot describe both create and response shapes, and serialises lazy associations. Four separate problems, one mapper layer.
  4. Constraints without @Valid are decoration.The annotation on the parameter is what triggers validation, and @Valid on a field is what makes it cascade into nested objects. Same law as every other annotation: something has to read it.
  5. Validate at the boundary and in the entity.The DTO's constraints produce a helpful 400 for a human; the entity's run at @PrePersist and protect the database from the seeder, the nightly job and the import that has not been written yet.
  6. 400 is “this message is wrong”; 409 is “the world says no”.A malformed request can be fixed by changing it; a conflict can only be fixed by the state changing. Getting this line right is most of what makes an API feel designed rather than assembled.
  7. Idempotency is a promise clients act on.PUT and DELETE are safe to retry, POST is not. Either supply an idempotency key or rely on a unique constraint — a timed-out POST that gets retried is not a hypothetical.
Chapter 14

Rules that live in the model

An entity that only has getters and setters is a database row wearing a Java costume. These ones defend themselves.

Rich versus anemic

The anemic domain model is the common shape: entities hold data, services hold all logic. It works, but every rule can be bypassed by any code that sets a field directly.

In plain words

An anemic object is a paper form: anyone can write anything in any box, and somebody else checks it afterwards — if they remember.

A rich object is a vending machine: there is no slot that accepts the wrong coin. The rule is built into the shape of the thing, so an illegal state cannot be entered at all rather than being caught later by someone paying attention.

Enrollment takes the other approach — it has no public setter for status. The only way to change it is a method that enforces the rules:

domain/model/Enrollment.javathe object protects its own invariants
public void recordPass(int grade, boolean withHonours, Instant at) {
    transitionTo(EnrollmentStatus.COMPLETED);      // legal move?

    if (grade < 18 || grade > 30)
        throw new IllegalArgumentException(...);   // Italian scale

    if (withHonours && grade != 30)
        throw new IllegalArgumentException(...);   // lode needs exactly 30
}

You cannot construct an invalid Enrollment through the public API of the class. That is the whole idea: make illegal states unrepresentable rather than merely unlikely.

The state machine — try it

Transitions are declared once, as data, and every mutating method routes through the check. Click a status to see what it can legally become:

Selected: ACTIVE — an enrollment starts here.

ACTIVE the starting state FAILED still holds a seat COMPLETED terminal — grade 18–30 WITHDRAWN terminal — frees the seat recordFail() retake recordPass() withdraw() withdraw() no setter reaches these boxes
Two arrows leave FAILED and none leave the teal boxes. That asymmetry is the whole rule, and it lives in an EnumMap rather than in if statements scattered across the service.
domain/model/EnrollmentStatus.javarules as data, not as if-statements
map.put(ACTIVE,    EnumSet.of(COMPLETED, WITHDRAWN, FAILED));
map.put(FAILED,    EnumSet.of(ACTIVE, WITHDRAWN));       // retake allowed
map.put(COMPLETED, EnumSet.noneOf(EnrollmentStatus.class));  // terminal
map.put(WITHDRAWN, EnumSet.noneOf(EnrollmentStatus.class));  // terminal
Java note

Enums here are not just constants — they carry behaviour and a lookup table. EnumSet and EnumMap are specialised implementations backed by bit vectors and arrays; they are dramatically faster than HashSet for enum keys, and they make the legal-move table impossible to get out of sync with the enum itself.

Custom validation

Bean Validation ships with @NotNull, @Size, @Positive and friends. When a rule is domain-specific you write your own — an annotation plus a validator:

domain/validation/StudentNumber.javaannotation + implementation
@Constraint(validatedBy = StudentNumberValidator.class)
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
@Retention(RUNTIME)
public @interface StudentNumber {
    String message() default "must be a valid student number: exactly 6 digits";
}

// The validator. Pattern is compiled ONCE as a static field —
// String.matches() would recompile it on every single call.
public boolean isValid(String value, ConstraintValidatorContext ctx) {
    return MATRICOLA_PATTERN.matcher(value).matches();
}

@Retention(RUNTIME) is mandatory — without it the annotation is discarded after compilation and the container can never see it. That single missing line is a common cause of "my custom constraint does nothing".

Golden rules

What it means for the rules to live in the objects themselves.

  1. No setter for status — only moves.recordPass() checks the transition first and the grade second. There is no public path that sets COMPLETED without passing both.
  2. Legal moves are data, not if statements.An EnumMap of EnumSets cannot drift out of sync with the enum, and one transitionTo is the single place a move is approved.
  3. Make illegal states unrepresentable, not merely unlikely.Grade 18–30, lode only at exactly 30, enforced by the object that owns the fact. Any caller that forgets is refused rather than trusted.
  4. @Retention(RUNTIME) or the container never sees your annotation.Without it the annotation is discarded after compilation. It is the usual cause of “my custom constraint does nothing”, and it fails silently.
Chapter 15

Everything here is unlocked

This application has no authentication whatsoever. That is worth studying carefully, because the reasons it is still safe on your machine are not the reasons most people assume.

Nothing is protecting these endpoints

Not "weak security". None. I searched the codebase.

In plain words

The doors of this building have very good locks. Nobody has fitted them yet.

Every endpoint here is open to anyone who can reach the port. That is a deliberate teaching decision, not an oversight — and this chapter is about knowing exactly which door is missing, and precisely what would go on it.

ControlFound
@RolesAllowed / @PermitAll / @DenyAll0
SecurityContext / Principal0
web.xml security-constraint0
login-config / auth-method0

Every request in this API is anonymous and equally privileged. The console you used in Chapter 6 sent no credentials — it did not need any. And these endpoints exist:

destructive endpoints, entirely openno credential required
DELETE  /api/students/{id}              // remove a student
POST    /api/students/{id}/suspension   // suspend anyone
DELETE  /api/enrollments/{id}           // withdraw an enrollment
PATCH   /api/courses/{id}/capacity      // resize any course
POST    /api/enrollments/{id}/grade     // award yourself a 30 e lode

Anyone who can reach the port can award grades. That is the honest description, and the README states it plainly under known limitations rather than hiding it.

The one thing standing in the way

So why is this not a catastrophe on your laptop? One line in your server log:

server.log — your actual bindingthe only real control
WFLYUT0006: Undertow HTTP listener default listening on 127.0.0.1:8280
WFLYSRV0060: Http management interface listening on http://127.0.0.1:10190

127.0.0.1 is the loopback interface. The operating system will not route external packets to it, so nothing outside your machine can open a connection — not another laptop on your wifi, not the coffee shop network. The API is unauthenticated and unreachable at the same time.

The Docker version was different

The container CMD ends with -b 0.0.0.0 -bmanagement 0.0.0.0, which binds every interface. Inside a container that is required — otherwise the port mapping reaches nothing. But it means the isolation there came from Docker's network, not from the binding. Change one flag and an unauthenticated grade-awarding API is on your local network. Network position is doing the entire job of an access-control system, and network position changes without anyone editing code.

What Jakarta EE would give you

The platform has this built in; the project simply does not use it. Roles are declarative, exactly like the transaction and interceptor annotations from earlier chapters:

what EnrollmentResource could look likenot currently in the codebase
@POST
@Path("/{id}/grade")
@RolesAllowed("PROFESSOR")          // only professors grade
public EnrollmentResponse recordGrade(...) { ... }

@DELETE
@Path("/students/{id}")
@RolesAllowed("ADMIN")
public void delete(...) { ... }

// And to know WHO is calling, rather than just what role:
@Inject SecurityContext securityContext;
String caller = securityContext.getCallerPrincipal().getName();

Enforcement is an interceptor, the same mechanism as @Transactional and @Loggable — which is why @RolesAllowed is mentioned in Loggable.java as a sibling example. That also means it inherits the self-invocation trap from Chapter 5: an internal call bypasses the proxy and therefore bypasses the role check.

TODAY any client POST /enrollments/{id}/grade no gate exists — the only thing in the way is the 127.0.0.1 binding WHAT IT SHOULD BE client with a token who are you? Elytron / JWT realm may your role? @RolesAllowed is it your course? a check in the service The first two are configuration and one annotation. The third depends on data, so no annotation can express it — it belongs beside the enrollment rules.
Three questions, three different places to answer them. Conflating the second and third is how systems end up with a professor who may grade — any course.

Where identity comes from

MechanismSuited to
BASIC / FORM authServer-rendered apps. Needs a session; awkward for APIs.
JWT bearer tokensStateless APIs. The token carries roles and is verified per request.
OIDC / OAuth2Delegating login to an identity provider — the university SSO.
mTLSService-to-service, no humans involved.

For an API like this one, JWT or OIDC is the realistic answer. WildFly ships Elytron to do the verification, so the application still only writes @RolesAllowed.

Authentication is not authorisation

Two different questions. Authentication is "who are you" — a verified token. Authorisation is "may you do this" — @RolesAllowed. A role check is not enough on its own either: a professor may grade, but only their own course's enrollments. That last mile is a domain decision and belongs in the service layer, next to the enrollment rules — not in an annotation.

What the code does get right

Missing authentication is a design decision. These are the bugs it did not have.

Injection is structurally impossible

Every query binds its inputs — 34 setParameter calls across the repositories, and not one string-concatenated parameter. Bound parameters are sent to the database separately from the SQL text, so a value can never be parsed as code, no matter what it contains.

Test it against your own server. The payloads below are the classics:

Injection attempts · read-only

Not connected. Serve this page from WildFly to run these against your database.

The classic ' OR '1'='1 returns fewer rows than a normal search, not more — because the server searched for a student literally named ' OR '1'='1. The payload was treated as data. That is the whole defence, and it is free as long as you never build query strings by concatenation.

Errors do not leak internals

When you broke the fetch plan in Chapter 3, the response was:

the real 500 bodynote what is absent
{ "detail": "An unexpected error occurred. Please quote the correlation id
              when reporting this problem.",
  "correlationId": "BREAK-2" }

No stack trace, no class names, no SQL, no framework versions. The full detail went to the server log, and the client got a correlation ID to quote. Stack traces in HTTP responses are a genuine disclosure problem — they hand an attacker your library versions and internal structure for free.

Input is validated before your code runs

Bean Validation rejects malformed input at the boundary, and @StudentNumber constrains a field to exactly six digits. That is not an anti-injection measure — parameter binding already handles that — but it shrinks the range of values that ever reach the domain.

Secrets, and the rest of the surface

IssueWhy it is tolerable hereReal answer
Hard-coded admin / Admin#2026Loopback-only, local machineSecret manager; never expose :10190
DB password enrollment in configSameVault, or env vars injected at deploy
hbm2ddl.auto=updateConvenient while learningFlyway or Liquibase; never let an app rewrite its own schema
No rate limitingSingle userGateway or filter — an open API is trivially floodable
Admin console on :10190Loopback-onlyNever routable; separate management network

The CORS question, answered properly

The project sends no CORS headers, and that is frequently misread as a vulnerability. It is the opposite. Without Access-Control-Allow-Origin, browsers refuse to let JavaScript on another origin read the responses — the restrictive default.

Two things follow. First, this tutorial page can call the API only because WildFly serves it from the same origin; that is why the artifact copy cannot. Second, CORS is a browser policy, not an access control: curl, a script, or any non-browser client ignores it entirely. It is not a substitute for authentication, and it never was.

The exercise

Adding authentication is the most valuable change you could make to this codebase, and it touches every layer you have studied.

  • Start with roles. Add @RolesAllowed("ADMIN") to one destructive endpoint and watch it return 401 instead of succeeding. One annotation, immediate feedback.
  • Then identity. Configure an Elytron JWT realm so a bearer token supplies the caller and their roles.
  • Then the domain rule. A professor may grade only their own course. That check belongs in EnrollmentService, beside the eligibility rules — not in an annotation, because it depends on data.
  • Then the tests. Assert that an unauthenticated call is refused. A security control with no test is a security control that will be removed by a future refactor without anyone noticing.
Keep the honesty

The README documents this gap openly rather than pretending. That is worth imitating: a study project claiming to be production-ready teaches the wrong lesson, and a real project with an undocumented gap is worse. Write down what you have not secured yet.

The half that is now written

The enrollment API above is still wide open, and deliberately so — it is the specimen. But this page's own accounts are real, and they live in the same WAR, under it.unicam.cs.enrollment.fieldbook. Everything the exercise asks for exists there in working form, which makes it the most useful thing in the repository to read after this chapter.

It is a second bounded context, not an extension of the enrollment model: a learner is not a student, even when they are the same person. One has a matriculation number and a registrar; the other has a password and a reading streak. Merging them looks like reuse for about a week, and then the registrar suspends somebody and the fieldbook logs them out.

The decisions, and what each one cost

DecisionWhy, and the trade
PBKDF2, 210k iterations, per-row saltSlow on purpose: you pay it once per login, an attacker pays it per guess. Argon2id is better because it is memory-hard, and would mean a third-party dependency — the JDK gives you PBKDF2 for free, so the algorithm path is readable end to end. The stored form is pbkdf2-sha256$210000$salt$hash, self-describing so the cost can be raised later and old rows upgraded on their next successful login.
An opaque session token in a table, not a JWTA JWT cannot be revoked without a denylist — at which point you have a session table again, but worse. The token is 32 random bytes; only its SHA-256 is stored, so a leaked backup yields nothing replayable. Plain SHA-256 rather than PBKDF2 here, because 256 bits of entropy has no dictionary to guess. JWT becomes the right answer when several services must verify a caller without sharing a database.
HttpOnly cookie, SameSite=StrictThe trade this chapter refuses to give you a free answer to. Script cannot read the cookie, so cross-site scripting cannot exfiltrate it; the browser attaches it automatically, so cross-site request forgery becomes possible. That second risk is then paid for twice: SameSite, and a custom request header a cross-site form cannot set.
One error for two causesUnknown address and wrong password return the same 401, and the hash runs even for an unknown address so the two take the same time. Registration does not report a duplicate either. Otherwise the form is an oracle for which addresses have accounts.
Two throttle countersPer account stops a password list against one email; per source address stops credential stuffing, where no single account is tried twice and the first counter never fires. Implementing only the first is the common half-measure, and it stops the attack nobody is running.
The owner is in the queryfindOwned(account, id) rather than findById(id) plus a check. There is no version of that method that can return somebody else's row, so the insecure-direct-object-reference bug cannot be reintroduced by a second code path.
Three bugs this actually shipped with, and what they teach

1. The deployment would not start. WELD-001435: not proxyable because it has no no-args constructor. A normal-scoped bean is injected as a generated proxy subclass, and a subclass needs a constructor it can call with no arguments. Constructor injection alone is not enough — which is exactly why every service in this codebase also has a protected no-arg constructor and non-final fields. Chapter 4 makes the general argument; this is the error message it produces.

2. LazyInitializationException on the first request. The streak was computed from a lazy collection on an account that the authentication filter had loaded in an earlier transaction. The fix was not to make the collection eager — that loads every study day on every request forever — but to fetch exactly the days, with their own query, where they are needed. The question is never eager or lazy; it is which query does this use case need.

3. Two browser tabs produced a 409. Writing lastSeenAt through the managed entity on every request made a @Version-ed row a write hotspot, so two overlapping requests collided over a timestamp nobody competes for. Optimistic locking is for business state where a human must be told about the conflict; telemetry belongs in a bulk UPDATE that skips the version entirely.

And a fourth, which is the best of them: the fix for a related race was em.find(id, PESSIMISTIC_WRITE), and it made things worse. Hibernate logged HHH000444 ... follow-on locking and quietly split the lock into read the row, then lock it — because the eager roles collection makes the query a join, and PostgreSQL will not accept FOR UPDATE on one. Two statements, a gap between them, and the gap is precisely what the lock existed to close. An abstraction that leaks with a warning rather than an error is the hardest kind to notice.

What is still missing, stated plainly

No email verification, so an address is never proved. No password reset, which for a real product is not optional and needs a mail server. No multi-factor. No audit log of sign-ins. The rate limiter is in memory, so it resets on redeploy and is per node — on a cluster an attacker gets its budget once per node, and the real answer is a shared store plus a limiter at the ingress. And the enrollment API next door is still completely unauthenticated, on purpose, because it is the thing this chapter is about.

That list is the point of the section. Writing down what you have not built reads as engineering judgement; discovering it in review reads as something else.

Golden rules

The chapter in six lines — and the ones most often got wrong in interviews.

  1. Network position is not access control.The only thing standing between this API and a stranger is 127.0.0.1. One flag — -b 0.0.0.0, which the Docker image uses — puts an unauthenticated grade-awarding API on the local network, with no code change at all.
  2. Authentication, authorisation and the domain rule are three different checks.Who you are (a verified token), what your role may do (@RolesAllowed), and whether this is your course (a service-layer check, because it depends on data). An annotation can only ever answer the middle one.
  3. A role check is an interceptor, so it inherits the self-invocation trap.An internal this.method() call bypasses @RolesAllowed exactly as it bypasses @Transactional. Same proxy, same hole.
  4. Bound parameters are the entire SQL-injection defence.34 setParameter calls, zero concatenated queries. The payload arrives at the database as a value, never as text to parse — which is why ' OR '1'='1 returns fewer rows, not more.
  5. CORS is a browser policy, not a gate.It stops other origins' JavaScript from reading responses. curl and every script ignore it, so it was never authentication.
  6. Write down what you have not secured.A study project claiming to be production-ready teaches the wrong lesson; a real project with an undocumented gap is worse. The README states this one plainly.
Chapter 16

Spring Boot — the same ideas, packed differently

The advert names Spring Boot: REST APIs, basic configuration, dependency management. All three are in this chapter, and every one of them rests on a mechanism you already met fifteen chapters ago.

Where the server went

Jakarta EE hands a WAR to a server that already exists. Spring Boot puts the server inside the jar and hands you an executable. That single packaging decision is most of the visible difference.

JAKARTA EE — THIS PROJECT WildFly — installed, running, shared JPA · CDI · JTA · JAX-RS the container provides all of it connection pool · thread pool enrollment.war — no libraries your code, and nothing else mvn package → copy into deployments/ the server has the main() SPRING BOOT app.jar — one file, ~30 MB Hibernate · Spring · Jackson packaged with you, versions pinned embedded Tomcat · HikariCP your @RestControllers same code, same annotations, other names mvn package → java -jar app.jar you have the main()
Neither is more modern than the other. The WAR is small because the server is shared — ideal when one server hosts several applications. The fat jar is self-contained — ideal when the unit of deployment is a container image, which is why it won.
In plain words

Jakarta EE is renting a flat. The building already has water, electricity and a lift; you bring furniture. Everyone in the building shares the plumbing, and if the landlord upgrades the boiler, everybody gets it.

Spring Boot is a camper van. Water tank, generator, bed — all inside, and you drive it anywhere. It is heavier, and you maintain your own boiler, but you never argue with a landlord about the heating.

There is one genuinely new thing, and it is the class with main() in it:

EnrollmentApplication.javathe whole bootstrap
@SpringBootApplication
public class EnrollmentApplication {
    public static void main(String[] args) {
        SpringApplication.run(EnrollmentApplication.class, args);
    }
}

// @SpringBootApplication is three annotations in a trench coat:
//   @Configuration        this class may define beans
//   @ComponentScan        scan THIS package and everything under it
//   @EnableAutoConfiguration  configure whatever you find on the classpath
The trap

@ComponentScan starts from the package of this class. Put EnrollmentApplication in it.unicam.cs.enrollment.api and your services in it.unicam.cs.enrollment.service, and none of the services are found — the failure is a startup error about a missing bean, and it is the most common first-day-with-Spring bug. The application class belongs in the top package.

Dependency management: starters and the parent

The advert asks specifically about dependency management. In Spring Boot it means two things: a starter, which is a bundle of dependencies, and a parent, which decides every version so you do not have to.

Compare the two build files. Both projects use Hibernate, both expose JSON over HTTP, both validate input:

this project — pom.xml
Jakarta EEone API, provided by the server
<dependency>
  <groupId>jakarta.platform</groupId>
  <artifactId>jakarta.jakartaee-api</artifactId>
  <version>10.0.0</version>
  <scope>provided</scope>
</dependency>

<!-- compiled against, never packaged.
     WildFly supplies the implementation. -->
Spring Boot — pom.xml
Spring Bootstarters, versions inherited
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.4</version>
</parent>

<dependency>  <!-- no version -->
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Two words explain that right-hand column.

  • A starter is an empty artifact whose only content is other dependencies. spring-boot-starter-web pulls in Spring MVC, Jackson, validation and an embedded Tomcat. You ask for a capability, not for eleven libraries.
  • The parent POM contains a BOM — a bill of materials listing a known-compatible version of roughly four hundred libraries. Omit <version> and you inherit the tested one.
In plain words

A starter is a meal deal. You do not order bread, cheese, tomato and basil separately and hope they go together — you order the sandwich, and somebody who cooks for a living already checked that the parts match.

The parent POM is the shopping list the kitchen tested. Override one item with your own brand and it usually still works — but now you own the consequence, which is exactly what a version conflict is.

Example — the two commands that end most dependency argumentsworks in any Maven project, including this one
terminalwhat am I actually shipping?
# the full tree, including everything pulled in transitively
mvn dependency:tree

# why is THIS library here, and who asked for it?
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind

# which version won, and which were suppressed?
mvn dependency:tree -Dverbose

Maven resolves a conflict by nearest wins: the version declared closest to your POM in the tree. That is a rule about graph distance, not about version numbers — an older version can beat a newer one. When it does, you pin the version explicitly in <dependencyManagement>.

ScopeOn the compile classpathShipped in the artifactTypical use
compileyesyesthe default — a library you call
providedyesnothe server supplies it — all of Jakarta EE here
runtimenoyesa JDBC driver you never import
testtests onlynoJUnit, Mockito, H2

Auto-configuration — the part that looks like magic

You add a dependency and a feature appears. There is no magic in it: a long list of conditional configuration classes runs at startup and each one asks “is my library present, and has the developer already defined this bean?”

classpath what jars are here? AutoConfiguration.imports ~150 candidate classes @Conditional each one asks a question beans registered DataSource, ObjectMapper… @ConditionalOnClass(DataSource.class) on the classpath? @ConditionalOnMissingBean did YOU define one? @ConditionalOnProperty("spring.datasource") configured? Your bean always wins. Auto-config only fills gaps — it never overrides. Run with --debug and Spring prints the full report: every condition it evaluated, matched and unmatched, with the reason.
@ConditionalOnMissingBean is the load-bearing annotation. It is why adding your own ObjectMapper silently replaces the default one instead of colliding with it — and why an auto-configured feature can disappear the moment you define a bean of the same type.
Example — make the magic print itselfthe answer to “why is this bean here?”
terminalthe auto-configuration report
java -jar app.jar --debug

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:
   DataSourceAutoConfiguration matched:
      - @ConditionalOnClass found 'javax.sql.DataSource'

Negative matches:
   MongoAutoConfiguration:
      - @ConditionalOnClass did not find 'com.mongodb.client.MongoClient'

Read that report once and Spring Boot stops feeling like magic permanently. It is the same report the Actuator exposes at /actuator/conditions when you add spring-boot-starter-actuator.

Configuration, and the order that decides who wins

The same application must run on a laptop, in CI and in production without recompiling. Spring Boot solves this with layered property sources and a strict precedence order.

src/main/resources/application.ymldefaults, plus one profile
server:
  port: 8080

spring:
  datasource:
    url: jdbc:postgresql://localhost:5433/enrollment
    username: enrollment
    password: ${DB_PASSWORD}          # from the environment, never in git
  jpa:
    hibernate.ddl-auto: validate      # never `update` in production
    properties.hibernate.format_sql: true

enrollment:
  max-courses-per-student: 8          # your own settings live here too

---
spring:
  config.activate.on-profile: test    # mvn test picks this up
  datasource.url: jdbc:h2:mem:enrollment
  jpa.hibernate.ddl-auto: create-drop

When the same key is set in more than one place, the later source wins. From weakest to strongest, the ones you will actually use:

weakestapplication.yml in the jar
thenprofile file, e.g. application-prod.yml
thenOS environment variable SPRING_DATASOURCE_URL
strongestcommand line --server.port=9000
The rule that follows from it

Because an environment variable beats the packaged file, the same jar can be promoted from test to production unchanged. That is not a convenience, it is the whole point: the artifact you tested is byte-for-byte the artifact you deploy. Relaxed binding means SPRING_DATASOURCE_URL maps onto spring.datasource.url automatically — upper case, underscores, no dots.

Reading your own settings has two forms, and the second is the one to prefer:

@Value — fine for one key
quickno type safety
@Value("${enrollment.max-courses-per-student:8}")
private int maxCourses;
// ↑ the :8 is a default. Without it,
//   a missing key fails at startup.
@ConfigurationProperties — prefer this
typedvalidated, grouped, discoverable
@ConfigurationProperties(prefix = "enrollment")
@Validated
public record EnrollmentProps(
    @Min(1) int maxCoursesPerStudent) {}
// Bad config now fails at STARTUP,
// not at 3am on the first request.
The trap

spring.jpa.hibernate.ddl-auto: update in production. It lets Hibernate alter your live schema based on your entity classes — which sounds helpful right up to the release where it drops a column. Production uses validate, and schema changes go through a migration tool (Flyway or Liquibase) that is versioned, reviewed and reversible, exactly like code.

Your app works locally and fails in production with "URL must start with jdbc". Where do you look first?

At the property sources, in precedence order. Almost always an environment variable is set but empty, or is named SPRING_DATASOURCE_URL in one place and DATASOURCE_URL in another — so nothing overrides the packaged default and the placeholder resolves to an empty string. Add spring-boot-starter-actuator and /actuator/env will tell you exactly which source supplied each value, which turns a guessing game into a lookup.

The bean container, next to the one you know

CDI and the Spring container solve the same problem with different vocabulary. If you understood chapter 10, you already understand this section — it is a translation, not a new idea.

IdeaJakarta EE / CDISpringNote
Declare a managed object@ApplicationScoped@Component / @ServiceSpring's default scope is singleton
Inject a collaborator@Injectconstructor parameterno annotation needed on a single constructor
One per request@RequestScoped@Scope("request")rare in Spring; usually a method parameter instead
Choose between two implementations@Qualifier@Qualifier / @Primarysame problem, same shape
Run at startup@Observes @InitializedApplicationRunnerthis project uses the first in ApplicationBootstrap
Cross-cutting behaviour@Interceptor@Aspect / proxyboth are proxies, both self-invocation traps
Spring — the shape to writeconstructor injection, final fields
@Service
public class EnrollmentService {

    private final EnrollmentRepository enrollments;   // final: cannot be swapped
    private final CourseRepository courses;

    // One constructor → Spring injects it. No @Autowired needed.
    // Field injection (@Autowired on the field) is discouraged:
    // it hides dependencies and cannot be tested without a container.
    public EnrollmentService(EnrollmentRepository enrollments, CourseRepository courses) {
        this.enrollments = enrollments;
        this.courses = courses;
    }
}
In plain words

Constructor injection means the object cannot be built wrong. If a service needs a repository, you cannot get a half-built service without one — the compiler refuses. Field injection is like being handed a car and told the engine will arrive later: it looks fine parked, and it fails on the motorway.

The trap that follows you from chapter 11

Spring's @Transactional works by wrapping your bean in a proxy — the same mechanism as a CDI interceptor. So the self-invocation trap is identical: calling this.otherMethod() inside the same class bypasses the proxy, and the annotation on otherMethod does nothing at all. No error, no warning, no transaction. If you learned that in chapter 11, you already know Spring's most-asked interview gotcha.

Golden rules

What actually differs, and what only looks like it does.

  1. The server moved into the jar; nothing else moved.Jakarta EE hands a WAR to a running container, Spring Boot embeds Tomcat and gives you a main(). JPA, transactions, injection and HTTP semantics underneath are the same specifications.
  2. The application class must live in the top package.@SpringBootApplication component-scans downward from its own package. Bury it one level too deep and half your beans are invisible — a startup failure that reads like something else entirely.
  3. A starter is a bundle; the parent POM is the version list.Ask for a capability, not for eleven libraries, and leave <version> out so you inherit a tested combination. When you must override one, pin it in <dependencyManagement> and own the consequence.
  4. Auto-configuration only fills gaps.Every auto-configured bean is guarded by @ConditionalOnMissingBean, so your own definition always wins. --debug prints the whole decision, matched and unmatched, with reasons.
  5. Environment beats file, so one artifact serves every environment.The precedence order is what lets you promote the exact jar you tested. Secrets come from the environment; ddl-auto: update never reaches production.
  6. Constructor injection, final fields, no @Autowired.A single constructor is injected automatically. It makes dependencies explicit, makes the object impossible to half-build, and makes the class testable with new and no container at all.
Chapter 17

Controllers, repositories, and the errors in between

The advert says REST APIs first. This is the chapter where you write one in Spring Boot — the same endpoints this project already exposes, in the other dialect, with the same rules about where each responsibility lives.

@RestController, and where each piece of a request goes

A controller does exactly three things: translate HTTP into Java, delegate to a service, and choose a status code. Any business rule that appears in it is in the wrong layer — that rule is identical in both frameworks.

StudentController.javathe same resource as api/rest/StudentResource.java
@RestController
@RequestMapping("/api/students")
public class StudentController {

    private final StudentService students;

    public StudentController(StudentService students) { this.students = students; }

    // GET /api/students/42
    @GetMapping("/{id}")
    public StudentResponse findById(@PathVariable Long id) {
        return StudentMapper.toDetail(students.findById(id));
    }

    // GET /api/students?name=ferrari&page=0&size=20
    @GetMapping
    public Page<StudentResponse> search(
            @RequestParam(required = false) String name,
            @RequestParam(defaultValue = "ACTIVE") StudentStatus status,
            Pageable pageable) {                       // page/size/sort, parsed for you
        return students.search(name, status, pageable).map(StudentMapper::toDetail);
    }

    // POST /api/students
    @PostMapping
    public ResponseEntity<StudentResponse> create(@Valid @RequestBody CreateStudentRequest req) {
        Student created = students.create(StudentMapper.toCommand(req));
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                           .path("/{id}").buildAndExpand(created.getId()).toUri();
        return ResponseEntity.created(location)              // 201 + Location header
                             .body(StudentMapper.toSummary(created));
    }
}
Part of the requestSpringJakarta EE (this project)
/students/42@PathVariable@PathParam
?name=ferrari@RequestParam@QueryParam
the JSON body@RequestBodythe plain method parameter
a header@RequestHeader@HeaderParam
several params as one objecta POJO parameter@BeanParam
status + headers + bodyResponseEntity<T>Response
Return the object, not the envelope

Notice that findById returns StudentResponse directly, while create returns ResponseEntity. Return the bare object whenever 200 is the right answer — it is shorter and it reads better. Reach for ResponseEntity only when you need to control the status or add a header, which for a creation you do: 201 Created plus a Location header is what tells the client where the new resource lives.

Errors: one place, one shape

This project maps every exception to application/problem+json through a set of ExceptionMapper classes. Spring does the same job with one class and RFC 9457 support built in.

ApiExceptionHandler.javathe Spring equivalent of api/exception/*Mapper.java
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    ProblemDetail notFound(ResourceNotFoundException ex) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        p.setTitle("Resource not found");
        p.setProperty("code", "RESOURCE_NOT_FOUND");
        return p;                                    // → 404 application/problem+json
    }

    @ExceptionHandler(BusinessRuleViolationException.class)
    ProblemDetail conflict(BusinessRuleViolationException ex) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        p.setProperty("code", ex.getCode());        // COURSE_FULL, PREREQUISITES_NOT_MET…
        return p;                                    // → 409
    }

    // Bean Validation failures arrive as this one specific exception.
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException ex) {
        ProblemDetail p = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        p.setProperty("violations", ex.getBindingResult().getFieldErrors().stream()
            .map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
            .toList());                              // name every bad field, not just the first
        return p;                                    // → 400
    }
}
What went wrongStatusWhy not something else
The body failed validation400The client can fix it by sending different data.
No token, or an invalid one401“I do not know who you are” — retrying with credentials may work.
Known user, not allowed403“I know who you are and the answer is still no.” Retrying will not help.
No such id404Also the right answer for a resource the caller may not know exists.
Course full, already enrolled, suspended409The request is well-formed; the state refuses it. This is the one juniors miss.
An unexpected exception500Log it with the correlation id, and never leak the stack trace to the caller.
In plain words

400 is “you filled in the form wrong.” 401 is “you did not show me any ID.” 403 is “I have seen your ID, and you still cannot come in.” 404 is “there is no such room.” 409 is “the form is perfect, the room is full.” And 500 is “something broke on my side and it is not your fault.”

Spring Data JPA — the repository you do not write

This project hand-writes its repositories over EntityManager. Spring Data generates the implementation at startup from the interface, and the query from the method name.

this project — written by hand
StudentRepository.javaexplicit, ~20 lines
public Optional<Student> findByStudentNumber(String n) {
    TypedQuery<Student> q = em()
        .createNamedQuery(
            Student.FIND_BY_STUDENT_NUMBER,
            Student.class);
    q.setParameter("studentNumber", n);
    return q.getResultStream().findFirst();
}
Spring Data — generated
StudentRepository.javaan interface, no implementation
public interface StudentRepository
        extends JpaRepository<Student, Long> {

    Optional<Student> findByStudentNumber(String n);
    // ↑ parsed into:
    //   select s from Student s
    //   where s.studentNumber = ?1
}

The parser understands a small, predictable grammar. Learn these six and you can read almost any Spring Data interface:

Method nameGenerated query
findByLastNameIgnoreCase(String)where lower(last_name) = lower(?)
findByStatusAndEnrollmentYear(..)where status = ? and enrollment_year = ?
findByLastNameContaining(String)where last_name like %?%
countByStatus(StudentStatus)select count(*) … where status = ?
existsByEmail(String)select 1 … limit 1
findTop5ByOrderByCreatedAtDesc()order by created_at desc limit 5
The trap

Method names stop being readable fast. findByStatusAndEnrollmentYearAndLastNameContainingIgnoreCaseOrderByLastNameAsc is a real thing people write. Past about three conditions, switch to an explicit @Query — it is shorter, greppable, and you can see the join.

when the name gets silly@Query, and the N+1 fix
@Query("select c from Course c " +
       "left join fetch c.prerequisites "       // ← one query instead of N+1
       "where c.academicYear = :year")
List<Course> findWithPrerequisites(@Param("year") int year);

// Everything chapter 08 said about lazy loading, the persistence context and
// N+1 applies here unchanged. Spring Data generates the boilerplate; it does
// not change JPA. The same getter that throws LazyInitializationException in
// a mapper here throws it there, for exactly the same reason.
In plain words

Spring Data is a very good assistant, not a new filing system. It writes the letters for you from a one-line instruction. But the filing cabinet — JPA, the persistence context, lazy loading, transactions — is exactly the same cabinet, with exactly the same drawer that jams. Learn the cabinet and the assistant is a convenience. Learn only the assistant and the jammed drawer is a mystery.

Pagination is where the similarity becomes almost literal. This project has its own Page and PageRequest in common/; Spring Data ships the same two types under org.springframework.data.domain, and a controller taking a Pageable parameter gets ?page=0&size=20&sort=lastName,asc parsed for free.

The trap

sort is a client-supplied string that becomes part of an ORDER BY. Accept it as-is and you have handed a caller a lever into your query. Both frameworks require the same defence, and it is the one this project uses: validate the sort field against an allow-list of property names before it reaches the query.

Transactions and testing, briefly

Two more translations and the picture is complete: where the transaction boundary goes, and what a Spring Boot test looks like.

EnrollmentService.javathe boundary is still the service
@Service
public class EnrollmentService {

    @Transactional                              // org.springframework.transaction.annotation
    public Enrollment enroll(Long studentId, Long courseId) { ... }

    @Transactional(readOnly = true)              // a real optimisation: no dirty checking
    public Page<Enrollment> search(Pageable p) { ... }
}

// One difference worth memorising, because it is asked constantly:
//   Spring     rolls back on RuntimeException only, unless you say otherwise
//   Jakarta EE rolls back on RuntimeException only, unless you say otherwise
// Same default. But the escape hatches differ:
//   Spring     @Transactional(rollbackFor = Exception.class)
//   Jakarta EE @Transactional(rollbackOn = Exception.class)
TestAnnotationWhat startsUse it for
Plain unit testnonenothing — new and Mockitobusiness rules; should be most of your tests
Web layer only@WebMvcTestcontrollers + MockMvc, services mockedstatus codes, JSON shape, validation
Repository only@DataJpaTestJPA + an in-memory databasequeries, mappings, constraints
The whole application@SpringBootTesteverything, slowesta handful of end-to-end paths
Example — a controller test that checks the contractno database, no server, milliseconds
StudentControllerTest.java@WebMvcTest + MockMvc
@WebMvcTest(StudentController.class)
class StudentControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean StudentService students;      // the real service is not started

    @Test
    void rejects_a_blank_first_name_with_400_and_names_the_field() throws Exception {
        mvc.perform(post("/api/students")
               .contentType(MediaType.APPLICATION_JSON)
               .content("""
                   {"firstName":"","lastName":"Rossi","email":"a@b.it"}
                   """))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.violations[0].field").value("firstName"));
    }
}

That test asserts the contract — a status code and a field name a client depends on — and it does it without a database, a server or a network. Chapter 20 Testing argues at length that this is the level most of your tests belong at, in either framework.

Your @Transactional method calls another method in the same class that is also @Transactional(REQUIRES_NEW). What actually happens?

Nothing. The call goes through this, so it never leaves the object and never touches the proxy that implements the annotation — the second method simply runs inside the first transaction. It is the same self-invocation trap as chapter 11, and the fix is the same: move the method to another bean and inject it, so the call goes through the proxy. This is one of the two or three most common Spring interview questions.

Golden rules

Everything in this chapter that is worth carrying into a code review.

  1. A controller translates, delegates and picks a status code.Nothing else. A business rule in a controller cannot be reused by a scheduled job, a message consumer or a second endpoint — and it is the first thing a reviewer will flag.
  2. 201 Created needs a Location header.Return the bare object when 200 is correct, and reach for ResponseEntity only to set a status or a header. Creation is the case where you must.
  3. 409 exists, and juniors under-use it.400 means the request is malformed; 409 means the request is perfect and the current state refuses it. Course full, already enrolled, student suspended — all 409.
  4. One @RestControllerAdvice, one error shape.Every exception becomes problem+json in a single place, and a validation failure names every bad field rather than the first one.
  5. Derived query names stop paying past three conditions.Beyond that an explicit @Query is shorter and greppable — and it is where you add join fetch to kill an N+1 that Spring Data will otherwise happily generate for you.
  6. Spring Data does not replace JPA, it types less of it.Lazy loading, the persistence context, detachment and N+1 behave identically. Everything chapter 08 taught applies unchanged — which is exactly why this course teaches JPA before it teaches the shortcut.
Chapter 18

Every annotation in this project, in Spring

One table and four side-by-side files. Print this chapter, keep it next to you for a week in a new Spring codebase, and you will stop needing it.

The translation table

Left column: what is actually in src/main/java of this repository. Right column: what you would type instead. Same mechanism, different import.

PurposeJakarta EE 11Spring Boot 3Same mechanism?
HTTP
Mark an HTTP endpoint class@Path("/students")@RestController @RequestMappingyes
Read a GET@GET @Path("/{id}")@GetMapping("/{id}")yes
Write a POST@POST@PostMappingyes
Path segment@PathParam("id")@PathVariableyes
Query string@QueryParam("name")@RequestParamyes
Request bodyplain parameter@RequestBodyyes
Content type@Produces @Consumesproduces = / consumes =yes
Status, headers, bodyResponse.created(uri)ResponseEntity.created(uri)yes
Map an exceptionExceptionMapper<E>@RestControllerAdviceyes
Intercept every request@Provider ContainerRequestFilterOncePerRequestFilter / interceptoryes
Injection
A managed singleton@ApplicationScoped@Service / @Componentyes
One per HTTP request@RequestScoped@Scope("request")yes
Inject a collaborator@Injectconstructor parameteryes
Pick between implementations@Qualifier@Qualifier / @Primaryyes
Produce a bean from a method@Produces@Beanyes
Cross-cutting behaviour@Interceptor@Aspectyes — both proxies
Fire an in-process eventEvent<T>.fire()ApplicationEventPublisheryes
Handle it after commit@Observes(during = AFTER_SUCCESS)@TransactionalEventListeneryes
Data
Entity, column, relations@Entity @Column @ManyToOneidenticalthe same JPA
Transaction boundary@Transactional (jakarta)@Transactional (spring)same idea, other import
Force a rollback on a checked exceptionrollbackOn =rollbackFor =yes
Talk to the databaseEntityManagerJpaRepository (or EntityManager)Spring Data wraps it
Paginationcommon/PageRequestPageable / Pagesame shape
Row lockLockModeType.PESSIMISTIC_WRITE@Lock(PESSIMISTIC_WRITE)yes
Everything else
Validate an argument@Valid@Validthe same Bean Validation
Custom constraint@Constraint + Validatoridenticalthe same spec
Scheduled job@Schedule(hour="*")@Scheduled(cron="…")yes
Run once at startup@Observes @InitializedApplicationRunneryes
Role check@RolesAllowed@PreAuthorize / @RolesAllowedyes
Configuration valueMicroProfile @ConfigProperty@Value / @ConfigurationPropertiesyes
In plain words

This table is a phrasebook between two dialects of the same language, not a dictionary between two languages. Every row is the same underlying thing wearing a different label — which is why someone who genuinely understands one can be useful in the other within a week, and why an interviewer who knows their job asks about mechanisms rather than annotation spellings.

The same endpoint, both ways

Real code from api/rest/StudentResource.java, next to the Spring version line for line.

Jakarta EE — in this repository
StudentResource.javaJAX-RS
@Path("/students")
@RequestScoped
@Produces(APPLICATION_JSON)
@Consumes(APPLICATION_JSON)
public class StudentResource {

    @Inject StudentService studentService;
    @Inject StudentMapper studentMapper;
    @Context UriInfo uriInfo;

    @POST
    public Response create(
            @Valid @NotNull CreateStudentRequest req) {

        Student created = studentService
            .create(studentMapper.toCommand(req));

        URI location = uriInfo
            .getAbsolutePathBuilder()
            .path(String.valueOf(created.getId()))
            .build();

        return Response.created(location)
            .entity(studentMapper.toSummaryResponse(created))
            .build();
    }

    @GET
    @Path("/{id}")
    public StudentResponse findById(
            @PathParam("id") Long id) {
        return studentMapper.toDetailResponse(
            studentService.findByIdWithEnrollments(id));
    }
}
Spring Boot — the same thing
StudentController.javaSpring MVC
@RestController
@RequestMapping(
    value = "/api/students",
    produces = APPLICATION_JSON_VALUE)
public class StudentController {

    private final StudentService studentService;
    private final StudentMapper studentMapper;

    public StudentController(StudentService s,
                             StudentMapper m) {
        this.studentService = s;
        this.studentMapper  = m;
    }

    @PostMapping
    public ResponseEntity<StudentResponse> create(
            @Valid @RequestBody CreateStudentRequest req) {

        Student created = studentService
            .create(studentMapper.toCommand(req));

        URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.getId())
            .toUri();

        return ResponseEntity.created(location)
            .body(studentMapper.toSummaryResponse(created));
    }

    @GetMapping("/{id}")
    public StudentResponse findById(
            @PathVariable Long id) {
        return studentMapper.toDetailResponse(
            studentService.findByIdWithEnrollments(id));
    }
}
What actually changed

Six annotation names, one injection style, and the class that builds a URI. The structure — a mapper on both sides, a service in the middle, a 201 with a Location header, validation at the boundary — is unchanged, because that structure is a design decision rather than a framework feature. Frameworks do not make those decisions for you, which is the real reason this course spends fifteen chapters on the mechanisms first.

What does not translate

Four places where the mapping is not one-to-one, and where assuming it is will cost you an afternoon.

  • The deployment model. There is no web.xml, no beans.xml, no persistence.xml and no server to install. A Spring Boot application is configured by properties and by what is on the classpath. Conversely there is no shared server to hold a connection pool for several applications — each jar has its own.
  • Transaction management is not JTA by default. Spring uses a local DataSourceTransactionManager or JpaTransactionManager over one datasource. That is simpler and faster, and it means distributed two-phase commit across two databases is not something you get for free — whereas WildFly hands it to you.
  • CDI's scoping model is richer, and Spring's is simpler on purpose. @RequestScoped beans, conversation scope and CDI's typesafe resolution have no exact Spring twin. In practice Spring code passes request data as method parameters, which most people find easier to follow.
  • Startup order and events. @Observes @Initialized(ApplicationScoped.class) in ApplicationBootstrap becomes an ApplicationRunner bean, and CDI's AFTER_SUCCESS observer becomes @TransactionalEventListener(phase = AFTER_COMMIT). The concept survives; the wiring does not.
The interview trap

“So Spring Boot is just Jakarta EE with different names?” — no, and saying so is a red flag. They are two implementations of overlapping ideas with genuinely different histories, governance and defaults. The accurate sentence is: “The specifications they share — JPA, Bean Validation, servlet semantics — behave identically, and the injection and transaction models are analogous. The packaging, the configuration model and the ecosystem are different.”

You join a Spring Boot team from this project. What is the first thing you should read in their codebase?

The pom.xml or build.gradle — the starters tell you what the application actually is (web? data? security? messaging?) in ten lines. Then application.yml, which tells you what varies per environment. Then the one class annotated @SpringBootApplication, which tells you the package root everything is scanned from. Those three files describe a Spring Boot application more completely than any amount of clicking through controllers.

Golden rules

How to carry twenty chapters of Jakarta EE into a Spring Boot job without pretending you have used it for years.

  1. Learn mechanisms, translate spellings.Proxies, the persistence context, transaction boundaries and HTTP semantics are the transferable knowledge. Annotation names are a lookup table you can keep open on a second monitor.
  2. The structure is yours, not the framework's.Mapper, service, repository, validation at the boundary, one error shape — identical in both columns above. Frameworks do not make architectural decisions for you and never have.
  3. Say what you actually know.“I have built this in Jakarta EE and I know the Spring equivalents because the specifications are shared” is credible and checkable. “Three years of Spring” falls apart at the first question about proxies.
  4. Three files describe any Spring Boot codebase.The build file lists the capabilities, application.yml lists what varies, and the @SpringBootApplication class fixes the scan root. Read those before a single controller.
Chapter 19

Git — four ideas and a lot of vocabulary

The advert asks for basic knowledge of version control. Basic knowledge is not a list of commands — it is knowing where your changes are at any moment, and being able to undo anything without fear.

The three places your work can be

Almost every confusing Git message is really the question “which of these three do you mean?” Learn the picture and half the confusion disappears permanently.

working directory the files you can see and edit right now git status shows these staging area (index) the draft of your next commit the bit people skip local repository every commit you have ever made, on your disk safe: this is history git add git restore --staged git commit git reset --soft remote — origin GitHub, GitLab, Bitbucket git push git pull Nothing is shared until you push. Nothing is history until you commit. Everything in the left box is one careless command away from being gone — everything in the right box can almost always be recovered.
The staging area is the part beginners try to skip with git commit -a, and the part professionals use most: it lets you commit part of your work, so one commit is one idea. git add -p stages hunk by hunk and is worth the ten minutes it takes to learn.
In plain words

You are writing a letter. The working directory is the paper on your desk, covered in crossings-out. The staging area is the envelope: you put in only the pages you actually want to send. The commit is sealing the envelope and writing the date on it — from then on it is a permanent record. Push is walking it to the post box, where everyone else can finally read it.

And this is the important part: an unsealed page can be lost forever if the wind takes it. A sealed, dated envelope in your drawer can nearly always be found again, even if you throw it in the bin.

A commit is a snapshot with a parent, and a branch is a sticky note

Git does not store differences. Every commit is a complete snapshot of the tree, identified by a hash, pointing at the commit that came before it. Once you see that, branches stop being mysterious.

a1b c4d e7f 9ab 2cd 5ef main feature/withdraw HEAD A branch is a 41-byte file containing one commit hash. Creating one is instant and free. HEAD is a second sticky note saying “this is the branch you are standing on right now”. Arrows point backwards, from child to parent — a commit knows where it came from, never where it is going.
This is the actual data structure: a directed acyclic graph. git log --oneline --graph --all draws it in your terminal, and running that command whenever you are lost is the fastest way to stop being lost.
Example — the day-to-day loop, in fullthis is 90% of the Git you will use
terminalone feature, start to finish
git switch main && git pull            # start from what everyone else has
git switch -c feature/withdraw-endpoint  # branch: instant, free, disposable

# ... edit EnrollmentResource.java and EnrollmentService.java ...

git status                               # what changed? read this every single time
git diff                                 # what exactly changed, line by line?
git add src/main/java/it/unicam/cs/enrollment/api/rest/EnrollmentResource.java
git add -p                               # stage the rest hunk by hunk
git commit -m "Add DELETE endpoint for withdrawing from a course"

git push -u origin feature/withdraw-endpoint
# → open a pull request in the browser

git switch and git restore are the modern replacements for the overloaded git checkout, which did both jobs and confused everyone for a decade. Use them.

Merge or rebase — the only real difference

Both bring your branch up to date with main. One preserves what happened; the other rewrites it into what you wish had happened.

MERGE — keeps the history that happened a1 c4 e7 x1 x2 M two parents Nothing is rewritten. Every hash stays valid, and the branch shape is preserved. REBASE — rewrites your commits onto the new tip a1 c4 e7 x1' x2' new hashes — new commits A straight line, easy to read. But x1 and x2 no longer exist: they were copied, not moved.
The practical rule follows directly from the picture: rebasing creates new commits, so anyone who already has the old ones now disagrees with you about history. Rebase your own unpushed branch freely. Never rebase a branch other people have pulled.
SituationDo thisWhy
My branch is behind main, nobody else uses itgit pull --rebaseStraight history, no noise commit.
My branch is shared with a colleaguegit merge mainRewriting shared commits breaks their clone.
My PR has 9 messy commitssquash on mergemain gets one clean commit per feature.
I need to fix a bad message before pushinggit commit --amendRewrites the last commit — only safe before push.
The one rule that keeps you out of trouble

Never rewrite history that someone else already has. That covers rebase, --amend and push --force. If you truly must force-push your own feature branch, use --force-with-lease: it refuses when someone else has pushed in the meantime, instead of quietly destroying their work.

Conflicts are not errors

A conflict means two people changed the same lines and Git is refusing to guess. It is a question, not a failure — and the markers tell you exactly what it is asking.

EnrollmentService.javawhat a conflict looks like in the file
<<<<<<< HEAD                       // ← what is on YOUR branch right now
    if (occupied >= course.getCapacity())
        throw new CourseFullException(courseId);
=======
    if (occupied >= course.getCapacity())
        throw new BusinessRuleViolationException("COURSE_FULL", ...);
>>>>>>> main                       // ← what is on the branch you are pulling in

Resolving it is four steps, and the third one is the one people skip:

1Read both sides and decide what the code should be — often a third thing.
2Delete all three marker lines. Every one.
3Compile and run the tests. A resolved conflict is guilty until proven innocent.
4git add the file, then git rebase --continue or git commit.
In plain words

Two people edited the same sentence in a shared document. The software is not broken — it simply refuses to choose for you, because choosing wrong would silently delete somebody's work. It shows you both versions and waits.

And if you panic, nothing is lost: git merge --abort or git rebase --abort puts everything back exactly as it was before you started.

Prevention beats resolution

Conflicts scale with how long a branch lives. A branch merged the same day it was created almost never conflicts; a branch open for three weeks always does. That is the real, practical argument for small pull requests — not tidiness, but the fact that long-lived branches are conflict factories. Chapter 22 makes the same argument from the team's side.

The undo table

Print this. The fear of Git comes almost entirely from not knowing that everything committed is recoverable.

“I want to…”CommandCareful
Throw away edits to one filegit restore path/File.javagone for good — never committed
Unstage a file, keep the editsgit restore --staged File.javasafe
Fix the last commit messagegit commit --amendonly before pushing
Undo the last commit, keep the changes stagedgit reset --soft HEAD~1safe
Undo the last commit, keep the filesgit reset HEAD~1safe
Undo the last commit and the filesgit reset --hard HEAD~1destroys uncommitted work
Undo a commit that is already pushedgit revert <hash>the correct answer — adds an inverse commit
Park work in progress for ten minutesgit stash / git stash popeasy to forget it is there
Find a commit I destroyedgit reflogthe safety net nobody knows about
See who wrote this line and whygit blame -L 40,60 File.javaread the commit message, not the name
Find the commit that introduced a buggit bisect startbinary search over history — see ch. 23
Example — the reflog, the command that ends the panictry it in this repository right now
terminal“I reset --hard and lost two hours of work”
git reflog
c64d96c HEAD@{0}: reset: moving to HEAD~1
12b8188 HEAD@{1}: commit: Add four study tools
264d911 HEAD@{2}: commit: Replace absolute home paths

# the commit is still there. Put the branch back on it:
git reset --hard HEAD@{1}

Git keeps every position HEAD has occupied for 90 days, even positions no branch points at any more. If it was ever committed, it is almost certainly still there. That is why the sealed-envelope rule matters: commit early and often, and mistakes become reversible.

You committed a password to a branch and pushed it. What now?

Two separate actions, in this order. First, rotate the secret — assume it is compromised the moment it reaches a remote, because it is in every clone, every CI cache and possibly a security scanner's index. Then clean the history (git filter-repo or the platform's support team) and add the file to .gitignore. People do these the wrong way round and spend an hour rewriting history while the live credential is still valid.

The workflow a team actually uses

Branch, push, pull request, review, merge. It is the same shape at almost every company, and the pull request is where chapters 20, 21 and 22 meet.

branchfrom main commitsmall, often pull requestthe review gate CI pipelinemvn verify — ch. 21 human reviewa colleague — ch. 22 squash& merge maindeployable Both gates must pass. The machine checks that it works; the human checks that it is a good idea. Neither can do the other's job, which is why removing either one is always a mistake.
Branch names carry meaning: feature/…, fix/…, chore/…, often with a ticket number — feature/ENR-142-withdraw-endpoint. It costs nothing and makes a list of thirty branches readable.

And the commit message. This is the smallest thing on the list and the one that most visibly separates a junior from someone who has worked on a team:

what a reviewer sees too often

fix

changes

wip

asdasd

final version FINAL 2

In six months, git log tells you nothing, git blame tells you nothing, and nobody dares delete the code.

what to write instead

Add DELETE /enrollments/{id} for withdrawal

Fix N+1 on course prerequisites with join fetch

Reject grades outside 18-30 in recordPass

Subject in the imperative, under ~50 characters, saying what the commit does. If it needs a why, leave a blank line and explain in the body.

Test: the subject should complete the sentence “If applied, this commit will…”

Golden rules

Enough Git to be a safe colleague on day one.

  1. Know which of the three places your work is in.Working directory, staging area, repository. git status before every command answers it, and almost every confusing message is really that question.
  2. Committed work is recoverable; uncommitted work is not.git reflog finds commits no branch points at for 90 days. git restore on an uncommitted file destroys it instantly. Commit early — it is what makes mistakes cheap.
  3. Never rewrite history someone else has.Rebase, --amend and push --force all create new commits and invalidate everyone else's copy. On your own unpushed branch they are free; use --force-with-lease if you must push one.
  4. A conflict is a question, not a failure.Read both sides, delete every marker, run the tests, then continue. --abort always puts you back where you started, so there is nothing to fear.
  5. Small branches, merged quickly.Conflicts scale with branch lifetime, and so do review quality and deployment risk. A branch that lives a day rarely conflicts; one that lives three weeks always does.
  6. Write the commit message for the person debugging in six months.Imperative subject, under fifty characters, and a body explaining why when the why is not obvious. That person is usually you.
Chapter 20

Proving it, and the test that lies

This project has 60 tests. One line in one of them is the difference between catching a production outage and shipping it.

Two kinds of test

Unit (*Test)Integration (*IT)
35 tests. No database. Repositories are mocked. Runs in milliseconds via Surefire. 25 tests. Real JPA against in-memory H2 in PostgreSQL mode. Runs via Failsafe.
Proves logic: given a full course, does enroll throw? Proves mapping: does the query actually fetch what it claims?

The split matters because they fail for different reasons. A unit test cannot catch a broken fetch plan — there is no database to fetch from. An integration test is too slow to run on every keystroke. You need both.

In plain words

A unit test asks: does this one gear turn correctly? An integration test asks: does the clock tell the right time?

You need both, and for a reason that is easy to miss. A box of perfect gears is not a clock — and a clock that happens to tell the right time will start lying the moment you change one gear.

Useful consequence

Because the integration tests use in-memory H2, mvn verify runs all 60 with no server and no database installed. Most of what this fieldbook covers can be studied without ever starting WildFly.

The line that makes the test honest

A test that asserts while the persistence context is still open is testing a world that never happens in production.

test/repository/CourseRepositoryIT.javaone line, everything hinges on it
Optional<Course> found = repository.findByIdWithPrerequisites(id);

entityManager.clear();   // ← detach everything, exactly as the REST layer sees it

Course detached = found.get();
assertThat(catchThrowable(() -> detached.getPrerequisites().size()))
        .as("prerequisites must have been fetch-joined")
        .isNull();

Here is what was measured on this codebase — the fetch join removed, then the same bug tested with and without that clear():

ScenarioEndpointTest suite
Fetch plan intact200 OKpasses
Fetch join removed500fails — caught it
Removed, clear() disabled500passes — missed it
production test without clear() test with clear() service — context open mapper runs here — DETACHED COMMIT context still open when the assertion runs PASSES the lazy getter still works here, so a missing fetch join is invisible — green build, 500 in production repository call clear() assert on detached CATCHES IT one line moves the assertion to the same side of the commit as the mapper A test is only honest when it stands where production stands.
The middle lane is the dangerous one, because nothing about it looks wrong: it is green, fast, and asserting the right thing — in the wrong world.
The lesson

In that last row the endpoint is returning 500 to real users while the build reports BUILD SUCCESS, 0 failures. A test configuration that differs from production will happily prove the wrong thing. The value of a test is not that it passes — it is that it would have failed.

Controlling time

Enrollment windows depend on "now". A test that reads the system clock is a test that fails on a Sunday, or in a different timezone, or at the end of a semester. So the clock is injected:

common/ClockProducer.java + teststime as a dependency
// Production: the real clock, produced as a CDI bean
@Produces public Clock clock() { return Clock.systemUTC(); }

// Test: pin it. The window is now fully deterministic.
Clock fixed = Clock.fixed(Instant.parse("2025-09-01T10:00:00Z"), ZoneOffset.UTC);

Any value that makes a test non-deterministic — time, randomness, UUIDs — should be a dependency rather than a static call. That is most of what "testable design" means in practice.

Golden rules

How to tell a test that proves something from one that only passes.

  1. A test's value is that it would have failed.Delete the fetch join and the endpoint returns 500. With clear() disabled the suite still reports BUILD SUCCESS — a green build over a broken endpoint.
  2. Call entityManager.clear() before asserting a fetch plan.Otherwise you assert against an open session, which is a world production never has. The test must stand where the mapper stands: on the detached side.
  3. Unit tests prove logic; integration tests prove mapping.A mocked repository cannot notice a missing join, and an in-memory H2 run is too slow to sit under every keystroke. 35 and 25, not 60 of one kind.
  4. Anything non-deterministic is a dependency.Time, randomness, ids. Injecting Clock is what stops an enrollment-window test failing on a Sunday or in another timezone.
Chapter 21

Continuous integration, and the pipeline for this project

The advert files this under nice to have, which is exactly why it is worth an hour: most junior candidates cannot describe a pipeline, and the ones who can sound like they have already worked somewhere.

The problem CI actually solves

Not “running tests automatically”. The problem is that main is a shared claim — everyone believes it works — and without a machine checking, that claim quietly stops being true.

In plain words

Six people are cooking one dinner in six separate kitchens. Everybody's dish is delicious on their own counter. Nobody discovers that three of them made dessert and nobody made the main course until dinner time, when it is far too late to fix.

Continuous integration means everyone brings their dish to the same table every few hours, and a machine tastes it immediately. Problems are found while they are still one dish, not one dinner.

Three consequences follow, and they are the honest reason CI exists:

  • The feedback loop shortens from days to minutes. A bug found eleven minutes after you wrote it costs a coffee. The same bug found three weeks later costs an afternoon of archaeology.
  • “It works on my machine” stops being an argument. The pipeline builds on a clean agent with no local database, no stale target/ folder and no IDE configuration.
  • The build becomes a gate, not a suggestion. A red pipeline blocks the merge. That is the entire mechanism by which a codebase stays healthy for years with people joining and leaving.

What a pipeline is made of

A pipeline is stages in order, each one a gate. Later stages are slower and more expensive, so cheap checks run first — a compile error should never wait for a deployment.

checkoutgit clone5 s compilejavac20 s unit testssurefire40 s integrationfailsafe + H290 s packagethe WAR15 s qualitycoverage, scan60 s deploystaging30 s any red stage stops the pipeline and tells the author Cheap and fast first. There is no point spending 90 seconds on integration tests for code that does not compile. Total: about four minutes — and four minutes is roughly the limit before people stop waiting and start ignoring it.
This project already has the first five stages: mvn verify runs Surefire for unit tests and Failsafe for integration tests against in-memory H2, with no server and no database required. The pipeline is largely a matter of wrapping a command you can already run.
Example — the whole pipeline, run locallyif this passes on your machine, it should pass in CI
terminalwhat CI is going to run anyway
mvn clean verify

# clean    delete target/ — no stale classes, same as a fresh agent
# verify   compile → surefire (unit) → package → failsafe (integration)
#
# Tests: 60, Failures: 0, Errors: 0, Skipped: 0
# BUILD SUCCESS

Running clean matters more than it looks. Most “works locally, fails in CI” reports are a stale target/ directory holding a class file whose source no longer exists.

Jenkins, concretely

Jenkins is a server that watches your repository and runs a script when something changes. The script lives in the repository, in a file called Jenkinsfile — which means the pipeline is reviewed and versioned like any other code.

Jenkinsfiledeclarative pipeline for this project
pipeline {
    agent { docker { image 'maven:3.9-eclipse-temurin-17' } }

    options {
        timeout(time: 20, unit: 'MINUTES')   // a hung build must not block forever
        buildDiscarder(logRotator(numToKeepStr: '30'))
    }

    stages {
        stage('Build') {
            steps { sh 'mvn -B clean compile' }     // -B = batch, no colour codes
        }

        stage('Test') {
            steps { sh 'mvn -B verify' }
            post {
                always { junit 'target/*-reports/TEST-*.xml' }  // results even on failure
            }
        }

        stage('Package') {
            steps {
                sh 'mvn -B package -DskipTests'
                archiveArtifacts artifacts: 'target/enrollment.war', fingerprint: true
            }
        }

        stage('Deploy to staging') {
            when { branch 'main' }                    // feature branches build but do not deploy
            steps {
                withCredentials([usernamePassword(credentialsId: 'wildfly-staging',
                                 usernameVariable: 'U', passwordVariable: 'P')]) {
                    sh './deploy.sh $U $P'            // secrets injected, never in git
                }
            }
        }
    }

    post {
        failure { mail to: 'team@example.com', subject: "FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER}" }
    }
}
Jenkins wordWhat it means
controllerThe server with the web UI that schedules work. It should not run builds itself.
agent / nodeThe machine (often a container) where your build actually runs.
job / pipelineOne configured piece of automation — usually one per repository.
stageA named group of steps, shown as a column in the UI.
stepOne command: sh, junit, archiveArtifacts.
artifactA file the build produced and kept — here, the WAR.
credentialsSecrets stored by Jenkins and injected as variables, never written in the file.
webhookThe call from GitHub that says “something was pushed”, so Jenkins starts.
The trap

A secret in a Jenkinsfile, an application.yml or a Dockerfile is a secret in Git forever, in every clone. Use the credentials store and inject it at run time. And note the ordering rule from chapter 19: if one does leak, rotate the credential first, then clean the history.

In plain words

Jenkins is an extremely reliable, entirely humourless colleague who does exactly one thing: every time anyone pushes code, they check out a clean copy, build it, run every test, and tell the whole team the result.

They never get bored, never skip the boring test, and never say “it worked on my machine.” That is the entire value: consistency you cannot get from people.

The same pipeline in GitHub Actions

The advert says “Jenkins or similar tools”, because the concepts transfer completely. Here is the identical pipeline in the tool you are most likely to meet on a personal project.

.github/workflows/build.ymlsame stages, different syntax
name: build
on:
  push:        { branches: [main] }
  pull_request: { branches: [main] }

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: temurin
          cache: maven                       # ~/.m2 reused between runs

      - name: Build and test
        run: mvn -B clean verify

      - name: Publish test report
        if: always()                        # run even when the tests failed
        uses: mikepenz/action-junit-report@v4
        with:
          report_paths: 'target/*-reports/TEST-*.xml'

      - name: Upload the WAR
        uses: actions/upload-artifact@v4
        with:
          name: enrollment-war
          path: target/enrollment.war
ConceptJenkinsGitHub ActionsGitLab CI
Where the config livesJenkinsfile.github/workflows/*.yml.gitlab-ci.yml
A group of stepsstagejobstage
Where it runsagentruns-onrunner
Kept outputarchiveArtifactsupload-artifactartifacts:
Secretscredentials()${{ secrets.X }}CI/CD variables
Triggerwebhook / pollon: pushrules:

An interviewer asking about Jenkins is checking whether you understand build automation, not whether you know Groovy. “I have used GitHub Actions and the concepts map directly — stages, agents, artifacts, credentials” is a complete and honest answer.

What makes a pipeline worth trusting

A pipeline nobody believes is worse than no pipeline, because it costs time and teaches the team to ignore red.

  • It must be fast. Past roughly ten minutes people stop waiting for it and start merging on hope. Parallelise, cache dependencies, and push slow end-to-end suites to a nightly run.
  • It must be deterministic. A test that fails one run in twenty is worse than a missing test: it trains everyone to press rebuild, and one day the rebuild hides a real failure. Fix flaky tests or delete them.
  • It must build the same way every time. Pinned plugin versions, a pinned JDK, a clean workspace. This project pins Surefire and Failsafe to 3.2.5 in pom.xml for exactly this reason — a build whose behaviour depends on the day it ran is not reproducible.
  • Red must actually block. Branch protection that requires the check to pass is what converts the pipeline from advice into a gate. Without it, CI is a very expensive notification system.
  • It must say what broke. Publish the JUnit report so the failure is a named test and a line number, not four hundred lines of log for someone to scroll through.
CI, CD and CD

Continuous integration: every change is merged and verified frequently — the pipeline above. Continuous delivery: every green build is deployable, and a human decides when. Continuous deployment: every green build goes to production automatically, with no human step. Most companies do the first two. Saying so accurately is a better answer than claiming the third.

The pipeline is green but production broke. What did the pipeline fail to check?

Something that only exists outside the build. The usual suspects: configuration and secrets that differ per environment, a database migration that was never run, a dependency version resolved differently on the agent than in production, or a load pattern no test reproduces. This is exactly why the H2-versus-PostgreSQL point in chapter 07 matters, and why a staging environment that genuinely resembles production is worth its cost.

Golden rules

What to say when an interviewer asks whether you have used CI.

  1. CI protects a shared claim, not a test suite.The claim is “main works”. A pipeline is simply the only way to keep that claim true while several people change the code every day.
  2. Cheap stages first, and fail fast.Compile, unit, integration, package, deploy. Never spend ninety seconds on integration tests for code that does not compile, and stop at the first red stage.
  3. The pipeline definition belongs in the repository.Jenkinsfile or .github/workflows/, reviewed in the pull request that changes it. A pipeline configured by clicking in a web UI cannot be reviewed, branched or rolled back.
  4. Secrets come from the credentials store, never from the file.Anything committed is in every clone forever. If one leaks, rotate it first and clean history second.
  5. A flaky test is worse than a missing one.It teaches the team that red means “press rebuild”, and that habit will one day wave a real failure straight through.
  6. Green must block the merge, or it is only a notification.Branch protection is the mechanism that turns a pipeline into a gate. Everything else about CI is optional; this part is not.
Chapter 22

How the work arrives, and how it gets reviewed

“Ability to work in a team” and “notions of Agile” are two lines in the advert and one skill in practice: knowing how a task reaches you, what done means, and how to disagree with a colleague about code without it being personal.

Why iterations, and what Agile actually claimed

The original argument was not about ceremonies. It was that you cannot specify software completely in advance, because the people asking for it learn what they want by using it.

SEQUENTIAL — one bet, resolved at the end analysis design build test release — month 9 First honest feedback arrives after nine months. Every wrong assumption has had nine months to compound. ITERATIVE — many small bets, each resolved in two weeks sprint 1working software sprint 2working software sprint 3working software sprint 4working software … and the plan changes here,because you learned something Same total effort. The difference is when you find out you were wrong — and how much you have to throw away.
The four values, in one line each: people over process, working software over documentation, collaboration over contract negotiation, and responding to change over following a plan. Note the phrasing — the right-hand items still have value, they are simply not the tie-breaker.
In plain words

You are cooking for someone whose taste you do not know. Waterfall is cooking a nine-course meal from a written menu and serving it all at once, then finding out they hate coriander. Agile is cooking one dish, letting them taste it, and adjusting the next one.

The second way costs a few more trips to the kitchen. It also never produces nine courses nobody can eat.

Scrum: the vocabulary you will hear on day one

Scrum is the most common way teams put those ideas into a calendar. It is a small framework with a lot of jargon, and the jargon is what makes a new joiner feel lost in the first week.

product backlog everything wanted, ordered by value planning what fits in two weeks, and why THE SPRINT — 2 WEEKS sprint backlog — committed daily standup, 15 min not a status report to a manager review show the working thing retrospective improve how you work what you learned re-orders the backlog — this loop is the whole point Everything else is detail. If the loop does not close, the ceremonies are theatre.
Three roles complete the picture: the product owner decides what is worth building and in what order, the scrum master removes obstacles and protects the process, and the developers decide how it gets built and how long it takes. The last part is not negotiable — estimates belong to the people doing the work.
CeremonyWhenReal purposeFailure mode
Planningstart of sprintAgree what is committed, and that everyone understands itBecomes a task-assignment meeting
Daily standupevery morning, 15 minSurface blockers early so someone can help todayBecomes a status report to a manager
Review / demoend of sprintShow working software to the people who asked for itSlides instead of a running system
Retrospectiveend of sprintChange one thing about how the team worksComplaints with no action, repeated forever
Refinementmid-sprintMake upcoming items clear enough to estimateSkipped — then planning takes three hours
What to say at your first standup

Three sentences, thirty seconds: “Yesterday I finished the withdrawal endpoint and opened the PR. Today I am adding the integration test. I am blocked on the staging database credentials.”

The third sentence is the only one that matters — a standup exists to surface blockers. Saying “I am stuck” on day one is a sign of a good colleague, not a weak one. Chapter 24 has the English phrasing for all three.

A user story you can actually build

A ticket that says “improve enrollments” wastes a sprint. The format exists to force three things into the open: who wants it, what they want, and how you will both know it is finished.

a ticket that will be reopened

Title: Withdrawal

Description: Students should be able to withdraw.

Withdraw from what? Until when? Does the seat come back? What if they already have a grade? Every one of those becomes a question mid-sprint, or worse, a wrong guess found in review.

a ticket you can start today

As a student
I want to withdraw from a course before the closing date
so that the seat is freed and it does not count against my record.

Acceptance criteria

DELETE /api/enrollments/{id} returns 204
• after withdrawal the status is WITHDRAWN and the seat is free
• after the closing date it returns 409 ENROLLMENT_CLOSED
• withdrawing from a completed enrollment returns 409
• another student's enrollment returns 403

Those five bullets are also, almost word for word, the five tests. That is not a coincidence — it is the reason the format survives. And a good story satisfies INVEST:

LetterMeansTest
IndependentCan be built without waiting for another storyCould it go into this sprint alone?
NegotiableDescribes a need, not a solution designIs the “how” still the team's decision?
ValuableSomeone outside the team benefitsWho notices when it ships?
EstimableThe team can size itIf not, it needs refinement or a spike
SmallFits comfortably in one sprintBigger → split it
TestableHas acceptance criteriaHow do we prove it is done?

And beyond the criteria of a single story, a team shares one definition of done — the bar every story clears before anyone calls it finished:

done meanscode merged to main
andreviewed by a colleague
andtests written and green in CI
anddeployed to staging
anddocumentation updated
The trap

“Done” meaning “it works on my branch” is how a sprint ends with eight finished stories and nothing released. A shared definition of done is what stops done from meaning five different things to five people — and it is why the pipeline in chapter 21 is part of the definition, not an afterthought.

Estimation, and why not in hours

Story points measure size and uncertainty relative to other stories, not hours. The reason is simple: humans are bad at absolute estimates and surprisingly good at comparisons.

Ask someone how many minutes it takes to walk to the station and they will be wrong. Ask whether it is further than the supermarket and they will be right. Points exploit the second ability.

PointsFeels likeExample from this project
1Obvious, no unknownsAdd an index to students.email
2Clear, a bit of workAdd GET /api/courses/{id}/enrollments
3Clear, several filesThe withdrawal story above, with tests
5Some unknownsAdd optimistic-lock retry to grade recording
8Real unknowns — consider splittingReplace the seat count with an event-sourced projection
13+Not a story, an epic“Add authentication” — split it
In plain words

Points are T-shirt sizes for work. Nobody can tell you their chest measurement in centimetres, but everyone knows whether they are a medium or a large. After a few sprints the team knows it delivers about twenty points per sprint, and that — not anyone's optimism — is what makes the next sprint predictable.

The number is a conversation, not a promise. When two people say 2 and 8, the useful part is not the average; it is finding out what the person who said 8 knows that the other one does not.

Code review, from both sides

This is where “ability to work in a team” and “attention to detail” stop being CV phrases and become observable behaviour. It is also where most new developers accidentally make a bad impression.

reviewing someone else's code

Comment on the code, never the person. “This query runs inside the loop” — not “you clearly did not think about performance.”

Separate must-fix from preference. Label them: blocking: for a real defect, nit: for taste. Reviewers who do not distinguish get ignored on both.

Ask before asserting. “What happens if the course is full here?” is better than “this is broken” — and it is right more often, because you have less context than the author.

Say what is good. One sincere sentence about a well-handled edge case changes how the whole review lands.

Be fast. A review sitting for two days blocks a person and grows conflicts. Same day, or say when you can.

having your code reviewed

It is not about you. The review is of a diff, by someone protecting the same codebase you are. This is the single hardest habit to build and the most valuable.

Make it small. A 200-line PR gets real comments; a 2,000-line PR gets “LGTM” and a rubber stamp. You control which review you receive.

Explain the why in the description. What changed, why, how you tested it, and anything you are unsure about. Reviewers cannot read intent from a diff.

Answer every comment. Fixed, or a reason. Silently resolving a thread reads as dismissal.

Disagree with reasons, then let it go. Present the argument once; if the team still disagrees, follow the team. Both halves matter.

What a reviewer is really checking

Roughly in order: is it correct (edge cases, error paths, concurrency), is it in the right layer (a business rule in a controller gets flagged every time), is it tested (does a test fail if the change is reverted?), will someone understand it in a year, and only then style — which a formatter should have settled before a human ever looked.

A senior developer leaves eleven comments on your first pull request. What does that mean?

Almost always that they read it carefully and think you are worth the time. The reviews to worry about are the ones that say “LGTM” in four seconds. Work through them one at a time, fix what is right, ask about what you do not understand, and push back with a reason where you disagree — that last one, done politely, is what makes a reviewer start treating you as a peer.

Golden rules

The habits that make you the junior a team is glad they hired.

  1. The loop is the point; the ceremonies are the mechanism.Build a small thing, show it, learn, re-order the plan. A team that runs every meeting but never changes the backlog is doing theatre, not Agile.
  2. A story without acceptance criteria is not ready to start.Who wants it, what they want, why — and the list of conditions that prove it is finished. Those conditions are also your test list, which is why the format has survived.
  3. Say you are blocked, on the day you are blocked.That is what a standup is for. A junior silently stuck for three days costs the team far more than one who asks at the first hour.
  4. Points are comparisons, not hours.Humans estimate badly in absolute terms and well in relative ones. A disagreement between 2 and 8 is information, not a problem to average away.
  5. Small pull requests get real reviews.You choose which kind of review you get by choosing the size of the diff. Two hundred lines gets thought; two thousand gets a rubber stamp.
  6. Review the code, take the review on the code.Comment on the diff and never the person; receive comments as being about the diff and never about you. This one habit does more for a career than any framework.
Chapter 23

Problem solving, as a method rather than a talent

The advert asks for a problem-solving attitude and attention to detail. Neither is a personality trait — both are a procedure, and the procedure can be learned in an afternoon and practised in this repository tonight.

Read the stack trace properly

A Java stack trace is not a wall of noise. It has a strict structure, and two lines in it carry almost all the information.

server.logthe real thing, trimmed
jakarta.ws.rs.WebApplicationException: HTTP 500 Internal Server Error
    at org.jboss.resteasy.core.ExceptionHandler.handleException(...)
    at io.undertow.servlet.handlers.ServletHandler.handleRequest(...)
    ... 48 more framework frames you can ignore ...
Caused by: org.hibernate.LazyInitializationException:
        failed to lazily initialize a collection of role:
        it.unicam.cs.enrollment.domain.model.Course.prerequisites:
        could not initialize proxy - no Session
    at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInit(...)
    at it.unicam.cs.enrollment.api.mapper.CourseMapper.toDetailResponse(CourseMapper.java:64)
    at it.unicam.cs.enrollment.api.rest.CourseResource.findById(CourseResource.java:118)
read 1The last Caused by — that is the real failure. Everything above it is a wrapper.
read 2The first line with your package name — that is your code, and where to put a breakpoint.
read 3The message text. Hibernate literally names the field: Course.prerequisites.
ignoreEvery frame belonging to a framework package. They describe how you got there, not what went wrong.

Three lines of a fifty-line trace give the complete diagnosis: a lazy collection was touched in CourseMapper at line 64, after the transaction closed. Chapter 08 explains why; the trace already told you where.

In plain words

A stack trace is the trail of breadcrumbs backwards, from where the alarm rang to where the fire started. The top of the list is the alarm bell — loud, and in the wrong room. Caused by at the bottom is the fire.

Beginners read the first line and give up. Read from the bottom, and find the first line with your own name on it.

The method

When you do not know what is wrong, the temptation is to change something and see. That works occasionally and teaches nothing. This is the alternative, and it is what separates twenty minutes from two days.

1. reproducereliably, on demand 2. narrowhalve the search space 3. hypothesiseone falsifiable guess 4. test itchange ONE thing 5. fix + testthat would have caught it hypothesis wrong → loop, with the search space now smaller Step 1 is the one people skip, and it is the one that decides everything. A bug you cannot reproduce on demand is a bug you cannot prove you fixed.
The discipline is in step 4: change exactly one thing. Change three and it works, you have learned nothing — you now have three changes, at least two of which are unnecessary and one of which may be a new bug.
Narrowing, concretely

“Halve the search space” sounds abstract. In practice it is: does it fail in the test suite or only in the running server? With the database or with H2? On the first request or only after several? With one thread or only under load? Every one of those questions cuts the problem in half, and four such questions turn a whole application into one method.

Binary search over anything

The single most useful technique in debugging is the one from chapter 07's index: halve the space, repeatedly. It applies to history, to code, and to data.

Example — git bisect, over historyfinds the commit that broke it in log₂(n) steps
terminal“it worked last month”
git bisect start
git bisect bad                    # the current commit is broken
git bisect good c64d96c           # this one was fine

# Git checks out the midpoint. Run the test, then say which it was:
mvn -q verify && git bisect good || git bisect bad

# repeat ~5 times for 40 commits, ~10 times for 1000
git bisect reset                  # back to where you started

And it can drive itself: git bisect run mvn -q verify hands Git the test command and it finds the guilty commit unattended, which is worth remembering next time you have a long lunch break.

The same move, applied elsewhere:

WhereThe halving move
Historygit bisect — which commit introduced it
A methodA log line or breakpoint in the middle — is the value already wrong here?
LayersCall the service directly from a test. Still broken → not the REST layer.
DataHalf the rows. Still fails → keep halving until one row reproduces it.
ConfigurationStart from an empty config and add settings back until it breaks.
Dependenciesmvn dependency:tree, then remove or pin half.
In plain words

You are looking for one broken bulb in a string of a thousand fairy lights. Checking them one at a time takes a thousand tries. Unplug the middle: whichever half goes dark contains the fault. Ten of those and you have found it.

Every technique in the table above is that same move, aimed at a different kind of string.

The five failures you will actually meet here

Recognising an error message once, calmly, is worth more than reading ten explanations of it. This project ships a script that causes four of these on purpose.

terminalbreak it on purpose, then put it back
./scripts/break.sh list          # what is available
./scripts/break.sh n-plus-one    # one line changed in a tracked file
mvn package                      # WildFly hot-redeploys in a second or two
tail -f $WILDFLY/standalone/log/server.log

./scripts/break.sh restore       # git checkout — nothing is ever lost
What you seeWhat it really meansWhere to look
LazyInitializationExceptionA lazy getter was touched after the transaction closed — usually in a mapper.ch. 08 · break.sh fetch-plan
One request, 50 SELECTsN+1: a loop is walking a lazy association. Fix with join fetch.ch. 08 · break.sh n-plus-one
409 DUPLICATE_RESOURCEWorking as designed. The unique constraint or the service check refused it.ch. 07 · ch. 13
Two students got the last seatThe pessimistic lock was removed; both transactions read the same count.ch. 11 · break.sh no-lock
Deployment fails at bootPostgreSQL is not up. The pool prefills at start, so the error names the pool, not the database.ch. 27
A test that passes wronglyIt asserts something that is true regardless of the code under test.ch. 20 · break.sh blind-test

Two tools make the invisible visible, and both are already switched on here. Hibernate's SQL logging shows you every statement your code caused — including the ones nobody wrote — and the correlation id filter puts one identifier on every log line of a single request:

terminalone request, every line it produced
grep "a3f9c21e" $WILDFLY/standalone/log/server.log

# That id came back to the client in the X-Correlation-Id response header.
# On a server handling 200 requests a second, this is the difference between
# reading one story and reading two hundred interleaved ones.
The debugger, briefly

docs/DEBUGGING.md in this repository walks through attaching IntelliJ, VS Code or Eclipse to the running WildFly on port 8787. A conditional breakpoint — break only when studentNumber.equals("100003") — is the feature most people never discover, and it turns “step through four hundred iterations” into “stop at the interesting one”.

Asking for help without wasting anyone's time

Knowing when to stop is part of the skill. The convention on most teams is roughly thirty to sixty minutes of genuine effort, then ask — and how you ask decides how useful the answer is.

a question that gets no answer

“Hi, the enrollment API doesn't work, can someone help?”

Nobody can act on this. Everyone who reads it has to ask three questions before they can even start thinking, so most people quietly skip it.

a question that gets answered in five minutes

What I am trying to do: enroll student 100003 in CS101 on my local WildFly.

What I expected: 201 Created.

What happens: 500, with LazyInitializationException on Course.prerequisites.

What I have tried: confirmed the service is inside @Transactional; it works in the integration test but not through HTTP.

Correlation id: a3f9c21e, log excerpt attached.

The right-hand version has a useful side effect: writing it out solves the problem roughly a third of the time before you ever press send. That is the rubber duck effect — explaining a problem in complete sentences forces you to state the assumption that is wrong.

The trap in both directions

Struggling silently for two days is not perseverance, it is an expensive way to protect your ego — and every senior developer would rather be asked on the first hour. Equally, asking before you have read the error message and tried one thing trains people to stop taking your questions seriously. The professional habit is one honest attempt, then a well-written question.

The bug only happens in production, never locally. What is different, in order of likelihood?

Data first — production has rows yours does not: nulls, duplicates, very old records, unusual characters. Then concurrency: production has real simultaneous requests, which is where the last-seat race in chapter 11 lives. Then configuration: a different property, a different pool size, a different timezone. Then scale: an N+1 that costs 4 ms with ten rows costs 4 seconds with ten thousand. Then the environment itself: a different JDK, a different database version, H2 versus PostgreSQL. Work down that list rather than guessing.

Golden rules

Problem solving as a repeatable procedure.

  1. Read the last Caused by, then the first line with your package in it.Those two lines are the diagnosis. Every framework frame between them describes how you arrived, not what broke.
  2. Reproduce before you fix.A bug you cannot trigger on demand is a bug you cannot prove you fixed. Making it reproducible is usually most of the work, and it is never wasted.
  3. Change one thing at a time.Change three and it works, and you have learned nothing — you now carry two unnecessary changes and possibly a new bug.
  4. Halve the search space, whatever the space is.git bisect over history, a breakpoint in the middle of a method, half the rows, half the config. Ten halvings cover a thousand candidates.
  5. Every fix gets a test that would have caught it.Otherwise you have fixed it once. With the test, it stays fixed — and the test documents the bug better than any comment.
  6. One honest attempt, then a well-written question.Expected, actual, tried, correlation id. Writing it properly solves it outright surprisingly often, and when it does not, it gets answered in minutes rather than ignored.
Chapter 24

The English you actually need

“Good knowledge of English” in a backend advert does not mean fluency. It means: read documentation, write a clear pull request, and say three sentences at a standup. That is a much smaller and much more learnable target.

Where the English actually appears

Almost none of it is conversation. Most of a backend developer's English is read silently and written asynchronously, which means you get time to think — and a dictionary.

SituationHow oftenSkill it needs
Reading documentation, Stack Overflow, error messagesevery hourreading — passive vocabulary only
Naming things in code and writing commit messagesevery day~200 words, always the same ones
Pull request descriptions and review commentsevery daywriting, with time to edit
Standup, in an international teamevery day, 30 secondsthree memorised sentence patterns
Written incident and bug reportsweeklystructure matters more than grammar
Meetings and interviewsoccasionallylistening, plus the courage to ask people to repeat
In plain words

Technical English is a small language living inside a big one. It has perhaps three hundred words, the same grammar every time, and no idioms, jokes or slang — because half the people using it are not native speakers either.

You do not need to discuss poetry. You need to say “this query runs inside the loop” clearly. That is a much smaller mountain, and you are already most of the way up it.

The most useful sentence you can learn

“Sorry, could you repeat that more slowly?” — and its written cousin, “Just to confirm I understood correctly: you want X, not Y?” Nobody has ever thought less of a colleague for asking. They have frequently thought less of one who nodded and built the wrong thing.

Standup, in three sentences

Learn these three patterns and you can fill in the blanks for the rest of your career. Thirty seconds, in this order, every single day.

Yesterday I finished the withdrawal endpoint and opened a pull request.
Past simple. “I finished”, “I fixed”, “I reviewed”, “I deployed”. Never the present perfect — “I have finished yesterday” is the single most common Italian-speaker slip in English.
Today I am going to add the integration tests for it.
“I am going to” or “I will” — both fine, and “going to” sounds more natural for a plan you already made.
I am blocked on the staging database credentials — could someone help me after this?
The only sentence that really matters. “I am blocked on X”, “I am stuck on X”, “I need X from someone”. Say it early and say it plainly.

Three variations worth having ready, because they come up constantly:

It is taking longer than expected — the lazy loading turned out to be more involved than I thought.
Honest, specific, no apology needed. This is a normal thing to say and everyone says it.
No blockers from me.
A complete and perfectly acceptable turn. You do not have to fill the silence.
Can we take that offline? / Let's follow up after the standup.
Standard phrasing for “this needs a longer discussion with two people, not fifteen”. Using it correctly makes you sound experienced.

The review phrasebook

Code review English is unusually formulaic, and that formula is deliberate: it keeps disagreement about code from sounding like disagreement about people. Learn the hedges.

writing a review comment

Ask, do not accuse: “What happens if the course is already full here?”

Suggest with a hedge:Consider extracting this into a method.” · “It might be worth caching this.” · “Would it make sense to move this to the service?”

Flag severity:nit: typo in the javadoc” · “blocking: this throws on a null email”

Praise plainly: “Nice catch on the null check.” · “This is much clearer than the old version.”

Admit uncertainty: “I may be missing something, but…” · “Not blocking, just curious — why merge rather than persist?”

replying to one

Agreeing: “Good point, fixed in a1b2c3d.” · “You are right, I missed that.”

Asking: “Could you clarify what you mean by ‘leaky’ here?”

Disagreeing politely: “I see your point, however the lock has to stay inside the transaction, otherwise…” · “I would rather keep it here, because…”

Deferring: “Fair — I have opened ENR-155 to handle that separately, to keep this PR small.”

Closing: “Thanks for the careful review — all comments addressed.”

A real cultural trap

English professional writing hedges far more than Italian, Spanish or German technical writing does. “This is wrong” is grammatically perfect and reads as blunt to the point of rudeness; “I think this might not handle the empty case” means exactly the same thing and lands completely differently.

This is not politeness for its own sake — hedged phrasing leaves the other person room to explain a reason you had not considered, which happens more often than you expect. It is also, in the other direction, why a British colleague writing “I wonder whether we should reconsider this approach” may well mean “absolutely not”.

Writing that other people read

Commit messages, pull request descriptions and bug reports. All three have a fixed shape, and the shape carries more of the meaning than the vocabulary does.

a commit messageimperative subject, then why
Add DELETE /enrollments/{id} for course withdrawal

Students could enroll but never leave, so a seat freed by a
withdrawal was never returned to the pool. The endpoint moves
the enrollment to WITHDRAWN, which the seat count already
excludes.

Refuses with 409 after the enrollment window closes.

Closes ENR-142

# Subject in the IMPERATIVE: "Add", "Fix", "Remove", "Refactor".
# Test: it must complete "If applied, this commit will ___".
#   Add the endpoint        ✔
#   Added the endpoint      ✘
#   Adding the endpoint     ✘
#   endpoint stuff          ✘

The imperative is not arbitrary: Git itself writes in it (“Merge branch…”, “Revert…”), so your commits read consistently with the tool's own. And the verb list is short:

VerbUse it whenExample
Addnew behaviour or a new fileAdd pagination to the students endpoint
Fixa defect — say what was brokenFix N+1 when loading course prerequisites
RemovedeletionRemove the unused CourseSummaryMapper
Refactorbehaviour unchanged, shape improvedRefactor EnrollmentService into two collaborators
Updatechanging something that already existsUpdate Hibernate to 6.4.4
Documentcomments, README, javadocDocument the seat-counting rule in EnrollmentStatus
Example — a pull request description that gets reviewed quicklyfour headings, no more
pull requestcopy this shape
## What
Adds DELETE /api/enrollments/{id} so a student can withdraw
from a course before the enrollment window closes.

## Why
ENR-142. Seats were never returned to the pool, so popular
courses appeared full for the rest of the semester.

## How to test
1. mvn verify — 4 new integration tests
2. Or: ./scripts/api-tour.sh, step 14

## Notes for the reviewer
I used a pessimistic lock on the course row here, matching
enroll(). Happy to discuss whether optimistic would be enough.

“Notes for the reviewer” is the heading that most improves the review you get. Naming your own uncertainty tells a reviewer exactly where to spend their attention — and it reads as confidence, not doubt.

Useful connectors, in roughly the order you will need them
however (but, more formal) · therefore (so) · whereas (contrast) · in order to (purpose) · as a result · on the other hand · for instance · at first glance · it turns out that · as far as I can tell

Words that mean something exact

A handful of English words are load-bearing in technical writing: they have a precise meaning, and reading them loosely causes real bugs.

WordPrecise meaningWhere it bites
MUST / MUST NOTAn absolute requirement (RFC 2119)“A 201 response MUST include a Location header”
SHOULDRecommended; you may deviate with a reasonNot optional-with-no-consequences
MAYGenuinely optionalDo not build a contract on it
deprecatedStill works, will be removed — stop using it nowNot “broken”, and not “fine forever”
idempotentDoing it twice gives the same result as onceWhy PUT and DELETE are safe to retry and POST is not
thread-safeCorrect when called from several threads at onceChapter 06 — almost nothing is, unless it says so
statelessKeeps nothing between callsWhy the service can be a singleton
eventually consistentCorrect later, not necessarily nowReading your own write and not seeing it
breaking changeExisting callers stop workingThe reason API versions exist
edge caseA rare but legal inputWhat a reviewer is hunting for
regressionSomething that used to work and no longer doesThe word for “we broke it again”
workaroundA temporary fix that does not solve the causeSay it out loud, or it becomes permanent
False friends for Italian speakers

Eventually means “in the end”, not eventualmente (possibly). Actually means “in fact”, not attualmente (currently — use “currently”). To pretend means to fake, not pretendere (to demand or expect). Library is a code library; a libreria that sells books is a bookshop. And argument in code means a parameter value, not a disagreement.

Talking about this project in an interview

The most likely English you will speak this month. Prepare four answers and the rest of the conversation gets much easier, because you stop translating and start remembering.

“Tell me about a project you have worked on.”
“I built a university enrollment REST API in Java. It manages students, courses and enrollments, with business rules like course capacity, prerequisites and an enrollment window. I used Jakarta EE with JPA and PostgreSQL, and I wrote around sixty tests for it.” — three sentences: what, what it does, what with.
“What was the hardest part?”
“Concurrency on the last seat. Two students could enroll at the same time and both see one seat free. I solved it with a pessimistic row lock on the course, plus a unique constraint on student and course as a safety net at the database level.” — a real problem, a real mechanism, and a second line of defence.
“You have used Jakarta EE, but we work with Spring Boot.”
“They share most of the specifications — JPA, Bean Validation, the same transaction semantics. I know the equivalents: @Path maps to @RestController, @Inject to constructor injection, and both @Transactional implementations are proxy-based, so both have the self-invocation trap. I would need a week on the ecosystem, not on the concepts.” — specific, honest, and checkable.
“Do you have any questions for us?”
Always yes, and make them about the work: “What does your CI pipeline run on a pull request?” · “How do you handle database migrations?” · “What would my first task look like?” · “How is code review handled here?” Each one signals that you have worked this way before.
You do not understand a question in an English interview. What do you do?

Ask. “Sorry, could you rephrase that?” or “Do you mean X or Y?” — both are completely normal, and interviewers ask each other to repeat things constantly. Guessing and answering a different question is much worse: it looks like you did not listen, and you lose the point you could have scored. If it is a vocabulary gap, say so plainly: “I know the concept but not the English word — do you mean when two transactions touch the same row?” That answer demonstrates the knowledge and the honesty.

Golden rules

Enough English to work, without needing to be fluent.

  1. Technical English is a small language.Roughly three hundred words, the same grammar every time, no idioms — because half the people speaking it are not native speakers either. It is a much smaller target than “learn English”.
  2. Learn three standup sentences and reuse them forever.Yesterday I did · today I am going to · I am blocked on. Past simple for yesterday — “I have finished yesterday” is the classic slip.
  3. Hedge when you review, and read hedges as strong.“I think this might not handle the empty case” is the normal register for “this is wrong”, and it leaves room for a reason you had not considered.
  4. Commit subjects are imperative and under fifty characters.“If applied, this commit will add the endpoint”. Six verbs cover almost everything: Add, Fix, Remove, Refactor, Update, Document.
  5. Structure beats grammar in anything written.What / Why / How to test / Notes for the reviewer. A reviewer forgives an article; they do not forgive not knowing what the change is for.
  6. Asking someone to repeat costs nothing.Building the wrong thing because you nodded costs a sprint. “Just to confirm I understood correctly…” is the most valuable sentence in this chapter.
Chapter 25

Where the time actually goes

Almost every performance problem in an application like this is one of three things: too many round trips, a queue you did not know existed, or work done in Java that the database could have done once. None of them are found by reading code.

The latency ladder

Orders of magnitude, not exact numbers. Knowing them changes which optimisation you reach for first.

In plain words

Think in distances, not speeds. Reading from memory is reaching into your pocket. Reading from an SSD is walking into the next room. Calling a database across a network is going to the shop.

A chatty loop that goes to the shop fifty times is fifty trips for one meal. No amount of running faster fixes the number of trips — which is why the fix for a slow endpoint is almost never a faster machine.

OperationRoughlyRelative
Java method call, L1 cache hit~1 ns
Main memory reference~100 ns100×
Object allocation + eventual young-gen GC~50 ns50×
SSD read~100 µs100,000×
One database round trip, same host~0.5–2 ms~1,000,000×
Database round trip, another datacentre~50–150 ms~100,000,000×
What the ladder tells you

A round trip costs roughly a million method calls. So micro-optimising a loop that runs beside a query is arithmetic on the wrong column — and conversely, eliminating one query is worth more than any amount of Java tuning. This is the whole reason the N+1 problem is the first performance bug anyone meets here: it converts one round trip into a hundred, and each one is a million units of nothing.

Which reframes the fetch-plan chapter. LEFT JOIN FETCH is not a clever trick; it is the difference between one item on this ladder and a hundred.

The connection pool is your real concurrency limit

Undertow can accept hundreds of requests at once. Twenty-five of them can talk to the database. The rest are in a queue you never wrote.

docker/wildfly/configure.clithe number that caps everything
--min-pool-size=5 \
--max-pool-size=25 \
--pool-prefill=true \

A @Transactional method holds a connection from its first database access until commit. Everything else it does inside that window — mapping, logging, an HTTP call to another service — holds the connection too. So the pool is not sized by "how many users"; it is sized by how long each request keeps a connection.

Pool saturation

Little's law, applied to a connection pool. Move the sliders and watch where the wall is.

4.8connections needed
19%utilisation
208max req/s at this hold time

 

The law behind it is one line — concurrency = arrival rate × service time — and it is worth carrying around. At 40 requests per second holding a connection for 120 ms you need about 5 connections. Let one slow query push the hold time to 600 ms and you need 24. Nothing about the traffic changed; the pool simply ran out, and every waiting request now blocks a worker thread as well.

Why a bigger pool usually makes it worse

The instinct on pool exhaustion is to raise max-pool-size. But the database has its own limit, and beyond a certain concurrency more connections mean more lock contention and more context switching for less total throughput. The pool is a queue in front of a finite resource, not a way to make that resource bigger. Fix the hold time first: shorten the transaction, move the remote call out of it, add the index.

The write path: batching and id allocation

Two settings in this project turn 50 inserts into 3 round trips, and they only work together.

persistence.xml + BaseEntity.javaa matched pair
<property name="hibernate.jdbc.batch_size" value="25"/>
<property name="hibernate.order_inserts" value="true"/>

@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "app_id_generator")
@SequenceGenerator(name = "app_id_generator", sequenceName = "app_id_seq",
                   allocationSize = 50)

The connection between them is not obvious and is the actual lesson. With GenerationType.IDENTITY the database assigns the id during the INSERT, so Hibernate must execute each INSERT immediately on persist() to learn the id — and can therefore never batch anything. SEQUENCE lets it ask for ids up front, keep the rows in memory, and send them as one JDBC batch at flush.

StrategyRound trips for 50 insertsBatching possible?
IDENTITY50 inserts, ids returned inlineNo — ever
SEQUENCE, allocationSize=150 sequence calls + 2 batchesYes, but the sequence dominates
SEQUENCE, allocationSize=501 sequence call + 2 batchesYes

allocationSize = 50 is the "pooled optimizer": one round trip buys fifty ids held in memory. The cost is gaps in the numbering after a restart, which are harmless — ids are identifiers, not counters. Anyone who needs a gapless sequence needs a different column, computed at report time.

order_inserts matters because a JDBC batch must be one statement repeated. Inserting a student, then an enrollment, then another student produces three statements and no batching; sorting by table first produces two batched statements. Hibernate cannot reorder across a flush, which is why interleaving flush() calls in a loop quietly disables everything above.

Measuring, not guessing

Every performance claim in this chapter is checkable on your machine in about a minute. Check them.

The SQL log is at DEBUG for exactly this reason. Three signatures to recognise in it:

What you seeWhat it meansFix
One SELECT, then n near-identical onesN+1 — a lazy association touched in a loopLEFT JOIN FETCH or an entity graph
The same SELECT repeated with the same idReloading inside one transactionNothing — L1 should prevent it; if not, the context was cleared
A SELECT you never wrote before an UPDATEmerge on a detached entityKeep it managed, or accept the read
A very wide SELECT with many JOINsSeveral fetch joins on collectionsTwo queries beat one Cartesian product
the one-minute experimentread the log while you do it
curl -s localhost:8280/enrollment/api/courses/55 > /dev/null
# then: comment out LEFT JOIN FETCH c.prerequisites, mvn package, repeat.
# Count the SELECTs both times. That number is the whole chapter.
  • Measure the ninety-fifth percentile, not the average. An average hides the queue. If p50 is 20 ms and p95 is 900 ms, you do not have a slow application — you have a saturated resource, and the ladder above says which one.
  • Measure with the log off. show_sql and DEBUG logging are real throughput costs. A benchmark run with them on measures your logger.
  • Look at the database's own view. pg_stat_statements ranks queries by total time. It answers "what is actually expensive" without any guessing about what the ORM emitted.
  • An index is usually the answer. A sequential scan over a growing table is a bug that gets slower every week. EXPLAIN ANALYZE on the slow query says so in one line.
The observability trio

Production Jakarta EE adds three standard endpoints beside the application: MicroProfile Health (/health/live, /health/ready) so an orchestrator knows whether to route traffic, Metrics for counters and histograms in a Prometheus format, and OpenTelemetry tracing so one request's spans can be followed across services. This project has none of them and has the correlation ID instead — which is the same idea at one tenth of the machinery, and enough to trace a request through one log file.

Memory, and the garbage collector you should not tune

The heap is not a resource you manage. It is a resource whose allocation rate you influence.

Objects are born in the young generation, which is collected constantly and cheaply — most objects die there, and a young collection costs a millisecond or two. Objects that survive long enough are promoted to the old generation, which is collected rarely and expensively. The rule that follows is counter-intuitive and correct: short-lived garbage is almost free; long-lived garbage is what hurts.

SymptomUsual cause here
Steady old-gen growth, then OutOfMemoryErrorA collection on an @ApplicationScoped bean that only ever grows — a hand-rolled cache with no eviction
Long GC pauses under loadLoading enormous result sets: 100k entities are 100k live objects plus 100k dirty-checking snapshots
Memory that never returns after redeployA classloader leak — a ThreadLocal or a thread you started yourself still holding the old deployment's classes

All three are design problems wearing a memory costume, and none is fixed by a JVM flag. The pagination in this project is the structural answer to the middle one: PageRequest caps what a single query can materialise, so no endpoint can be asked for a million rows at once.

Golden rules

Performance rules that hold long after the specific numbers change.

  1. A round trip costs about a million method calls.Removing one query is worth more than any amount of Java micro-optimisation beside it. That single ratio is why N+1 is the first bug worth hunting and why LEFT JOIN FETCH matters.
  2. Concurrency = arrival rate × service time.The pool is sized by how long a request holds a connection, not by how many users exist. Double the hold time and you have halved your capacity without a single extra user.
  3. Enlarging the pool moves the queue, it does not remove it.The database has a limit of its own, past which more connections buy contention rather than throughput. Shorten the transaction before widening the pool.
  4. IDENTITY makes batching impossible.The id is assigned during the INSERT, so each one must be executed immediately. SEQUENCE with an allocationSize buys ids up front and lets fifty inserts become one batch.
  5. Never hold a transaction across a remote call.The connection is held for the whole method. An HTTP call inside a @Transactional block puts a third party's latency directly into your pool's service time.
  6. Read the SQL log before changing anything.Every performance claim here is checkable in a minute against a DEBUG log on your own machine. An optimisation applied without a measurement is a guess that now also costs readability.
Chapter 26

Work nobody requested

Two jobs are running on your server right now, on a timer, with no HTTP request behind them. That absence changes more than you would expect.

@Schedule — cron in an annotation

No scheduler library, no configuration file. One annotation and the container runs the method.

In plain words

Everything else in this application happens because somebody knocked on the door. A scheduled job is the one thing that happens because the clock said so.

Which changes everything about it: nobody is waiting for the answer, nobody sees the error page, and there is no user to report it. So it has to be far more careful about failing quietly than any endpoint ever needs to be.

service/EnrollmentMaintenanceJob.javaboth jobs in this project
// Nightly at 03:00 — withdraw enrollments from finished academic years.
@Schedule(hour = "3", minute = "0", second = "0", persistent = false,
          info = "Nightly stale-enrollment sweep")
public void closeStaleEnrollments() { ... }

// Every 5 minutes, on the clock — a metrics heartbeat.
@Schedule(minute = "*/5", hour = "*", persistent = false,
          info = "Enrollment statistics heartbeat")
public void reportStatistics() { ... }

These are calendar-based expressions, and they are not quite cron. Each unit is its own attribute, so minute = "*/5" means "every fifth minute of every hour" — not "five minutes after startup". The job fires at :00, :05, :10 and so on, aligned to the wall clock. Anything you leave out defaults to 0, which is why the nightly sweep does not also run every minute of hour 3.

persistent = false means the timer lives in memory only. A persistent timer is written to the database and survives a restart — so a nightly sweep missed while the server was down would fire on boot. For a heartbeat that would be pointless catch-up noise, and for the sweep it is a decision you want to make deliberately rather than inherit.

It is running as you read this

The heartbeat has already fired dozens of times on your server. Watch the next one land:

server.log — real outputnote the empty brackets
16:25:00,016 INFO [EnrollmentMaintenanceJob] [] [METRICS] students.active=3 students.suspended=1
16:30:00,016 INFO [EnrollmentMaintenanceJob] [] [METRICS] students.active=3 students.suspended=1
16:35:00,059 INFO [EnrollmentMaintenanceJob] [] [METRICS] students.active=3 students.suspended=1

Those empty brackets are where a correlation ID would be. There is none — the MDC is populated per HTTP request by CorrelationIdFilter, and no request happened here. Scheduled work is invisible to request-scoped tooling, which is exactly why the job logs a count on every run: a job that silently does nothing is indistinguishable from a job that is broken.

What the next heartbeat will log

Not connected. Serve this page from WildFly to read the same counts the job reads.

Two doors into the same service layer

A scheduled job is a second entry point. It reaches the same repositories and the same domain rules as a REST call, without any of the HTTP machinery above it.

HTTP request CorrelationIdFilter Resource container timer no filter no resource no MDC service + repository + domain The rules cannot be bypassed by either route — which is why they live down there.
Both paths converge on the service layer. Everything the request path adds above it — correlation IDs, validation, status-code mapping — simply does not exist for the timer.

This is the practical argument for keeping business rules out of resource classes. Put an eligibility check in a JAX-RS method and the nightly job silently skips it.

@Singleton, and who is allowed in

the class declarationtwo annotations, one guarantee
@Singleton
@ConcurrencyManagement(ConcurrencyManagementType.CONTAINER)
public class EnrollmentMaintenanceJob { ... }

An EJB @Singleton is one instance for the whole application — and by default the container gives every business method a write lock. Calls are serialised: if the nightly sweep is still running at 03:05, the heartbeat waits rather than running alongside it.

That is usually what you want for maintenance work, and it is the opposite of the CDI @ApplicationScoped beans from Chapter 4, which are shared and fully concurrent. Same "one instance" idea, completely different concurrency contract — a distinction worth keeping straight.

REQUIRES_NEW — there is no caller

on both job methodsan explicit choice
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)

A REST request arrives with no transaction, and @Transactional starts one. A timer callback also arrives with none — but it has no caller at all, so there is nothing to inherit and nothing to join. REQUIRES_NEW states plainly: always begin a fresh transaction, always commit it independently.

The payoff is isolation. If the nightly sweep fails, it rolls back its own work and nothing else is affected — there is no surrounding transaction for it to poison.

REQUIRED, from a request TX 1 (the caller's) joins it one commit, all or nothing REQUIRES_NEW, from a request TX 1 suspended TX 2 two commits, independent of each other REQUIRES_NEW, the timer no caller at all TX 2 nothing to join, nothing to poison Propagation is a question about the caller. The job's answer is easy: there isn't one.
The third panel is why the annotation is not redundant. REQUIRED would behave identically here today — and would quietly join someone else's transaction the day this method is called from a request.
AttributeCaller has a transactionCaller has none
REQUIREDJoins itStarts one
REQUIRES_NEWSuspends it, starts its ownStarts one
MANDATORYJoins itThrows
NEVERThrowsRuns with none

Bulk updates go around the persistence context

The sweep does not load a single entity. It issues one UPDATE.

repository/EnrollmentRepository.java:212JPQL bulk update
return em().createQuery(
        "UPDATE Enrollment e "
      + "SET e.status = :withdrawn, e.updatedAt = :now "
      + "WHERE e.status = :active AND e.course.academicYear < :year")
    ...
    .executeUpdate();     // returns the row count

Loading ten thousand stale enrollments to call withdraw() on each would mean ten thousand objects in memory and ten thousand UPDATE statements. This is one statement, and the database does the work.

The cost is that it bypasses everything JPA normally does for you:

  • No entity is loaded, so Enrollment.withdraw() never runs — the state-machine check from Chapter 7 is skipped entirely. The WHERE status = ACTIVE clause is doing that job instead, in SQL.
  • No lifecycle callbacks fire, which is why updatedAt is set by hand in the query.
  • @Version is not bumped automatically, so optimistic locking does not protect these rows.
  • Any entity already in the persistence context is now stale — it holds the old status while the database holds the new one.
The rule

Bulk operations are right for maintenance work that touches many rows and runs in its own transaction. They are wrong inside a request that has already loaded the entities it is modifying — there you would be reading stale objects immediately afterwards. The job gets away with it precisely because REQUIRES_NEW gives it a persistence context that ends moments later.

Why this breaks on a second server

Deploy this WAR to two nodes and you have two schedulers. Both fire at 03:00. Both run the sweep. The WHERE clause makes it harmless here — the second run updates zero rows — but a job that sends emails or charges cards would do it twice.

The README lists this openly under known limitations, and the fixes are the standard ones:

  • A database lock — every node tries to claim a row before running; only one wins.
  • An external scheduler — Kubernetes CronJob or Quartz in clustered mode, triggering an endpoint rather than a timer.
  • Make the work idempotent, so running it twice is harmless by construction. That is what the WHERE status = ACTIVE clause achieves, and it is the cheapest defence available.
Try it

Change the heartbeat to minute = "*", run mvn package, and watch [METRICS] appear every minute in the log. Then suspend a student through the API and see the next heartbeat report a different number. Put it back to */5 afterwards.

Golden rules

Everything changes when there is no request behind the work.

  1. A timer has no request, no MDC, no filter — and no caller.Which is why the job methods say REQUIRES_NEW: there is no transaction to inherit, and nothing above to join.
  2. Any rule that lives above the service does not exist for the job.Put an eligibility check in a resource class and the nightly sweep walks straight past it. That is the practical argument for the layer rule from Chapter 01.
  3. Everything you omit from @Schedule defaults to zero.minute = "*/5" fires at :00, :05, :10 on the wall clock — not five minutes after boot. And hour = "3" alone would not repeat all hour, because the other fields are already 0.
  4. A bulk update buys speed by skipping everything JPA does for you.No entity loaded, so no state machine; no callbacks, so updatedAt by hand; no @Version bump; and anything already in the persistence context is now stale.
  5. Two nodes means two schedulers.Make the work idempotent (WHERE status = ACTIVE), take a database lock, or move the trigger outside the application. Hoping is not one of the three.
Chapter 27

What happens between mvn package and a working URL

Roughly two seconds pass and an endpoint exists. Inside those two seconds the server parses descriptors, builds a classloader, discovers beans, boots Hibernate and scans for resources — in a fixed order, where each phase can fail in its own recognisable way.

What is actually inside the WAR

A WAR is a zip with a required layout. Every path in it means something to the server.

In plain words

A WAR is a lunchbox handed to a canteen. It contains your food and nothing else — no cooker, no plates, no cutlery, because the canteen already has all of those.

That is why this one carries no libraries at all, rather than 40 MB of them. And forgetting the word provided in the build file is packing an oven in your lunchbox: it still works, it is just absurd, and one day two ovens disagree.

target/enrollment.warjar tf
WEB-INF/
  classes/                     ← your compiled code + resources
    it/unicam/cs/enrollment/…
    META-INF/persistence.xml   ← must be exactly here for JPA to find it
    db/migration/              ← the schema, versioned — chapter 29
  beans.xml                    ← marks this as a CDI bean archive
  web.xml                      ← servlet-level configuration
  jboss-deployment-structure.xml
index.html  tutorial.html      ← static content, served directly

   there is no WEB-INF/lib/ in that listing, and that is the whole point:
   not one dependency in this project is anything but `provided` or `test`

Two things there carry the whole model. There is no WEB-INF/lib/ directory, because the Jakarta EE API is provided and the runtime supplies every implementation — the largest thing in the archive is in fact this page you are reading. And META-INF/persistence.xml is inside classes/, because the JPA provider scans the classpath for that exact path. Put it one directory higher and the deployment fails with "no persistence unit named enrollmentPU", which is a filesystem problem wearing a configuration error's clothes.

Anything under WEB-INF is unreachable over HTTP

That is a servlet-spec guarantee, not a convention: the container refuses to serve it. It is why configuration and classes live there and why tutorial.html lives beside index.html at the root instead.

WildFly's modular classloader

There is no single flat classpath. Each deployment gets its own module graph and sees only what it has been given.

This is the reason two applications on one server can use incompatible versions of the same library — a genuinely painful problem in the era of one shared lib/ directory. A deployment sees exactly three things:

  1. Its own classes and WEB-INF/lib.Always visible, and always searched first for a class it contains.
  2. Implicit modules the server adds.The Jakarta EE APIs, the logging facades, the JDK. This is what makes provided work at runtime.
  3. Anything requested explicitly.Which is the entire purpose of the one vendor-specific descriptor in this project.
WEB-INF/jboss-deployment-structure.xmlthe one non-portable file
<deployment>
    <dependencies>
        <module name="org.slf4j"/>
    </dependencies>
</deployment>

WildFly would usually add SLF4J implicitly; declaring it makes the dependency visible instead of relying on a server default that could change between versions. Every other descriptor in WEB-INF/ is standard and portable — this one has an equivalent with a different name on every other server (glassfish-web.xml, weblogic.xml, ibm-web-bnd.xml), which is a fair summary of how portable Jakarta EE really is: the specifications travel, the deployment metadata does not.

The error this explains

NoClassDefFoundError for a class that is unmistakably in your pom.xml nearly always means a scope problem or module isolation — the jar is provided and the server did not supply it, or it is in a module your deployment cannot see. A ClassCastException between two classes with the same name is the other symptom: the same class loaded by two different classloaders is two different types, and this is why.

The deployment sequence

Six phases, always in this order. Step through it and see what each one can fail on.

deploy enrollment.war

Ready

Press Play to hand the WAR to the deployment scanner.

Two phases in that sequence are worth extra attention, because both are annotation-driven and both fail silently when misconfigured.

config/JaxRsActivator.javaan empty class that does everything
@ApplicationPath("/api")
public class JaxRsActivator extends Application { }

No methods. Because getClasses() and getSingletons() are not overridden, RESTEasy scans the deployment and registers everything annotated @Path or @Provider automatically. Override either method and scanning stops — you now maintain an explicit list, and a new resource silently returns 404 until someone remembers to add it.

The other is CDI discovery, and the mode matters:

bean-discovery-modeWhat becomes a beanVerdict
annotatedOnly classes with a scope annotationThe default, and what this project uses
allEvery class in the archiveDeprecated in CDI 4.0 — slow, and enables accidental injection
noneNothingTurns the archive off as far as CDI is concerned

Under annotated, a service class that is missing @ApplicationScoped is not a bean, so @Injecting it fails at deploy time with "unsatisfied dependency" rather than at runtime with a null. That is the correct trade, and it is why CDI errors read like compiler errors: the container resolves the entire injection graph before the application starts.

Descriptors, annotations, and where configuration belongs

Every descriptor in this project has an annotation equivalent. Choosing between them is a question about who needs to change the value.

ConcernIn codeIn a descriptorBelongs in
Which classes are beans@ApplicationScopedbeans.xmlCode — it is a design decision
REST base path@ApplicationPathweb.xml servlet mappingCode
Session timeoutweb.xmlDescriptor — ops may want to change it
Database host, user, passwordneverserver config (JNDI)Neither — the environment
Interceptor ordering@Prioritybeans.xml <interceptors>Code, since CDI 1.1

The fourth row is the one with teeth. persistence.xml contains a JNDI name, not a URL:

META-INF/persistence.xmlindirection on purpose
<jta-data-source>java:jboss/datasources/EnrollmentDS</jta-data-source>

The host, user and password live in the server's configuration, which means the identical WAR deploys to development, staging and production untouched. Credentials are an operational concern; a build artifact that differs per environment is a build artifact you have not really tested. The general form of that rule is the twelve-factor one: anything that varies between deployments is configuration, and configuration comes from the environment, not the archive.

And when both define the same thing, web.xml wins over the annotation — deliberately, so an operator can override a developer without a recompile.

Hot redeploy, and what it cannot do

The development loop here is one command, and understanding why it works tells you when it will not.

the loopedit → rebuild → live
mvn package     # writes enrollment.war + enrollment.war.dodeploy into the scanned folder

WildFly's deployment scanner watches that directory. The marker file is the handshake: Maven finishes writing the WAR, then touches .dodeploy, so the scanner never picks up a half-written archive. The server replies with .deployed or .failed — and the .failed file contains the actual reason, which is faster to read than the log.

A redeploy is a full undeploy and deploy: a new classloader, a new CDI container, a new EntityManagerFactory, every @ApplicationScoped bean destroyed and rebuilt, every @Startup singleton re-run. That is why it is reliable, and also why:

  • In-memory state does not survive. Anything cached in a bean is gone. If that matters, it was in the wrong place.
  • Schema changes may not. schema-generation = update adds columns and tables; it never drops or narrows one. A renamed field leaves the old column behind, forever, silently.
  • Leaks accumulate. A thread you started yourself, or a ThreadLocal you never cleared, holds a reference to the old deployment's classloader — and therefore to every class it loaded. Redeploy twenty times and the server runs out of metaspace. This is the practical reason for the "never new Thread()" rule.
Where update stops being acceptable

Automatic schema generation is wonderful while learning and forbidden in production: it cannot be reviewed, cannot be rolled back, and never removes anything. The production answer is versioned migration files — Flyway or Liquibase — checked into git and applied in order, so the schema has the same history and the same review as the code that depends on it.

Golden rules

Deployment is where configuration errors become runtime mysteries. These are the ones worth recognising instantly.

  1. Paths in an archive are contracts.META-INF/persistence.xml is scanned for at exactly that path, and anything under WEB-INF/ can never be served over HTTP. Both are spec guarantees, not conventions.
  2. Each deployment gets its own classloader.That isolation is what lets two applications use different library versions — and it is why the same class loaded twice produces a ClassCastException between two types with identical names.
  3. CDI resolves the whole graph before the application starts.A missing scope annotation is an unsatisfied-dependency failure at deploy time, not a null at runtime. Errors that look like compiler errors are the mode working correctly.
  4. Overriding getClasses() turns off scanning.The empty @ApplicationPath class registers every @Path automatically. Add one method and every future resource returns 404 until it is listed by hand.
  5. Anything that varies per environment is configuration.persistence.xml holds a JNDI name and never a password, so the identical WAR runs in dev, staging and production. A per-environment artifact is one you have not tested.
  6. schema-generation=update never removes anything.It adds columns and tables and silently leaves the rest. Production needs versioned migrations that can be reviewed, ordered and rolled back like the code they belong to.
Chapter 28

Maven, scopes, and the dependency tree

Maven is on roughly half the junior adverts and in most technical interviews, usually as one specific question: what is the difference between compile and provided scope? This project is built entirely on the answer.

Convention, and the price of it

Maven's bargain is that you get a working build with almost no configuration, provided you put things where it expects. When it feels like magic, that is the convention doing the work.

Four defaults explain most of what happens when you type mvn:

ConventionWhat Maven does with it
src/main/javaCompiled into target/classes and packaged.
src/main/resourcesCopied into target/classes unchanged — which is why META-INF/persistence.xml and the migration files end up on the classpath without anyone configuring anything.
src/test/javaCompiled into a separate target/test-classes and never packaged.
src/test/resourcesOn the test classpath before main resources, which is the trick that lets the test persistence.xml shadow the production one.

The price is that fighting the convention is expensive. Everything you configure is an exception someone later has to discover. This project's pom.xml is 25 KB and most of it is comments explaining the handful of places where an exception was genuinely necessary.

In plain words

Maven is a recipe that assumes a standard kitchen. Flour is in the flour jar, the oven is on the wall, and the recipe therefore does not have to say where anything is — it just says “bake”. That is why a Maven project you have never seen builds with one command. It is also why moving the oven means writing three paragraphs explaining where the oven now is.

The lifecycle, and the two commands people confuse

A Maven phase is not a task you run — it is a point in a sequence. Naming a phase runs it and everything before it. That single sentence answers most Maven questions.

PhaseWhat happensIn this project
validateIs the project well formed?Where a bad pom.xml fails fastest.
compileMain sources to bytecode.maven.compiler.release is 21, so the bytecode targets Java 21 whatever JDK you run.
testSurefire runs the unit tests.Everything ending *Test, minus the exercise tag.
packageBuilds the artifact.The WAR — and the war plugin writes it straight into the deployment directory, which is what makes hot redeploy work.
verifyFailsafe runs the integration tests.Everything ending *IT, against in-memory H2.
installCopies the artifact into your local ~/.m2.Only needed when another project on this machine depends on it.
The trap: package is not verify

mvn package stops before verify, so it builds the WAR and never runs a single integration test. A pipeline that runs mvn package and reports green is telling you that the code compiled and the unit tests passed. That is exactly the sort of thing that looks fine for eight months and then does not.

And clean is not part of that sequence at all — it belongs to a separate lifecycle, which is why you write mvn clean verify with both names.

Examplethe four commands worth memorising
terminalfrom the project root
# everything: compile, unit tests, WAR, integration tests
mvn clean verify

# just build and hot-deploy — no integration tests, so it is fast
mvn package

# one test class, without running the rest
mvn test -Dtest=EnrollmentServiceTest

# why is THAT version of THAT library on my classpath?
mvn dependency:tree

The last one is a plugin goal rather than a phase: plugin:goal, run directly, outside the lifecycle. flyway:migrate from the next chapter has the same shape.

Scopes, and the reason this WAR has no libraries in it

Scope answers two separate questions at once: is this on the compile classpath, and is it packaged into the artifact? Conflating them is what produces a 40 MB WAR that then refuses to deploy.

ScopeCompileTestPackagedUse it for
compileyesyesyesA library your code genuinely ships with. The default, and the one to justify.
providedyesyesnoSomething the runtime already has. The whole Jakarta EE API.
runtimenoyesyesA JDBC driver you use only through an interface.
testnoyesnoJUnit, Mockito, AssertJ, H2.
importOnly inside dependencyManagement, to pull in a BOM.

Now look at what this project actually declares. There is not one compile-scope dependency in it:

pom.xmlevery dependency, by scope
provided   jakarta.platform:jakarta.jakartaee-api   # the entire platform API
provided   org.slf4j:slf4j-api                      # WildFly supplies the binding

test       org.junit.jupiter:junit-jupiter
test       org.mockito:mockito-core
test       org.assertj:assertj-core
test       com.h2database:h2                        # the database, only in tests
test       org.hibernate.orm:hibernate-core          # the provider, only in tests
test       org.jboss.resteasy:resteasy-core          # and so on

That is the argument of chapter 02 restated as a build file. provided means “compile against this, do not ship it” — so the WAR contains your classes, your persistence.xml, and nothing else. No JPA implementation, no CDI container, no JAX-RS runtime. All of that belongs to WildFly, which is precisely why the same WAR runs on a different server without recompiling.

The test-scoped Hibernate and RESTEasy entries are the mirror image and are worth understanding, because they look contradictory at first. In production the container supplies both. In a unit test there is no container, so the test classpath has to supply them itself — and because they are test scope, they still never reach the WAR.

The trap

Change provided to compile on the Jakarta EE API and the build still succeeds. The WAR grows, deploys, and then fails at runtime with a LinkageError or a ClassCastException between two copies of the same interface — one from your WAR, one from the server, loaded by different class loaders and therefore not the same type. It is a genuinely confusing failure, and the cause is one word in the build file.

The dependency tree

You declare six libraries and get sixty. The forty-four you did not ask for are transitive dependencies, and knowing how Maven picks between conflicting versions is the difference between fixing a class-not-found in ten minutes and in two days.

The rule is nearest wins. Not highest version, not newest release — shortest path from your pom.xml. If library A pulls in Guava 19 at depth two and library B pulls in Guava 31 at depth three, you get Guava 19, and a method missing at runtime that exists perfectly well in the source you were reading. Where two paths are the same length, the first declaration in the file wins, which makes ordering significant in a way nothing warns you about.

Examplethe command to run before guessing
terminalwho brought this in, and which version won
# the whole tree
mvn dependency:tree

# just the paths that lead to one artifact — the useful form
mvn dependency:tree -Dincludes=org.hibernate.orm:hibernate-core

# conflicts, with the losing versions shown as "omitted for conflict"
mvn dependency:tree -Dverbose

# what actually ends up on the classpath, flattened
mvn dependency:list

Three fixes, in order of preference. Pin the version in dependencyManagement, which sets it everywhere regardless of depth — this is what a BOM does, and it is why Spring Boot's parent POM means you never write a version number for a Spring library. Exclude the unwanted path with an <exclusion>, which is surgical and easy to forget about. Or declare it directly at depth one, which wins by the nearest rule but hides the fact that you are overriding something.

This project uses the first: every version lives in a <properties> block at the top, and every dependency refers to it as ${version.hibernate}. That is not a Maven feature so much as a habit, and it means an upgrade is a one-line diff that a reviewer can actually see.

Your application throws NoSuchMethodError at runtime for a method you can see in the library's source. What happened, and what do you run?

You compiled against one version and are running against another. Something transitive won the nearest-wins contest and put an older jar on the classpath — the method exists in the source you read and not in the class file that loaded. Run mvn dependency:tree -Dverbose -Dincludes=<the artifact>, find the path that won, and pin the version in dependencyManagement. NoSuchMethodError and NoClassDefFoundError at runtime, for code that compiled, are almost always this.

Profiles, and the two in this build

A profile is a block of the build that is switched off until asked for. It is how one pom.xml serves several purposes without a second build file.

This project has two, and they are worth reading because they solve genuinely different problems.

-Pexercises

The exercise tests are tagged exercise and excluded from the normal build, so mvn verify stays an honest signal about the application. The profile inverts that: exclude nothing, and include only the tagged tests.

Two commands, two different questions: is the application healthy, and how far through the exercises am I. Mixing them into one number would answer neither.

-Pflyway

Adds the Flyway plugin and the PostgreSQL driver — but only when asked. The default build never downloads them, and nothing Flyway-related is packaged into the WAR.

Note where the JDBC driver sits: it is a dependency of the plugin, not of the project. A build tool needing a driver says nothing about whether your application needs one, and here it does not, because WildFly supplies it as a server module.

The profile smell worth knowing

Profiles that change what is in the artifact — a dev profile that packages different configuration from prod — are a well-known way to ship something you never tested. The rule from chapter 33 applies here too: build the artifact once and configure it per environment at runtime. Profiles are for build-time concerns such as which tests run, not for producing different WARs.

Reading a build failure

Maven's output is long and the useful part is small. Three habits find almost everything.

Read the first error, not the last.
The final BUILD FAILURE block is a summary. The real cause is the first [ERROR] in the log; everything after it is consequence. This is the same discipline as reading a stack trace bottom-up in chapter 23.
Note which plugin failed.
The line always names it — maven-compiler-plugin is your code, maven-surefire-plugin is a test, maven-war-plugin is packaging. That one word tells you which of the four commands above to re-run in isolation.
“Could not resolve dependencies” is usually the network or the version.
Either the coordinates are wrong or you are offline. mvn -o forces offline mode and tells you which it is immediately; -U forces a re-check of snapshots and failed downloads.
An interviewer asks why the Jakarta EE dependency in your project is provided.

“Because the application server already has an implementation of it. I compile against the API so the annotations resolve, but I must not package it, or the WAR ships a second copy of every interface and the class loaders disagree about which one is the real type. It is why the WAR has no WEB-INF/lib directory at all, and why the same file deploys to any Jakarta EE 11 server.” That answer contains a mechanism, a consequence and a number, which is three more things than most candidates offer.

Golden rules

Five things that make the build a tool rather than a mystery.

  1. Naming a phase runs everything before it.Which is why mvn package skips integration tests and mvn verify does not, and why clean has to be named separately. Get this one sentence right and most Maven confusion disappears.
  2. Default to provided for anything the runtime already has.Packaging a second copy of an API the server supplies is the standard cause of LinkageError and ClassCastException between two identical-looking types.
  3. Nearest wins, not newest.Maven resolves version conflicts by shortest path from your POM. When runtime disagrees with the source you read, run dependency:tree -Dverbose before forming a theory.
  4. Pin versions in one place.dependencyManagement and a properties block turn an upgrade into a reviewable one-line diff. Version numbers scattered through the file are how two libraries silently drift apart.
  5. Build the artifact once.Profiles decide which tests run, not what goes in the WAR. The moment the artifact differs per environment, the thing you tested is not the thing you shipped.
Chapter 29

Migrations: giving the schema a git history

Every chapter so far has let Hibernate create the tables. That is wonderful for learning and forbidden in production, and the reason why is a good interview answer. This chapter replaces it with versioned files — which are now in this repository, waiting.

What hbm2ddl actually does

One line in persistence.xml has been quietly rewriting your database on every deploy.

src/main/resources/META-INF/persistence.xmlthe line in question
<property name="jakarta.persistence.schema-generation.database.action"
          value="update"/>

On deployment Hibernate reads your entity mappings, reads the live database, works out the difference, and issues DDL to close it. It is genuinely convenient: add a field, redeploy, the column is there.

Here is what it will not do, and each item is a real outage somebody has had:

update will not…Which means
Remove anything, everRename a field and you now have two columns: the old one, still NOT NULL, and the new one. The next insert fails.
Change a column's typeWidening a VARCHAR(10) to 20 in the entity silently does nothing. The truncation error arrives from production.
Tell you what it didThe DDL is decided at deploy time, on that machine, against that database. There is no artefact to review and none to keep.
Run in a reviewable orderYou cannot put a data fix between two structural changes, because you never see the structural changes.
Roll backThere is no inverse. The only recovery is a backup.
Behave identically twiceThe result depends on what the database already contained, so dev, staging and production drift apart and nobody notices until one of them fails.
In plain words

update is a decorator who rearranges your flat while you are out and leaves no note. Mostly you come home and things are better. But they never throw anything away, they will not repaint a wall that is the wrong colour, and if you ask what changed, nobody knows. Migrations are the same work done with a list, in order, that everyone can read — and which produces the identical flat in every city.

Never use create-drop where it matters

The sibling value create-drop drops every table on shutdown. It is right for a test suite and it has genuinely been deployed to production by someone who copied a configuration file. The pairing to remember: create-drop in tests, update while learning, validate in production — and validate only works if something else owns the schema. That something is this chapter.

The files, which are already here

Two migrations live in src/main/resources/db/migration. Open them — they are the schema you have been using all along, written down.

src/main/resources/db/migration/the naming rule is load-bearing
V1__baseline_schema.sql     # tables, keys, constraints
V2__query_indexes.sql       # the indexes, added later, on purpose
 │  │
 │  └── two underscores. One is a common and confusing mistake.
 └───── version. Applied in order, once each, forever.

Flyway keeps its own table, flyway_schema_history, recording which files ran, when, how long they took, and a checksum of each one. That checksum is the rule that surprises people: edit a migration that has already run anywhere and Flyway refuses to start.

This is not pedantry. An applied migration is history — it has already happened on someone's database, and editing the file cannot un-happen it. The only way to change something is to add V3. Once that clicks, the schema behaves exactly like the code: append-only, reviewable, and identical everywhere.

Look at what the split between V1 and V2 encodes. The tables arrived with the feature; the indexes arrived afterwards, because somebody read an execution plan. That is real history, and it is the kind of thing a reader six months later needs and cannot get from the current schema alone:

V2__query_indexes.sqlevery index names the query it pays for
-- The seat count: SELECT count(*) FROM enrollments WHERE course_id = ?
-- AND status <> 'WITHDRAWN'. This is the hottest read in the application -
-- it runs inside the enrollment transaction, holding a row lock on the
-- course while it does - so it is the one index here that is not optional.
CREATE INDEX idx_enrollments_course_status
    ON enrollments (course_id, status);

An index with no named query is pure cost, as chapter 25 argued: storage, plus work on every write. Writing the query into the migration is how the justification survives the person who added it.

Running them

A flyway profile is in the pom.xml, switched off by default. Nothing about the WAR changes; the migrations are a build-time concern.

Exampleread first, then act — in this order
terminalthe port here is the native install; compose uses 55433
# what has been applied, and what is pending? Read-only, always safe.
mvn -Pflyway flyway:info -Dflyway.url=jdbc:postgresql://localhost:5433/enrollment

# YOUR DATABASE ALREADY HAS TABLES — Hibernate made them. So first:
mvn -Pflyway flyway:baseline -Dflyway.url=jdbc:postgresql://localhost:5433/enrollment

# now apply anything newer than the baseline
mvn -Pflyway flyway:migrate -Dflyway.url=jdbc:postgresql://localhost:5433/enrollment

# do the applied files still match what is in git?
mvn -Pflyway flyway:validate -Dflyway.url=jdbc:postgresql://localhost:5433/enrollment

The baseline step is the whole story of adopting migrations on a project that already has a schema. It stamps the current database as “version 1, already done” without executing V1, so only later migrations run. Skip it and migrate fails on CREATE TABLE professors with relation already exists, and records V1 as failed — which then has to be repaired before anything else will run.

Where V1 came from, and why that matters

It was generated from the entity mappings, not typed by hand — Hibernate can write the DDL it would have executed, for a chosen dialect, without connecting to anything:

jakarta.persistence.schema-generation.scripts.action        = create
jakarta.persistence.schema-generation.scripts.create-target = baseline.sql
jakarta.persistence.database-product-name                   = PostgreSQL

That is the honest way to adopt Flyway on an existing project: let the tool that built the schema describe it, then read every line and decide what you actually want. Hand-typing a baseline is how a constraint goes missing.

Two decisions people get wrong

Where migrations should run. The convenient answer is at application startup, and Spring Boot makes it the default — add flyway-core to the classpath and it migrates before the first bean is created. It works beautifully with one instance and breaks with three: chapter 33's replicas all start at once and all try to migrate the same database. Flyway does take a lock, so the outcome is usually two of them waiting rather than corruption — but a slow migration now becomes a startup timeout, a failed liveness probe, and a restart loop.

The production answer is that migrations are a deployment step, not an application step: the pipeline runs flyway:migrate once, then rolls out the new version. That is why the profile in this project is a build-time plugin and not a runtime dependency — the shape of the pom is the argument.

Migrations must be backwards compatible for one release. This is the part that is invisible until you deploy without downtime. During a rolling update the old code and the new code are both running, against one database. A migration that drops a column the old code still reads takes down every instance that has not been replaced yet.

release 1Add the new column, nullable. Deploy code that writes both and reads the old one.
release 2Backfill the new column. Deploy code that reads the new one.
release 3Now, and only now, drop the old column.

Three releases to rename a column. It looks absurd written down and it is simply what “no downtime” costs. Knowing the shape of that dance is a strong signal in an interview, because it is the point where someone stops describing a tool and starts describing an operation.

The trap

A migration that runs a long UPDATE over a large table holds locks while it does. On PostgreSQL, adding a column with a volatile default, or an index without CREATE INDEX CONCURRENTLY, can block writes to the whole table for the duration. The migration passes in dev on 200 rows and locks production for eleven minutes on 40 million. Always ask how big the table is on the machine that matters.

Finish the job

The exercise for this chapter is the switch itself, and it is three steps.

step 1Run flyway:baseline, then flyway:info, and read the history table it created.
step 2Change database.action in persistence.xml from update to validate, and redeploy. Hibernate now checks the schema against the mappings and refuses to start if they disagree — instead of silently changing it.
step 3Add a field to an entity and redeploy. Watch the deployment fail. Write V3__add_that_column.sql, migrate, redeploy, watch it pass.

Step three is the point of the whole chapter. Under update the schema change was invisible; under validate it is a file, in a commit, with a reviewer. Nothing about the application got better — what got better is that the schema is now something a team can reason about.

An interviewer asks how you manage database changes. What is the complete answer?

“Versioned migration files in the repository, applied in order by Flyway from the pipeline, with the application on validate so it refuses to start against a schema it does not expect. Migrations are immutable once applied — to change something you add another one. And for a rolling deploy each migration has to be compatible with the previous release, so a rename is add, backfill, switch, drop, across three releases.” The last sentence is the one that marks experience.

Why does this project keep Flyway as a Maven plugin instead of a dependency of the WAR?

Because migrating is a deployment step, not an application responsibility. Nothing Flyway-related is packaged, so the WAR is unchanged and cannot migrate anything by accident; the pipeline runs it once before the rollout, rather than every replica racing to do it at startup. The convenient alternative — migrate on boot — is fine for a single instance and turns a slow migration into a restart loop as soon as there are three.

Golden rules

Five rules that cover the whole subject.

  1. update for learning, validate in production.Auto-DDL never drops, never retypes, leaves no record and behaves differently on every database. validate turns a silent schema change into a deployment that refuses to start, which is what you want.
  2. An applied migration is history.Never edit one — Flyway checksums them for exactly this reason. To change something, add the next version. This is the same instinct as never rewriting pushed commits in chapter 19.
  3. Baseline before you migrate an existing database.One command, and forgetting it is the classic first mistake: migrate fails on tables that already exist and records the failure, which then has to be repaired.
  4. Migrate from the pipeline, not from the application.Migration is a deployment step. Doing it at startup works with one instance and becomes a race, a lock wait and a restart loop with three.
  5. Every migration must survive the previous release.During a rolling deploy both versions run against one schema. Add, backfill, switch, then drop — across separate releases. Dropping a column in the same release that stops using it is how a deploy takes the site down.
Chapter 30

Java 8 to 21, and what “Java 17+” on an advert means

The listings say Java 8+, Java 11+ or Java 17+, and interviewers ask what is new. This chapter is the answer, restricted to the features you will actually be asked about and actually use.

Why the version numbers look like that

Since Java 9 there has been a release every six months, and one LTS — long-term support — every two or three years. Only the LTS versions appear on job adverts, because only they get security updates for years.

VersionStatusWhat it brought that matters to you
8LTS, 2014Lambdas, streams, Optional, the new date and time API. Still enormously common in Italian enterprise code.
11LTS, 2018var, List.of and friends, String.isBlank/strip/lines. Still enormously common on existing listings.
17LTS, 2021Records, sealed types, switch expressions, text blocks, pattern matching for instanceof. The most common requirement on new listings.
21LTS, 2023Pattern matching for switch, record patterns, virtual threads. This project's target.

This project compiles to Java 21 — maven.compiler.release in the pom.xml, as chapter 28 explained. It used to target 11, and moving it was not a cosmetic edit: Jakarta EE 11, Hibernate 7 and RESTEasy 7 all refuse to run below 17, so the Java number is the gate on the whole dependency tree. Be ready for the other side of that in an interview, though — plenty of Italian shops are still on 8 or 11, and the codebase you are handed on your first day may well be. Everything below is what to say when the interviewer asks what you would use given a newer one.

The honest framing

None of these features is hard, and none of them changes how you design anything. They remove ceremony. That is exactly how to talk about them: “records replaced about forty lines of boilerplate in my DTOs and made them immutable by default” is a better answer than a list of version numbers, because it says what you did with the feature.

Records, and the DTOs in this project

A record is a class whose whole purpose is to carry a few values. The compiler writes the constructor, the accessors, equals, hashCode and toString, and the fields are final.

The response DTOs here are the perfect case, because they are exactly that and nothing else:

Longhand — what this project still has
public class CourseResponse {
    private final Long id;
    private final String code;
    private final String title;

    public CourseResponse(Long id, String code,
                          String title) {
        this.id = id;
        this.code = code;
        this.title = title;
    }
    public Long getId(){ return id; }
    public String getCode(){ return code; }
    public String getTitle(){ return title; }
    // equals, hashCode, toString...
}
A record — the same thing
public record CourseResponse(
        Long id,
        String code,
        String title) { }

Accessors are id(), not getId() — which matters, because some older JSON and bean tooling looks for the get prefix. That is the one practical friction, and it is why records took a while to reach enterprise code.

The trap: records are not entities

A record is final, its fields are final, and it has no no-arg constructor. JPA requires a mutable class with a default constructor it can instantiate reflectively and fill in later — so Student and Course can never be records, however much you would like them to be. Records belong at the boundary: DTOs, commands, query results, value objects. The distinction is chapter 13's, arriving from the language side.

You can also add validation. A compact constructor runs before the fields are assigned:

public record EnrollRequest(Long studentId, Long courseId) {
    public EnrollRequest {          // no parameter list — that is the syntax
        Objects.requireNonNull(studentId, "studentId");
        Objects.requireNonNull(courseId,  "courseId");
    }
}

Sealed types and the state machine

A sealed interface names, in its own declaration, every type permitted to implement it. The compiler can then check that you handled all of them.

This is the feature that pairs with switch expressions, and together they let the compiler enforce something this project currently enforces at runtime. Look at what happens to exhaustiveness:

Java 17a switch expression over an enum
String describe(EnrollmentStatus status) {
    return switch (status) {                  // an expression: it RETURNS
        case ACTIVE    -> "holds a seat";     // no break; no fall-through
        case COMPLETED -> "passed";
        case FAILED    -> "may retake";
        case WITHDRAWN -> "gave up the seat";
    };                                        // no default — and that is the point
}

There is no default branch, and that is deliberate. A switch expression over an enum must be exhaustive, so adding a fifth status to EnrollmentStatus turns this method into a compile error. With the old switch statement plus a default, the same change compiles and quietly describes the new state as whatever the default said.

In plain words

A default branch is a “whatever”. It is convenient right up to the day someone adds a case, and then it is the reason nothing complained. Exhaustive switching is the compiler agreeing to nag you at every place that needs updating — which is the cheapest kind of correctness there is, because it happens before the code runs.

Sealed types extend the same guarantee beyond enums. If the business exceptions in this project were declared sealed permits ResourceNotFound, DuplicateResource, BusinessRuleViolation, InvalidRequest, then a switch handling them would be checked the same way, and adding a fifth exception would break every mapper that had not been updated. Which is what you want.

The small ones you will use every day

Four features that are not interesting and that you will type constantly.

var, for local variables only. The type is inferred from the right-hand side. It is not dynamic typing and it is not Object: the variable has a real static type, you simply did not write it. Use it when the type is already obvious from the line — var courses = new ArrayList<Course>() — and not when it hides something the reader needs: var result = service.process(input) tells nobody anything.

Text blocks, which end the misery of embedded SQL and JSON:

Before
String jpql =
  "SELECT c FROM Course c " +
  "JOIN FETCH c.professor " +
  "WHERE c.semester = :sem " +
  "ORDER BY c.code";

Every line needs quotes, a plus, and a trailing space that is invisible when it is missing. A missing space is the classic bug: "...c.professor" + "WHERE...".

Java 17
String jpql = """
    SELECT c FROM Course c
    JOIN FETCH c.professor
    WHERE c.semester = :sem
    ORDER BY c.code
    """;

Indentation is stripped relative to the closing delimiter, so the string contains no leading spaces. The query is readable, and it can be pasted straight into a database console.

Pattern matching for instanceof, which removes the cast that always followed it:

// before
if (ex instanceof BusinessException) {
    BusinessException be = (BusinessException) ex;      // say it twice
    return problem(be.getErrorCode());
}

// Java 17
if (ex instanceof BusinessException be) {              // bound right there
    return problem(be.getErrorCode());
}

Immutable collection factories, from Java 9. List.of(...), Set.of(...), Map.of(...) build genuinely unmodifiable collections in one call. Two things to know, because both get asked: they reject null elements, and they throw UnsupportedOperationException on any attempt to modify — which is the behaviour you want for a constant, and a nasty surprise if you hand one to code that expects to add to it. That is the immutability argument of chapter 04 with library support.

Virtual threads are on a job advert. What is the one-sentence version?

From Java 21, a thread that is scheduled by the JVM rather than the operating system, so you can have hundreds of thousands of them instead of hundreds — which makes the plain blocking style of code from chapter 06 scale like asynchronous code, without being written asynchronously. For a junior the honest addition is that it changes almost nothing about how you write a service, and that the classic gotcha is synchronized blocks, which pin a virtual thread to its carrier and undo the benefit.

“What is new in Java since 8?” — you have thirty seconds.

“The big ones are records, sealed types, switch expressions, text blocks and pattern matching. Records are the one I would reach for first — my response DTOs are pure data carriers and a record removes the constructor, the getters and equals/hashCode while making them immutable. The one I find most valuable, though, is exhaustive switch expressions over an enum, because adding a state then becomes a compile error at every place that has to handle it rather than a silent default.”

Golden rules

Four things to hold about a subject that is mostly convenience.

  1. Records at the boundary, never as entities.DTOs, commands, value objects — yes. JPA entities need a mutable class with a no-arg constructor, so they cannot be records, and knowing why is the interesting half of the answer.
  2. Prefer the exhaustive switch expression to a default.Over an enum or a sealed type, leaving out default makes the compiler find every place that must change when a case is added. A default is a promise to handle the future silently and wrongly.
  3. var where the type is already on the line.var x = new HashMap<String, List<Course>>() is clearer than repeating that. var x = doThing() is worse than writing the type. The test is whether the reader can still answer “what is this?”.
  4. Talk about features by what they replaced.“Records removed forty lines from my DTO layer” beats “I know Java 17”. Interviewers are checking whether you have written the code, and only one of those two sentences proves it.
Chapter 31

SOLID, clean code, and the patterns you will be asked to name

“Clean code and design patterns” is one of the most common lines in a junior Java advert, and one of the most commonly answered with a memorised list. This chapter gives every principle a file in this repository, so you can answer with a place instead of a definition.

Why this chapter exists

Two adverts in five in the survey behind chapter 36 ask for “clean code”, “OOP”, “SOLID” or “design patterns” — usually more than one of the four, usually in the same sentence, and almost never with any indication of what they would accept as an answer. In the interview itself the rate is far higher than two in five, because it is the cheapest question to ask.

What they are testing is narrow and predictable. An interviewer wants to know whether you can look at a class and say why it is shaped that way. The failure mode is not ignorance of the acronym; it is the candidate who recites Single Responsibility Principle: a class should do one thing and then cannot point at a class, anywhere, that violates it.

In plain words

Think of a kitchen. SOLID is not a cookbook, it is the reason the knives live in one drawer and the spoons in another. Nobody dies if you mix them. But six months later, with four people cooking at once, the kitchen where everything has a place is the one where dinner still arrives on time. Design principles are storage rules for code, and their whole value shows up later, under load, with other people.

Every principle below is followed by the file in this project that demonstrates it. Open the file. A principle you have seen enforced in real code is one you can defend; one you have only read is one an interviewer can talk you out of in two questions.

The four pillars, in this codebase

Encapsulation, abstraction, inheritance, polymorphism. This is the first question in most junior interviews, and it is much better answered with four files than with four sentences.

PillarThe one-line answerWhere it is, here
Encapsulation State is private; the only way to change it is a method that enforces the rules. domain/model/Enrollment.java
Abstraction Callers depend on what a thing does, never on how it does it. repository/CourseRepository.java
Inheritance Shared identity and shared behaviour move up into a common base. domain/model/BaseEntity.java
Polymorphism One call site, many implementations, chosen at runtime. api/exception/*ExceptionMapper.java

Enrollment is the strongest of the four, because it is encapsulation with something at stake. There is no setStatus(). The only route from ACTIVE to COMPLETED is recordPass(), which checks the transition table, the 18–30 grade range, and the rule that lode requires exactly 30:

Examplean anemic model beside a rich one
Anemic — what most juniors write
public class Enrollment {
    private EnrollmentStatus status;
    private Integer grade;

    // getters and setters for everything
    public void setStatus(EnrollmentStatus s){
        this.status = s;
    }
    public void setGrade(Integer g){
        this.grade = g;
    }
}

The rules now live in whichever service happens to call the setters — which means they live in all of them, slightly differently, and a new one can forget. Nothing stops setStatus(COMPLETED) on a withdrawn enrollment with a grade of 4.

Rich — what this repository does
public void recordPass(int grade,
                       boolean lode) {
    requireTransitionTo(COMPLETED);
    if (grade < 18 || grade > 30)
        throw new BusinessRuleViolationException(...);
    if (lode && grade != 30)
        throw new BusinessRuleViolationException(...);
    this.status = COMPLETED;
    this.grade  = grade;
}

One place. Every caller gets the check for free, and an invalid Enrollment cannot be constructed through the public API at all. This is tell, don't ask: you tell the object what happened, you do not ask it for its fields and decide on its behalf.

The sentence to have ready

“Encapsulation is not private fields with public getters and setters — that is a public field with extra steps. It is that the object owns its invariants. In my project an enrollment has no status setter; the only way to complete it is a method that checks the state machine and the grade range first.”

SOLID, one letter at a time

Five principles, five files. Read the letter, then read the file, then say the principle back using the file as the example.

S — Single responsibility. A class should have one reason to change. Look at the request path: CourseResource knows about HTTP and nothing else; CourseService knows the business rules and nothing about HTTP; CourseRepository knows about queries and nothing about either. A new status code changes exactly one file. A new business rule changes exactly one file.

The common misreading is “one method per class”. It is not about size, it is about audiences: who would ask for a change to this file? If the answer is two different people for two different reasons, it is doing two jobs.

O — Open for extension, closed for modification. The exception mappers are the cleanest example in the repository. Adding a new failure mode does not mean editing a growing if/else chain in one place — you add a new ExceptionMapper class and JAX-RS finds it:

api/exception/DuplicateResourceExceptionMapper.javaone new file, nothing edited
@Provider
public class DuplicateResourceExceptionMapper
        implements ExceptionMapper<DuplicateResourceException> {

    @Override
    public Response toResponse(DuplicateResourceException exception) {
        return ProblemDetails.build(
                Response.Status.CONFLICT,
                "duplicate-resource",
                "Duplicate Resource",
                exception.getMessage(),
                exception.getErrorCode(),
                uriInfo);
    }
}

The container scans for @Provider and dispatches on the exception's type. That dispatch is polymorphism doing the job an instanceof ladder would otherwise do — which is what “closed for modification” buys you.

L — Liskov substitution. A subtype must be usable anywhere its parent is, without surprising the caller. Every repository here extends AbstractJpaRepository<T> and none of them weakens a promise the base class made — no override starts throwing on a case the parent handled, none returns null where the parent returned an Optional. The textbook violation is the square that extends rectangle and breaks setWidth; the everyday violation is an override that throws UnsupportedOperationException.

I — Interface segregation. Clients should not be forced to depend on methods they do not use. This project keeps repositories narrow: CourseRepository exposes findByIdWithPrerequisites and findByIdWithPessimisticLock because the enrollment flow needs precisely those, not a general-purpose query(String jpql) that every caller could abuse.

D — Dependency inversion. High-level policy depends on abstractions, not on concrete details. The best example here is time. EnrollmentService never calls Instant.now(); it injects a Clock produced by ClockProducer:

common/ClockProducer.javatime as an ordinary collaborator
@Dependent
public class ClockProducer {
    @Produces
    public Clock systemClock() {
        return Clock.systemUTC();
    }
}

In production that is the wall clock. In a test it is Clock.fixed(...), so the rule “you may only enrol while the window is open” can be asserted one nanosecond either side of the deadline. Dependency injection is the mechanism; dependency inversion is the reason you wanted it.

In plain words

Dependency inversion is the difference between a lamp that is wired into the wall and one with a plug. Both light the room. But only one of them can be tested on the bench, moved to another room, or swapped for a brighter bulb without an electrician. Instant.now() is soldered to the wall. An injected Clock has a plug.

The trap

SOLID is a set of heuristics, not laws, and a junior who applies all five to a fifty-line program produces an interface for every class and a factory for every interface. Interviewers notice that too. The honest senior answer is: “I reach for these when something has already changed twice and hurt both times”. Premature abstraction costs more than the duplication it was meant to prevent.

The patterns that actually come up

There are twenty-three Gang of Four patterns. Junior interviews ask about six, and five of them are already in this repository.

PatternWhat it is forIn this project
Repository A collection-like facade over persistence, so business code never sees an EntityManager. AbstractJpaRepository
Factory Centralise construction and hide the concrete type from the caller. ClockProducer
LoggerProducer
Static factory method A named constructor that can validate and can refuse to build. ProblemDetails.build(…)
Email.of(…)
Decorator / interceptor Wrap behaviour around a method without touching it — the AOP idea. @Loggable
LoggingInterceptor
State machine Legal transitions in one table instead of scattered booleans. EnrollmentStatus
Strategy Interchangeable algorithms behind one interface, selected at runtime. — not used here; see below

Being able to say “we did not need strategy in this project” and explain when you would is worth more than reciting all twenty-three. The moment for strategy is when a method grows a switch over a type and each branch is a different algorithm — grading schemes, say, if this university had three of them.

Singleton deserves a specific note, because it is the pattern juniors are most likely to name and most likely to name badly. In a Jakarta EE or Spring application you almost never write one:

The pattern, hand-rolled
public class Registry {
    private static final Registry INSTANCE
            = new Registry();
    private Registry(){}
    public static Registry get(){
        return INSTANCE;
    }
}

Global mutable state, invisible to tests, impossible to replace, and a classic source of the thread-safety bug from chapter 06.

The container's version
@ApplicationScoped
public class CourseService {
    // one instance per application,
    // injected, mockable, proxied
}

Same lifetime, none of the costs. This is the honest interview answer: “singleton scope, yes — the singleton pattern, no, the container gives me that with a scope annotation.”

Clean code, and what a reviewer actually says

“Clean code” on an advert means: will your pull requests be cheap or expensive to review? Here is what that reduces to in practice.

Name things after what they mean, not what they are.
List<Course> c tells the reader nothing. availableCourses tells them the filter has already been applied. A good name removes the need for a comment.
A function should sit on one screen.
Not because long is forbidden, but because a method you must scroll is a method you cannot hold in your head while reading the branch you care about.
Comment the why, never the what.
// increment i is noise. // FOR NO KEY UPDATE, so two requests cannot both see the last seat is the sentence the next reader needs and cannot recover from the code.
Fail fast, at the edge.
Validate the request where it arrives, not four layers down where the error message can no longer say which field was wrong. Chapter 13 is this rule at length.
Delete the code you replaced.
Commented-out blocks and oldMethodV2 are what version control exists to make unnecessary. Chapter 19 is the reason you can delete fearlessly.
Leave it better than you found it.
The boy scout rule. Not a refactor of the whole file in an unrelated pull request — one name, one dead import, one missing test. Reviewers notice, and it is free.
An interviewer asks: “what is the difference between an interface and an abstract class, and when do you use each?”

An interface is a contract with no state; a class can implement many. An abstract class can hold state and shared implementation, and a class can extend only one. The practical rule: use an interface when you are describing a capability that unrelated types might have, and an abstract class when you are factoring out shared machinery from types that genuinely are the same kind of thing. AbstractJpaRepository in this project is the second case — it holds the injected EntityManager and the paging logic, which is state and implementation, so an interface could not have carried it.

Which SOLID letter does a 900-line UtilService with thirty unrelated static methods break, and why is that the mild version of the problem?

Single responsibility, most obviously — but the real damage is dependency inversion. Because it is static, no caller can inject a different one, so every class that touches it becomes untestable in isolation, and that spreads. A class that is merely too big can be split later. A static dependency graph has to be unpicked everywhere at once.

Golden rules

Five habits that turn a memorised acronym into something you can defend.

  1. Answer a principle with a file, not a definition.“Encapsulation means hiding data” is a phrase anyone can repeat. “My Enrollment has no status setter because the state machine has to be checked first” is evidence, and it invites the follow-up question you actually want.
  2. Objects own their invariants.If a rule about a field can be broken by anyone holding a reference, the rule does not exist. Put it behind a method on the object that owns the data — tell, don't ask.
  3. Apply SOLID after the second time something hurt, not before the first.These are remedies for change you have observed, not decorations to apply up front. An interface with exactly one implementation and no plans for a second is a cost with no benefit yet.
  4. Prefer the container's mechanism to the hand-rolled pattern.Scope annotations replace singleton, producers replace factories, interceptors replace decorators. Knowing both is the point: you can say what the framework is doing for you and why that version is safer.
  5. Clean code is measured in review time, not in beauty.The question is never “is this elegant”. It is “can a colleague who has never seen this file approve it in ten minutes”. Every rule in this chapter is downstream of that one.
Chapter 32

JavaScript, React and Angular, for a backend developer

Roughly half the junior Java adverts in Bologna, Milano and Tirana say full stack. They are not asking for a front-end specialist — they are asking whether you can be handed a screen that talks to your own API and not be helpless. This chapter is that much and no more.

How much front end a Java advert actually wants

Enough to consume the API you built, render three states, and not break the build. That is a smaller target than it looks, and it is reachable in a weekend.

The adverts behind chapter 36 divide cleanly. A bank or a consultancy hiring for a Java team lists the front end under nice to have. A product company or a smaller software house lists it as required, and then names a framework — and which framework is close to a coin toss:

FrameworkWhere it turned up in the surveyWhat to do about it
Angular Bologna and Milano listings pairing J2EE / Spring / Hibernate with Angular; Lutech Tirana full-stack; Unipol full-stack. Dominant in Italian enterprise and banking work. If you are aiming at consultancies, this is the one.
React Lufthansa Industry Solutions Tirana (React, TypeScript, Redux, JWT); Aqrate Bologna (Java + Node + React); most product companies. The larger job market overall and the easier one to learn first.
Plain JS / JSP / Thymeleaf Legacy maintenance roles, and every “small internal tool” task you will be given in month one. Never advertised, always needed. This is why the section below starts with the language, not the framework.
The honest answer in an interview

“My depth is backend. I have written the front end for my own API — fetch, async/await, rendering loading, error and success states, and a controlled form. I have used React; the Angular concepts map closely and I would expect a couple of weeks to be productive.” Nobody expects a junior Java candidate to be strong on both sides. They expect you to know which side you are on and not be frightened of the other.

You are also already holding a worked example. This page is a front end: several thousand lines of plain JavaScript that call the enrollment API on localhost:8280, render what comes back, and handle the case where the server is not running. Nothing in this chapter is theoretical for you.

The JavaScript that matters

Six things. Everything else you can look up when you need it, and everything else is what the interviewer will skip.

1. let and const, never var. let and const are block-scoped, like a Java local. var is function-scoped and hoisted, which produces the classic loop bug where every callback sees the final value of the counter. const prevents reassignment of the binding, not mutation of the object — the same distinction as final on a Java field.

2. ===, not ==. == coerces types before comparing, which is how 0 == "" and null == undefined both come out true. === compares without coercion. There is no defensible reason for a junior to use ==.

3. Promises and async/await. A promise is a value that has not arrived yet: pending, then either fulfilled or rejected. await is syntax that lets you write the sequential version, and try/catch then works exactly as you expect. This is the single most important thing to be fluent in, because every call to your Java API goes through it.

4. fetch, and the fact that it does not throw on 4xx. This one catches everybody:

The trap

fetch() rejects only on a network failure. A 404, a 409, a 500 — all of those resolve normally, with response.ok === false. Code that only uses try/catch around a fetch will happily treat your carefully designed 409 Conflict as a success and render undefined. You must check response.ok yourself.

5. Closures. A function keeps access to the variables of the scope it was created in, even after that scope has returned. This is how every event handler and every React hook works, and it is the most commonly asked JavaScript interview question after == versus ===.

6. map, filter, reduce. If you have chapter 05's stream pipeline in your head, you already know these — they are the same operations with the same names, applied eagerly to an array. You will use map constantly, because rendering a list in React is a map.

In plain words

A promise is a cloakroom ticket. You hand over your coat, you get a ticket immediately — the ticket is not the coat. Later you present the ticket and get either your coat back (fulfilled) or an apology (rejected). await just means “stand at the counter until one of those two things happens”, and it is why the code underneath it reads top to bottom like ordinary Java.

React in one sitting

A component is a function that takes data and returns markup. State changes re-run the function. That is genuinely most of it.

React ideaWhat it meansThe nearest thing you know
componentA function returning JSX — markup written inside JavaScript.A method that renders one part of a page.
propsInputs passed in by the parent. Read-only.Constructor arguments.
stateData the component owns. Changing it re-renders.A field, plus an automatic repaint.
useStateDeclares one piece of state; returns the value and a setter.private T x with a setter that also refreshes.
useEffectRuns after render, to talk to the world outside React — usually a fetch.A lifecycle callback like @PostConstruct.
keyStable identity for each item in a rendered list.The primary key. It is, literally, the id.

Four rules cover the mistakes juniors actually make:

  1. Never mutate state directly.items.push(x) changes the array but not its identity, so React sees no change and does not re-render. Build a new array: setItems([...items, x]). The same instinct as immutability in chapter 04.
  2. Keys are ids, not array indexes.Index keys look fine until a row is inserted or removed, at which point React reuses the wrong DOM node and the text in your inputs jumps to the wrong row. Use the id the server gave you.
  3. Every fetch has three states, not one.Loading, error, success. A component that renders only the success case shows an empty page for the first 300 ms and a blank one forever if the API is down.
  4. A missing dependency array is an infinite loop.useEffect with no second argument runs after every render, so an effect that sets state re-triggers itself. Pass the dependencies, and pass [] when you mean “once”.

A component against this API

Everything above, in one file, calling the endpoint from chapter 12 and honouring the RFC 7807 error body from chapter 13.

Examplethe front end of the enrollment endpoint you already built
EnrollButton.jsxloading, error and success — all three
import { useState } from "react";

const API = "http://localhost:8280/enrollment/api";

export default function EnrollButton({ studentId, courseId }) {
  const [state, setState] = useState("idle");   // idle | busy | done | error
  const [error, setError] = useState(null);

  async function enroll() {
    setState("busy");
    setError(null);
    try {
      const res = await fetch(API + "/enrollments", {
        method:  "POST",
        headers: { "Content-Type": "application/json" },
        body:    JSON.stringify({ studentId, courseId })
      });

      // fetch does NOT throw on 409. Check it yourself.
      if (!res.ok) {
        const problem = await res.json();       // RFC 7807 body
        setError(problem.detail || problem.title);
        setState("error");
        return;
      }
      setState("done");
    } catch (e) {
      setError("The server is not reachable.");  // network only
      setState("error");
    }
  }

  if (state === "done") return <p>Enrolled.</p>;

  return (
    <div>
      <button onClick={enroll} disabled={state === "busy"}>
        {state === "busy" ? "Enrolling…" : "Enrol"}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

Read what the error path does, because it is the whole reason chapter 13 insisted on a machine-readable error shape. The server answers a full course with 409 and a body containing "detail": "Course CS101 has no seats left". The component does not invent a message, does not print “something went wrong”, and does not need a lookup table of codes — it renders the sentence the domain already wrote.

Why this matters on both sides

This is the clearest argument for a good error contract, and it is a strong thing to say in an interview: “I designed the API error body so the front end could display it without translating it. When I wrote the client I found out whether I had got that right.” Very few juniors have stood on both ends of their own contract.

Two things this small component still needs before it is production code, and both are worth knowing by name. Cancellation: if the component unmounts while the request is in flight, the setState that follows is a leak — an AbortController fixes it. And double submit: the disabled attribute stops the second click, which matters because POST is not idempotent, exactly as chapter 12 said.

TypeScript, and the Angular translation

TypeScript is on more listings than React or Angular individually, because both use it. For a Java developer it is the easy part — it is the type system you already think in, bolted onto JavaScript.

types.tsthe response DTO from chapter 13, restated for the client
export interface EnrollmentResponse {
  id:          number;
  studentId:   number;
  courseCode:  string;
  status:      "ACTIVE" | "COMPLETED" | "WITHDRAWN" | "FAILED";
  grade?:      number;      // optional — the ? is the whole idea
}

That union of four string literals is the enum from EnrollmentStatus.java, and the compiler will now reject a typo in a comparison. The ? is Optional<Integer>. interface versus type is the one TypeScript question that gets asked: both describe shapes, interfaces are the natural choice for extensible object contracts, type aliases can also express unions and intersections like the one above.

And if the offer turns out to be Angular, almost nothing you learned is wasted — the vocabulary changes more than the ideas:

ReactAngularWhat is actually different
function component@Component classA decorated class with a separate HTML template file, rather than JSX in the function.
props@Input()Same idea, declared as an annotated field.
useStatea plain field, or a signalAngular tracks changes for you; there is no explicit setter to call.
useEffectngOnInit / ngOnDestroyNamed lifecycle hooks instead of one general-purpose effect.
fetchHttpClientReturns an RxJS Observable, not a promise. This is the genuinely new concept.
props drilling / context@Injectable service + DIAngular has a real dependency-injection container — the one part of it that will feel immediately familiar.

Angular being an opinionated framework with built-in DI, modules and its own CLI is precisely why Italian enterprise shops standardised on it: it looks and behaves like Spring, on the other side of the wire.

Your React app on localhost:3000 calls this API on localhost:8280 and the browser console says the request was blocked by CORS. The server logs show the request arriving and returning 200. What is happening?

The request was made and answered; the browser refused to hand the response to your JavaScript, because the origin differs and the response carried no Access-Control-Allow-Origin header. CORS is enforced on the client side, which is why the server log looks perfectly healthy. The fix is a filter on the API that sets the header for the origins you allow — and note that a POST with Content-Type: application/json triggers a preflight OPTIONS request first, which your API must also answer. Chapter 15 covers why you do not simply set * and move on.

Where should the JWT from chapter 15 be stored in a browser, and what is wrong with the obvious answer?

The obvious answer is localStorage, and the problem is that any injected script on your page can read it — one cross-site scripting hole and the token is exfiltrated. An HttpOnly cookie cannot be read by JavaScript at all, which removes that class of theft but introduces cross-site request forgery, which you then handle with SameSite and a CSRF token. There is no free option; the interview answer is knowing that the choice is between two different attacks, not between secure and insecure.

Golden rules

Four rules that cover most of what a Java developer gets wrong on the other side of the wire.

  1. fetch does not throw on 4xx or 5xx.Check response.ok before parsing. A try/catch alone catches network failure only, and will silently render a success page for your 409.
  2. Every remote call renders three states.Loading, error, success. If your component has one branch, it has two bugs — and the error state is the one your interviewer will ask to see.
  3. Never mutate state; replace it.React compares identities, not contents. push mutates and re-renders nothing; a new array re-renders correctly. Chapter 04's immutability argument, enforced by a framework.
  4. Say which side you are strong on.“Backend, with enough front end to consume my own API” is a credible junior answer and is what the full-stack listings actually mean. Claiming equal depth in both invites the question that exposes it.
Chapter 33

Microservizi, Docker, Kubernetes and the cloud

This cluster of words appears on nearly every Java advert above the very smallest, and almost never with a definition. Nobody expects a junior to design a distributed system. They expect you to know what the words cost, and not to say them as if they were free.

What the advert means by it

A junior on a microservices team writes ordinary code in one small service. The distributed part is somebody else's problem for a year. What you need is the vocabulary, the trade-off, and honesty about where you stand.

Read the listings carefully and the word carries three quite different meanings:

“We have microservices”

large bank · consultancy

Forty services, a service mesh, a platform team. You will be given one of them and a ticket. Your Spring Boot knowledge is the whole job; the topology is scenery.

“We are moving to microservices”

product company · modernisation project

A monolith being carved up, often with Quarkus or Spring Boot, in Docker and Kubernetes. Here the interesting interview question is where would you cut it, and it has real answers.

“Microservices” on the tech list

small software house

Frequently means two or three deployables and a REST call between them. Perfectly respectable, and much closer to what you can reason about after this chapter.

The answer that lands

“I have not run microservices in production. I have built a modular monolith with clear layer boundaries, and I know what changes when those layers become network calls — a method call becomes a request that can time out, and a transaction that used to roll back everything no longer can.” That sentence is the entire chapter, and it is honest.

Where you would cut this project

The useful exercise is not defining a microservice. It is finding the seam — and this application has one obvious one and one tempting, wrong one.

A service boundary is drawn where the business is loosely coupled, not where the code happens to be in different packages. In this project:

Candidate splitVerdictWhy
Notifications
EnrollmentNotificationListener
a good cut It already listens to events, it never participates in the enrollment transaction, and nobody is waiting for it. It can fail, retry, and lag without anyone losing a seat.
Reporting / analytics a good cut Read-only, tolerates stale data by minutes, and its query load is the thing you most want off the transactional database.
Courses and enrollments apart a bad cut This is the seam that looks tidiest and hurts most, for the reason in the next section.

Splitting courses from enrollments destroys the mechanism chapter 11 spent a whole chapter on. Today, taking the last seat is one transaction: lock the course row, count the enrollments, insert, commit. Two services means two databases, and there is no row lock that spans them.

In plain words

Right now, booking a seat is like a shop with one till. You pick up the last item, you pay, and while you are at the till nobody else can take it. Split the service and you have two shops: one holds the shelf, the other holds the receipts, and they can only talk by telephone. The line can be busy. The call can drop after the item is taken but before the receipt is written. Everything you now have to build — retries, compensating cancellations, a nightly job that reconciles the two — is the price of the second shop. Sometimes it is worth paying. It is never free.

That price has a name. Because you cannot hold one ACID transaction across two databases, you use a saga: a sequence of local transactions, each with a compensating action if a later step fails. The seat is reserved, then the enrollment is written; if the write fails, a compensation releases the seat. Between those two moments the system is inconsistent and visible to users — that is eventual consistency, and it is a product decision as much as a technical one.

The trap

Two-phase commit across services is the answer that sounds right and is almost never used, because it holds locks across a network and turns every participant's availability into every other participant's availability. If an interviewer asks how you keep two services consistent, saying “a distributed transaction” is a weaker answer than “a saga with a compensating action, and I would ask the business how long an inconsistent window is acceptable”.

Containers, and the one in this repository

An image is a filesystem plus a command. A container is that image running. Everything else is detail, and this project ships a real example of both.

You have been running WildFly natively, but docker-compose.yml in the project root describes the same system as two containers, and it is worth reading as documentation even if you never start it:

docker-compose.ymlabridged — the real file explains every line
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: enrollment
      POSTGRES_USER: enrollment
      POSTGRES_PASSWORD: enrollment
    ports:
      - "55433:5432"          # host:container
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U enrollment -d enrollment"]

  wildfly:
    build: { context: ./docker/wildfly }
    depends_on:
      postgres:
        condition: service_healthy   # waits for READY, not for "started"
    ports:
      - "8280:8080"
    volumes:
      - ./docker/deployments:/opt/jboss/wildfly/standalone/deployments

Five ideas are worth taking from that file, and they are the five things a junior is asked about containers:

The port mapping is host:container.
Postgres still listens on 5432 inside the network, and WildFly still reaches it as postgres:5432. Only the host side moved, which is why changing it never requires an application change. Container networking exists to make that true.
Without a volume, your data dies with the container.
A container's writable layer is discarded on removal. The named volume is the only reason docker compose down is not destructive — and down -v is, deliberately.
“Started” is not “ready”.
Postgres accepts connections seconds after the container exists. Without condition: service_healthy WildFly fills its pool against a database that is not listening and the deployment fails. This single distinction is most of what Kubernetes probes are for too.
Configuration comes from the environment.
The same image runs in dev, test and production; only the variables differ. This is the twelve-factor rule, and it is why chapter 16's application.properties plus profiles matters more than it first appears.
Those credentials in the file are the giveaway.
POSTGRES_PASSWORD: enrollment in a committed file is fine for a local toy and a finding in a real review. Real deployments inject secrets at runtime. Say this before an interviewer does — it is chapter 15's habit applied to infrastructure.

One conceptual note, because it is the neatest thing about this particular project: you have been doing containers all along. A WAR handed to WildFly and an image handed to Docker are the same relationship at different scales — cargo, and a runtime that already exists. Chapter 02 said your code has no main(). Neither does an image.

Kubernetes, in one page

You will not be asked to operate it. You may well be asked what a pod is, and whether you know why your application needs a health endpoint.

WordWhat it isThe thing to remember
podOne or more containers scheduled together, sharing a network address.The smallest unit. Usually one container. Disposable by design.
deploymentA declaration: “I want three of this pod, at this image version.”You describe the desired state; the controller makes reality match, and keeps it matching.
serviceA stable name and address in front of a changing set of pods.Pods come and go with new IPs. The service is the DNS name your code calls.
ConfigMap / SecretConfiguration and credentials, injected as environment variables or files.The Kubernetes answer to the hardcoded password above.
liveness probe“Are you broken?” Fails → the pod is killed and restarted.Must not check your database, or one slow query restarts the whole fleet.
readiness probe“Can you take traffic yet?” Fails → removed from the service.This is the one that should check the database. Same distinction as the compose healthcheck.

Two consequences reach all the way back into your code, and they are the reason this belongs in a programming course at all. Pods are disposable, so a service that keeps anything in memory between requests loses it without warning — which is chapter 10's rule about stateless @ApplicationScoped beans, arriving from a completely different direction. And pods are replicated, so the scheduled job in chapter 26 now runs on three machines at once unless something stops it. That is a real bug with a standard fix: a database lock, or a scheduler that elects one leader.

Your service is restarted every two minutes in production and the logs show nothing wrong. What do you look at first?

The liveness probe. If it hits an endpoint that touches the database, and the database is briefly slow, the probe times out, Kubernetes concludes the application is broken and kills it — which drops the connections, which makes everything slower, which fails the next probe. It is a self-inflicted outage that looks like a mysterious crash. Liveness answers “is this process wedged”; readiness answers “are my dependencies healthy”. Mixing them up is one of the most common production incidents in the industry.

The remaining words on the advert

NoSQL, Kafka, the cloud providers. A paragraph each is the right amount for a junior interview, and more than most candidates have.

SQL versus NoSQL. The listings that ask for both are usually not asking you to choose. A relational database gives you joins, constraints and a transaction; a document store gives you a flexible schema and horizontal scale for one access pattern. The junior-level answer is that the relational database is the default and the burden of proof is on the alternative — this application, with its foreign keys, its unique constraint on (student, course) and its seat-counting transaction, would be materially harder to make correct in a document store. Say that and you have said something true and specific.

Kafka and message queues. A queue is what the CDI event from chapter 10 becomes once the listener lives in another process. Two properties matter: the sender no longer waits, and the message survives if the receiver is down. Two problems arrive with it: delivery is at least once, so consumers must be idempotent, and ordering is only guaranteed within a partition. “The notification listener would become a queue consumer, and I would make it idempotent because it can be delivered twice” is a complete junior answer.

AWS, Azure, GCP. Named on most listings, required on very few junior ones. Learn three ideas rather than a console: managed service (someone else runs the database and patches it — the same Postgres, with backups), object storage (S3 and its equivalents, where files go because pods are disposable), and identity and roles (the reason your application gets no password at all, only permission). Those three carry across all providers; the click paths do not.

What to actually do this week

If you want one concrete thing from this chapter on your CV, it is not a Kubernetes cluster. It is being able to walk someone through docker-compose.yml in this repository line by line — why the volume is named, why depends_on carries condition: service_healthy, why the host port differs from the container port — and, on a machine where the Docker daemon cooperates, a screenshot of docker compose up serving the application. That is a real, checkable claim, and it is more than most junior candidates can show.

Golden rules

Four things to hold, in a subject where confident nonsense is the standard failure.

  1. Never claim distributed experience you do not have.Say what you have built and what you understand about the difference. “A method call becomes a request that can time out” is a better sentence than any list of tools, and it cannot be caught out by a follow-up question.
  2. A service boundary is a business boundary.Cut where the business is loosely coupled — notifications, reporting. Cutting between two things that must change together in one transaction buys you a saga, a compensating action, and a class of bug you did not previously have.
  3. Containers make state explicit.Anything not in a volume or a database is gone at the next restart. Disposable pods are chapter 10's stateless-bean rule enforced by the platform instead of by discipline.
  4. Liveness is not readiness.Liveness asks “am I wedged” and must not touch the database. Readiness asks “can I serve” and should. Getting this backwards turns a slow dependency into a restart loop.
Chapter 34

Reading code you did not write

On your first Monday you will be given a repository nobody has time to explain, and a small ticket inside it. This is the skill that decides how that week goes, and it is taught almost nowhere.

The thing nobody warns you about

You will spend far more of your career reading code than writing it, and most of what you read will be unfamiliar, undocumented and written by someone who has left.

The instinct every junior has is to read the codebase from the top, understand it, and only then touch anything. On a 200-file project that takes three weeks and does not work: you retain almost nothing, because you are reading without a question to answer. Understanding is a by-product of doing something specific, not a prerequisite for it.

In plain words

You do not learn a city by reading the map. You learn it by going somewhere — and after five errands you have a real map in your head, built out of the routes you actually walked. A codebase is the same. Take a ticket, follow it end to end, and let the parts you walked through become the parts you know. The neighbourhoods you never visit did not need knowing yet.

What good looks like in week one

Not “I understand the system”. It is: I can name the layers, I can find where a request enters and where it hits the database, I have run the tests, and I have shipped one small change. That is a strong first week, and it is achievable by Wednesday.

The first hour, in order

Six moves, before reading a single line of business logic. They take about an hour and they save days.

1Build it and run the tests. Nothing else matters until the build is green on your machine — and if it is not, that is your first ticket and everyone will be glad you found it.
2Read the build file, not the code. pom.xml tells you the Java version, the framework, the database, the test stack and the deployment target in ninety seconds. Chapter 28 is this exact reading.
3Look at the folder names one level down. api / service / repository / domain tells you the architecture before any file does.
4Find the entry points. Grep for the routing annotation — @Path, @RestController, @Scheduled, a main. Every behaviour in the system starts at one of them.
5Read the tests for the thing you are about to change. They are the specification, they are shorter than the code, and they were written by someone explaining themselves.
6Run git log on the file you must touch. Who changed it, how often, and why. A file changed forty times this year is the hot spot; a file untouched for three years is either finished or feared.
Examplethe same six moves, on this repository
terminalan hour compressed into eight commands
# 1. does it build, and do the tests pass?
mvn clean verify

# 2. what is this, technically?
grep -E "artifactId|release" pom.xml | head -20

# 3. what is the architecture?
ls src/main/java/it/unicam/cs/enrollment/

# 4. where does a request come in?
grep -rn "@Path" src/main/java --include=*.java

# 5. what is this class supposed to do?
sed -n '1,60p' src/test/java/.../EnrollmentServiceTest.java

# 6. who has touched it, and how often?
git log --oneline -- src/main/java/.../EnrollmentService.java
git log --format='%an' -- src/main/java/.../EnrollmentService.java | sort | uniq -c

Nothing there requires understanding the domain. It is all shape, and shape is what lets you place the next thing you read.

Following one thread all the way down

Then take a single behaviour and trace it from the outside in, ignoring absolutely everything it does not touch. This is the highest-value hour you will spend in a new codebase.

You already have the worked example: chapter 03 is one request through this application, and the reason it is chapter 03 rather than a late appendix is that following a single request is how you learn a system. Do the same thing on your new job, with whatever the equivalent request is, and take notes as you go.

QuestionHow you answer itHere
Where does it enter?The routing annotation for the path.EnrollmentResource
What validates the input?Follow the parameter's annotations.@Valid + EnrollRequest
Where do the rules live?The first call that is not framework code.EnrollmentService.enroll
Where is the transaction?Grep @Transactional and see which layer has it.the service, not the resource
What talks to the database?The repository the service calls.CourseRepository
What comes back?The mapper between entity and response.EnrollmentMapper

Six answers, and you can now place any new file you are shown. More importantly, you can ask a precise question — which is the difference between a colleague who costs two minutes and one who costs an afternoon.

The trap: the debugger is faster than reading

When a call goes into framework code, or through three layers of indirection, or into a proxy, reading stops working — you are guessing which implementation runs. Put a breakpoint at the entry point and step. Chapter 23 covers the mechanics; the point here is that in unfamiliar code the debugger is not a last resort for bugs, it is the fastest way to read. Watching the actual path taken settles in ten seconds what an hour of grep will not.

Legacy code, and how not to make it worse

Sooner or later you will be handed something with no tests, 900-line methods and a comment from 2013. There is a standard, well-tested way to work in it.

Assume it is the way it is for a reason you cannot see.
That bizarre conditional is usually a customer, a regulation, or an incident. Chesterton's fence: find out why the fence is there before removing it. git log -S"theWeirdThing" finds the commit that introduced it, and commit messages are often the only documentation that survived.
Get a test around it before you change it.
Even a bad one. A characterisation test asserts what the code currently does — not what it should do — so that your change either preserves that behaviour or tells you precisely what it broke. This is the single most useful technique in legacy work.
Change one thing per pull request.
A fix mixed with a reformat is unreviewable: the diff is 400 lines and the one that matters is invisible. Reformat in its own commit, or not at all. Reviewers reject on this alone, and they are right to.
Refactor along the path you are already walking.
Improve what your ticket touches — a name, a dead import, a missing test. Not the whole file, and never a class you have no reason to be in. That is chapter 31's boy scout rule with a boundary on it.
Write down what confused you.
You have one chance to see this code with fresh eyes, and it expires within a fortnight. The notes you take in week one are the onboarding document nobody has written — and turning them into a README is a genuinely valued first contribution.

Asking well

You will be stuck. The question is what you do at minute thirty, and it is being watched more closely than your code.

The convention on most teams is roughly thirty to sixty minutes of genuine effort before asking — long enough to have found something, short enough that you have not burned a day. What matters more is the shape of the question:

Costs an afternoon

“The enrollment endpoint doesn't work, can you help?”

Now the colleague has to run it, reproduce it, and discover everything you already know. You have handed over the whole problem including the part you had solved.

Costs two minutes

“POST /api/enrollments returns 500 for student 42 on course CS101. The log shows LazyInitializationException on course.getPrerequisites() in EnrollmentMapper line 38. I think the transaction has closed before the mapper runs — is the fix a fetch join in the repository, or should the mapper move inside the service?”

Specific symptom, the evidence, your hypothesis, and a closed question with two options. Often you solve it while writing this.

That second version also does something a junior rarely realises: it demonstrates the debugging method from chapter 23 in public. Colleagues cannot see you thinking. A well-formed question is one of the few times they can.

You are given a ticket in a codebase with no tests, no README, and a method called process() that is 600 lines long. What do you do first?

Not read it. Reproduce the behaviour the ticket describes, then put a breakpoint at the entry point and step until you reach the part that matters — which is usually a few dozen of those 600 lines. Then write a characterisation test that pins the current behaviour of just that part, and only then change it. Reading 600 lines top to bottom is an hour spent loading things into your head that the ticket does not touch.

An interviewer asks how you would approach a large unfamiliar codebase in your first week.

“Build it and run the tests first, so I know the ground is solid. Then read the build file and the top-level packages to get the shape, find the entry points, and trace one request all the way from the endpoint to the database and back — I did exactly that on my own project and it is the thing that made the architecture stick. Then take the smallest ticket available and ship it, because I learn the parts I actually walk through, and I would rather have one merged change by Wednesday than a complete mental model by never.”

Golden rules

Five habits for code that is not yours.

  1. Never read a codebase without a question.Take a ticket, follow one thread, and let the map build itself out of routes you actually walked. Reading breadth-first is how three weeks pass with nothing retained.
  2. Build and test before you read.A green build is the ground you stand on. If it is red on a fresh clone, that is the first real thing you have learned about the team, and fixing it is a genuinely useful first contribution.
  3. In unfamiliar code the debugger is a reading tool.Through proxies, framework code and three layers of indirection, stepping settles in ten seconds what grep will not settle in an hour.
  4. Characterise before you change.A test that pins what the code does today — not what it should do — is what makes a change in untested legacy code safe. Then the diff tells you exactly what you altered.
  5. Ask with a symptom, evidence and a hypothesis.Thirty to sixty minutes of real effort, then a question with a closed end. It costs a colleague two minutes instead of an afternoon, and it is one of the few chances they get to see how you think.
Chapter 35

Working with an assistant, without becoming one

“Familiarity with AI development tools” now appears on junior Java adverts — including, in the survey behind chapter 36, a bank hiring an AI-driven junior backend developer. This chapter is about the part that decides whether it helps you or hollows you out.

What the advert is asking

Not that you can prompt. That you can use a generator and still be accountable for what ships — which is a review skill, not a typing skill.

Three lines from real 2026 listings, and what each one means in practice:

“AI-driven junior backend developer”

banking group · Milano

Java and Spring Boot, plus data pipelines in a cloud-native context. The AI is in the product, not the process. Ordinary backend work next to a model.

“Proficiency with AI tools”

product company · Bologna

They mean an assistant in the editor, and they mean it as a productivity expectation. Answer with how you review what it produces.

“Recent experience using AI assistants”

consultancy · Milano

Often a screen for “has worked recently”. The interesting follow-up is always where it went wrong for you.

The answer that lands, and the one that does not

Bad: “I use it for everything, it's much faster.” That tells an interviewer you may not be able to defend your own pull requests.

Good: “I use it heavily for the boring parts — boilerplate, test scaffolding, a first draft of a query, explaining unfamiliar code. I review everything it writes the way I would review a colleague's, and I have learned it is most confident exactly where it is most wrong: transaction boundaries, lazy loading, anything that depends on how this specific framework behaves at runtime.”

Where it is genuinely good, and where it is quietly dangerous

The line is not difficulty. It is whether being wrong shows up immediately.

Delegate freely

Boilerplate. A DTO, a mapper, twenty getters, a builder. Wrong is obvious and the compiler finds it.

Test scaffolding. The arrange block, fixtures, parameterised cases you would not have bothered to enumerate.

Explaining unfamiliar code. Paste the 600-line method from chapter 34 and ask what it does. You verify against the code in front of you.

The syntax you always forget. A regex, a bash pipeline, the shape of a CriteriaBuilder query.

A first draft to react to. Often the fastest way to work out what you actually want is to reject something specific.

Verify line by line

Anything about transactions. Generated code will happily put @Transactional on a private method or call it from inside the same class — the self-invocation trap from chapter 11. It compiles, it runs, it does nothing.

Anything about fetching. Lazy versus eager, fetch joins, N+1. The code works in a test with an open session and fails detached.

Concurrency. Missing locks and shared mutable state produce code that passes every test and fails under load.

Security. Plausible-looking authorisation that checks the wrong thing, or an error response that leaks internals.

Anything with a version. Library APIs drift; confidently generated calls to methods that no longer exist are routine.

In plain words

It is an extremely well-read colleague who has never run the code. They have seen a million Spring projects and can produce something shaped exactly right in four seconds. They have never once watched your transaction commit, so when the answer depends on what happens at runtime rather than what things usually look like, their confidence stays high and their accuracy does not. Everything in the right-hand column above is a runtime question wearing a syntax costume.

The trap this course has been building towards

Chapter 20 showed a test in this repository that passes for the wrong reason — the missing entityManager.clear(), so a lazy getter just fires another query and the broken fetch plan goes unnoticed. Generated tests produce that exact failure mode constantly, because a test that passes looks correct and the model optimises for looking correct.

So: after generating a test, break the code on purpose and check the test fails. A test you have never seen fail is not evidence of anything. That is scripts/break.sh, and it matters more now than it did before generators existed.

The habits that keep you accountable

Five rules, all of which reduce to: you sign the commit, so you have to be able to defend every line in it.

Never commit code you could not have written.
Not “would not have” — could not. If you cannot explain why a line is there, you cannot review it, debug it at 3am, or answer for it in a code review. Ask for an explanation until you can, or throw it away.
Review it as a stranger's pull request.
The danger is that generated code arrives feeling like your code, so it skips the scrutiny you would give a colleague's. It should get more, not less: a colleague at least ran it once.
Small, checkable asks beat one large one.
“Write the enrollment feature” produces something plausible and unverifiable. “Write the JPQL for courses with free seats in a given semester” produces something you can read in ten seconds and test in thirty.
Make it show its work.
Ask why that annotation, what happens if two requests arrive together, which queries this will issue. Answers that dissolve under a follow-up were never grounded — and the questions are the same ones an interviewer will ask you.
Learn the thing you delegated, once.
Generating a fetch join is fine. Generating your fortieth fetch join without ever having learned what it does means you now cannot debug the one that is wrong. Delegate the typing; keep the understanding.
Two lines that end careers early

Never paste proprietary code, credentials or customer data into a tool your employer has not approved. Many companies have a specific policy and an approved instance; some sectors — banking, health, public administration, which is most of the Italian market — have legal constraints on top. Ask on day one, before you need to know. “I checked what the policy was” is a good sentence; the alternative is a disciplinary conversation.

And never claim generated work as unaided in an assessment. Take-home tests increasingly come with a follow-up call whose entire purpose is to ask why you made a choice. Being unable to explain your own submission is worse than not submitting.

The part that actually matters for a junior

The risk is not that a generator writes bad code. It is that it removes the struggle that was making you good.

Chapter 40 exists because retrieval builds memory and rereading does not. The same mechanism runs here, with higher stakes: watching a correct answer appear feels like learning, in exactly the way rereading a chapter feels like learning, and neither is. The failed attempt is the part that works.

This is a real trade-off with a practical resolution rather than a moral one:

WhenWhat to doWhy
Learning something newStruggle first, generate second, then compare.The comparison is where the learning is — and you will spot what your version got right, which is a real signal.
Doing something you knowDelegate it and review carefully.You can already evaluate the output, so the risk is low and the saving real.
Preparing for an interviewDo it unaided. All of it.The interview is unaided. Practising with a tool you will not have is practising the wrong thing — the katas in exercises/ exist for exactly this.
DebuggingForm your own hypothesis before asking.Otherwise you get a plausible theory you cannot evaluate, and you will chase it. Chapter 23's method still comes first.
What makes you employable in 2026

Not typing speed — that was the part that got automated. It is judgement: knowing why the seat count needs a row lock, recognising a broken fetch plan on sight, choosing 409 over 400, and being able to say what your tests structurally cannot catch. Every one of those is a thing you can defend and a generator cannot originate, and every one of them is a chapter in this course.

An assistant hands you a service method annotated @Transactional that calls another @Transactional method on the same class. It compiles and the tests pass. What do you say in review?

That the inner annotation does nothing. Interceptors work through a proxy; a call from inside the object goes straight to this and never crosses it, so the second method runs in the caller's transaction whatever it declares — the self-invocation trap from chapter 11. The tests pass because they only assert the happy path, where sharing a transaction is harmless. It becomes a bug the day someone relies on REQUIRES_NEW to survive a rollback. This is the archetype of generated code that is shaped right and wrong at runtime.

“How do you use AI in your work?” — what is the strongest thirty-second answer?

Name what you delegate, name what you do not, and give one concrete failure you caught. “Boilerplate, test scaffolding and explaining unfamiliar code, yes. Transaction boundaries, fetch plans and anything concurrent I write myself, because that is where it is confidently wrong — it once gave me a repository test that passed only because the persistence context was still open, and it would have hidden a missing fetch join. I review everything it writes as if it came from someone else, because I am the one signing the commit.”

Golden rules

Five rules for staying the engineer rather than the conduit.

  1. You sign the commit.Never ship a line you could not have written and cannot explain. Authorship is not who typed it — it is who is accountable for it in review, in production and in the interview.
  2. It is most confident where it is most wrong.Transactions, lazy loading, concurrency, security, anything version-specific. All runtime behaviour, all invisible to something that has never run your code.
  3. Watch every generated test fail once.Break the code on purpose. A generated green test is the most convincing way to be told nothing at all — and it is the exact trap chapter 20 documents in this repository.
  4. Struggle first when you are learning.Generate second, then compare. Watching a correct answer appear feels like learning in precisely the way rereading does, and neither is retrieval.
  5. Check the policy before you paste.Proprietary code, credentials and customer data go only into tools your employer has approved. Ask on the first day, not the day you need it.
Chapter 36

What junior Java adverts actually ask for

Chapter 01 mapped one job description. This chapter is what happens when you read thirty of them — live listings in Bologna, Modena, Milano and Tirana — and count what they have in common. Every requirement below points at the chapter that answers it.

How this was gathered, and what it is worth

A snapshot, read by hand on 4 September 2026, of junior and entry-level Java listings on the Italian aggregators, plus published employer requirements for the Tirana market.

Roughly thirty listings across the four cities carried a genuine requirement list rather than a paragraph of marketing. Those are what the counts below come from. Two honest caveats before you read a single number:

Read the numbers as a shape, not a statistic

Aggregator listings duplicate heavily — the same agency advert reappears under four titles — and every board over-represents consultancies, because consultancies advertise continuously and product companies advertise when they need someone. A hand-counted sample of thirty tells you reliably what the top of the list is. It does not tell you that Kubernetes appears in exactly 17 per cent of Italian junior roles.

What survives that caveat is still the most useful thing in this course, because the top of the list is remarkably stable across all four cities, and it is short.

The ranking

Share of the listings that named each thing. Red is what is asked as a requirement; green is what is normally filed under nice to have — which, for a junior, is where the competition thins out.

Java — SE, usually “8+” or “11+”
~100%
SQL and one relational database
~90%
Spring / Spring Boot
~83%
Git
~70%
REST APIs, HTTP, JSON
~67%
A degree in Informatica or Ingegneria
~67%
Hibernate / JPA
~57%
English, B1 to B2
~53%
Maven (occasionally Gradle)
~47%
Agile / Scrum
~47%
Clean code, OOP, SOLID, design patterns
~43%
Angular or React, JavaScript, TypeScript
~43%
Unit testing — JUnit, Mockito
~40%
Microservices
~37%
Docker
~30%
CI/CD — Jenkins, GitLab CI, Actions
~30%
An application server — JBoss, WildFly, Tomcat
~23%
A cloud — AWS, Azure, GCP
~20%
Kubernetes
~17%
NoSQL, Kafka, message queues
~13%

Three observations that are worth more than the ranking itself.

The top five are not negotiable and they are all in this course. Java, SQL, Spring, Git, REST. Nothing above 60 per cent is exotic, and a candidate who is genuinely solid on those five outperforms one who has heard of everything on the list.

Testing sits lower than it deserves. Only about four listings in ten name JUnit, and yet it is asked about in almost every technical interview, because it is the fastest way to tell a developer who has worked on a team from one who has not. This is the single largest gap between what adverts say and what interviews ask.

The green half is where a junior wins. Every applicant writes “Java, SQL, Spring”. Perhaps one in five can talk about a Jenkins pipeline, a rebase, or why a liveness probe should not touch the database. That is chapters 19–21 and 33, and they are the shortest chapters in the book.

The soft-skill line, which every single advert has

“Capacità di lavorare in team, problem solving, precisione, curiosità, proattività, buona organizzazione personale.” It is boilerplate, and it is still assessed — in the project conversation, not with a question. Chapter 22 is what it looks like when it is real: how you take a code review, how you say you are blocked, what you do on day three when the ticket is bigger than you were told.

Four cities, four flavours

The stack is nearly identical everywhere. What changes is who is hiring, and that changes what the first two years look like.

Bologna

Emilia-Romagna · the deepest junior market of the four

Two clear populations. Enterprise and banking work — J2EE, Spring, Hibernate, JAX-WS, JBoss, Maven, Git, Angular, Oracle, SQL in a single junior advert, which is almost exactly this course's syllabus. And product companies advertising Spring Boot with microservices, or Java with Node and React on an internship contract.

Spring BootHibernateOracleAngularJBossmicroservizi

Modena

Emilia-Romagna · smaller, more industrial

Fewer pure-Java junior openings, and the ones that exist lean toward ERP, manufacturing and internal tools. Typical junior wording is deliberately soft: “predisposizione allo sviluppo software con linguaggio Java EE”, then Spring, Spring Boot and Hibernate; or simply “conoscenza base di linguaggi di programmazione” plus SQL. Expect more C#/.NET and PLC roles competing for the same graduates.

Java EESpringHibernateSQLERP

Milano

Lombardia · the largest volume, and the highest pay

Consultancies and the big four, banking and insurance, and an increasing number of listings that attach AI or data engineering to a backend role — “junior backend developer, Java/Spring Boot, cloud-native”. Hybrid three days on site is close to standard. Salaries run visibly above the national average and so does cost of living.

Spring BootRESTcloudKubernetesAngularAgile

Tirana

Albania · strong junior and internship pipeline

An unusually explicit market. Published internship and junior descriptions name the syllabus outright — JDBC, exceptions, Maven, Hibernate and Spring Data JPA, Spring Boot, RESTful web services, clean code, plus HTML/JavaScript/CSS and SQL. Several employers publish the process itself: HR screen, online technical assessment, technical interview, decision.

Java 11+Spring BootJPASOLIDReactAngularSQL/NoSQL

The named employers behind those cards, so you can go and read the current wording yourself rather than trusting a summary: in Bologna, ACTION ICT, CRIF, Aqrate, Esprimo, Gruppo Finmatica, Gruppo Unipol; in Modena, Bisy and the agency listings around Mirandola, with Var Group and Log.it advertising at senior level; in Milano, ACTION ICT again, Softlab, PwC, Intesa Sanpaolo, Avanade, Gi Group; in Tirana, Sisal, ikubINFO, Lutech, Lufthansa Industry Solutions, TeamSystem, ATIS and Ritech.

The money, and the contract

Advertised figures for a junior Java role, from the same September 2026 snapshot. Gross annual salary — RAL, retribuzione annua lorda — which is the number every Italian offer is expressed in.

~26k
national average, junior
23–28k
Bologna / Modena
27–33k
Milano
~1.55k
net per month, at 26k

The national junior average from the salary aggregators clusters around €25,800–€26,800 gross. Board averages for the search terms used here came out near €22,800 for Bologna and Modena and around €32,500 for Milano, though the Milano figure is inflated by senior listings sharing the same keyword. Individual junior adverts that publish a range mostly sat between €27,000 and €32,000 in Milano and €25,000 and €30,000 in Emilia-Romagna. Milano pays visibly more and costs visibly more.

The contract type matters at least as much as the number, and this is the part nobody explains to a graduate:

What the advert saysWhat it meansWhat to check
apprendistatoApprenticeship contract, typically three years. Lower gross, big tax relief for the employer, and it is meant to convert to permanent.That there is a written training plan and a named tutor. This is the standard, reasonable route for a graduate.
tempo indeterminatoPermanent. Common even for juniors at consultancies, because they are hiring continuously.The probation period (periodo di prova) and the CCNL level.
stage / tirocinioInternship, often with only a rimborso spese — an expense allowance, not a salary.Duration, the allowance, and specifically what happens at the end. Six months is normal; a second consecutive one is a warning.
somministrazioneStaffing-agency contract. You are employed by the agency and work at the client.Length, and whether the client has a history of hiring people on.
partita IVAYou invoice as a freelancer. No sick pay, no holiday, no severance, and you carry your own tax and pension.Rarely appropriate for a first job. If it is offered, the rate must be substantially higher — it is not comparable to a RAL.
CCNL Metalmeccanico / CommercioThe national collective agreement, which fixes minimum pay, holiday and notice by livello. Metalmeccanico usually pays a junior developer better than Commercio.Which CCNL and which level. It is a fair, ordinary question to ask, and asking it signals you know how the system works.
presso il clienteConsultancy placement — you are hired by one company and work inside another.Which client, for how long, and what happens between assignments. See below.
In plain words

Two kinds of company will offer you a job. A product company builds one thing, and you learn it deeply — slower hiring, harder interview, better second year. A consultancy places you inside other companies — much easier to get into as a junior, and you may see three stacks in two years, or you may spend eighteen months maintaining somebody's 2011 JSF application. Neither is a trap. But they are different jobs, and the advert usually tells you which one it is with the phrase “presso il cliente”.

Questions worth asking at the end of the interview

Which client and which project, specifically. Who reviews my code. How long is the onboarding. Is there a test suite, and does the pipeline run it. What did the last person hired into this role do in their first three months. Those five questions tell you more about the next two years than the salary does, and asking them is itself a signal.

Every requirement, and the chapter that answers it

The map from chapter 01, extended to everything the wider survey turned up. If you can answer every left-hand column, you are done.

Conoscenza del linguaggio Java (8+ / 11+), OOP
ch. 04 Java laws · 05 Collections · 06 Threads
SQL e database relazionali (Oracle, PostgreSQL, MySQL, SQL Server)
ch. 07 SQL · 08 Persistence
Spring, Spring Boot, Spring MVC, Spring Data
ch. 16 Spring Boot · 17 Spring REST · 18 Rosetta stone
Hibernate / JPA, mapping, lazy loading
ch. 08 Persistence · 09 Lifecycle
Sviluppo di API REST, web services, JSON
ch. 12 REST · 13 The boundary
Transazioni, concorrenza, integrità dei dati
ch. 11 Transactions · 07 SQL
Versionamento del codice (Git)
ch. 19 Git
Maven, gestione delle dipendenze, build
ch. 02 Foundations · 16 Spring Boot · 27 Deployment
Clean code, OOP design, SOLID, design pattern
ch. 31 Design and SOLID · 14 Domain
Unit testing (JUnit, Mockito), qualità del codice
ch. 20 Testing
Metodologie Agile / Scrum, lavoro in team
ch. 22 Agile and the team
Buona conoscenza della lingua inglese (B1–B2)
ch. 24 English for developers
JavaScript, TypeScript, Angular o React
ch. 32 The front end
Application server: JBoss / WildFly / Tomcat, Java EE
ch. 02 Foundations · 27 Deployment
Problem solving, precisione, autonomia, proattività
ch. 23 Debugging · 22 Agile

And the nice to have block, which is where the gap between candidates is widest:

Processi di continuous integration (Jenkins, GitLab CI, Actions)
ch. 21 CI and Jenkins
Architetture a microservizi
ch. 33 Microservices
Docker, container, Kubernetes
ch. 33 Microservices
Cloud: AWS, Azure, GCP
ch. 33 Microservices · 27 Deployment
NoSQL, code di messaggi, Kafka
ch. 33 Microservices · 10 Injection
Sicurezza, secure coding, autenticazione
ch. 15 Security
Performance, profiling, tuning
ch. 25 Performance
Job schedulati, batch notturni
ch. 26 Scheduled jobs
An advert asks for “1 anno di esperienza” and you have none. Do you apply?

Yes. In the Italian junior market that line is a filter against complete beginners, not a legal threshold, and academic or personal projects count — several of the listings in this survey say “esperienza anche accademica” outright. What actually disqualifies you is applying with nothing to show. A repository you can walk someone through, with a README, tests and a pipeline, answers the experience question better than a year of doing tickets you cannot describe.

A listing wants “J2EE, Spring, Hibernate, JAX-WS, JBoss, Maven, Git, Angular, Oracle, SQL”. You have this course and this project. Which of the ten can you honestly claim?

Eight of the ten, at junior depth, and you can name the file for each: Java EE and JBoss because you deployed a WAR to WildFly by hand; Hibernate, Maven, Git, SQL and Oracle-family SQL from the whole middle of the course; Spring from chapters 16–18; Angular at the level of chapter 32. JAX-WS — SOAP — is the one genuinely missing piece, and the right move is to say so. A candidate who volunteers the one gap in a list of ten is far more credible than one who claims all ten.

Where this came from

Read the current wording yourself. Listings churn weekly, and a live advert beats any summary of one — including this chapter.

Golden rules

Five conclusions from thirty adverts, none of which is a technology.

  1. Be genuinely solid on five things before being vaguely aware of twenty.Java, SQL, Spring, Git, REST are in almost every listing and in every interview. Depth in those beats a CV that lists Kubernetes you have never run — and the second is easy to expose with one follow-up question.
  2. The nice-to-haves are the differentiator, and they are cheap.A pipeline, a Dockerfile, a rebase, a written test. Each is an afternoon. Together they move you out of the pile where every CV says the same five words.
  3. Name the gap before the interviewer finds it.“I have not used JAX-WS” costs nothing and buys credibility for the other nine claims. A candidate who has never said “I don't know” is a candidate nobody believes.
  4. Read the contract as carefully as the tech list.Apprendistato, somministrazione, stage con rimborso spese and presso il cliente describe four very different first years. The RAL is one number; the CCNL level, the client and who reviews your code decide what you will be worth in two years.
  5. One project you can walk through beats a year of unexplainable experience.Every advert asks for experience; every interview asks you to describe something you built. This repository, with its README, its tests and its pipeline, is an answer to both. Chapter 38 is how to tell it.
Chapter 37

The CV, the repository, and the email

Chapter 36 ends at the advert and chapter 38 begins at the interview. Between them sits the step where most applications die — not because the candidate was weak, but because nothing in the application gave anyone a reason to spend twenty minutes on them.

What actually happens to your CV

It gets somewhere between twenty seconds and two minutes, from someone reading forty of them, who is not an engineer, looking for the words in the advert.

Two consequences, and they are the whole strategy. The words matter more than the prose, because the first pass is a match against the requirement list — and at larger companies part of that pass is automated. And the evidence matters more than the adjectives, because the second reader is an engineer who has been told “is this person worth an hour”.

Instead ofWriteWhy
“Good knowledge of Java”“Java 21 · Jakarta EE 11 · JPA/Hibernate · JAX-RS · CDI”Named technologies match the advert; adjectives match nothing.
“Familiar with databases”“PostgreSQL · SQL · JPQL · indexes and execution plans · transactions and isolation”The second version is searchable and is a set of interview topics you have chosen.
“Team player, problem solver”Nothing. Delete the line.Everyone writes it, it is assessed in conversation, and it costs you space that evidence could use.
“University project”“REST API for university enrollment — Jakarta EE 11, PostgreSQL, 60 tests, CI pipeline. Handles concurrent enrollment on the last seat with pessimistic locking.”One sentence that is specific, checkable, and invites the exact question you want to be asked.
The trap

Do not list a technology you cannot discuss for two minutes. Every word on that page is a question you have invited, and the fastest way to lose an interview is to have written Kubernetes because it was on the advert. A short honest list beats a long aspirational one — and the gap you leave off is one you can then volunteer, which chapter 36 argued buys credibility for everything else.

Shape, for an Italian junior CV

One page. Contact details, then a two-line summary, then skills grouped by kind (language, framework, database, tools), then projects with links, then education, then languages with levels (inglese B2). Work experience goes above projects only when it is relevant. No photo is now the safer default; no date of birth; no marital status. The GDPR consent line at the bottom is still conventional in Italy and costs nothing: “Autorizzo il trattamento dei dati personali ai sensi del Regolamento UE 2016/679.”

The repository is the application

Every advert asks for experience you do not have. A repository someone can open is the closest available substitute, and almost nobody sends one that is ready to be opened.

The engineer who clicks your link gives it about ninety seconds. They will look at the README, the shape of the folders, whether there are tests, and the commit history — in roughly that order. What they are deciding is not “is this impressive”. It is “has this person worked the way we work”.

They look atThey are askingCheap fix
The README, first screenWhat is this, and how do I run it?Two sentences and two commands, above the fold. Nothing else matters as much.
Whether there are testsDo they know what a test is for?A count and a command. “60 tests, mvn verify” answers it instantly.
The commit historyHave they worked with other people?Messages that say why, not “update” × 40. Chapter 19 is this exact skill.
Folder namesDo they think in layers?api / service / repository / domain tells the story before a file is opened.
A CI badgeDo they know pipelines exist?A GitHub Actions workflow is twenty lines and it is the single highest-signal-per-minute item on this list.
Whether it runsWill a demo waste my time?A seeding script, or a compose file, so a stranger reaches a working state in one command.
In plain words

A repository without a README is a flat with no address on the door. It might be beautiful inside. Nobody is going to try forty keys to find out. The README is not documentation — it is the doorbell, and writing it is thirty minutes that decides whether the previous three months get looked at.

Say what is missing

A short Known limitations section at the bottom of the README — “no authentication; bound to localhost, which is not a security model” — reads as engineering judgement rather than as a confession. It also pre-empts the reviewer's first criticism by making it your observation. This repository does exactly that, and it is one of the more copyable things about it.

The message you send

Short, specific, and about them. Three sentences beats three paragraphs, because the third paragraph is not read.

The universal mistake is a message about how much you would learn. The recruiter is not buying your growth; they are buying a person who can do the ticket. Say what you have, name the thing in their advert, and give a link.

Examplethe same message, in both languages
Italiano

Buongiorno,

vi scrivo per la posizione di Junior Java Developer (rif. XYZ).

Ho sviluppato un'API REST per la gestione delle iscrizioni universitarie in Jakarta EE 11 e PostgreSQL: livelli separati (API, servizi, repository), 60 test tra unit e integration, pipeline CI e gestione della concorrenza sull'ultimo posto disponibile tramite lock pessimistico. Il codice è qui: [link].

Nell'annuncio vedo Spring Boot e Angular: ho portato lo stesso endpoint su Spring Boot per confrontare i due modelli, mentre su Angular parto da basi di TypeScript e React.

Resto a disposizione per un colloquio.

English

Dear [name],

I am writing about the Junior Java Developer position (ref. XYZ).

I built a REST API for university enrollment in Jakarta EE 11 and PostgreSQL: separated layers, 60 unit and integration tests, a CI pipeline, and concurrent access to the last free seat handled with pessimistic locking. The code is here: [link].

Your advert mentions Spring Boot and Angular. I have rebuilt the same endpoint in Spring Boot to compare the two models; on Angular I am starting from TypeScript and React fundamentals.

I would be glad to talk.

Four things are doing the work there. It names the reference. It gives one concrete technical fact that no template would produce — the row lock. It links to something openable. And it addresses the gap in their list honestly, which is the sentence a hiring manager remembers.

The trap

Sending the identical message to thirty companies is visible, and it is visible in a specific way: nothing in it could only have been written for them. One sentence referring to something in their advert — the stack, the sector, the client, the internship programme — is the difference between a template and an application. Fifteen well-aimed applications beat sixty copies, and take less total time.

LinkedIn, and where the adverts actually are

In the Italian junior market a substantial share of hiring starts on LinkedIn, and a lot of it never reaches a public job board at all.

The headline is a search field, not a job title.
“Junior Java Developer · Spring Boot, SQL, REST” is findable. “Aspiring developer passionate about technology” matches nothing a recruiter types.
Turn on “open to work”, recruiters-only.
It is the single mechanical thing that changes how many messages you get, and the recruiters-only setting keeps it off your public profile.
Set the location to the city you want.
Searches are geographic. If you would move to Milano or Bologna, say so, or you will not appear in the search that matters.
Apply within the first days.
Junior postings attract hundreds of applications and many are effectively closed in a week. Recency beats polish here.
Apply to the consultancies too.
They hire continuously, which is why they dominate the boards. Read chapter 36's contract section first, and ask which client and which project — but as a route to a first job they are a legitimate one.
Use the university channels.
Career services, the placement office, thesis-linked internships, and companies that already recruit from your course. Far less competition per position than any public board.
Expect silence, and plan around it

Most applications get no reply. That is the base rate for junior technical roles everywhere, not a verdict on you. Keep a simple sheet — company, role, date, contact, outcome — because after thirty applications you will not remember, and because it converts a demoralising process into a countable one. Follow up once, politely, after ten working days, and then let it go.

You have no professional experience. What goes in the “experience” section?

Projects, treated exactly like jobs: a title, a period, a stack, and two or three lines of what you did and what it handles. A project with tests, a pipeline and a README is genuine engineering evidence, and it is assessed as such. What does not belong there is coursework listed as coursework — “Object-Oriented Programming, 30/30” is education, and it goes in the education section where a reader can find it if they care.

An advert lists ten technologies and you have eight. Do you apply?

Yes, and you say which two you do not have. Advert requirement lists are wishes, not thresholds — they are frequently written by combining several people's preferences, and nobody expects a junior to satisfy all of them. What loses the interview is claiming ten and failing the follow-up question on the ninth. Chapter 36's version of this: a candidate who volunteers one gap out of ten is more credible on the other nine, not less.

Golden rules

Five rules for the step between the advert and the interview.

  1. Name technologies, delete adjectives.The first pass matches your page against the advert's words. “Good knowledge of Java” matches nothing; “Java 21, JPA, JAX-RS, PostgreSQL” matches the list they are holding.
  2. Never list what you cannot discuss for two minutes.Every word on the CV is a question you invited. A short honest list beats a long aspirational one, and the gap you leave off is one you get to volunteer.
  3. The README is the doorbell.Two sentences and two commands, above the fold, plus the test count and how to run them. Ninety seconds is what your repository gets, and most of it is spent on that first screen.
  4. One sentence that could only have been written for them.Their stack, their sector, their reference number. It is the entire difference between an application and a template, and fifteen aimed ones beat sixty copies.
  5. Track it, and expect silence.No reply is the base rate, not a verdict. A sheet with company, date and outcome turns an emotional process into a countable one — and tells you which of your two versions of the CV is working.
Chapter 38

A hundred and forty-eight questions, and the hour they arrive in

The self-test in chapter 40 asks what this project taught you. This chapter asks what an interviewer asks — the standard junior question bank, drawn from published employer requirements and real candidate reports, filterable by topic and by how likely it is to come up.

The shape of the hour

Junior interviews are far more predictable than they feel. Almost all of them are the same seven blocks in the same order, and knowing the order is worth several points on its own.

StageTimeWhat is actually asked
HR / introduction5–15 minTell me about yourself. Why this company. What are you looking for. Notice period and expectations.
Project discussion10–20 minExplain one project end to end. Your exact contribution. The hardest bug. What you would change.
Java and OOP15–25 minThe four pillars; interface versus abstract class; equals/hashCode; collections; exceptions; one small coding problem.
Spring / backend10–20 minDependency injection; Spring Boot; a REST controller; JPA; transactions; DTO versus entity.
SQL10–15 minJoins, GROUP BY, indexes, keys, and one query written by hand.
Front end (if full-stack)10–20 minJavaScript basics; props, state and hooks; calling an API; controlled forms.
Testing and Git5–10 minUnit versus integration; mocks; branching workflow; what happens in code review.
Your questions5 minNot a formality. Chapter 36 lists the five worth asking.

Two structural notes from the employer research behind chapter 36. Several companies publish the process itself — HR screen, then an online technical assessment, then the technical interview — so the coding problems may arrive before a human ever speaks to you. And at least one employer's reported style is deliberately not textbook: questions about production incidents, obstacles in a real project, and decisions made with incomplete information. Prepare the bank below, but do not prepare only the bank.

In plain words

An interview is not an exam, it is a conversation with an audition in the middle. Nobody is scoring your recall. They are working out one thing: what will it be like to sit next to you when something is broken and neither of us knows why. That is why “I don't know, but here is how I would find out” is a genuinely good answer, and a confident wrong answer is the worst one available.

How to answer a definition question well

Four beats, in this order: define it in one sentence, give a small example, say why it matters, then name one pitfall. “A transaction is a unit of work that commits or rolls back as a whole. In my project, enrolling a student reads the course row under a lock, counts seats and inserts — all three or none. It matters because otherwise two students take the last seat. The pitfall is that a checked exception does not roll back by default.” That is fifteen seconds and it ends four separate follow-up questions.

The question bank

Filter by topic, or by priority: P1 is must-know before any interview, P2 is likely, P3 is a differentiator. Answer out loud before you reveal — retrieval is the mechanism, reading is not.

Score 0 right 0 missed of 148

Topic

Priority

The coding problems, and where to rehearse them

Fifteen exercises cover almost every junior coding task, whether it arrives as a whiteboard question or an online assessment. None is difficult. All of them are embarrassing to fumble.

ExerciseWhat it is really testing
Reverse a string without a library helperLoops and indices; can you say O(n) time, O(n) space.
Palindrome checkTwo pointers, and whether you ask about case and punctuation before coding.
Find duplicates in an arrayHashSet.add() returning false. The idiomatic answer, not the nested loop.
First non-repeated characterA frequency map plus a second pass — and knowing that insertion order matters, so LinkedHashMap.
Count word or character frequenciesmerge or computeIfAbsent. Chapter 05 territory.
Missing number from 1..nThe arithmetic sum, or XOR — and spotting the integer overflow.
Merge two sorted listsTwo indices advancing independently. The classic off-by-one.
Binary searchO(log n), and the mid = low + (high - low) / 2 overflow fix.
Second-highest distinct valueOne pass with two variables, and asking how ties should behave.
Balanced parenthesesA stack. Also the standard test of whether you know what a stack is for.
A query with JOIN, GROUP BY and HAVINGChapter 07. This one is asked more often than any Java problem.
A Spring Boot CRUD endpointChapter 17. Controller, service, repository, and the right status codes.
A unit test with one mocked repositoryChapter 20. Arrange, act, assert — and mocking the boundary, not everything.
A React form with controlled inputsChapter 32. State as the single source of truth.
Fetch an API in React with three statesChapter 32. Loading, error, success. The error branch is the one being marked.

You do not need a separate practice site for most of these. This repository already has a place to write them: src/main/java/it/unicam/cs/enrollment/exercises/ holds four graded exercises with tests, and docs/EXERCISES.md explains them. Solve the list above in the same package, with a JUnit test each, and you have simultaneously rehearsed the coding round and produced evidence for the project round.

The trap

The most common way to fail a junior coding round is not getting the algorithm wrong — it is going silent. The interviewer cannot mark what they cannot hear. State the approach before you type, say the complexity when you finish, and name the edge cases you are not handling: empty input, a single element, null, duplicates. A candidate who says “this breaks on an empty array and here is the guard” scores above one who silently handles it.

Telling the story of this project

The project question is the one block you can fully prepare, and the one most candidates improvise. Ninety seconds, six beats, rehearsed out loud until it is boring.

beat 1The problem, in one sentence. “A university enrollment API: students take seats on courses, with prerequisites, capacity limits and grades.”
beat 2The shape. “Jakarta EE 11 on WildFly with PostgreSQL — REST resources, a service layer, JPA repositories, and a domain model that owns its own rules.”
beat 3One decision you can defend. “No endpoint returns a JPA entity; everything goes through a mapper, so the API contract cannot change because someone renamed a column.”
beat 4The hardest thing. “Two people taking the last seat at the same time. I read the course row with a pessimistic lock so the second request waits and then sees the true count.”
beat 5How you know it works. “Unit tests with a mocked repository, plus integration tests that clear the persistence context first, so a missing fetch join actually fails.”
beat 6What you would change. “There is no authentication — it is bound to localhost, which is not a security model. That is the next thing I would add.”

Beat six is the one juniors leave out, and it is the one that most reliably impresses. A candidate who names a real weakness in their own work is demonstrating the thing every interviewer is actually screening for: that you can look at code critically, including your own.

For anything that went wrong, use STAR — situation, task, action, result — and spend most of the time on action. “The enrollment endpoint threw LazyInitializationException in production but never in tests” is a situation. The action is the interesting part: read the stack trace, find that the mapper ran after the transaction closed, reproduce it by calling entityManager.clear() in the test, fix it with a fetch join, keep the test. That is chapter 23's method, told as a story, and it is worth more than any definition in the bank above.

“What is your biggest weakness?”

Pick something true, technical and bounded, and attach the thing you are doing about it. “I have not worked on a team with a real release process, so my instinct for what is safe to merge on a Friday is untested — that is partly why I built the pipeline in this project.” What fails is the disguised strength (“I care too much”), and what fails worse is naming a weakness in the exact skill the job requires.

“What do you do when you disagree with a senior developer?”

Ask first, argue second, commit third. Establish what they are optimising for — often it is a constraint you cannot see, like a client contract or an incident from last year. If you still disagree, make it concrete: a small experiment, a benchmark, a link. And once the team decides, support the decision, including in front of other people. Interviewers ask this because juniors who cannot do the third part are expensive.

The last seven days

If the interview is a week away, this is the order. It is deliberately not “reread the course” — at this range, retrieval beats reading by a wide margin.

DayFocusDo this, not just read it
1Java and OOPChapters 04–05 and 28. Filter the bank to Java and OOP, P1 only. Say every answer aloud.
2Collections and algorithmsWrite eight of the fifteen exercises from scratch, with a JUnit test each. Time yourself: twenty minutes per problem.
3Spring and persistenceChapters 16–18. Build one CRUD endpoint in Spring Boot. Then filter the bank to Spring.
4SQLChapter 07. Write fifteen queries by hand against this project's schema — joins, grouping, a subquery, an index decision.
5Front endChapter 32 if the role is full-stack. Build the fetch component with all three states. Otherwise: chapters 20–21, testing and CI.
6Craft and the project storyChapters 19–23. Then rehearse the six beats above, out loud, twice, without notes.
7Mock interviewThirty questions from the bank at random, two coding problems, the project story, and the chapter 40 self-test. Review only what you missed.

And on the day itself, the two hours before: chapter 18, the Rosetta stone, so every Spring annotation has a mechanism behind it; then chapter 39, the cheat sheet; then nothing. Two hours cannot teach you the material. They can organise what you already know into sentences, which is the whole job.

Golden rules

Five things that decide the hour, none of which is knowing more.

  1. Define, example, why, pitfall.Four beats for every definition question. The example is what separates you from someone who read a list last night, and the pitfall is what makes an interviewer write something down.
  2. “I don't know” is a complete answer when followed by a method.“I have not used Kafka. Coming from CDI events, I would expect at-least-once delivery to be the thing I have to design around — is that right?” turns a gap into a conversation. A confident wrong answer ends the interview quietly.
  3. Never go silent in a coding round.State the approach, narrate as you type, give the complexity, name the edge cases you are skipping. Unspoken thinking cannot be marked.
  4. Rehearse the project story until it is boring.Six beats, ninety seconds, ending on what you would change. This is the only block of the interview that is entirely within your control, and it is where most juniors improvise badly.
  5. Retrieve, do not reread.Answering and failing moves a fact into memory; rereading it makes you feel that it already is. In the last week, use the filters above to hunt for what you miss, and spend your time only there.
Chapter 39

The whole thing on one page

One diagram to redraw from memory, then every golden rule from the chapters before it — with the reasons hidden, because recalling one is worth more than rereading it.

One request, one page

Everything the earlier chapters took apart, put back together in the order it actually happens.

HTTP request POST /api/enrollments CorrelationIdFilter puts one id in the MDC Resource @Path @Valid translate, delegate, map a status Service @Transactional rules · events · the boundary Repository JPQL / Criteria 8 statements for 8 rules PostgreSQL FOR NO KEY UPDATE holds the seat one id, every log line no business rules here persistence context open from here until the commit errors leave here as problem+json, with the id container timer REQUIRES_NEW, no MDC commit → context closes → every entity detached → mapper → DTO → 201 Created CDI built every box above. Not one of them was created with new.
If you can redraw this from memory — the order of the boxes, where the dashed line starts and ends, and which door the timer comes through — you have the shape of the whole application.

Every rule

0 rules, grouped by the chapter they came from. Say why one is true, then tap it to check.

How to use this page

Read a card, cover it, and try to restate the rule in your own words before opening the reason. If a rule feels obvious but you cannot say why it is true, that is the chapter to reread — not the one you found difficult.

This page also prints cleanly: the navigation drops away and every reason is expanded, which makes it a usable revision sheet on paper.

Chapter 40

Answer before you look

Re-reading a chapter feels like learning and mostly is not. Trying to retrieve the answer — and failing — is what actually moves it into memory. Your verdicts are remembered between visits, so the questions you miss come back and the ones you know get out of the way.

How to use this

Say the answer out loud, or write it down, before you reveal. The struggle is the mechanism, not an obstacle to it.

Getting one wrong is more useful than getting it right — it tells you precisely which chapter to reread, and the failed attempt makes the correction stick harder than reading it cold would have. Mark yourself honestly.

Score 0 right 0 missed of 24

Where you actually are

One number, built so that the only way to move it far is to answer questions correctly after a delay. Reading a chapter is worth 15% of it, the end-of-chapter checkpoint 45%, and the spaced cards the remaining 40% — and the last of those can only rise days later.

0%
course mastery
Nothing answered yet.
0
day streak
A day counts once you answer anything. Yesterday still counts until midnight.
0 / 0
checkpoints passed
A checkpoint passes at 75% — at most one wrong out of four. Retake it as often as you like; the best score is the one that is kept, so going back to check something is never punished.

By part

A part is earned when every chapter in it reaches 80%.

Weakest first

The five chapters with the least evidence behind them. Start here rather than at the beginning.

    Why there are no points, badges or leaderboards

    Because the evidence on them is not flattering. The meta-analyses find that game elements reliably raise extrinsic motivation and barely move competence, and that the effect fades once the novelty does — while shifting attention from the material to the reward. A leaderboard also punishes exactly the person it should encourage.

    So what is measured here is the thing you actually came for: whether you can still answer, days later, without looking. The streak is the one concession, because turning up regularly is a real predictor and a day counter is honest about what it counts.

    Today's revision

    This page remembers what you got wrong. The queue below is drawn from both banks — these 91 questions and the 148 in the interview chapter — and shows only what is due.

    Answer a question correctly and it moves up a box and disappears for longer: ten minutes, then a day, then three, then a week, then three weeks. Miss it and it drops straight back to box 1 and returns in ten minutes. That is the whole schedule, and it is the reason twenty minutes a day beats four hours the night before.

    0
    due now
    0
    still learning
    0
    known
    0
    not yet seen

    Where this is kept, and what that means

    By default, in your browser's localStorage, on this machine only. Nothing is sent anywhere, no account is required, and clearing your site data loses it — which is the answer to the question chapter 33 asks about disposable state: if it is not in a database, it is not really saved.

    If you sign in (the button in the top bar, when WildFly is serving this page) the same record is also kept server-side and merged with whatever this browser knows, so it follows you between machines and survives a cleared profile. The merge is last-write-wins per item, with ties resolved towards keeping progress; the honest costs of that choice are written up in ProgressService and in docs/ACCOUNTS.md. Chapter 15 walks through how the sign-in itself is built.

    The questions

    Chapter 41

    Your setup, and what to try

    This application is installed natively on your machine — WildFly and PostgreSQL, no Docker.

    What the server holds right now

    Read live from your database when this page is served by WildFly.

    Live snapshot

    Not connected. Serve this page from WildFly to read live counts.

    Where everything lives

    ThingWhere
    Applicationhttp://localhost:8280/enrollment/
    API basehttp://localhost:8280/enrollment/api
    Admin consolehttp://localhost:10190 — admin / Admin#2026
    PostgreSQL 16.9localhost:5433 — enrollment / enrollment
    WildFly 41.0.1%USERPROFILE%\tools\wildfly-41.0.1.Final

    Ports are offset because 8080 and 5432 were already taken: WildFly runs with -Djboss.socket.binding.port-offset=200, which shifts every port by 200 at once. That is why the app is on 8280 and the console on 10190.

    Starting and stopping

    Neither service auto-starts. PostgreSQL must come up first — WildFly's connection pool tries to prefill at boot.

    start postgresthen wildfly
    pg_ctl.exe -D %USERPROFILE%\tools\pgsql\data -o "-p 5433" -l pg.log start
    
    standalone.bat -Djboss.socket.binding.port-offset=200

    %USERPROFILE% expands to your home directory in cmd. In PowerShell write $env:USERPROFILE instead, and wrap the path in quotes.

    The development loop is one command. Maven writes the WAR into docker/deployments/ plus a .dodeploy marker; WildFly's scanner is pointed at that folder and redeploys within a second or two.

    the loopedit → rebuild → live
    mvn package        # WildFly hot-redeploys. No restart.
    mvn verify         # all 60 tests, no server needed

    Experiments worth running

    • Watch the SQL. Logging is at DEBUG. Call /api/courses/open and read every statement Hibernate emits — that is Chapter 3 happening live.
    • Break the fetch plan. Delete LEFT JOIN FETCH c.prerequisites, run mvn package, and call /api/courses/55. Then put it back.
    • Trigger optimistic locking. Load a course, change its capacity twice from two clients, and watch the second attempt return 409.
    • Force self-invocation. Call one @Transactional method from another in the same class and observe that no transaction starts.
    • Chase a correlation ID. Send X-Correlation-Id: mine-1 on any request, then grep the server log for it.
    One caution

    scripts/api-tour.sh is not idempotent — it changes course capacity and creates enrollments. It reports 41/41 only against a freshly seeded database, so a second run showing failures is leftover data, not a broken build.

    Golden rules

    Operational rules for this machine, learned the hard way.

    1. PostgreSQL first, then WildFly.The connection pool prefills at boot. Start them the other way round and the deployment fails for a reason that looks nothing like “the database is not up”.
    2. mvn package is the whole loop.Maven writes the WAR plus a .dodeploy marker into the scanned folder and WildFly redeploys in a second or two. Restarting the server is almost never the answer.
    3. mvn verify needs no server and no database.60 tests against in-memory H2. Most of this fieldbook can be studied with WildFly stopped.
    4. api-tour.sh is not idempotent.It creates enrollments and changes capacity, so 41/41 only holds against a freshly seeded database. A second run showing failures is leftover data, not a broken build.