1. Persistence context: idea and boundaries
If you are hearing the phrase "there is memory inside ORM" for the first time, it is easy to imagine something suspicious: as if Hibernate secretly builds a "mini-database in RAM". In reality, it is much more pragmatic. We have a transaction as one business operation, and inside it we want data reads and changes to be consistent and predictable — not only at the SQL level, but also at the level of Java objects.
Imagine a simple scenario from our mini-shop. We are placing an order. Inside placeOrder() we may encounter the same product several times: once when checking inventory, a second time when building order items, a third time when calculating the total or logging something for diagnostics. If each such "contact" created a new Java object Product, we would quickly get:
- confusion (which object is the "real" one?),
- inconsistency (one object already has a field changed, the other does not),
- duplicates in memory,
- surprises in associations (the object from orderItem.getProduct() and the object from productRepository.findById() are suddenly different, even though it is the same row in the database).
That is why, within a unit of work, ORM keeps a single set of managed entities so that one table row with a specific id corresponds to one Java reference within the current operation. This is exactly where the two key concepts of our lecture come from: identity map and first-level cache.
The word "context" sounds as if we are about to discuss psychology. But here everything is engineering. Persistence context is the "working area" in which JPA/Hibernate keeps entities while a unit of work runs. In JPA terminology, it is associated with EntityManager. In Hibernate terminology, you sometimes see the word Session. In our Spring Boot application, this is all neatly hidden under the hood: repositories use EntityManager, and Spring carefully binds its lifetime to the transaction.
To avoid getting lost, keep a simple picture in mind: when a service method enters @Transactional, Spring opens a transaction and provides the context associated with it, where ORM tracks loaded entities. At that moment, Product is no longer just an "object", but an object under ORM supervision.
It is convenient to visualize it like this:
flowchart TD
A["Service method @Transactional"] --> B["Transaction boundary / Unit of Work"]
B --> C[EntityManager]
C --> D[Persistence Context]
D --> E[(Database)]
Important: persistence context is not "another database layer", but an internal ORM structure that helps execute a unit of work correctly. It lives for a limited time: usually exactly as long as the transaction lives. That is why you should not think of it as an "application cache" or "state storage between requests".
2. Identity map and the level-1 cache
Identity map: one id — one reference
Now to the actual "wow magic", which is really better understood as strict mathematics. Identity map is the rule: within one persistence context, for the same id, there is one and the same entity instance. In other words, if you load Product with id = 10 twice in the same transaction, you do not get two copies, but the same reference.
Let us look at a minimal example from our project. We assume that ProductRepository already exists (it appeared earlier in the repositories module), and the service is an ordinary Spring @Service.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogDebugService {
private final ProductRepository productRepository;
public CatalogDebugService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public void identityMapDemo(Long id) {
// The first call will usually do a SELECT and put the entity into the persistence context
Product p1 = productRepository.findById(id).orElseThrow();
// The second call within the same transaction will usually return the same object from the context
Product p2 = productRepository.findById(id).orElseThrow();
// Compare references: identity map guarantees that this is the same instance in memory
System.out.println(p1 == p2); // true
}
}
If you enable SQL logs, you will usually see that the first findById() executes a SELECT, and the second one already takes the object from the context. But even if you do not look at the logs, p1 == p2 gives a very important observation: this is indeed one object.
Why is this valuable? Because a Java reference is the object's "identity" in memory. When you change an object in one place by simply changing a field, you will see that change everywhere else where you hold a reference to it. And this is not some "setter magic" — it is just ordinary Java: one object, one memory, one state.
Identity map in a service
On paper, comparing references looks like a party trick: you can show your friends and say, "Look, Java!" But in a real service it is much more useful: within one unit of work, repeated reads by id should not break your model or create copies.
Imagine this scenario: you load a product, change its name in memory (for now without discussing whether it will go to the database), and then "find" it again by id. You expect to see the changed name already, because it is the same object.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductRenameDemoService {
private final ProductRepository productRepository;
public ProductRenameDemoService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public void renameAndReadAgain(Long id) {
// Load the entity: from this moment on it is managed (inside the persistence context)
Product product = productRepository.findById(id).orElseThrow();
// Change the state of the object in memory (we are not discussing flush/UPDATE yet)
product.setName("Green Tea");
// Read again by id in the same transaction: the same object will be returned
Product again = productRepository.findById(id).orElseThrow();
// Therefore, we will see the already changed value (this is not "re-reading from the DB", it is the same instance)
System.out.println(again.getName()); // Green Tea
}
}
Please note: for now, we are not discussing whether an UPDATE will go to the database or whether save() is needed. That is a separate important topic, and it will come later. Here we are only fixing one thing: inside the context, you do not get "another copy from the database"; you get the same object from the memory of the unit of work.
First-level cache: what is stored
The term first-level cache often misleads beginners with the word "cache". It seems like something similar to Redis, only inside Hibernate, and now we are going to learn how to "speed up the application with a single annotation". In reality, the first-level cache is just a consequence of the identity map: since the context must keep "one id -> one object", it naturally has to store already loaded entities and return them without re-reading from the database.
An important detail: this "cache" is always local. It lives inside the persistence context, which usually means inside a transaction. It does not survive an application restart, is not shared between threads, is not available to "other requests", and is not intended as a system-level caching tool. It is the working memory of the unit of work, not "optimization for optimization's sake".
It is sometimes useful to literally imagine it as an internal map:
flowchart TD
PC[Persistence Context] --> M[Identity Map]
M --> K1["key: Product#10"]
K1 --> V1["value: (same Java object ref)"]
M --> K2["key: Category#3"]
K2 --> V2["value: (same Java object ref)"]
This representation helps to eliminate two myths right away. Myth one: "ORM goes to the database every time I call find()." Not necessarily, if the entity is already in the context. Myth two: "if there is a cache, then I do not need to think about queries." No, because the context is limited and works only within a unit of work.
Repository and EntityManager: one context
Sometimes it seems that a repository is some kind of magical standalone thing. In reality, productRepository.findById() internally uses the JPA infrastructure, and that relies on EntityManager. And the nicest part is that this all works in one and the same persistence context.
Let us make an example where we compare an object obtained through the repository and an object obtained directly through EntityManager.find(). Within one transaction, it should be the same instance.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class EntityManagerDemoService {
private final ProductRepository productRepository;
private final EntityManager entityManager;
public EntityManagerDemoService(ProductRepository productRepository, EntityManager entityManager) {
this.productRepository = productRepository;
this.entityManager = entityManager;
}
@Transactional
public void repositoryAndEntityManagerSeeSameObject(Long id) {
// Read through the repository (under the hood, the EntityManager from the same context is used)
Product p1 = productRepository.findById(id).orElseThrow();
// Read directly through EntityManager in the same transaction
Product p2 = entityManager.find(Product.class, id);
// This is the same managed entity within the persistence context
System.out.println(p1 == p2); // true
}
}
This example is not useful because you will write entityManager.find() every day. It is useful because it helps to "disassemble" what is happening into layers: repositories are a convenient declarative wrapper, but underneath them the JPA machinery with the same persistence context still works.
Context boundary: a different transaction
This is the most important place for common sense. The identity map rule works within a single persistence context. As soon as the context ends, usually together with the transaction, the rule "same id -> same reference" is no longer guaranteed.
So in another transaction, you will read Product with id = 10 again — and that will be a new Java object, just with the same data. And that is normal: JVM cannot (and should not) guarantee that an object from yesterday's transaction "continues to be the very same object" today.
The easiest way to show this deterministically is with TransactionTemplate: it lets you open two different transactions right in one method and compare references. It looks a bit more "infrastructure-like", but the example is short and clearly demonstrates the boundary.
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class TwoTransactionsDemoService {
private final ProductRepository productRepository;
private final TransactionTemplate tx;
public TwoTransactionsDemoService(ProductRepository productRepository, TransactionTemplate tx) {
this.productRepository = productRepository;
this.tx = tx;
}
public void compareObjectsAcrossTransactions(Long id) {
// First transaction: its own persistence context, its own managed entity
Product p1 = tx.execute(status -> productRepository.findById(id).orElseThrow());
// Second transaction: a new persistence context, a different object will be created/loaded
Product p2 = tx.execute(status -> productRepository.findById(id).orElseThrow());
// References are usually different because the contexts are different
System.out.println(p1 == p2); // false (different persistence context)
}
}
Notice that we are not saying "always false" as an absolute law of physics. But from the JPA model's point of view, that is exactly how it should be: two different contexts — two different objects. The main thing you should take away is: identity map is a local rule within a unit of work, not a promise "forever".
Mini-shop: consistency of references
Let us return to the practical meaning. In our project, we have relationships: Category -> Product, CustomerOrder -> OrderItem, OrderItem -> Product. Within a transaction, when you build an order, you can obtain Product in at least two ways: directly through ProductRepository and indirectly as a reference from OrderItem.
If ORM did not implement an identity map, you could run into a situation where "I have one Product in my order, and separately in the service I loaded apparently the same product, but it is a different object." Then any reference comparisons, any collections, any checks for "already added" would turn into a minefield.
This can be shown with a small example: inside a transaction, we take the product from an order item and separately load the same product by id — and we expect that it will be the same entity within the context.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadConsistencyDemoService {
private final CustomerOrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderReadConsistencyDemoService(CustomerOrderRepository orderRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}
@Transactional
public void compareProductReferencesInsideTransaction(Long orderId) {
// Load the order within the transaction: everything read after this will go into one context
CustomerOrder order = orderRepository.findById(orderId).orElseThrow();
// Take Product through the association (how exactly it loads depends on fetch settings)
Product fromItem = order.getItems().get(0).getProduct();
// Load the same Product by id directly through the repository
Product byId = productRepository.findById(fromItem.getId()).orElseThrow();
// Identity map ensures consistency: this is the same managed entity within the context
System.out.println(fromItem == byId); // true (within one context)
}
}
There is a subtlety here: exactly how items and product are loaded depends on your fetch settings and on what you read before. But the idea itself is correct: persistence context makes the model connected in memory within a unit of work, and that is the foundation of normal ORM behavior.
3. What persistence context does not do
It is very easy to confuse the boundaries and start expecting from persistence context something it never promised. It will not make your application "fast automatically", will not replace proper queries, will not provide a global caching strategy, and will not become a "source of truth" between requests. Its purpose is much more down-to-earth: to ensure correct and predictable entity handling within a unit of work.
It is convenient to reinforce this with a small comparison table (without diving into future topics). It is not here so that you memorize everything right now, but so that you do not build wrong expectations:
| Mechanism | Where it lives | Lifespan | Main idea |
|---|---|---|---|
| Database | PostgreSQL | Long | Source of truth and long-term state |
| Persistence context / first-level cache | Inside EntityManager | Usually one transaction | One entity per one id + working memory of unit of work |
| "Application cache" (in the broad sense) | In a separate layer (for example, in-memory/Redis) | Long / policy-based | Speed up repeated reads between requests |
If you keep this table in mind, you stop expecting Redis-like behavior from persistence context and stop blaming Hibernate for not "caching everything under the sun". It is not supposed to.
4. Common mistakes when working with persistence context
Mistake #1: treating persistence context as a "global application cache".
This is the most common and the most insidious mistake, because it creates false expectations: "I already read this product, why another SELECT?" The answer is almost always boring: because it is a different transaction, a different context, a different moment in time. Persistence context is local unit of work memory, not a shared storage for all requests and users.
Mistake #2: assuming that repeated reads always must go to the database.
After JDBC experience, a beginner often thinks: "I called findById() — so there was definitely a query." But in JPA that is not how it works: if the entity is already in the context, it will be returned from there. And that is normal. What usually scares people is "I did not see SQL — therefore I do not control the situation." Control here is elsewhere: in understanding the transaction boundary and the context lifecycle.
Mistake #3: extending the rule "one id -> one object" to the entire application runtime.
Within one transaction, p1 == p2 can be true. But that does not mean that tomorrow, in another transaction, you will get the same reference. Trying to "hold on to an object" across unit of work boundaries almost always leads to disappointment. ORM works with data; it does not promise you eternal object identity in the JVM.
Mistake #4: ignoring the connection between context and transaction, and writing service code as if there were no transactions.
If you do a lot of reads/changes but do not give the service a transaction boundary, you break the foundation for yourself: each repository call may live in its own little context. As a result, the identity map stops being "felt", and behavior becomes fragmented. Usually this is fixed not by "one more setting", but by a proper unit of work boundary on the service.
Mistake #5: expecting persistence context to "update the object by itself" if the database changed somewhere else.
An entity living in the context is an object in memory. It is not telepathic. If data changed somewhere in another scenario, your object is not required to suddenly "pull fresh values". Persistence context ensures consistency within a unit of work, but not synchronization with external reality at every little sneeze.
GO TO FULL VERSION