Spring Boot – JPA and Hibernate

July 11, 20265 min readUpdated 8/18/2026

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=false

ddl-auto — get this right once

ValueDoesUse
nonenothingwhen something else owns the schema
validatecompares entities to tables, fails on mismatchthis one
updatealters tables to match entitiesnever
create-dropdrops everything on shutdownthrowaway 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 is ORDINAL, 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 picks BINARY(16) and validate refuses to start.
  • @DynamicUpdate makes Hibernate emit only the changed columns in an UPDATE instead 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 queries

It 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=false

Boot 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=false so N+1 surfaces in development.
  • Soft deletes are invisible to hand-written SQL.

Next: JdbcTemplate — for the queries JPA has nothing to offer.