1. Deletion in Hibernate: remove(entity)
When a developer first sees entityManager.remove(entity), they often form a very simple picture in their head: “I called remove() — the row disappeared.” It is like expecting the “Delete” button in the code editor to brew coffee too: nice, but reality is more down-to-earth and, at the same time, trickier. In ORM, deletion is not only a write operation, but also a change in read rules.
Let us start with the basic idea: deletion is a decision about the data lifecycle. And that decision almost always affects three layers at once.
First, the domain meaning. A product in the catalog can become “hidden,” but you cannot “erase it from history” because it may have participated in orders, in inventory snapshots, in audit history. In Commerce Persistence Lab we deliberately model the domain so that such situations arise naturally: Product is linked to orders, to stock levels (InventoryItem), to categories (through a link entity), and “the row disappearing” can hurt integrity.
Second, relational integrity. Physical delete is a real DELETE that must respect foreign keys. If something in the DB references that row, the DB will either prevent the deletion or cascade-delete half the table (and then you will be explaining in code review that “it just did it itself”). Soft delete changes the nature of the problem: the row stays, links do not break, but another risk appears — invisibility.
Third, read behavior. And this is the key point of the day: after deletion, you still have “ordinary queries” — product lists, SKU search, category cards, existence checks. And those determine how predictable your system is. Because if after soft delete queries keep returning “deleted” items, users and services will regularly get surprises.
To feel why remove() is not the end of the story, it helps to keep a simple chain in mind:
flowchart TD
A["Service: remove()"] --> B["Entity is marked as REMOVED in the persistence context"]
B --> C["flush / commit"]
C --> D{"Delete rule"}
D -->|physical| E["SQL: DELETE"]
D -->|soft| F["SQL: UPDATE (deleted flag)"]
E --> G["The row disappeared physically"]
F --> H["The row remains, but the visibility rule changes"]
Notice: service code may look the same, while the final SQL is different. This is not “magic for magic’s sake,” but a direct consequence of ORM allowing different data lifecycles to be modeled.
2. Physical delete: when remove() becomes DELETE
Physical delete is the “honest” removal of a row from a table. It looks as straightforward as possible, and that is exactly why it is so easy to do incorrectly in a domain where data must live longer than the current screen in the admin panel. But first we need to simply pin down how it works in Hibernate as a runtime system: the state changes immediately, SQL goes out on flush.
Let us look at very familiar code. It looks boring, and therefore suspiciously useful — because intuition usually breaks at boring spots.
import com.example.commerce.catalog.entity.Category;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void deleteCategory(Long id) {
// Important: find() can return null — then remove() will cause an error.
Category category = entityManager.find(Category.class, id);
// Important: remove() does not necessarily send SQL immediately — DELETE will go out on flush/commit.
entityManager.remove(category);
}
What happens in reality, if we speak the language of the previous days:
1) find() returns a managed entity (or null).
2) remove() moves it into the removed state inside the persistence context.
3) Until flush, the database may know nothing at all about your “deletion.” If you make another query in the same transaction, flush may happen earlier, but that is already from our flush lectures: Hibernate cares about query correctness and sometimes must synchronize.
In the SQL log, physical delete usually looks something like this:
-- Physical delete: the row is really removed from the table
delete from category where id = ?;
And this is where the adult part of the conversation starts. Physical delete requires answering the question: “Who else uses this row?” If Category is referenced by ProductCategoryAssignment rows, or Product is referenced by InventoryItem/OrderItem, deletion may either fail because of an FK or trigger a cascade. And if you just thought, “well, cascade is convenient” — great, you have just mentally stepped on the rake from the module about relationship lifecycle. Cascade is not “make everything happen by itself,” but a rule for the life of the aggregate.
In the catalog and orders, physical delete often turns out to be too “strong”: you are not just hiding an object from lists, you are erasing the fact that it ever existed in the system. Sometimes that is fine (for example, test data, drafts, technical junk), but often — no.
3. Soft delete: the row stays, but stops being "visible"
Soft delete is usually described as “we do not delete the row, we mark it as deleted.” Formally — yes. Practically — that is an incomplete explanation, because the most important thing is not that the row remains, but how read behavior changes. And that is where the engineering begins: soft delete is an agreement about data visibility, not just about a deleted field.
Imagine that Product is not a note on a desk, but an entry in an accounting ledger. You can cross out the line with a red pen (soft delete), but you cannot tear out the page (physical delete) if someone will later reconcile history from that record. The same logic applies in ordinary systems: you need a trace, recovery, audit, and stable references.
In the context of Commerce Persistence Lab, soft delete is especially natural for the catalog and reference data:
- a product can be discontinued, but its SKU should not suddenly become “free” for another product, otherwise you get chaos in search and integrations, even in a training project;
- a category can be hidden, but old links/history remain, and you do not want to break the link model;
- the product-category relationship can also have a lifecycle, especially if it is a link entity. And that matters: in our project, the relationship is not an “invisible join table,” but a full entity, and its visibility rules are a separate topic.
It is important to notice another subtlety: soft delete almost always means that a “deleted” object still exists as data, but stops existing as the “ordinary result of a query.” Therefore, when asked “is the product deleted?” in a soft delete world, it is often more correct to answer: “it is active or hidden,” “visible or not visible by default.”
And that immediately raises the question: what mechanism is responsible for “by default”? Because if you just added a boolean field and changed nothing else, your “by default” would be: “show everything,” including hidden items. And that is not soft delete, but “I recorded the deletion fact, but notified no one.”
4. Data visibility in ordinary queries
If you want to understand whether your soft delete is implemented correctly, forget the word “delete” for a moment and ask a different question: what will a typical read case return? Not the one you specifically wrote “for deletion,” but the most ordinary one: a product list, a search, an existence check. That is where soft delete either becomes a convenient standard or turns into a permanent source of bugs.
Here is an example of an “ordinary” query from the catalog that looks completely harmless:
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import java.util.List;
public List<Product> listProducts(EntityManager em) {
// Important: without a shared visibility rule, this query will return both active and "deleted" (soft delete) products.
return em.createQuery("select p from Product p order by p.name", Product.class)
.getResultList();
}
Now imagine that we “deleted” one product using soft delete. If there is no visibility rule in the system, this query will return both active products and hidden ones. And the mistake here is not in the query — it is normal. The mistake is that the system has not defined which products are considered visible by default.
The naive solution that almost everyone comes to first: “well, let us add a condition to the query.”
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import java.util.List;
public List<Product> listVisibleProducts(EntityManager em) {
// Naive option: filter manually.
// Problem: exactly the same condition will have to be remembered in all the other queries.
return em.createQuery(
"select p from Product p where p.deleted = false order by p.name",
Product.class
).getResultList();
}
And this is the moment when you want to pat yourself on the back: “Done, I have implemented soft delete.” But in a week (or after two endpoints), the classic will happen: someone will write another query, forget the condition, and hidden products will suddenly appear in the results. This is not an “error by a specific person,” it is an approach error: the visibility rule is smeared across the code.
Soft delete is not “a condition in one query,” but a single visibility rule for all ordinary reads.
There is an important balance here. We do not want to turn every read into a quest of “do not forget deleted = false,” but we also do not want magic that cannot be turned off where archival data must be visible, for example, in the admin panel. That is why the distinction between always-on and scenario-dependent mechanisms appears, which we will discuss a bit later.
5. Business keys and uniqueness
Soft delete has a very “non-obvious” side: it almost always touches uniqueness and business keys. If with physical delete the row disappears, then theoretically you can create a new row with the same sku or code, and the unique index will no longer object. With soft delete, the row remains — and uniqueness keeps applying as if the object still “occupies the name.” And that is not a bug, it is a consequence of your lifecycle choice.
Let us take a simple example from the catalog: checking whether a SKU is taken.
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Important: in our domain, a SKU is considered taken even after soft delete.
// Therefore, "exists" here must take hidden (deleted=true) records into account.
boolean existsBySku(String sku);
}
Now let us imagine the business rule of our course (it is explicitly stated in the project): a SKU is not reused after soft delete. That is a very sane choice for a training domain, because it makes the system predictable: if a SKU ever existed, it is forever tied to a specific “historical” product, even if the product is hidden.
What this means in practice:
- existsBySku("SKU-123") should return true, even if the product is hidden; otherwise we create a new product with the same SKU and get two different objects under one identifier. That is no longer an ORM error, that is a domain nightmare.
- The unique constraint in the DB continues to work “as usual,” because the row remains in the table.
- Any “restorations” (un-delete) become easier: you do not risk someone having already taken your SKU with a new record.
Of course, in real systems it is sometimes done differently: for example, you may want to allow reuse of a code after deletion. Then an entire layer of additional decisions appears: partial unique indexes, extended keys, prefixes, versioned keys. But that is already a separate engineering discussion, and it easily eats a week of life. In our course, we will choose a path that is easier to explain and diagnose through SQL: the row remains, the key remains taken, the rule is obvious.
The key meaning here is this: soft delete is not only about “visibility in lists,” it is also about “what counts as the object’s existence.”
6. Always-on and scenario-dependent visibility
When a team introduces soft delete for the first time, they often want to make one universal rule: “hidden is not visible anywhere.” It gives a nice feeling of calm... until the first admin, migration, or support scenario where you need to see the archive. Then the rule suddenly becomes an obstacle. That is why it is useful to distinguish two types of visibility in advance: always enabled and switchable.
Always-on (always-on) visibility is a rule like “ordinary queries must never see deleted data.” It is similar to a traffic rule: you do not “sometimes” drive through a red light (unless you are an ambulance or in a movie). For the catalog this is often appropriate: user-facing lists should not show hidden products.
Scenario-dependent (scenario-dependent) visibility is when in one use case an object must be hidden, and in another — visible. A classic example: an admin screen “Show archive” or an internal service scenario that rebuilds relationships and must take hidden records into account.
To make this easier to remember, let us fix a simple comparison:
| Question | Always-on rule | Scenario-dependent rule |
|---|---|---|
| Should it work by default? | Yes, always | No, enabled explicitly |
| Can you “show the archive” within an ordinary query? | Only through a special workaround | Yes, if you enable the rule/parameter |
| Main advantage | Predictability and simplicity of reads | Flexibility for different use cases |
| Main disadvantage | Hard to see hidden data where it is needed | Stateful behavior, easy to forget to enable/disable |
Today we are precisely building a mental model: which data should always be hidden, and which should be hidden only in ordinary scenarios, but visible in special ones.
And notice: we are deliberately talking about rules in general, not about specific annotations yet. Because if you start with the technique (“here, @Something”), without answering the question “where and when is it visible?”, you will almost certainly build a system where visibility is accidental.
Three questions before implementing soft delete
Before choosing an implementation mechanism, it is useful to honestly answer three questions. They sound simple, but they are exactly what turns soft delete into a designed solution rather than an endless “why did this product appear in the list again?”
The first question: what exactly counts as deleted? Sometimes this is a binary state “active/deleted.” Sometimes “active/hidden/archived/blocked.” In our lecture we will think in terms of a simple “deleted / not deleted” indicator, but it is important to understand that you define the meaning, not Hibernate.
The second question: where does the deletion indicator live? In the table of the entity itself (for example, product.deleted)? Or in the relationship table (for example, “the product-category link row is deleted”)? This is critical because the rules for an entity and the rules for join rows are different in nature.
The third question: should visibility switch by scenario? If not, you need an “always-on” mechanism. If yes, you need a mechanism that can be enabled/disabled within a specific Session/transaction/use case. And here we are already approaching the choice of tools for the day, but for now we are fixing the question itself.
And now the “bonus question” that you usually hear from a DBA or from production reality: how will this affect queries and indexes? Because soft delete increases the table size (rows do not go away), and the condition deleted = false suddenly becomes part of almost every read. Today we will not dive into index optimization, but it is useful to keep this in mind already: “hiding” data means they continue to live in the table and affect query plans.
If you want to see this as a mini-algorithm for choosing, you can keep this sketch in mind:
flowchart TD
A["Need data 'deletion'"] --> B{"Should the row disappear physically?"}
B -->|Yes| C["Physical delete: DELETE + FK/cascade consequences"]
B -->|No| D["Soft delete: indicator + visibility rule"]
D --> E{"Is the visibility rule always the same?"}
E -->|Yes| F["Always-on rule (static)"]
E -->|No| G["Scenario-dependent rule (switchable)"]
This is not about “which annotation to choose,” but about not confusing the meaning. The annotation is only the implementation tool for the chosen meaning.
And from here, three families of mechanisms are already visible. If you need to change the semantics of deletion itself, so that remove() means not a physical DELETE, but logical hiding, there is @SoftDelete for that. If the flag already lives in the data, and we only need to enforce the reading rule, @SQLRestriction / @SQLJoinTableRestriction come into play; if the same visibility needs to be switched by scenario, @Filter / @FilterJoinTable will be useful.
7. Checking soft delete: SQL log and read cases
In the Hibernate world it is very easy to “believe” that you have done something, because the code looks correct. With soft delete this trap is especially cruel: you can write a beautiful “delete product” method, set a breakpoint, see that the deleted field changed... and still ordinary queries will return hidden rows, because the visibility rule is not закрепed anywhere. That is why our old course habit — looking at SQL — is especially appropriate here.
Let us take product removal at the service level. The code is intentionally “boring,” because the point will be what SQL actually goes out (today we are not fixing the final technique yet, but we are preparing the right check).
import com.example.commerce.catalog.entity.Product;
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void hideProduct(Long id) {
// Important: we are working in a transaction so Hibernate can apply the change on flush/commit.
Product product = entityManager.find(Product.class, id);
// Important: the meaning of remove() depends on the chosen strategy (physical vs soft delete).
entityManager.remove(product);
}
Then you run a typical read case, for example a product list, and inspect the result and the SQL trace. If this is physical delete, the row disappears and the query does not return it by definition. If this is soft delete (in a correct implementation), the row remains, but an ordinary select p from Product p ... should no longer return it. And this difference is the main correctness criterion.
It is useful to state the “sanity check” in plain words:
- if you do soft delete, but the hidden product still appears in the product list, then soft delete as a visibility rule is not implemented (you have merely recorded the fact in a column, nothing more);
- if you do soft delete and suddenly cannot show the hidden product even in an admin scenario, then you implemented an always-on rule where you need switchability, or you did not consider the archive scenario at all.
Notice: we again do not get stuck on “which exact mechanism,” we are checking the meaning itself through observable behavior. That is how it should be in this course: first the behavior model, then the technique.
8. Common mistakes with soft delete
Mistake #1: treating soft delete as “just another boolean field,” not as a visibility rule.
Often a developer adds the deleted column, sets it to true, and mentally considers the task done. But then it turns out that hidden records show up in lists, search, and exists checks. This is fixed not by adding five more conditions to different queries, but by designing a single “default visibility behavior.”
Mistake #2: thinking only about deletion and forgetting about reading.
Deleting a record is one write case. But after deletion, dozens of read cases keep living, often written by different people. If the visibility rule is not centralized, it will be violated constantly, and every such bug will look like “well, someone just forgot the condition.” That is not forgetfulness, that is an architectural crack.
Mistake #3: ignoring business keys and uniqueness (sku, code).
Soft delete leaves rows in the table, which means unique constraints continue to apply. If the team wanted “after deletion the SKU can be reused,” but did not think through how to support that at the database and query level, they will get either insert errors or two different products with one key (and then the errors will no longer be technical, but semantic).
Mistake #4: smearing deleted = false across every query by hand.
At first this even seems disciplined: “we are not forgetting.” But the system grows, new queries appear, new repositories, new projections, and the visibility rule turns into copy-paste. At the same time, SQL behavior becomes hard to explain: one query filters, another does not, the third filters “not quite right.” It is much healthier when “by default” is set by one mechanism and works the same everywhere.
Mistake #5: not checking the result through the SQL log.
Soft delete is like silent mode on a phone: it seems to be on until your mother calls. If you did not look at SQL and did not run a typical read case, you do not know how the system really behaves. And the whole course is built around the idea: “I trust SQL, not the annotation.”
GO TO FULL VERSION