CicloUrbana's entities already live in tables, but they are isolated. A Bike does not know which Ribalta station it is parked at; a Rental stores userId and bikeId as loose numbers, with no guarantee at all that they point to anything real. The relational model we drew in 04-01 has arrows and we have not drawn a single one yet.
This lesson draws them. It is, by a wide margin, the part of JPA where most projects go wrong, because relationships introduce two behaviours that do not exist in an in-memory model: lazy loading —an object that pretends to be there and only goes looking for its data when you touch it— and cascading —an operation that spreads to neighbouring entities—. Misunderstood, they produce the LazyInitializationException everybody has suffered and the N+1 problem, the single most common cause of slowness in applications with an ORM. We are going to model each relationship with its correct annotation and, above all, with an understanding of what SQL it generates.
Contents
- CicloUrbana's complete relational model
- The four cardinalities and their annotation
@ManyToOne: the natural owning side@OneToManyandmappedBy- Owning and inverse side: the classic mistake
@OneToOnewith@MapsId@ManyToManyand why it is almost never a good ideaFetchType.LAZYversusEAGERLazyInitializationException- The N+1 problem
cascadeandorphanRemoval- Value collections with
@ElementCollection - Entity inheritance
- Common Mistakes and Tips
- Exercises
- CicloUrbana's complete relational model
erDiagram
STATIONS ||--o{ BIKES : "hosts (0..n)"
STATIONS ||--o{ RENTALS : "origin"
STATIONS ||--o{ RENTALS : "destination"
BIKES ||--|| SPEC_SHEETS : "has (1..1)"
BIKES ||--o{ RENTALS : "is rented in"
BIKES ||--o{ INCIDENTS : "accumulates"
USERS ||--o{ RENTALS : "makes"
USERS }o--o{ PROMOTIONS : "enjoys"
Each arrow translates into a different annotation:
| Relationship | Cardinality | Annotation in CicloUrbana |
|---|---|---|
Bike → Station |
Many to one | @ManyToOne on Bike |
Station → bikes |
One to many | @OneToMany(mappedBy = "station") |
Bike → SpecSheet |
One to one | @OneToOne with @MapsId |
Rental → User, Bike, stations |
Many to one (×4) | Four @ManyToOne |
User ↔ Promotion |
Many to many | @ManyToMany (or an intermediate entity) |
Bike → incidents |
One to many with cascade | @OneToMany + cascade + orphanRemoval |
- The four cardinalities and their annotation
| Cardinality | Annotation | Where the foreign key goes | Default fetch |
Example |
|---|---|---|---|---|
| Many to one | @ManyToOne |
In the "many" side's table | EAGER |
Bike → Station |
| One to many | @OneToMany |
In the other table (owning side) | LAZY |
Station → bikes |
| One to one | @OneToOne |
In the owning side's table | EAGER |
Bike → spec sheet |
| Many to many | @ManyToMany |
In a join table | LAZY |
User ↔ promotions |
Two columns deserve immediate attention. The default fetch column is a trap: @ManyToOne and @OneToOne load EAGER, that is, they always bring the related entity along, whether you use it or not; it is the source of most performance problems with JPA and in section 8 we will see that the solution is to put every one of them on LAZY. And the foreign key always lives in exactly one place: in a bidirectional relationship the physical column is in one of the two tables, and that is the one in charge. It is called the owning side, and it is the concept of section 5.
@ManyToOne: the natural owning side
@ManyToOne: the natural owning sideMany bikes are at one station. In the relational model, the bikes table has a station_id column:
@Entity
@Table(name = "bikes")
public class Bike extends AuditableEntity {
// ... id, plate, status, batteryLevel, version
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "station_id",
foreignKey = @ForeignKey(name = "fk_bikes_station"))
private Station station;
public Station getStation() { return station; }
public void setStation(Station s) { this.station = s; }
}| Element | What it does |
|---|---|
@ManyToOne |
Many bikes point at one station |
fetch = LAZY |
Do not load the station until it is used |
optional = true |
The column accepts nulls: a bike out on the street has no station |
@JoinColumn(name = ...) |
Name of the foreign key column |
foreignKey = @ForeignKey(name = ...) |
Names the constraint, instead of FK7a3b1c... |
@ManyToOne is the natural owning side and there is no choice about it: the foreign key can only be in the "many" side's table, because if it were in stations one row would have to point at several bikes. The practical consequence: to assign a bike to a station, bike.setStation(station) is enough, and it is the only operation that writes to the database.
optional is not cosmetic: with optional = false, Hibernate knows the association is never null, it can use INNER JOIN instead of LEFT JOIN and, in some cases, optimise lazy loading.
Rental accumulates four relationships of this kind, and it is a good example of why @JoinColumn needs an explicit name:
@Entity
@Table(name = "rentals")
public class Rental extends AuditableEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false) private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "bike_id", nullable = false) private Bike bike;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "origin_station_id", nullable = false) private Station originStation;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "destination_station_id")
private Station destinationStation; // null while the rental is in progress
}Two different relationships point at Station: without an explicit @JoinColumn, both would generate derived names that could collide or turn out incomprehensible. And destinationStation is null while the rental is in progress, which models a business fact rather than an oversight.
@OneToMany and mappedBy
@OneToMany and mappedByFrom the station we want to navigate to its bikes. Since the foreign key is already in bikes, this side is the inverse one: it writes nothing, it only reads.
@Entity
@Table(name = "stations")
public class Station extends AuditableEntity {
// ... id, name, address, capacity, location, version
@OneToMany(mappedBy = "station", fetch = FetchType.LAZY)
private Set<Bike> bikes = new HashSet<>();
public Set<Bike> getBikes() { return Collections.unmodifiableSet(bikes); }
/** Helper methods: they keep BOTH sides of the relationship in sync. */
public void addBike(Bike bike) {
bikes.add(bike);
bike.setStation(this);
}
public void removeBike(Bike bike) {
bikes.remove(bike);
bike.setStation(null);
}
}mappedBy = "station" literally means: "this relationship is already mapped by the station field of Bike; I merely mirror it". Without mappedBy, JPA would assume they are two independent relationships and would create a join table stations_bikes that nobody wants.
Why the helper methods are mandatory. Java does not synchronise references on its own. If you only do station.getBikes().add(bike), the in-memory collection contains the bike, but bike.getStation() is still null and, above all, the station_id column is not updated: when you read back from the database, the bike is not at the station. The object and the row contradict each other. addBike and removeBike encapsulate the double write so that forgetting it becomes impossible, and returning the collection as an unmodifiableSet reinforces the rule: whoever wants to modify it must go through the method.
Why Set and not List. With List, Hibernate may delete the whole collection and reinsert it when a single element is removed. With Set and a correct equals/hashCode (section 11 of 04-03) the behaviour is predictable. If order matters, use List with @OrderBy("plate").
- Owning and inverse side: the classic mistake
This is the concept to have engraved, and it fits in one sentence:
Only the owning side writes to the database. The inverse side, the one with
mappedBy, is completely ignored when generating the SQL.
The classic mistake, in executable form: destination.getBikes().add(bike) inside a @Transactional method. It only touches the inverse side, so no UPDATE is generated and the station_id column does not change. The method does not fail, does not warn and does nothing: the in-memory collection changes until the transaction ends and afterwards everything is back the way it was. The correct version:
@Transactional
public void moveBike(Long bikeId, Long stationId) {
Bike bike = bikeRepository.findById(bikeId)
.orElseThrow(() -> new ResourceNotFoundException("Bike", bikeId));
Station destination = stationRepository.findById(stationId)
.orElseThrow(() -> new ResourceNotFoundException("Station", stationId));
if (destination.getBikes().size() >= destination.getCapacity()) {
throw new StationFullException(stationId);
}
destination.addBike(bike); // writes BOTH sides
// Dirty checking generates: UPDATE bikes SET station_id = ? WHERE id = ?
}StationFullException is the domain exception we defined in 03-06; the @RestControllerAdvice turns it into a 409 with a ProblemDetail. Notice too that there is no call to save(): the bike is a managed entity and dirty checking takes care of it (04-07).
| Situation | Does it write to the DB? |
|---|---|
bike.setStation(destination) (owning) |
Yes |
destination.getBikes().add(bike) (inverse) |
No |
destination.addBike(bike) (both) |
Yes, and memory stays consistent |
@OneToOne with @MapsId
@OneToOne with @MapsIdEvery CicloUrbana bike has a spec sheet: model, manufacturer, serial number, purchase date. It is bulky data that is almost never consulted, so keeping it in its own table keeps the usual bikes query nimble.
@Entity
@Table(name = "spec_sheets")
public class SpecSheet {
@Id
private Long id; // no @GeneratedValue: @MapsId supplies it
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "id", foreignKey = @ForeignKey(name = "fk_spec_sheets_bike"))
private Bike bike;
@Column(name = "model", nullable = false, length = 80) private String model;
@Column(name = "serial_number", nullable = false, length = 40, updatable = false)
private String serialNumber;
@Column(name = "purchase_date", nullable = false) private LocalDate purchaseDate;
}And in Bike, the inverse side:
@OneToOne(mappedBy = "bike", fetch = FetchType.LAZY,
cascade = CascadeType.ALL, orphanRemoval = true)
private SpecSheet specSheet;What @MapsId brings. Without it, spec_sheets would have two columns: its own id and a bike_id. With @MapsId, the primary key is the foreign key: the spec sheet of bike 42 has id = 42. That saves a column and an index, guarantees uniqueness in the schema —it is impossible for two spec sheets to point at the same bike— and makes the JOINs go through the primary key, the fastest path.
A warning about LAZY on @OneToOne. The inverse side (Bike.specSheet) cannot really be lazy: to return a proxy, Hibernate would have to know whether the related row exists, and to know that it has to query it. Loading a bike therefore runs an extra query against spec_sheets even if nobody uses the sheet. The owning side is genuinely lazy, because the foreign key is in its own row. The practical solution: avoid bidirectional @OneToOnes; keep the relationship on the owning side only and ask the repository for the sheet with findById(bikeId).
@ManyToMany and why it is almost never a good idea
@ManyToMany and why it is almost never a good ideaThe Ribalta council launches promotions (student pass, free month) and a user can have several, while each promotion has several users. It is a textbook many-to-many relationship:
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_promotions",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "promotion_id"),
uniqueConstraints = @UniqueConstraint(
name = "uk_user_promotion", columnNames = {"user_id", "promotion_id"})
)
private Set<Promotion> promotions = new HashSet<>();And in Promotion, the inverse side: @ManyToMany(mappedBy = "promotions") private Set<User> users;.
It works. And even so the recommendation is to replace it with an intermediate entity, for a reason that is always discovered too late: the join table cannot have attributes of its own. As soon as the business asks "when was it applied?" or "how many uses are left?", @ManyToMany falls short and the model has to be redone with data already in production. The version with an intermediate entity:
@Entity
@Table(name = "promotion_user",
uniqueConstraints = @UniqueConstraint(name = "uk_promotion_user",
columnNames = {"user_id", "promotion_id"}))
public class PromotionUser extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "promotion_user_seq")
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "promotion_id", nullable = false)
private Promotion promotion;
@Column(name = "applied_at", nullable = false) private Instant appliedAt;
@Column(name = "remaining_uses", nullable = false) private int remainingUses;
}| Aspect | @ManyToMany |
Intermediate entity |
|---|---|---|
| Attributes on the relationship | Impossible | Yes |
| Directly queryable | No | Yes, with its own repository |
Auditing (createdAt) |
No | Yes, inherited from AuditableEntity |
| Initial complexity | Lower | Slightly higher |
| Cost of migrating later | High | — |
CicloUrbana's rule: use @ManyToMany only if you are certain the relationship will never carry attributes. Tags on an incident, yes. Anything with business nuance, an intermediate entity.
FetchType.LAZY versus EAGER
FetchType.LAZY versus EAGERThis is the most important performance decision in the whole lesson.
| Mode | When the relationship is loaded | Cost |
|---|---|---|
EAGER |
Always, together with the entity | A JOIN or an extra query on every load |
LAZY |
Only when the field is accessed | Nothing until it is used; can fail outside the transaction |
And the specification's defaults:
| Annotation | Default | Correct? |
|---|---|---|
@ManyToOne |
EAGER |
No, change it |
@OneToOne |
EAGER |
No, change it |
@OneToMany |
LAZY |
Yes |
@ManyToMany |
LAZY |
Yes |
Why EAGER is a bad idea as a default, with a CicloUrbana case: if Rental left its four @ManyToOnes on EAGER, querying a rental would fire:
select ... from rentals r
left join users u on u.id = r.user_id
left join bikes b on b.id = r.bike_id
left join stations so on so.id = r.origin_station_id
left join stations sd on sd.id = r.destination_station_id
where r.id = ?And since Bike in turn has a Station and a SpecSheet on EAGER, those JOINs chain together: with four or five levels, a query that should have read one row reads a cartesian product of several thousand. The worst part is that it cannot be turned off per query: EAGER always applies, even when all you want is the amount.
CicloUrbana's rule, with no exceptions: write fetch = FetchType.LAZY on every association, even where it is redundant on @OneToMany and @ManyToMany. When a specific query needs related data, it is requested explicitly with JOIN FETCH or @EntityGraph (04-06). It is the difference between deciding on each query and suffering a decision taken in the entity.
LazyInitializationException
LazyInitializationExceptionIt is JPA's most famous exception:
org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.stations.Station#1] - no SessionWhat happens. With LAZY, Hibernate does not put the real entity in the field but a proxy: an object of a generated subclass that only holds the id and a reference to the session. When you call any of its methods, the proxy goes to the database to complete itself; if the session —the persistence context— is already closed, it cannot, and it throws the exception.
public StationDetailResponse getDetail(Long id) { // no @Transactional!
Station station = stationRepository.findById(id).orElseThrow();
// by now the repository's implicit transaction has already ended
return mapper.toDetail(station, station.getBikes()); // BOOM!
}Why switching open-in-view off makes it visible sooner. With open-in-view: true (the default, which we switched off in 04-02), the context stays open for the whole HTTP request, so lazy access works... firing queries during JSON serialisation, without anyone seeing it. With open-in-view: false, the exception is raised in development, pointing exactly at where data is missing. It is a loud failure that replaces silent slowness: an excellent trade.
The four solutions, from best to worst:
| Solution | When to use it | Verdict |
|---|---|---|
Load what you need in the query (JOIN FETCH, @EntityGraph) |
Almost always | The right one |
| Map to a DTO inside the transaction | Always, as a complement | The right one |
Widen the transaction with @Transactional on the service |
When the work belongs to the service | Acceptable |
Put the relationship on EAGER |
Never | Swaps an error for permanent slowness |
Turn open-in-view back on |
Never | Hides the problem until production |
The correct version of the previous example:
@Transactional(readOnly = true)
public StationDetailResponse getDetail(Long id) {
Station station = stationRepository.findWithBikes(id) // JOIN FETCH
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
return mapper.toDetail(station); // the mapping happens INSIDE the transaction
}Two already-established ideas converge here: the service returns fully mapped DTOs (03-05) and the mapping happens inside the transaction. With that discipline, LazyInitializationException stops appearing.
- The N+1 problem
This is the characteristic performance problem of ORMs, and it deserves to be understood through a concrete case.
The council asks for a listing of the four stations with the number of bikes at each one:
@Transactional(readOnly = true)
public List<StationResponse> list() {
List<Station> stations = stationRepository.findAll(); // 1 query
return stations.stream()
.map(s -> new StationResponse(s.getId(), s.getName(),
s.getBikes().size())) // N queries
.toList();
}The resulting SQL:
select s1_0.id, s1_0.name, ... from stations s1_0; -- 1
select b1_0.id, ... from bikes b1_0 where b1_0.station_id=1; -- +1
select b1_0.id, ... from bikes b1_0 where b1_0.station_id=2; -- +1
select b1_0.id, ... from bikes b1_0 where b1_0.station_id=3; -- +1
select b1_0.id, ... from bikes b1_0 where b1_0.station_id=4; -- +11 + N queries, hence the name. With Ribalta's 4 stations that is 5 and nobody notices; when the network grows to 200 it will be 201, and at 2 ms per round trip the endpoint goes from 10 ms to more than 400 ms without any individual query being slow. That is the treachery: the profiler finds no culprit because every query is fast.
How to detect it. With the logging configuration from 04-02 (org.hibernate.SQL: DEBUG and hibernate.generate_statistics: true), at the end of each request you see:
Two hundred and one statements to list stations is a textbook N+1. The mental rule: the number of queries in an endpoint must be constant, not proportional to the number of results.
The three solutions, which we will develop in 04-06 and 09-01:
// 1. JOIN FETCH: a single query with a JOIN. The most direct one.
@Query("select distinct s from Station s left join fetch s.bikes")
List<Station> findAllWithBikes();
// 2. @EntityGraph: declarative, without writing JPQL.
@EntityGraph(attributePaths = "bikes") List<Station> findAll();
// 3. @BatchSize (Hibernate): groups the N queries into N/size.
@OneToMany(mappedBy = "station") @BatchSize(size = 25)
private Set<Bike> bikes = new HashSet<>();| Solution | Queries | Advantage | Drawback |
|---|---|---|---|
JOIN FETCH |
1 | Optimal in number of round trips | Breaks pagination with collections |
@EntityGraph |
1 | Declarative and reusable | Same limitation with pagination |
@BatchSize |
1 + N/size | Compatible with pagination | Never gets down to a single query |
The warning about pagination matters: when you JOIN FETCH a collection, SQL's LIMIT applies to the rows of the cartesian product, not to the stations. Hibernate detects it, warns with HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory and loads everything into memory to paginate afterwards. That is where @BatchSize is the right answer.
And there is a fourth solution, often the best: do not load entities at all. If all you need is the count, ask for it with a projection (04-06):
@Query("""
select new com.ciclourbana.stations.dto.StationSummary(
s.id, s.name, count(b))
from Station s left join s.bikes b
group by s.id, s.name
""")
List<StationSummary> occupancySummary();A single query, with no managed entities and without fetching data nobody is going to look at.
cascade and orphanRemoval
cascade and orphanRemovalcascade propagates an operation from the parent to the children.
| Type | What it propagates | Typical use in CicloUrbana |
|---|---|---|
PERSIST |
Saving the parent saves the new children | Rental → incidents created with it |
MERGE |
Merging the parent merges the children | Bulk updates |
REMOVE |
Deleting the parent deletes the children | Bike → its spec sheet |
REFRESH |
Reloading the parent reloads the children | Uncommon |
DETACH |
Detaching the parent detaches the children | Uncommon |
ALL |
The five above | Only in strict composition |
When to apply cascading. The right question is not technical but a domain one: does the child exist on its own or does it only make sense inside the parent? A SpecSheet does not exist without its bike (cascade = ALL, orphanRemoval = true); neither does an Incident (cascade = {PERSIST, MERGE}, orphanRemoval = true). A Bike, on the other hand, does exist without its station —it moves to another one or goes to the workshop, and deleting a station must not delete its bikes—, just as a User exists independently of their rentals: no cascading in either case.
@Entity
@Table(name = "bikes")
public class Bike extends AuditableEntity {
@OneToMany(mappedBy = "bike",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = true,
fetch = FetchType.LAZY)
private Set<Incident> incidents = new HashSet<>();
public void reportIncident(Incident incident) {
incidents.add(incident);
incident.setBike(this);
if (incident.isBlocking()) this.status = BikeStatus.RETIRED;
}
public void resolveIncident(Incident incident) {
incidents.remove(incident); // with orphanRemoval, it is DELETED from the DB
}
}orphanRemoval versus CascadeType.REMOVE. They are constantly confused:
CascadeType.REMOVE |
orphanRemoval = true |
|
|---|---|---|
| Deletes the children when the parent is deleted | Yes | Yes |
| Deletes a child when it is removed from the collection | No (it is orphaned with a null FK, or it fails) | Yes |
orphanRemoval is stronger and expresses composition better: the child cannot exist outside the parent. That is why in resolveIncident simply removing it from the collection is enough for it to disappear from the table.
Warning: cascade = REMOVE over a large collection loads all the child entities into memory and issues one DELETE per child. Deleting a bike with 5,000 sensor records would generate 5,001 statements. For volumes like that, a bulk DELETE with @Modifying (04-06) or an ON DELETE CASCADE in the schema (04-08) are far better.
- Value collections with
@ElementCollection
@ElementCollectionSometimes an entity needs a list of simple values that do not deserve to be entities: the tags on an incident, for example.
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "incident_tags",
joinColumns = @JoinColumn(name = "incident_id"),
foreignKey = @ForeignKey(name = "fk_incident_tags_incident"))
@Column(name = "tag", length = 40)
private Set<String> tags = new HashSet<>();An incident_tags table with two columns is created, but its rows are not entities: no id of their own, no repository and not queryable separately.
| Aspect | @ElementCollection |
@OneToMany to an entity |
|---|---|---|
| Identity of its own | No | Yes |
| Repository | No | Yes |
| Queryable separately | No | Yes |
| When the collection is modified | Hibernate deletes and reinserts everything | Selective UPDATE |
| Suitable for | Simple values, short lists | Anything with a life of its own |
That "deletes and reinserts everything" is the key limitation: modifying one element of a 500-value collection generates 501 statements. @ElementCollection is for short, stable lists. It also accepts @Embeddable: for example, the history of GPS positions of a rental as a collection of Location.
- Entity inheritance
CicloUrbana's incidents are not all the same: a flat battery, an act of vandalism and a mechanical breakdown share fields but have data of their own. JPA offers three strategies.
@Entity
@Table(name = "incidents")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING,
length = 20)
public abstract class Incident extends AuditableEntity {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "incidents_seq")
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "bike_id", nullable = false)
private Bike bike;
@Column(name = "description", nullable = false, length = 500)
private String description;
public abstract boolean isBlocking();
}
@Entity
@DiscriminatorValue("BATTERY")
public class BatteryIncident extends Incident {
@Column(name = "detected_level") private Integer detectedLevel;
@Override public boolean isBlocking() { return detectedLevel < 5; }
}
@Entity
@DiscriminatorValue("VANDALISM")
public class VandalismIncident extends Incident {
@Column(name = "police_report", length = 40) private String policeReport;
@Override public boolean isBlocking() { return true; }
}| Strategy | How it is stored | Queries | NOT NULL on children |
When to choose it |
|---|---|---|---|---|
SINGLE_TABLE (default) |
One table with every column and a discriminator | Fast, no JOIN |
Impossible | Few own fields, performance first |
JOINED |
One base table + one per subclass | A JOIN per level |
Yes | Many own fields, integrity first |
TABLE_PER_CLASS |
One complete table per subclass | UNION ALL when querying the parent |
Yes | Almost never; it complicates keys and polymorphic queries |
For CicloUrbana we choose SINGLE_TABLE, with its trade-off accepted: the specific columns (detected_level, police_report) must accept nulls, because a vandalism row has no battery level. It is a reasonable price with few own fields; if each type accumulated ten mandatory columns, JOINED would be the right choice.
It is also worth recalling the distinction from 04-03: @MappedSuperclass is not entity inheritance. AuditableEntity is neither queryable nor polymorphic, it merely contributes columns to each child table. Incident is an entity, and incidentRepository.findAll() returns instances of its subclasses.
Common Mistakes and Tips
Leaving @ManyToOne and @OneToOne at their default fetch. They are EAGER, and that oversight generates chained JOINs on every query. Write fetch = FetchType.LAZY always, even when it seems redundant.
Modifying only the inverse side. It generates no SQL. The symptom is "I save and it doesn't get saved" with no error whatsoever. Always use helper methods that synchronise both sides.
Forgetting mappedBy on a @OneToMany. JPA creates an unexpected join table. If a stations_bikes table shows up in the H2 console, that is the diagnosis.
Confusing orphanRemoval with CascadeType.REMOVE. The first deletes the child when it is removed from the collection; the second only when the parent is deleted.
Putting cascade = ALL out of habit. Deleting a station would delete all its bikes. Apply cascading only when the child has no life of its own.
Using @ManyToMany for relationships with nuance. As soon as the business asks for a date or a counter on the relationship, the model will have to be migrated with data in production.
Tip: count your endpoints' queries. Turn on generate_statistics and check that the number of statements does not grow with the number of results. It is the best net against N+1.
Tip: use Set for collections, with correct equals/hashCode. With List, Hibernate may delete and reinsert the whole collection when one element is removed.
Tip: do not model relationships nobody navigates. Every bidirectional association adds complexity. If CicloUrbana never needs to go from a user to their rentals in memory, keep only Rental → User and query through the repository.
Exercises
Exercise 1: model Rental completely
Write the Rental entity with its four relationships (User, Bike, origin station and destination station), knowing that the destination one is null while the rental is in progress. Justify the fetch, the optional and the presence or absence of cascading in each one. State also which indexes you would declare.
Exercise 2: diagnose and fix an N+1
This CicloUrbana endpoint takes 1.2 seconds with 200 stations. Identify the problem, work out the number of queries and propose three different solutions, saying which one you would choose and why.
@GetMapping("/api/v1/stations/occupancy")
public List<OccupancyResponse> occupancy() {
return stationRepository.findAll().stream()
.map(s -> new OccupancyResponse(
s.getName(),
s.getCapacity(),
s.getBikes().stream()
.filter(b -> b.getStatus() == BikeStatus.AVAILABLE)
.count()))
.toList();
}Exercise 3: choose the inheritance strategy
The council extends incidents to four types: battery (detected level), vandalism (police report, photos, estimated repair cost), mechanical breakdown (component, severity, assigned workshop, expected date) and accident (accident report, insurer, injured people, police report, cost). Choose the appropriate inheritance strategy and justify it against the other two.
Solutions
Solution 1.
@Entity
@Table(name = "rentals", indexes = {
@Index(name = "idx_rentals_user", columnList = "user_id"),
@Index(name = "idx_rentals_bike", columnList = "bike_id"),
@Index(name = "idx_rentals_started_at", columnList = "started_at")
})
public class Rental extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rentals_seq")
@SequenceGenerator(name = "rentals_seq", sequenceName = "rentals_id_seq",
allocationSize = 50)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, updatable = false,
foreignKey = @ForeignKey(name = "fk_rentals_user"))
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "bike_id", nullable = false, updatable = false,
foreignKey = @ForeignKey(name = "fk_rentals_bike"))
private Bike bike;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "origin_station_id", nullable = false, updatable = false,
foreignKey = @ForeignKey(name = "fk_rentals_origin_station"))
private Station originStation;
@ManyToOne(fetch = FetchType.LAZY) // optional = true by default
@JoinColumn(name = "destination_station_id",
foreignKey = @ForeignKey(name = "fk_rentals_destination_station"))
private Station destinationStation;
@Column(name = "started_at", nullable = false, updatable = false) private Instant startedAt;
@Column(name = "ended_at") private Instant endedAt;
@Column(name = "total_amount", precision = 8, scale = 2) private BigDecimal totalAmount;
@Version @Column(name = "version", nullable = false) private Long version;
protected Rental() { }
}Justification of each decision:
fetch = LAZYon all four. WithEAGER, listing the day's rentals would bring complete users, bikes and stations even though the listing only shows date and amount. On top of thatBikeandStationhave relationships of their own, and theJOINs would chain together.optional = falseon user, bike and origin: a rental without them makes no sense. It lets Hibernate useINNER JOINand documents the rule in the schema withnullable = false.- Default
optional(true) on the destination: it is null for the whole rental and is filled in when the bike is returned. It is a business fact, not an omission. - No cascading anywhere. The user, the bike and the stations exist on their own. Cascading here would mean that deleting a rental deletes the user: a disaster.
updatable = falseon user, bike and origin: once the rental has started, that data is immutable. It is historical integrity.- Indexes: on
user_id(the "my rentals" query), onbike_id(a bike's history) and onstarted_at(reports by date range). A foreign key does not create an index automatically in PostgreSQL, unlike in MySQL: you have to declare it.
Solution 2.
The problem is an N+1. findAll() runs 1 query and getBikes() fires one lazy query per station: 201 queries with 200 stations. At ~5 ms per round trip, roughly 1.2 seconds, without any individual query being slow.
Solution A — JOIN FETCH: @Query("select distinct s from Station s left join fetch s.bikes"). A single query, but it brings every bike of every station into memory even though only a count is needed.
Solution B — @EntityGraph: @EntityGraph(attributePaths = "bikes") over findAll(). Equivalent in result, declarative and without association JPQL; same drawback.
Solution C — projection with aggregation (the one I would choose):
@Query("""
select new com.ciclourbana.stations.dto.OccupancyResponse(
s.name, s.capacity, count(b.id))
from Station s
left join s.bikes b on b.status = com.ciclourbana.bikes.BikeStatus.AVAILABLE
group by s.id, s.name, s.capacity
order by s.name
""")
List<OccupancyResponse> findOccupancy();Why C. All three eliminate the N+1, but A and B load every bike in the network —with 200 stations and 30 bikes each, 6,000 managed entities in the persistence context— just to end up counting. C makes the database do the counting, which is exactly what it is optimised for, and returns 200 rows with three columns. Less SQL, less memory, less garbage collection and no risk of LazyInitializationException because there are no managed entities. What is more, the result is directly the response DTO.
General rule: if all you need is aggregated data, do not load entities. Projections are developed in 04-06.
Solution 3. The appropriate strategy is JOINED.
Counting own fields: battery 1, vandalism 4, breakdown 4, accident 5. That is 14 specific columns spread across four subclasses.
With SINGLE_TABLE, the incidents table would carry those 14 columns plus the common ones, and every specific one would necessarily be nullable. Consequences: you cannot require in the schema that an accident incident has an insurer, every row wastes space on empty columns, and anyone querying the table directly needs to know which columns apply to each type. With four types and different mandatory fields, integrity would rest entirely in the hands of the Java code.
With JOINED there is an incidents table with the common part (id, bike, description, date, auditing) and four child tables —battery_incidents, vandalism_incidents, breakdown_incidents, accident_incidents— whose primary key is also a foreign key to the base one, and each declares its NOT NULLs where they belong. The schema comes out normalised and describes the domain faithfully. The cost is one JOIN per level when querying; with a single level of inheritance and a volume of incidents that is low compared with rentals, it is acceptable, and the most frequent queries —"open incidents for this bike"— touch only the base table.
Why not the other two:
SINGLE_TABLE: 14 nullable columns and no integrity guarantee in the database. It would be correct if each subclass had one or two own fields and the volume were very high.TABLE_PER_CLASS: four independent tables repeating the common columns; queryingIncidentgenerates aUNION ALLof all four,IDENTITYcannot be used and foreign keys towards the base table are impossible. It is practically never the answer.
Conclusion
CicloUrbana's model now has arrows. You know how to translate each cardinality into its annotation and, more importantly, you know where the foreign key lives and what that implies: the owning side is the only one that writes, the side with mappedBy merely mirrors, and modifying only the inverse one is the mistake that does not fail, does not warn and saves nothing. You have written @ManyToOne with a named @JoinColumn —indispensable when Rental points twice at Station—, @OneToMany with mappedBy and its helper methods that synchronise both sides, @OneToOne with @MapsId so that the primary key is the foreign key, and you know why bidirectional @OneToOnes are best avoided. You know @ManyToMany and, above all, why it is almost always replaced with an intermediate entity: as soon as the business asks "when?" or "how many times?", the join table falls short and migrating is already expensive.
You have the two rules that govern performance with an ORM. The first: fetch = FetchType.LAZY on every association, against the EAGER defaults of @ManyToOne and @OneToOne, because EAGER takes in the entity a decision that belongs to each query. The second: the number of queries in an endpoint must be constant, not proportional to the number of results, which is the operational formulation of the N+1 problem, detectable with generate_statistics and solvable with JOIN FETCH, @EntityGraph, @BatchSize or —often the best— not loading entities at all. And you understand LazyInitializationException not as an enemy but as a valuable warning that arrives sooner thanks to having switched open-in-view off in 04-02. You have applied cascade and orphanRemoval according to a domain criterion —does the child exist on its own?—, modelled value collections with @ElementCollection and chosen among the three inheritance strategies for Ribalta's incidents.
What is still missing is how all these entities are used. InMemoryStationRepository is still there, with its ConcurrentHashMap and its hand-written methods, and we have been writing example queries against a repository that does not yet exist. Lesson 04-05, Using Spring Data Repositories, solves that: we will see the hierarchy of interfaces and which one to choose, how Spring manufactures the implementation at startup through proxies, and we will replace InMemoryStationRepository with StationRepository extends JpaRepository<Station, Long>, checking how much code disappears at a stroke. We will review the exact semantics of each inherited method —including the difference between findById and getReferenceById, and the fact that save performs a merge if the entity already has an id—, add pagination and sorting to the Ribalta API with Pageable, and see why a Page is never returned directly to the client.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
