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

  1. CicloUrbana's complete relational model
  2. The four cardinalities and their annotation
  3. @ManyToOne: the natural owning side
  4. @OneToMany and mappedBy
  5. Owning and inverse side: the classic mistake
  6. @OneToOne with @MapsId
  7. @ManyToMany and why it is almost never a good idea
  8. FetchType.LAZY versus EAGER
  9. LazyInitializationException
  10. The N+1 problem
  11. cascade and orphanRemoval
  12. Value collections with @ElementCollection
  13. Entity inheritance
  14. Common Mistakes and Tips
  15. Exercises

  1. 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

  1. 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.

  1. @ManyToOne: the natural owning side

Many 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.

  1. @OneToMany and mappedBy

From 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").

  1. 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

  1. @OneToOne with @MapsId

Every 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).

  1. @ManyToMany and why it is almost never a good idea

The 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.

  1. FetchType.LAZY versus EAGER

This 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.

  1. LazyInitializationException

It is JPA's most famous exception:

org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.stations.Station#1] - no Session

What 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.

  1. 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;   -- +1

1 + 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:

Session Metrics { 201 JDBC statements, 200 collections fetched, 1204 entities loaded }

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.

  1. cascade and orphanRemoval

cascade 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.

  1. Value collections with @ElementCollection

Sometimes 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.

  1. 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 = LAZY on all four. With EAGER, listing the day's rentals would bring complete users, bikes and stations even though the listing only shows date and amount. On top of that Bike and Station have relationships of their own, and the JOINs would chain together.
  • optional = false on user, bike and origin: a rental without them makes no sense. It lets Hibernate use INNER JOIN and documents the rule in the schema with nullable = 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 = false on 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), on bike_id (a bike's history) and on started_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; querying Incident generates a UNION ALL of all four, IDENTITY cannot 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved