Entities, repositories and Hibernate
Think of Hibernate as a translator between two languages: your Java or Kotlin objects, and the database’s tables and rows.
- An entity is a class that maps to a table: each object is a row, each field a column.
- A repository is an interface with methods like
findByIdandsave. Spring Data writes the implementation for you, and can even build queries from method names likefindByAuthorName.
- The
EntityManageris your door to the database:findloads an entity by id,persistsaves a new one, andcreateQueryruns JPQL, a query language like SQL that talks about entities and fields instead of tables and columns.
- Hibernate is the JPA implementation that turns those calls into SQL, and turns the results back into objects.
The magic has rules, and most bugs come from not knowing them: which related objects are loaded when, and which changes are saved automatically.
JPA maps entities to tables; Hibernate implements it with a persistence context (a first-level cache and unit of work per transaction) that tracks loaded entities, writes back changes on flush (dirty checking), and loads associations through proxies lazily or eagerly. Spring Data generates repositories, derived queries, paging and projections on top.
The defaults are where production problems start: @ManyToOne and @OneToOne are eager by default, collection associations are lazy and fail outside a session, open-in-view hides that by keeping sessions open during JSON rendering, and collection fetch joins can’t be paginated in SQL. The Detailed version covers fetch planning, batch fetching, projections, entity equality and Kotlin-specific pitfalls.
JPA maps entities to tables; Hibernate implements it with a persistence context (a first-level cache and unit of work, scoped to the JTA transaction for an injected EntityManager) that tracks loaded entities, writes back changes on flush (dirty checking), and loads associations through proxies lazily or eagerly. Queries are JPQL or the Criteria API; Jakarta EE 11 adds Jakarta Data repositories.
The defaults are where production problems start: @ManyToOne and @OneToOne are eager by default, collection associations are lazy and fail once the persistence context has closed (right after the call, when there’s no transaction), and collection fetch joins can’t be paginated in SQL. The Detailed version covers fetch planning, batch fetching, projections, entity equality and Kotlin-specific pitfalls.
@Entity
class Author(
var name: String,
@Id @GeneratedValue var id: Long? = null,
) {
@OneToMany(mappedBy = "author") // lazy by default
val books: MutableList<Book> = mutableListOf()
}
@Entity
class Book(
var title: String,
var year: Int,
@ManyToOne(fetch = FetchType.LAZY) // eager by default: make it lazy
@JoinColumn(name = "author_id")
var author: Author,
@Id @GeneratedValue var id: Long? = null,
)
interface BookRepository : JpaRepository<Book, Long> {
fun findByAuthorNameOrderByYearDesc(authorName: String): List<Book>
fun findByYearGreaterThan(year: Int, pageable: Pageable): Page<Book>
}@Entity
public class Author {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "author") // lazy by default
private List<Book> books = new ArrayList<>();
// getters, setters, protected no-arg constructor …
}
@Entity
public class Book {
@Id @GeneratedValue
private Long id;
private String title;
private int year;
@ManyToOne(fetch = FetchType.LAZY) // eager by default: make it lazy
@JoinColumn(name = "author_id")
private Author author;
// getters, setters, protected no-arg constructor …
}
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByAuthorNameOrderByYearDesc(String authorName); // SQL generated from the name
Page<Book> findByYearGreaterThan(int year, Pageable pageable); // paging and sorting
}Three things to notice:
mappedBy = "author"says the relationship is stored on theBookside (theauthor_idcolumn). TheBookis the owner;Author.booksis a mirror.- Lazy loadingLazy loadingLoading related data only when it is first used, instead of together with the object that refers to it.Open in glossary →:
author.getBooks()doesn’t load the books when the author is loaded, only the first time you use the list. That saves queries when you don’t need them, and causes surprises when you do. - Derived queries:
findByAuthorNameOrderByYearDescbecomes... join author a where a.name = ? order by b.year desc. Misspell a property and the app fails at startup, which is a good thing.
@Entity
public class Author {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "author") // lazy by default
private List<Book> books = new ArrayList<>();
// getters, setters, protected no-arg constructor …
}
@Entity
public class Book {
@Id @GeneratedValue
private Long id;
private String title;
private int year;
@ManyToOne(fetch = FetchType.LAZY) // eager by default: make it lazy
@JoinColumn(name = "author_id")
private Author author;
// getters, setters, protected no-arg constructor …
}
@ApplicationScoped
public class BookQueries {
@PersistenceContext
EntityManager em;
public List<Book> byAuthorName(String authorName) {
return em.createQuery("""
select b from Book b join b.author a
where a.name = :name
order by b.year desc
""", Book.class)
.setParameter("name", authorName)
.getResultList();
}
public List<Book> newerThan(int year, int page, int size) { // paging
return em.createQuery("select b from Book b where b.year > :year order by b.id", Book.class)
.setParameter("year", year)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
}
}Kotlin note
Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin with the kotlin-jpa (no-arg) and allopen compiler plugins, configured for @Entity and the CDI scopes.
Three things to notice:
mappedBy = "author"says the relationship is stored on theBookside (theauthor_idcolumn). TheBookis the owner;Author.booksis a mirror.- Lazy loadingLazy loadingLoading related data only when it is first used, instead of together with the object that refers to it.Open in glossary →:
author.getBooks()doesn’t load the books when the author is loaded, only the first time you use the list. That saves queries when you don’t need them, and causes surprises when you do. - JPQL talks about entities and fields (
b.year,b.author), not tables, and:nameis a parameter thatsetParameterfills in safely. Hibernate turns the query into SQL with ajoinand anorder by.
Dev note: Repositories in Jakarta EE 11
@Repository public interface Books extends CrudRepository<Book, Long> gets findById, save and delete, and @Query("where author.name = :name order by year desc") methods, implemented by the runtime. On older versions, and in most existing code, the EntityManager is used directly as here.Kotlin note
Kotlin classes are final and have no no-argument constructor, and Hibernate needs both for proxies and instantiation. The kotlin("plugin.jpa") (no-arg) and kotlin("plugin.spring") / allopen Gradle plugins generate them; configure allOpen for @Entity too, or lazy @ManyToOne proxies silently become eager.
Changes save themselves
Inside a transaction, Hibernate remembers every entity it loaded. When the transaction commits, it compares each one with how it was loaded, and writes the differences:
Predict the output
Book 1 is titled 1984. What does the second request print?
@Service
class BookService(private val books: BookRepository) {
@Transactional
fun rename(id: Long, title: String) {
val book = books.findById(id).orElseThrow()
book.title = title // no save() call
}
@Transactional(readOnly = true)
fun title(id: Long): String = books.findById(id).orElseThrow().title
}
bookService.rename(1, "Animal Farm") // request 1
println(bookService.title(1)) // request 2@Service
public class BookService {
@Transactional
public void rename(long id, String title) {
Book book = books.findById(id).orElseThrow();
book.setTitle(title); // no save() call
}
@Transactional(readOnly = true)
public String title(long id) {
return books.findById(id).orElseThrow().getTitle();
}
}
bookService.rename(1, "Animal Farm"); // request 1
System.out.println(bookService.title(1)); // request 2rename loads the book inside a transaction, so the entity is managed: Hibernate tracks it. At commit, dirty checking sees the changed title and issues an UPDATE, no save() needed. That's convenient, and also a trap: any change to a managed entity inside a transaction is written, even one you only meant for a response.
Predict the output
Book 1 is titled 1984. What does the second request print?
@ApplicationScoped
public class BookService {
@PersistenceContext EntityManager em;
@Transactional
public void rename(long id, String title) {
Book book = em.find(Book.class, id);
book.setTitle(title); // no persist() or merge() call
}
public String title(long id) {
return em.find(Book.class, id).getTitle();
}
}
bookService.rename(1, "Animal Farm"); // request 1
System.out.println(bookService.title(1)); // request 2rename loads the book inside a transaction, so the entity is managed: Hibernate tracks it. At commit, dirty checking sees the changed title and issues an UPDATE, no persist() or merge() needed. That's convenient, and also a trap: any change to a managed entity inside a transaction is written, even one you only meant for a response.
Practice: a two-sided relationship
An author has books, and a book has an author. In the database there’s one column (book.author_id); in Java there are two references. Step by step:
- Pick the owner: the side with the foreign key,
Book.author(@ManyToOne+@JoinColumn). Only the owner’s value is written to the database. - Mark the mirror:
@OneToMany(mappedBy = "author")onAuthor.books. It’s for reading and navigation only. - Keep both sides in sync in your Java objects with a helper method, so code that reads
author.getBooks()in the same transaction sees the new book. - Decide cascades deliberately:
cascade = CascadeType.ALLandorphanRemoval = trueonly for true parts of a whole (order lines of an order), not for independent entities (books of an author).
@Entity
class Author(var name: String, @Id @GeneratedValue var id: Long? = null) {
@OneToMany(mappedBy = "author")
private val _books: MutableList<Book> = mutableListOf()
val books: List<Book> get() = _books
fun addBook(book: Book) {
_books += book
book.author = this // the owning side: this is what gets saved
}
}@Entity
public class Author {
// …
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
public void addBook(Book book) {
books.add(book);
book.setAuthor(this); // the owning side: this is what gets saved
}
public void removeBook(Book book) {
books.remove(book);
book.setAuthor(null);
}
}Here’s what happens when the owner is forgotten:
Tap the line with the bug.
@Transactional
fun importBooks(authorId: Long, titles: List<String>) {
val author = authors.findById(authorId).orElseThrow()
for (title in titles) {
val book = Book(title) // author is a nullable var
author.books.add(book)
books.save(book)
}
}@Transactional
public void importBooks(long authorId, List<String> titles) {
Author author = authors.findById(authorId).orElseThrow();
for (String title : titles) {
Book book = new Book(title);
author.getBooks().add(book);
books.save(book);
}
}Hint: Which side of the relationship does Hibernate read when it writes book.author_id?
The books are added to author.books, the mirror side (mappedBy). Hibernate ignores the mirror when writing: it only looks at book.author, which is still null. Every imported book is saved with author_id = NULL (or the insert fails if the column is NOT NULL), while the in-memory list looks perfectly fine until the next request.
Set the owning side, ideally through the helper method that keeps both sides consistent:
author.addBook(book) // sets book.author = author and adds to the listauthor.addBook(book); // sets book.author = author and adds to the list(In the Kotlin version, the nullable author is itself a hint: if a book must always have an author, make the property non-null and pass the author to the constructor, as in the entity at the top of the tutorial. Then this bug can’t compile.)
Tap the line with the bug.
@Transactional
public void importBooks(long authorId, List<String> titles) {
Author author = em.find(Author.class, authorId);
for (String title : titles) {
Book book = new Book(title);
author.getBooks().add(book);
em.persist(book);
}
}Hint: Which side of the relationship does Hibernate read when it writes book.author_id?
The books are added to author.books, the mirror side (mappedBy). Hibernate ignores the mirror when writing: it only looks at book.author, which is still null. Every imported book is saved with author_id = NULL (or the insert fails if the column is NOT NULL), while the in-memory list looks perfectly fine until the next request.
Set the owning side, ideally through the helper method that keeps both sides consistent:
author.addBook(book); // sets book.author = author and adds to the listThe persistence context in practice
- Managed entities are flushed automatically before queries that might see them and at commit;
readOnly = truetransactions skip dirty checking (and Hibernate can skip snapshot copies). - Bulk updates (
@Modifying @Query("update …")) go straight to the database and bypass the persistence context: already-loaded entities are now stale. UseclearAutomatically = trueor run them in their own transaction.
- Managed entities are flushed automatically before queries that might see them and at commit. Jakarta’s
@Transactionalhas no read-only flag; for big read-only loads, use DTO projections, or theorg.hibernate.readOnlyquery hint so Hibernate skips snapshots and dirty checking. - Bulk updates (
em.createQuery("update …").executeUpdate()) go straight to the database and bypass the persistence context: already-loaded entities are now stale. Callem.clear()afterwards, or run them in their own transaction.
- Long transactions that load thousands of entities keep all of them (plus snapshots) in memory; process large sets in chunks with
clear(), or use a stream with a fetch size and detach as you go. - Entity identity:
equals/hashCodebased on a generated id break when the id isnullbefore persisting (an entity changes hash aftersave, losing it fromHashSets). Use a natural business key, or the id with a constanthashCode(), and never a Kotlindata classfor entities.
The toString that loads the database
Predict the output
What happens when this Kotlin code logs the author?
@Entity
data class Author(
@Id @GeneratedValue val id: Long? = null,
val name: String = "",
@OneToMany(mappedBy = "author") val books: MutableList<Book> = mutableListOf(),
)
@Entity
data class Book(
@Id @GeneratedValue val id: Long? = null,
val title: String = "",
@ManyToOne(fetch = FetchType.LAZY) val author: Author? = null,
)
@Transactional
fun show(id: Long) {
val author = authors.findById(id).orElseThrow()
log.info("Loaded {}", author)
}// Lombok's @Data on entities causes the same problem in Java:
// it generates toString(), equals() and hashCode() over every field, including lazy associations.
@Entity @Data
public class Author {
@Id @GeneratedValue private Long id;
private String name;
@OneToMany(mappedBy = "author") private List<Book> books = new ArrayList<>();
}
@Transactional
public void show(long id) {
Author author = authors.findById(id).orElseThrow();
log.info("Loaded {}", author);
}data class generates toString() (and equals/hashCode) from all constructor properties. Author.toString() prints books, each Book.toString() prints its author, which prints its books… infinite recursion and a StackOverflowError, after loading every lazy collection on the way (or a LazyInitializationException if the session is closed first). The same recursion hits hashCode() when an entity is put in a HashSet. Use regular classes for entities, with toString() that only prints the id and simple fields.
Deep dives
Deep diveDirty checking: why there's no save() callInterview favorite
Entities loaded inside a transaction are managed by the persistence context, which keeps a snapshot of their state. At flush time (before commit, and before queries that might see the changes), Hibernate compares every managed entity with its snapshot and issues an UPDATE for the ones that changed. That’s why order.status = SHIPPED inside a transaction is saved without calling save().
The costs follow: memory for the snapshots and CPU for the comparison grow with the number of loaded entities, so a transaction that loads 50,000 rows to read them pays for dirty checking it never needed. Read-only transactions (Spring’s @Transactional(readOnly = true), Hibernate read-only sessions) can skip snapshots, and bulk updates belong in a single UPDATE query.
- The persistence context is a first-level cache and unit of work: loaded entities are tracked, compared at flush (dirty checking) and written without
save(). Large transactions pay for it in memory; process in chunks and clear. - Only the owning side (with
@JoinColumn) writes the foreign key; keep both sides in sync with helper methods. No data classes or@Datafor entities: generatedequals/toStringtouch lazy associations.
Check this part
Inside a transaction, you load a book and change its title without calling save() or persist(). What happens?
In a bidirectional relationship, which side does Hibernate use to write the foreign key?
Why avoid Kotlin data classes (or Lombok @Data) for entities?
A job loads 200,000 entities in one transaction to read them. What does the persistence context cost?