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
- Why a
recordcannot be an entity @Entityand@Table@Idand the four@GeneratedValuestrategies@Columnand controlling the column- Data types and their mapping
- Enums:
STRINGversusORDINAL - Derived fields and
@Transient - Embedded objects:
@Embeddableand@Embedded - Automatic auditing
- Optimistic locking with
@Version equalsandhashCodein JPA entities- Entities and DTOs: the separation stands
- Common Mistakes and Tips
- Exercises
- Why a
record cannot be an entity
record cannot be an entitySince 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.
@Entity and @Table
@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
nameexplicitly. By default Spring Boot appliesCamelCaseToUnderscoresNamingStrategy(SpecSheet→spec_sheet): it works, but it leaves the physical name at the mercy of a configurable strategy. indexesanduniqueConstraintsonly take effect when Hibernate generates the schema. Withddl-auto: validatethey 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
namedoes not replace business validation:StationServicewill still checkexistsByNamein order to return a409with a readableProblemDetail(03-06) instead of letting aDataIntegrityViolationExceptionescape. The constraint is the last line of defence, the one that covers race conditions.
@Id and the four @GeneratedValue strategies
@Id and the four @GeneratedValue strategiesEvery 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.
@Column and controlling the column
@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
lengthis 255. Leaving it alone turns every piece of text into an unconsideredvarchar(255). CicloUrbana'splateisRB-0142: seven characters, andlength = 10is generous and descriptive. nullable = falseis executable documentation, and it goes hand in hand with@NotNull(03-04): validation produces a readable400, the column constraint is the ultimate guarantee of integrity.updatable = falsefits the plate: it is assigned when the bike is registered and never changes, so Hibernate excludes it fromUPDATEs.unique = trueon@ColumnversusuniqueConstraintson@Table: the former generates a random constraint name (UK_a7f3b2...) that will end up showing in error messages; with@Tableyou name it and you can also cover several columns.
- 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.3A 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.
- Enums:
STRING versus ORDINAL
STRING versus ORDINALBikeStatus comes from module 3:
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:
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.
- Derived fields and
@Transient
@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.
- Embedded objects:
@Embeddable and @Embedded
@Embeddable and @EmbeddedStation 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.
}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.
- 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:
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.
- Optimistic locking with
@Version
@VersionIn 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.
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.
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.
equals and hashCode in JPA entities
equals and hashCode in JPA entitiesThis 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 foundUsing 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 anullid) are never equal to each other, only to themselves throughthis == o. They are two different stations that have not been saved yet, and that is the correct semantics.Hibernate.getClass()instead ofgetClass(): with lazy loading the object may be a proxy of a generated subclass, and comparinggetClass()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.
- 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:
@Table(name = "rentals")is missing. Without it, the physical name depends on the naming strategy. Add it, along with its indexes onuser_idandbike_id.@EnumeratedwithoutEnumType.STRING. The ordinal is stored. ReorderingRentalStatuswould corrupt Ribalta's whole rental history.double totalAmount. Rounding error accumulated in the invoices. It must beBigDecimalwith@Column(precision = 8, scale = 2).- 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. equals/hashCodeover mutable fields. ThehashCodechanges when the amount is computed at the end of the rental, and the entity gets lost in anyHashSet.
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
- 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
