CicloUrbana already has plumbing: a DataSource, a properly sized HikariCP pool and two engines ready to go —in-memory H2 for development and PostgreSQL 16 in Docker—. But the database is completely empty, because Hibernate has nothing to map. This lesson fills that gap: it turns the domain model of the Ribalta network into JPA entities, the classes Hibernate knows how to translate into tables and rows.

This is a lesson about decisions. Every annotation you write here conditions the physical schema, the performance of the queries and how easily the model will be able to evolve two years from now. Choosing the wrong identifier strategy penalises every insert; storing amounts in double produces incorrect invoices that nobody notices until a citizen complains; mapping an enum by its position turns a simple reordering of the code into silent data corruption. We are going to settle each of those decisions along with its reason.

Contents

  1. Why a record cannot be an entity
  2. @Entity and @Table
  3. @Id and the four @GeneratedValue strategies
  4. @Column and controlling the column
  5. Data types and their mapping
  6. Enums: STRING versus ORDINAL
  7. Derived fields and @Transient
  8. Embedded objects: @Embeddable and @Embedded
  9. Automatic auditing
  10. Optimistic locking with @Version
  11. equals and hashCode in JPA entities
  12. Entities and DTOs: the separation stands
  13. Common Mistakes and Tips
  14. Exercises

  1. Why a record cannot be an entity

Since 01-03 the CicloUrbana domain has used record:

public record Station(Long id, String name, String address,
                      int capacity, double latitude, double longitude) { }

It has been an excellent decision for module 3: immutability, free equals/hashCode and zero boilerplate. But a record cannot be a JPA entity, and not because of a whim of the specification:

JPA requirement Does a record meet it Why JPA needs it
No-argument constructor No Hibernate instantiates the entity empty and then fills in fields
Mutable fields No (they are final) Dirty checking and lazy loading rewrite fields
The class cannot be final No (records are) Hibernate generates proxy subclasses for LAZY
Identity by primary key Disagrees A record's equals compares all the fields

The first three are technical and are enough to close the discussion. The fourth is conceptual and runs deeper: a record is a value object, defined by the set of its fields; an entity is an object with identity, and station 1 is still station 1 even if its name, address and capacity all change.

The rule this course adopts: record for DTOs and value objects; mutable classes for entities. The records in com.ciclourbana.common.dto stay exactly where they are (03-05); what changes is the domain.

The resulting entity:

package com.ciclourbana.stations;

import jakarta.persistence.*;

@Entity
@Table(name = "stations")
public class Station {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "stations_seq")
    @SequenceGenerator(name = "stations_seq", sequenceName = "stations_id_seq",
                       allocationSize = 50)
    private Long id;

    private String name;
    private String address;
    private int capacity;

    protected Station() { }   // required by JPA; not to be used from the domain

    public Station(String name, String address, int capacity) {
        this.name = name;
        this.address = address;
        this.capacity = capacity;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

Two deliberate details: the no-argument constructor is protected, not public —JPA only requires it to be visible to the proxy subclass, and this way business code cannot create half-built stations—; and there is no setId, because the identifier is assigned by the database and letting it be changed from outside opens the door to corrupting a row's identity.

  1. @Entity and @Table

@Entity marks the class as manageable by JPA. That alone is enough: if you say nothing, the table will be named after the class.

@Table gives you control over the physical mapping:

@Entity
@Table(name = "stations", schema = "public",
    uniqueConstraints = @UniqueConstraint(name = "uk_stations_name",
                                          columnNames = "name"),
    indexes = {
        @Index(name = "idx_stations_active", columnList = "active"),
        @Index(name = "idx_stations_location", columnList = "latitude, longitude")
    })
public class Station { /* ... */ }
Attribute What it is for
name Table name. Always declare it
schema Database schema
uniqueConstraints Uniqueness constraints over one or several columns
indexes Indexes to be created along with the schema

Three warnings:

  • Always declare name explicitly. By default Spring Boot applies CamelCaseToUnderscoresNamingStrategy (SpecSheet → spec_sheet): it works, but it leaves the physical name at the mercy of a configurable strategy.
  • indexes and uniqueConstraints only take effect when Hibernate generates the schema. With ddl-auto: validate they are pure documentation —the real indexes will be created by Flyway in 04-08—, but it is worth declaring them so that entity and schema are described in the same place.
  • The uniqueness of name does not replace business validation: StationService will still check existsByName in order to return a 409 with a readable ProblemDetail (03-06) instead of letting a DataIntegrityViolationException escape. The constraint is the last line of defence, the one that covers race conditions.

  1. @Id and the four @GeneratedValue strategies

Every entity needs a primary key marked with @Id. @GeneratedValue says who produces the value.

Strategy How it works Advantage Drawback Engine
AUTO Hibernate chooses for you Nothing to decide Unpredictable across versions and engines All
IDENTITY Auto-incrementing DB column Simple, no extra objects It disables batch inserting MySQL, PostgreSQL (serial)
SEQUENCE A DB sequence object Allows batching and block reservation Requires sequence support PostgreSQL, Oracle, H2
TABLE A table that stores counters Portable to any engine Slow, contention and locks All

With PostgreSQL, the answer is SEQUENCE with allocationSize, and the reason is concrete and measurable. With IDENTITY, Hibernate does not know the id until after the INSERT, and since it needs the id to register the entity in the persistence context, it is forced to run the INSERT immediately, skipping deferred writing: that completely disables batch inserting, and inserting 1,000 bikes means 1,000 round trips. With SEQUENCE and allocationSize = 50, in contrast, Hibernate asks the sequence for a value and reserves the next 50 in memory, so it assigns ids without querying anything and groups the INSERTs into batches:

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "bikes_seq")
@SequenceGenerator(name = "bikes_seq", sequenceName = "bikes_id_seq",
                   allocationSize = 50)
private Long id;

With spring.jpa.properties.hibernate.jdbc.batch_size: 20 (which we already set in 04-02), inserting 1,000 bikes goes from ~1,000 round trips to ~50. In Ribalta's nightly bike catalogue load the difference is minutes versus seconds.

Critical rule: the annotation's allocationSize must match the INCREMENT BY of the real sequence. If the sequence was created with INCREMENT BY 1 and you declare allocationSize = 50, Hibernate will assign ids that already exist or that will collide. In 04-08 we will create the sequences in Flyway with the right increment (CREATE SEQUENCE stations_id_seq START WITH 1 INCREMENT BY 50;).

One last warning about AUTO: with Hibernate 6 on PostgreSQL it usually ends up as SEQUENCE, but it delegates to the provider a decision that affects performance and that may change on an upgrade. Declare it yourself.

  1. @Column and controlling the column

@Column describes the physical column. Without it, the name is derived from the field and everything else takes default values.

@Column(name = "name", nullable = false, length = 80, unique = true)
private String name;

@Column(name = "capacity", nullable = false)
private int capacity;

@Column(name = "plate", nullable = false, length = 10, updatable = false)
private String plate;

@Column(name = "total_amount", precision = 8, scale = 2)
private BigDecimal totalAmount;
Attribute What it controls Generated DDL
name Physical name name
nullable Accepts nulls not null
length Text length varchar(80)
precision / scale Total digits and decimals numeric(8,2)
unique Uniqueness of a single column unique
updatable Whether it is included in UPDATEs Affects only the SQL, not the DDL
insertable Whether it is included in INSERTs Affects only the SQL

Four practical observations:

  • The default length is 255. Leaving it alone turns every piece of text into an unconsidered varchar(255). CicloUrbana's plate is RB-0142: seven characters, and length = 10 is generous and descriptive.
  • nullable = false is executable documentation, and it goes hand in hand with @NotNull (03-04): validation produces a readable 400, the column constraint is the ultimate guarantee of integrity.
  • updatable = false fits the plate: it is assigned when the bike is registered and never changes, so Hibernate excludes it from UPDATEs.
  • unique = true on @Column versus uniqueConstraints on @Table: the former generates a random constraint name (UK_a7f3b2...) that will end up showing in error messages; with @Table you name it and you can also cover several columns.

  1. Data types and their mapping

Java type SQL type (PostgreSQL) Use in CicloUrbana Notes
String varchar(n) name, address, plate Set length
int / Integer integer capacity, batteryLevel The primitive accepts no nulls
long / Long bigint Identifiers Use Long for the @Id
BigDecimal numeric(p,s) totalAmount, pricePerMinute Mandatory for money
double double precision Never for amounts Physical magnitudes only
boolean boolean active With nullable = false
LocalDate date A user's signupDate No time and no zone
LocalDateTime timestamp Local use without a zone Ambiguous across zones
Instant timestamp with time zone startedAt, endedAt of a rental Recommended
Duration bigint (nanoseconds) Rental duration Mapped automatically in Hibernate 6
enum varchar with STRING BikeStatus Never ORDINAL
byte[] + @Lob bytea Photo of an incident Load it lazily
UUID uuid Public keys Native in PostgreSQL

Why BigDecimal and never double for money. This is not purism: double is binary floating point and cannot exactly represent decimal values such as 0.1. The result:

System.out.println(0.1 + 0.2);                                   // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3

A CicloUrbana rental at €0.15/minute for 47 minutes must come to exactly €7.05. With double, after thousands of rentals accumulate, the cents drift and the accounting discrepancy shows up at the end of the month with no identifiable culprit. With BigDecimal and numeric(8,2), the value is exact end to end.

Two precautions when using it: always build from a String, because new BigDecimal(0.1) carries the double's error along; and compare with compareTo, not with equals, since equals considers 2.50 and 2.5 different because their scale differs.

Why Instant for timestamps. Instant is an absolute point on the timeline, independent of time zone, and Hibernate 6 maps it to timestamp with time zone. A rental started at 02:30 on the last Sunday of October —when the clock in Ribalta goes back an hour— happens at a single, unambiguous instant. With LocalDateTime that time exists twice and the rental duration can come out negative.

For byte[], always declare @Lob @Basic(fetch = FetchType.LAZY): without LAZY you would fetch several megabytes every time an incident is read. Even so, the general recommendation is to store binaries in an object store and keep only their URL in the database.

  1. Enums: STRING versus ORDINAL

BikeStatus comes from module 3:

public enum BikeStatus {
    AVAILABLE, IN_USE, MAINTENANCE, RETIRED
}

And in the entity:

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private BikeStatus status = BikeStatus.AVAILABLE;
Mode What it stores Direct SQL querying On a reordering
ORDINAL (default) The position: 0, 1, 2 Unreadable Silent corruption
STRING The name: AVAILABLE Readable Safe

The danger of ORDINAL is that JPA's default value is precisely the dangerous one. Imagine that a year from now someone adds a new status while respecting alphabetical order:

public enum BikeStatus {
    ALLOCATED, AVAILABLE, IN_USE, MAINTENANCE, RETIRED
}

Every row with status = 0, which used to mean AVAILABLE, now means ALLOCATED. With no error, no exception and no warning: hundreds of Ribalta's bikes show up as allocated from one day to the next, and there is no way to recover the original data because the 0 does not say what it used to mean.

@Enumerated(EnumType.STRING) on every enum, no exceptions. The cost —a few bytes per row— is irrelevant against data that is readable, queryable (WHERE status = 'AVAILABLE') and immune to reordering. Add a CHECK constraint to the schema as well (04-08) so that the database rejects unknown values.

  1. Derived fields and @Transient

@Transient marks a field that is not persisted. It is the counterpart to the fact that, by default, JPA maps every field.

@Transient
private int availableBikes;

@Transient
public boolean isNearlyFull() {
    return availableBikes >= capacity * 0.9;
}

Legitimate cases: values computed at run time, local caches or data arriving from another service. Watch the import: jakarta.persistence.Transient, not java.beans.Transient and not Java's transient keyword.

There is also @Formula, specific to Hibernate, which computes a field with an SQL subquery embedded in the entity —for example, counting the station's available bikes—. It is tempting, but it has three serious drawbacks: it puts engine-specific SQL inside the domain, it runs on every load even if nobody uses the field, and it cannot be used from JPQL. In CicloUrbana we prefer to compute those aggregates in an explicit query with a projection (04-06), where the cost is visible and is paid only when needed.

  1. Embedded objects: @Embeddable and @Embedded

Station has latitude and longitude, two fields that always travel together and that back in 03-05 already gave rise to LocationResponse. An embeddable lets you group them into an object without creating a table:

package com.ciclourbana.common;

@Embeddable
public class Location {

    @Column(name = "latitude", nullable = false, precision = 9, scale = 6)
    private BigDecimal latitude;

    @Column(name = "longitude", nullable = false, precision = 9, scale = 6)
    private BigDecimal longitude;

    protected Location() { }

    public Location(BigDecimal latitude, BigDecimal longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public BigDecimal getLatitude() { return latitude; }
    public BigDecimal getLongitude() { return longitude; }
    // equals/hashCode comparing both fields with compareTo: this is a VALUE object.
}
@Embedded
private Location location;

The columns still live in the stations table: there is no JOIN and no new table, just a grouping in the Java model. What CicloUrbana gains from it: behaviour travels with the data (distanceTo(Location other) is a method of Location, not a loose static utility); the @ValidCoordinates constraint from 03-04 applies to an object instead of two separate fields; the class is reusable, because Bike will also have a Location with its last GPS position; and it is a textbook value object, with no identity of its own, which is why here equals does compare fields, unlike in an entity.

When an entity needs two embeddables of the same type, the columns would clash. @AttributeOverride renames them:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "latitude",
        column = @Column(name = "pickup_latitude", precision = 9, scale = 6)),
    @AttributeOverride(name = "longitude",
        column = @Column(name = "pickup_longitude", precision = 9, scale = 6))
})
private Location pickupLocation;

// The second embeddable is identical, renaming to dropoff_latitude and dropoff_longitude:
@Embedded
@AttributeOverrides({ /* ... */ })
private Location dropoffLocation;

A Rental can thus record where the bike was picked up and where it was left, reusing the same class.

  1. Automatic auditing

Knowing when each row was created and when it was last modified is a universal need. Spring Data automates it.

First, enable it with a @Configuration class annotated with @EnableJpaAuditing —in CicloUrbana, com.ciclourbana.common.config.AuditingConfig—. Then, a base class with the common fields:

package com.ciclourbana.common;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {

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

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

And the entities extend it:

@Entity
@Table(name = "stations")
public class Station extends AuditableEntity { /* ... */ }

The pieces and their role:

Annotation What it does
@EnableJpaAuditing Activates the mechanism in the context
@MappedSuperclass Base class whose fields are inherited without a table of its own
@EntityListeners(AuditingEntityListener.class) Hooks up the interceptor that fills the fields
@CreatedDate Filled only on the INSERT
@LastModifiedDate Updated on every UPDATE

@MappedSuperclass is key: it does not create an auditable_entity table. Its columns are copied into each child entity's table. It is different from entity inheritance, which we will see in 04-04.

To know who made the change there are also @CreatedBy and @LastModifiedBy, which require an AuditorAware<String> holding the current user. Since there will be no authenticated user until module 5, this is noted down as future work.

  1. Optimistic locking with @Version

In 03-03 we solved lost updates with ETag and If-Match, leaning on ShallowEtagHeaderFilter. That solution worked at the HTTP layer, but it computed the ETag from the response body, that is, after all the work had already been done. JPA offers something much better: optimistic locking at the data layer.

@Version
@Column(name = "version", nullable = false)
private Long version;

How it works: when loading station 1, Hibernate also reads version = 7; when updating it, it generates an UPDATE that carries the version in the WHERE clause and increments it.

UPDATE stations SET name = ?, capacity = ?, version = 8 WHERE id = 1 AND version = 7;

If another user already modified it, its version is 8 and the UPDATE affects 0 rows. Hibernate detects this and throws OptimisticLockException, which Spring translates into ObjectOptimisticLockingFailureException.

sequenceDiagram
    participant A as Operator A
    participant B as Operator B
    participant DB as PostgreSQL

    A->>DB: read station 1 (version=7)
    B->>DB: read station 1 (version=7)
    A->>DB: UPDATE ... WHERE id=1 AND version=7
    DB-->>A: 1 row -> now version=8
    B->>DB: UPDATE ... WHERE id=1 AND version=7
    DB-->>B: 0 rows
    Note over B: OptimisticLockException -> HTTP 409

It is called "optimistic" because it locks nothing: it bets that conflicts are rare and only detects them when they happen. Compared to the ETag:

Aspect ETag/If-Match (03-03) @Version (JPA)
Where it lives HTTP layer Data layer
What it protects One specific HTTP request Every write, wherever it comes from
Cost Serialising and computing a hash One bigint column
Detects conflicts between internal threads No Yes

The practical conclusion: @Version replaces ShallowEtagHeaderFilter. An ETag header can still be generated from the version number —it is clean and cheap—, but the real protection no longer depends on HTTP.

All that is left is to close the loop in the global handler from 03-06:

@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ProblemDetail handleConcurrencyConflict(
        ObjectOptimisticLockingFailureException ex) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "The resource was modified by another user. Reload and try again.");
    problem.setTitle("Concurrency conflict");
    problem.setType(URI.create("https://api.ciclourbana.example/errors/concurrency-conflict"));
    problem.setProperty("code", "CONCURRENCY_CONFLICT");
    return problem;
}

The client receives a 409 in the same RFC 7807 format as the rest of CicloUrbana's errors. In 04-07 we will compare this lock with the pessimistic one, which does lock rows and is needed in the race for a station's last bike.

  1. equals and hashCode in JPA entities

This section corrects one of the most widespread mistakes. The temptation is to let the IDE generate equals/hashCode from every field, or from the id alone. Both options fail.

The problem with the generated id. A new entity has id = null. If you put it in a HashSet and then save it, the id becomes 42, its hashCode changes and the object is lost inside the set: contains returns false even though it is in there, because the lookup happens in a different bucket.

Set<Station> set = new HashSet<>();
Station station = new Station("University", "South Campus, Gate B", 36);
set.add(station);                 // hashCode computed with id = null
stationRepository.save(station);  // now id = 4: the hashCode has changed
set.contains(station);            // false. The entity is there and cannot be found

Using every field is no good either, because equals would change every time an attribute is modified, with the same effect on collections, and because conceptually it is false: station 1 is still the same station even if its name changes.

The recommended solution, and the one CicloUrbana adopts:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    // Hibernate.getClass unwraps the lazy-loading proxy
    if (o == null || !Hibernate.getClass(this).equals(Hibernate.getClass(o))) return false;
    return id != null && id.equals(((Station) o).getId());
}

@Override
public int hashCode() {   // constant: stable throughout the object's life
    return getClass().hashCode();
}

The three decisions and the reason for each:

  • A constant hashCode. It looks like heresy —every entity falls into the same bucket—, but the contract only requires equal objects to have the same hash, and an in-memory collection of entities rarely goes beyond a few dozen elements: the cost is negligible next to correctness.
  • id != null && ...: two new entities (both with a null id) are never equal to each other, only to themselves through this == o. They are two different stations that have not been saved yet, and that is the correct semantics.
  • Hibernate.getClass() instead of getClass(): with lazy loading the object may be a proxy of a generated subclass, and comparing getClass() would make an entity and its own proxy come out as different.

If you would rather not depend on Hibernate inside the domain, the professional alternative is an immutable business key: plate on Bike, email on User. It is stable from creation and does not depend on the generated id. It only works if there really is a unique, immutable attribute.

  1. Entities and DTOs: the separation stands

In 03-05 we separated domain and contract with DTOs. Now that the domain consists of JPA entities, that separation goes from advisable to indispensable for three new reasons: circular references (in 04-04, Station will have a list of Bike and each Bike a reference to its Station, and serialising that is an infinite loop); lazy loading, which with open-in-view: false throws LazyInitializationException when serialising outside the transaction; and the hidden queries that serialisation can fire while generating the JSON.

The good news is that nothing has to change: CreateStationRequest, StationResponse, StationDetailResponse and StationMapper are all still valid. Only the mapper adapts to the move from record to class:

package com.ciclourbana.stations;

@Mapper(componentModel = "spring")
public interface StationMapper {

    @Mapping(target = "latitude", source = "location.latitude")
    @Mapping(target = "longitude", source = "location.longitude")
    StationResponse toResponse(Station station);

    @Mapping(target = "id", ignore = true)
    @Mapping(target = "version", ignore = true)
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "updatedAt", ignore = true)
    @Mapping(target = "active", constant = "true")
    @Mapping(target = "location", source = ".", qualifiedByName = "toLocation")
    Station toEntity(CreateStationRequest request);

    @Named("toLocation")
    default Location toLocation(CreateStationRequest r) {
        return new Location(r.latitude(), r.longitude());
    }
}

The ignore = true entries are deliberate and deserve an explanation: the id is generated by the sequence, the version is managed by Hibernate and the auditing dates by the AuditingEntityListener. Having MapStruct touch them would be, at best, useless and, at worst, a source of phantom optimistic locking conflicts.

Common Mistakes and Tips

Leaving @Enumerated unspecified. The default value is ORDINAL and the failure is silent and catastrophic. Always write @Enumerated(EnumType.STRING).

Using double for amounts. Cents get lost and the discrepancy shows up weeks later with no identifiable origin. BigDecimal with precision/scale, always.

Generating equals/hashCode with the IDE. It produces a hashCode that changes on save and entities that get lost in HashSets. Use the pattern from section 11.

Choosing IDENTITY out of habit. With PostgreSQL it disables batch inserting. SEQUENCE with allocationSize is the right option, taking care that the increment matches the real sequence's.

Forgetting the no-argument constructor. The error is cryptic: No default constructor for entity. And if you add a constructor with parameters, Java stops generating it automatically.

Using @Transient from java.beans. It compiles and does nothing; the field is persisted all the same. Check the import: jakarta.persistence.Transient.

Tip: @Version on every entity that gets modified. It costs one column and prevents a whole family of concurrency errors. In CicloUrbana it is carried by Station, Bike and Rental.

Tip: review the DDL Hibernate generates. Open the H2 console and run SHOW COLUMNS FROM stations: you will find varchar(255) where you expected varchar(80) and discover which annotation is missing.

Tip: do not put heavy business logic in the entity, but do not leave it anaemic either. Methods such as station.hasFreeDocks() or bike.canBeRented() belong to the entity; orchestrating a full rental belongs to the service.

Exercises

Exercise 1: turn Bike into an entity

Turn CicloUrbana's Bike class into a complete JPA entity. Requirements: table bikes; a plate that is unique, mandatory, 10 characters long and not modifiable; BikeStatus stored as text; a battery level between 0 and 100 and not null; identifier from a sequence with a reservation of 50; optimistic locking; inherited auditing; and an index on status. Do not include the relationship with Station yet.

Exercise 2: find five mistakes

This Rental entity has five serious defects. Find them and fix them.

@Entity
public class Rental {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated
    private RentalStatus status;

    private double totalAmount;

    private LocalDateTime startedAt;

    public Rental(Long userId, Long bikeId) {
        this.userId = userId;
        this.bikeId = bikeId;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Rental other)) return false;
        return Objects.equals(id, other.id)
            && Objects.equals(totalAmount, other.totalAmount)
            && Objects.equals(startedAt, other.startedAt);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, totalAmount, startedAt);
    }
}

Exercise 3: optimistic locking end to end

Describe exactly what happens, step by step and with the SQL involved, when two of the Ribalta council's operators update the capacity of the "North Station" station (version = 3) simultaneously. State what each of them receives and what the client should do.

Solutions

Solution 1.

package com.ciclourbana.bikes;

import com.ciclourbana.common.AuditableEntity;
import jakarta.persistence.*;

@Entity
@Table(
    name = "bikes",
    uniqueConstraints = @UniqueConstraint(name = "uk_bikes_plate",
                                          columnNames = "plate"),
    indexes = @Index(name = "idx_bikes_status", columnList = "status")
)
public class Bike extends AuditableEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "bikes_seq")
    @SequenceGenerator(name = "bikes_seq", sequenceName = "bikes_id_seq",
                       allocationSize = 50)
    private Long id;

    @Column(name = "plate", nullable = false, length = 10, updatable = false)
    private String plate;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 20)
    private BikeStatus status = BikeStatus.AVAILABLE;

    @Min(0) @Max(100)
    @Column(name = "battery_level", nullable = false)
    private Integer batteryLevel;

    @Version
    @Column(name = "version", nullable = false)
    private Long version;

    protected Bike() { }

    public Bike(String plate, Integer batteryLevel) {
        this.plate = plate;
        this.batteryLevel = batteryLevel;
    }

    public boolean canBeRented(int batteryThreshold) {
        return status == BikeStatus.AVAILABLE && batteryLevel >= batteryThreshold;
    }

    // Read accessors for everything; setters only on status and batteryLevel.
    // equals/hashCode with the pattern from section 11 (non-null id + constant hash).
}

The 0-100 range is declared with @Min(0) @Max(100) from Bean Validation (03-04), which Hibernate also translates into a CHECK constraint when generating the schema; in 04-08 we will write it explicitly in the Flyway SQL. The canBeRented method receives the threshold from NetworkProperties (ciclourbana.network.battery-threshold), configured in 02-05.

Solution 2. The five defects:

  1. @Table(name = "rentals") is missing. Without it, the physical name depends on the naming strategy. Add it, along with its indexes on user_id and bike_id.
  2. @Enumerated without EnumType.STRING. The ordinal is stored. Reordering RentalStatus would corrupt Ribalta's whole rental history.
  3. double totalAmount. Rounding error accumulated in the invoices. It must be BigDecimal with @Column(precision = 8, scale = 2).
  4. The no-argument constructor is missing. By declaring one with parameters, Java no longer generates the implicit one, and Hibernate fails at startup with No default constructor for entity.
  5. equals/hashCode over mutable fields. The hashCode changes when the amount is computed at the end of the rental, and the entity gets lost in any HashSet.

Minor but equally objectionable defects: IDENTITY instead of SEQUENCE, LocalDateTime rather than Instant, and the absence of @Version —especially necessary on a rental, which is modified at least twice—. Corrected version:

@Entity
@Table(name = "rentals",
       indexes = @Index(name = "idx_rentals_user", columnList = "user_id"))
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;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 20)
    private RentalStatus status;

    @Column(name = "total_amount", precision = 8, scale = 2)   // never double
    private BigDecimal totalAmount;

    @Column(name = "started_at", nullable = false, updatable = false)
    private Instant startedAt;                                 // never LocalDateTime

    @Version @Column(name = "version", nullable = false)
    private Long version;

    protected Rental() { }   // required by JPA
    // equals/hashCode with the pattern from section 11
}

Solution 3. Both operators open the "North Station" record and receive version = 3.

Operator A sends PUT /api/v1/stations/2 with capacity 34. Their transaction loads the entity (version = 3), modifies it and on commit Hibernate runs UPDATE stations SET capacity=34, version=4 WHERE id=2 AND version=3. The database reports 1 row affected, the commit succeeds and A receives 200 OK.

Operator B sends their PUT with capacity 28 a few seconds later, but their copy is still at version = 3, so Hibernate runs UPDATE stations SET capacity=28, version=4 WHERE id=2 AND version=3. The row now has version = 4, the WHERE finds nothing: 0 rows affected. Hibernate compares the expected count with the real one, throws StaleObjectStateException and the transaction rolls back. Spring translates it into ObjectOptimisticLockingFailureException, and the @RestControllerAdvice from 03-06 turns it into:

{
  "type": "https://api.ciclourbana.example/errors/concurrency-conflict",
  "title": "Concurrency conflict", "status": 409,
  "detail": "The resource was modified by another user. Reload and try again.",
  "code": "CONCURRENCY_CONFLICT", "instance": "/api/v1/stations/2"
}

What the client should do: on a 409, reload the resource, show operator B the current version (capacity 34, set by A) and ask them to confirm or redo their change. What it must never do is retry automatically with the same data: that would overwrite A's work, which is exactly the problem the mechanism prevents.

Notice that the protection works without HTTP headers. If B's change arrived through an internal process, a scheduled task or an administration console, the conflict would be detected just the same. That is the leap from the ETag of 03-03.

Conclusion

CicloUrbana's domain now lives in tables. You know why a record cannot be an entity —no-argument constructor, mutable fields, non-final class— and, more importantly, why conceptually a record is a value object while an entity has an identity of its own; that is why the DTOs from 03-05 remain records and the domain becomes mutable classes. You have mapped tables with @Entity and @Table, with their named indexes and uniqueness constraints. You have chosen SEQUENCE with allocationSize = 50 knowing exactly what it buys: batch inserting, impossible with IDENTITY. You control the column with @Column and its length, nullable, precision/scale and updatable. You are clear about the mapping of each type, including the two rules that prevent the most grief: BigDecimal for money and Instant for timestamps. And you know that @Enumerated(EnumType.STRING) is not an aesthetic preference but the only way to keep reordering an enum from corrupting Ribalta's history.

You have grouped latitude and longitude into an embedded Location, reusable in Bike and duplicable in Rental with @AttributeOverride. You have automatic auditing through @MappedSuperclass, @EntityListeners and @EnableJpaAuditing, without repeating a single field. You have debuted @Version, understanding that optimistic locking protects every write and not just the ones that go through HTTP, which retires the ShallowEtagHeaderFilter from 03-03 and lets the 409 be produced from the global handler of 03-06. And you know the correct equals/hashCode pattern for entities, with its constant hashCode and its comparison by non-null id, which stops entities from getting lost inside a HashSet when they are saved.

But the entities are isolated. A Bike does not know which station it is 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. Lesson 04-04, Relationships Between Entities, draws them: the four cardinalities with their annotation, @ManyToOne and the owning side, @OneToMany with mappedBy and the methods that keep both sides in sync, @OneToOne with @MapsId, @ManyToMany and why it is almost always better to replace it with an intermediate entity. And with them come the two problems that define day-to-day work with an ORM: the LazyInitializationException and N+1.

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