CodeGym /Courses /Hibernate deep-dive /findById ( ), getReferenceById ( ) and repository delete...

findById ( ), getReferenceById ( ) and repository delete methods

Hibernate deep-dive
Level 27, Lesson 2
Available

1. findById(), getReferenceById(), and delete: reading by meaning

If you have ever caught yourself thinking, “well, it says findById(), so it must just find it,” then you are normal: the brain likes to trust method names. But in persistence code, names are only hints, while reality is determined by what Hibernate can do to ensure correctness and synchronization. That is why we read these methods not as “API”, but as “operations on the persistence context”. Delete methods have the same story: a short name can hide either entity lifecycle deletion or a bulk-like operation over rows.

In our mental model of the world (the same one we built from day 1 of the course), any repository call should be mentally expanded into a mini-scenario. It has a transaction boundary, a persistence context as an identity map, entity states (managed, detached...), flush, which can unexpectedly send SQL before the method ends. And against this background, findById() and getReferenceById() are not “two ways to get an object”, but two different agreements between your code and the ORM.

To keep your head straight, it helps every time to ask yourself three questions, but not as a ritual, rather as an engineering check: what do I get back, an entity or a reference/proxy; when does SQL appear, immediately or on first access; and what happens if the record does not exist in the database, an empty result right away or an error later. The rest of the lecture is just a detailed unpacking of these three questions for reading and deletion.

2. findById(): Optional and persistence context

findById() is the most “honest” method for reading by identifier in Spring Data. It returns an Optional, because the record may not exist, and that is not an exception, but a perfectly normal business fact. It is important to understand that findById() is a request for real entity state loading, which means the ORM has the right to hit the database to get the row, if it is not already in the first-level cache.

Let us start with the smallest but correct code: read Product by id and, if it is not found, fail explicitly with a clear error. It looks boring — but boring code in the data layer is usually the most reliable.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogWriteService {

    private final ProductRepository productRepository;

    public CatalogWriteService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameProduct(Long id, String newName) {
        // Important: findById() returns Optional because the record may not exist
        Product product = productRepository.findById(id)
                // Important: fail right away with a clear message, not Optional.get() without context
                .orElseThrow(() -> new IllegalArgumentException("Product not found: " + id));

        // Important: modify the managed entity inside the transaction
        product.renameTo(newName);

        // save() is not required: product is already managed, dirty checking will do the UPDATE on flush
    }
}

Pay attention to one important thing that beginners often miss. There is no explicit save() here, but that does not mean “we forgot to save”. It means “we trust the unit of work”: we changed a managed entity, Hibernate will see the dirty state and synchronize the changes on flush, usually before commit. If you see UPDATE in the SQL trace, it is not because you called save(), but because you changed a managed object.

Now the second layer of understanding: findById() works within the persistence context. That means if the entity is already loaded in the current transaction, a repeated findById() does not have to issue a second SELECT. It can return the same Java object from the first-level cache (identity map). This is not “an optimization”, but a consistency guarantee: one database row → one managed object in one session.

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void showIdentityMap(Long id) {
    // The first call may hit the database (if the entity is not yet in the 1L cache)
    Product p1 = productRepository.findById(id).orElseThrow();

    // The second call often returns the same object from the persistence context without a second SELECT
    Product p2 = productRepository.findById(id).orElseThrow();

    // Important: this is a reference equality check (identity), not equals()
    System.out.println(p1 == p2); // true (in one transaction, one managed instance)
}

If you enable SQL trace, you will often see that SELECT was issued only once (depending on whether the object was already in the context). That is exactly what we want: repeated access inside one unit of work should not create “two different versions of one entity in memory”.

One more nuance that matters especially for beginners: Optional is not an “annoyance” and not a reason to rush to .get(). Optional is a type-level hint: “data may be absent”. If you ignore that and call .get(), you get an exception in the most unpleasant form: late, unexpected, with no proper message. It is better to accept right away that “not found” is a normal branch.

And finally, an important link to the previous module: findById() does not guarantee that SQL appears strictly at the moment of the call. For example, if before findById() you modified other managed entities, Hibernate may do a flush before the query (AUTO flush) so that the query sees the correct state. In other words, you call “read”, but in the log you suddenly see UPDATE before SELECT. That is not “Spring Data acting up”, but the flush cycle we covered earlier.

3. getReferenceById(): proxies and “SQL on demand”

getReferenceById() is a method with attitude. It is useful, but it easily tricks a beginner. It says: “Here is a Product object for you,” but in reality it often returns a proxy, that is, a lazy reference that does not yet have the data. It is like getting a claim ticket instead of the coat itself in a cloakroom: the number exists, but the coat appears only when you ask for it.

In Spring Data JPA, getReferenceById() is essentially a convenient wrapper over EntityManager.getReference(...). The semantics are: “I need a reference to an entity with this id, do not promise that it is already loaded”. Therefore SQL is usually not executed immediately. It is executed later, when you actually access the entity data (except for id).

Let us show this with an example. Suppose we are adding a relation (for example, creating an assignment between a product and a category). We may only need to set foreign keys without loading Product and Category in full.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogAssignmentService {

    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;
    private final ProductCategoryAssignmentRepository assignmentRepository;

    public CatalogAssignmentService(
            ProductRepository productRepository,
            CategoryRepository categoryRepository,
            ProductCategoryAssignmentRepository assignmentRepository
    ) {
        this.productRepository = productRepository;
        this.categoryRepository = categoryRepository;
        this.assignmentRepository = assignmentRepository;
    }

    @Transactional
    public void assign(Long productId, Long categoryId) {
        // Important: we take references (proxy), usually without a SELECT right here
        Product productRef = productRepository.getReferenceById(productId);
        Category categoryRef = categoryRepository.getReferenceById(categoryId);

        // Important: we build the relation by identity (FK), without touching entity fields
        ProductCategoryAssignment a = ProductCategoryAssignment.assign(productRef, categoryRef, 10);

        // Important: we save the assignment; errors may appear on flush/commit (for example, FK)
        assignmentRepository.save(a);
    }
}

The idea here is simple: we are building a relation at the level of identities. If the assignment table contains product_id and category_id, then the ORM can save it without reading “what product is this” and “what category is this”. This is exactly the case where a reference is justified.

But now for the “dark side of proxies”. As long as you do not touch the data, everything stays quiet. The moment you access a regular field, Hibernate will try to initialize the proxy — and execute a SELECT. So these lines are potentially “surprisingly expensive”:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void touchProxy(Long productId) {
    // Important: this is a proxy/reference, not necessarily a loaded entity
    Product ref = productRepository.getReferenceById(productId);

    // Usually id is available without initializing the proxy
    System.out.println(ref.getId());    // prints id, usually without SELECT

    // Almost certainly requires initialization => SELECT
    System.out.println(ref.getName());  // almost certainly triggers SELECT to initialize
}

And here the second important point comes up: what happens if the record does not exist in the database. findById() will honestly return Optional.empty() right away. But getReferenceById() may return a proxy “on promise”, and the problem will show up later — at initialization time. Then you may get a jakarta.persistence.EntityNotFoundException (or a similar exception), and it will not be in the line where you obtained the reference, but in the line where you first touched the data.

There is another typical failure scenario, and it is especially “fun” in production systems. You take a reference for a nonexistent id, do not touch the fields, build a relation, and save. The error then appears only on flush/commit as a foreign key violation (if there is an FK, and in a normal schema there should be). In other words, it looks like you “saved” it, and then the transaction fails on commit. That is not good or bad — it is just a different point where the error becomes visible. The important part is to expect it and handle it correctly.

And the last nuance, almost philosophical: a reference is not a “free entity”. It is “deferred cost”. If in this same method you are going to use the product name, price, or status anyway, you have simply moved the SELECT a few lines lower, but not eliminated it. That is why getReferenceById() is good specifically as a tool for “I need a link for an FK”, not as a way to “outsmart the database”.

4. Choice: findById() vs getReferenceById()

Both functions are needed, and both can be the right choice — the question is what you are doing in the scenario. The classic beginner mistake is to choose a method “because it is shorter” or “because it seems faster”. The correct choice is made from the need: do you need the entity state or only its identity for a relation.

Below is a small table that helps quickly compare expectations with reality. This is not an “exam cheat sheet”, but a way not to fool yourself when reading code.

Criterion findById() getReferenceById()
What you get a real loaded entity (or nothing) a reference/proxy to the entity
SQL right now often does a SELECT (if not in 1L cache) usually no SQL immediately
If the record does not exist Optional.empty() an error later on initialization or on flush (FK/EntityNotFound)
When to choose when you need fields, invariants, data checks when you only need an FK link inside a transaction
Main risk forgetting to handle “not found” letting a proxy escape the transaction boundary or getting an error late

Now let us connect this to our Commerce Persistence Lab project so the choice is not abstract. If you are writing a write scenario where you change product state, check its status and price, validate business invariants — you need findById(). You want to get a managed object with data, and then work through find + mutate.

If you are doing an operation like “create a relation” or “put a reference into a to-one field”, and you really do not need the related object data, then a reference is a normal tool. But you must be honest: if two lines later you still read ref.getName(), then it is no longer a “reference” but “lazy loading”, and maybe it would be simpler to just use findById() and not play surprise games.

There is another interesting point that connects this lecture well with the identity map topic. Inside one transaction, JPA/Hibernate strives to maintain the rule “one row — one managed instance”. So if you first obtained a reference and then called findById() for the same id, you will most likely get the same object, only now initialized. That is useful to remember when debugging: “why is there a proxy here?” — because it has already settled into the persistence context, and the ORM does not want to create a second object.

And one last practical rule that saves hours of life. If you are not sure whether you need a reference, start with findById(). It is more predictable: either the data exists or it does not. When you already know for sure that in this case you only need a link (for example, to optimize away an unnecessary SELECT in a very hot operation), then switch to getReferenceById() — but be sure to check the SQL trace to make sure you really won, and did not just move the SELECT elsewhere.

5. Deletion in the repository: lifecycle delete

Deletion is the part of CRUD that most often “seems simple” and then suddenly turns into a long SQL log, cascades, orphanRemoval, and the sad eyes of a developer who wanted to “just delete one row”. In the context of Hibernate, the right question is not “how do I delete”, but “which deletion semantics am I choosing now”: delete an entity as part of its lifecycle, taking relations and rules into account, or perform a mass operation over rows.

Let us start with entity-oriented deletion. It is thought of as an operation on the entity: we either load it (or get a managed instance), move it into the removed state, and then flush synchronizes that with the database. In Spring Data terms this usually looks like delete(entity) or deleteById(id).

The most transparent option for understanding is to find the entity and delete it as an object:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogDeleteService {

    private final ProductRepository productRepository;

    public CatalogDeleteService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void deleteProduct(Long id) {
        // Important: first we get the entity as an object (managed)
        Product product = productRepository.findById(id).orElseThrow();

        // Important: we delete the entity itself so lifecycle behavior runs (cascades/soft delete/listeners)
        productRepository.delete(product);

        // SQL will go out on flush/commit (or earlier if AUTO flush happens)
    }
}

Why is this version “educationally correct”? Because it honestly shows that we are operating on an entity, not “a table”. If the entity has lifecycle rules, cascades, soft delete, listeners, Envers auditing — all of this is logically tied to the entity-oriented operation. For example, if in the project Product is marked as soft-deletable via Hibernate 7.2 @SoftDelete, then remove may become not a DELETE, but an UPDATE deleted=true. In other words, you “delete” it, but at the SQL level an update is performed. And that is normal — if you designed the data lifecycle that way.

deleteById() looks even simpler, but precisely because of that simplicity it often misleads:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void deleteProductById(Long id) {
    // Important: deleteById(...) may issue an extra SELECT (depends on strategy and lifecycle)
    productRepository.deleteById(id);
}

At the level of meaning, this is “delete the entity with this id”. But at the implementation level and in terms of side effects, everything depends on how Spring Data decides to do it and what the ORM needs in order to delete correctly. Often, lifecycle delete needs to make sure the object exists, and obtain a managed instance (or at least a reference). Therefore deleteById() can absolutely lead to an extra SELECT. Sometimes that is good (explicit existence check), sometimes it is unexpected (you thought deletion was one SQL). So I will repeat the course mantra: do not guess — look at the SQL trace.

It is also worth remembering deleteAll() (not batch). On the surface it sounds like “just delete everything”, but in the entity-oriented version this can mean that the ORM will walk through entities and delete them as objects, with cascades, orphanRemoval, and everything else. That can be correct, but expensive. And that is exactly why Spring Data has batch delete methods, which we will discuss in the next section.

To keep the picture in your head, it is useful to think of entity-oriented delete like this:

sequenceDiagram
    participant S as "Service @Transactional"
    participant R as JpaRepository
    participant EM as "EntityManager/Session"
    participant DB as PostgreSQL

    S->>R: findById(id)
    R->>EM: find(...)
    EM->>DB: SELECT ... WHERE id = ?
    DB-->>EM: row
    EM-->>R: managed entity
    R-->>S: entity
    S->>R: delete(entity)
    R->>EM: remove(entity)
    Note over EM: "entity -> state=REMOVED"
    EM->>DB: "DELETE/UPDATE on flush"

This is not “real Spring Data code”, but a model of behavior. Still, it is useful because it shows the main thing: deletion does not have to fire SQL immediately, and it lives in the entity lifecycle.

6. Batch delete: bulk semantics

Batch delete methods in JpaRepository exist for scenarios where you want to delete data “as rows”, not “as an object graph”. That sounds like “an optimization”, but it is more correct to think of it like this: it is a different operating mode. It is closer to bulk operations (which we already covered in the performance module): you execute a query that directly changes the table, and the persistence context is not required to automatically synchronize with what just happened in the database.

The most famous methods here are deleteAllInBatch() (delete all rows in one table) and deleteAllByIdInBatch(ids) (delete by a list of ids). Conceptually they are similar to JPQL delete ... where ... and execute like bulk update/delete.

An example of a careful batch delete by id:

import java.util.List;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void purgeSnapshots(List<InventorySnapshotId> ids) {
    // Important: this is a bulk/batch deletion — it works "by rows", not as an entity lifecycle
    inventorySnapshotRepository.deleteAllByIdInBatch(ids);

    // This is a bulk-like operation: the database changed, but the persistence context may not update itself
}

Notice that I used InventorySnapshot, not Product. This is intentional: for catalog entities we often have soft delete and lifecycle rules, while for historical snapshot data a physical cleanup is appropriate. Batch delete is especially logical for such “technical” entities, where the business does not require cascades and a complicated lifecycle.

What is the catch with batch delete, besides the fact that “it is dangerous”? The catch is that it can leave the persistence context in a state of “I still think these entities exist”. Imagine that in the same transaction you have already loaded some InventorySnapshot as a managed object, and then you call deleteAllByIdInBatch(ids). The rows vanish from the database, but your managed object in memory is still alive. If you then try to modify it and reach flush, Hibernate may try to issue an UPDATE for a row that is already gone. After that, different unpleasant things are possible: from StaleStateException to unexpected optimistic locking errors, depending on the mapping and versioning.

Here it is important not to memorize scare stories, but to keep a simple formula in mind: bulk/batch operations bypass the normal entity lifecycle and do not synchronize the persistence context automatically. That is not a Spring Data bug — it is the price of the chosen mode.

Another point: batch delete, unlike entity delete, may not trigger your entity listeners (@PreRemove and friends), may not traverse cascades the way you expect, and generally may not “feel the graph”. Again, that is not good or bad — it is a different tool. If you are deleting historical rows for technical reasons, it is perfect. If you are trying to implement the business rule “delete an order and all its line items” with it, you will almost certainly break the model (or, at the very least, spend a long time explaining in code review why orphanRemoval suddenly did not work).

To avoid turning the lecture into “do this”, I will give a very practical rule that is easy to apply. If deletion is part of an aggregate lifecycle, and cascades/rules/soft delete matter to you — choose entity-oriented delete. If deletion is “bulk data cleanup by criteria” (archive, historical cleanup, test data, temporary tables) — batch delete is appropriate, but after it you need to remember the possible desynchronization of the persistence context (often solved by a separate transaction or context clearing, but we will not go deeper into the details here).

7. Typical mistakes

In this topic, mistakes are usually not “syntax errors”, but worldview errors. The code compiles, the happy-path tests pass, and then in production you get strange exceptions, extra SELECTs, a random flush, or “why does the object still seem alive after deletion”. These mistakes are especially treacherous because they look like small details, but in reality they break the predictability of the persistence layer.

Mistake #1: using getReferenceById() as a way to check whether a record exists.
Sometimes a developer thinks: “well, since the method is called getReference, it must verify that the record exists.” No: it may return a proxy, and you will only learn that the record is absent later — at initialization time or on commit (through FK). If you need to check existence, use findById() with explicit handling or a separate existence check, but do not try to do a “check” through a reference.

Mistake #2: letting a proxy “escape” beyond the transaction boundary and then reading fields.
getReferenceById() works great as long as you live inside @Transactional and understand when initialization will happen. But if you returned the entity/proxy outside (for example, to the web layer or another service without a transaction) and later touched a field somewhere, you are almost guaranteed to get a lazy problem. In a project with open-in-view=false, such errors surface quickly, and that is good: the system does not let you live in an illusion.

Mistake #3: doing findById().get() and assuming that “if it is missing, oh well”.
This looks like a small thing, but in practice it turns a “normal business branch” into a “random exception without context”. As a result, you do not know what exactly was not found, cannot log properly, cannot return a correct error contract (even if the web layer is not currently in focus). Optional does not bite — ignoring Optional does.

Mistake #4: using batch delete as if it were ordinary lifecycle delete.
deleteAllInBatch() and deleteAllByIdInBatch() are not “faster delete”, but a bulk-like operation. They are not required to synchronize the persistence context, not required to respect your object graph the way remove() does, and may leave managed objects in memory in a stale state. If you use batch delete, do it consciously and do not be surprised that later “inside the same transaction” the data may look strange.

Mistake #5: expecting deleteById() to always be one SQL statement with no SELECT.
It is very tempting to believe that “delete by id” is just DELETE FROM ... WHERE id=?. Sometimes it will be exactly that, but sometimes the ORM/repository will do extra work for correctness. Instead of guessing, enable SQL trace and check what actually happens in your scenario and your library versions. It is boring, but that is what mature engineering looks like.

1
Task
Hibernate deep-dive, level 27, lesson 2
Locked
Link by id instead of full loading
Link by id instead of full loading
1
Task
Hibernate deep-dive, level 27, lesson 2
Locked
Repository-level batch delete
Repository-level batch delete
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION