JPA is the specification, Hibernate is the implementation, and Spring Data JPA is the layer that removes the boilerplate on top of both. This lesson is about the middle one — what Hibernate is actually doing — because that is where the surprises live.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>spring.datasource.url=jdbc:mysql://127.0.0.1:3306/pizza
spring.datasource.username=root
spring.datasource.password=
# Liquibase owns the schema. `validate` makes Hibernate refuse to start if the
# entities and the tables have drifted apart, which is exactly what we want:
# a loud failure at boot beats a silent mismatch discovered in production.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=falseddl-auto — get this right once
| Value | Does | Use |
|---|---|---|
none | nothing | when something else owns the schema |
validate | compares entities to tables, fails on mismatch | this one |
update | alters tables to match entities | never |
create-drop | drops everything on shutdown | throwaway tests |
update is the trap. It is convenient and it never removes anything,
so your schema slowly accumulates columns nobody uses; it makes changes in an order it chooses; and
it gives you no migration history and no way to review what it did before it does it in production.
Let a migration tool own the schema (lesson 18) and set validate, so a drift between
code and database fails at startup with a message naming the table.
An entity
@Entity
@DynamicUpdate
@SQLRestriction("deleted = false")
@Table(
name = DatabaseTableNames.PRODUCT,
indexes = {
@Index(name = "idx_product_type_active", columnList = "type, active"),
@Index(name = "idx_product_deleted", columnList = "deleted")
})
public class Product implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, updatable = false, unique = true)
private Long id;
/**
* {@code @JdbcTypeCode(SqlTypes.CHAR)} is load-bearing: without it Hibernate stores a
* {@code java.util.UUID} as BINARY(16), which would not match the CHAR(36) column and
* {@code ddl-auto=validate} would refuse to start.
*/
@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "public_id", nullable = false, updatable = false, unique = true, length = 36)
private UUID publicId;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private ProductType type;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}Three details that are each worth a bug report:
@Enumerated(EnumType.STRING), always. The default isORDINAL, which stores the enum's position. Insert a new constant in the middle of the enum and every existing row silently means something else. This is unrecoverable without backups.@JdbcTypeCode(SqlTypes.CHAR)for UUIDs on MySQL, or Hibernate picksBINARY(16)andvalidaterefuses to start.@DynamicUpdatemakes Hibernate emit only the changed columns in anUPDATEinstead of all of them.
Relationships, and keeping both sides in sync
@ToString.Exclude
@EqualsAndHashCode.Exclude
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@OrderBy("price ASC")
private List<ProductSize> sizes = new ArrayList<>();
/** Keeps both sides of the relationship in sync — forgetting this is a classic JPA bug. */
public void addSize(ProductSize size) {
sizes.add(size);
size.setProduct(this);
}
public void removeSize(ProductSize size) {
sizes.remove(size);
size.setProduct(null);
}mappedBy says the other side owns the foreign key. Hibernate only writes the
owning side, so product.getSizes().add(size) without
size.setProduct(product) saves a row with a null FK — or saves nothing at all. Helper
methods that set both are the standard fix.
orphanRemoval = true means removing a size from the list deletes the row.
cascade = ALL means saving the product saves its sizes.
⚠️ Never put a collection in equals, hashCode or
toString, which is why those two Lombok exclusions are there. The entity's own
comment says it best: @Data would walk the collection, forcing a lazy load on every
toString and recursing forever through the child's parent reference. Lesson 20 covers
this.
Lazy loading and the N+1 problem
@OneToMany is lazy by default: the collection is a proxy, and the query runs when you
first touch it. That is usually right, and it produces the most common performance bug in JPA:
List<Product> products = productRepository.findAll(); // 1 query
for (Product product : products) {
product.getSizes().size(); // 1 query EACH
}
// 14 products = 15 queriesIt never shows up in development with fourteen rows and is catastrophic with fourteen thousand. The fix is to fetch what you need in one query:
@Query("SELECT DISTINCT p FROM Product p LEFT JOIN FETCH p.sizes WHERE p.publicId = :publicId")
Optional<Product> findByPublicIdWithSizes(@Param("publicId") UUID publicId);JOIN FETCH loads the association in the same statement. DISTINCT is
needed because joining a collection multiplies the parent rows.
To see the queries while developing:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=TRACE⚠️ @ManyToOne is EAGER by default — the opposite of
@OneToMany. That default is almost always wrong, because loading one order then drags in
its user, and the user's everything. Set fetch = FetchType.LAZY explicitly on every
@ManyToOne.
open-in-view
spring.jpa.open-in-view=falseBoot defaults this to true and logs a warning about it. It keeps the persistence
context open for the whole request, so lazy loading still works in the view or the serialiser. That
sounds helpful and it means your database session is held open while JSON is being written,
and that lazy loads fire from places you cannot see.
Turning it off means LazyInitializationException when you touch an unloaded
association outside a transaction — which is the point. It surfaces the N+1 at development time
instead of hiding it until production. Load what you need in the service layer, map to a DTO, and
return that.
Soft deletes
/** Gone for good. @SQLRestriction filters these out of every query automatically. */
@Column(name = "deleted", nullable = false)
private boolean deleted = false;With @SQLRestriction("deleted = false") on the class, Hibernate adds that predicate to
every query it builds. Deletes become updates, and order history keeps working because the product
row still exists.
⚠️ Hand-written SQL never sees @SQLRestriction. Hibernate applies it
when it builds a query from the entity model; SQL you wrote yourself does not go near the entity
model. In the pizza API this silently counted deleted orders as revenue — the reports stayed entirely
plausible, just wrong. Lesson 17 covers it, and it is the single most expensive gotcha in this
codebase.
Transactions
/**
* {@code readOnly = true} lets Hibernate skip dirty checking and tells the driver this will not
* write — cheaper, and it makes an accidental write fail loudly.
*/
@Override
@Transactional(readOnly = true)
public List<ProductDTO> getMenu() {
return mapper.mapProductsToProductDTOs(productDAO.findActiveMenu());
}Inside a transaction, entities are managed: Hibernate tracks changes and flushes them at
commit. You do not need to call save() on a loaded entity you modified — dirty checking
writes it anyway. That surprises people in both directions.
Three rules: put @Transactional on the service, not the repository,
because the business operation is the unit of work; use readOnly = true for reads; and
remember it is proxy-based, so a self-invocation gets no transaction at all (lesson 8).
What to take from this
ddl-auto=validate, and let a migration tool own the schema.@Enumerated(EnumType.STRING)— the ordinal default corrupts data silently.- Sync both sides of a relationship with helper methods.
open-in-view=falseso N+1 surfaces in development.- Soft deletes are invisible to hand-written SQL.
Next: JdbcTemplate — for the queries JPA has nothing to offer.