1. The role of cascade and orphanRemoval
We have already verified the relationship itself: the foreign key landed where it should, and the round-trip through the database confirmed it. But an entity graph is interesting not only in the static case. We also need to understand what happens when the parent entity starts saving, deleting, and rebuilding child objects.
When you first see cascade = ... and orphanRemoval = true, you want to react honestly in a junior way: “Oh, two more parameters in the annotation. I guess I should set them like in the tutorial and forget about it.” But in a real application these settings are not cosmetic — they are behavior, which determines whether you end up with “orphaned” rows in the database and whether saving/deleting the entity graph works predictably.
In the ContentHub domain, you can feel this literally with your hands. An article has attachments (ArticleAttachment) — metadata for uploaded files. When an editor deletes an attachment or replaces it, we need to make sure that the attachments table does not keep “ghosts”: records for files that no longer exist, or records that no longer belong to any article. And vice versa: when we save a new article with attachments, we want the attachments to actually be saved, not get lost somewhere between persist(article) and the reality of the database.
If we put it in one sentence, today we are learning how to prove with tests two things: operations on the parent entity (Article) are correctly propagated to the children (ArticleAttachment), and removing an attachment from the article collection leads to a predictable effect in the database, not just in the Java list.
2. Cascade vs orphanRemoval: different mechanisms
Both words sound as if they are about “automatic deletion of everything,” but these are different stories. cascade is about how one EntityManager operation “flows” further through the object graph. orphanRemoval is about the fate of a child row when it stopped being part of the parent’s collection. Confusing them is like confusing “water flowing through pipes” with “the faucet being removed from the wall.”
Let’s pin down the difference as practically as possible, in terms of “what I do” → “what happens.”
| Mechanism | What you do in code | What JPA does in response | Typical meaning for ContentHub |
|---|---|---|---|
| cascade = PERSIST | entityManager.persist(article) | JPA automatically does persist for article.getAttachments() as well | “I create an article together with attachments” |
| cascade = REMOVE | entityManager.remove(article) | JPA deletes the attachments that belong to the article | “I delete an article — its attachments should disappear too” |
| orphanRemoval = true | article.getAttachments() .remove(x) | JPA deletes the ArticleAttachment row from the database | “I removed an attachment from the article — it should not stay in the table” |
It is very important to get the intuition right: cascade is triggered by invoking an operation on the parent (persist/remove/merge/...). orphanRemoval is triggered by changing the parent’s collection. The mechanism is like a “smart cleaner”: it sees that an object no longer belongs to the parent — so it is an “orphan,” and it must be deleted.
To reinforce it, here is a small behavior diagram. Yes, it is almost a comic strip, but the brain remembers it better:
flowchart TD
%% Parent entity
A[Article] -->|has many| B[ArticleAttachment]
%% EntityManager operations trigger cascade from parent to children
EM[EntityManager] -->|persist Article| A
A -->|cascade persist| B
%% Orphan removal is triggered when you change the parent's collection
A -->|remove attachment from list| OR[orphanRemoval]
OR -->|DELETE in DB| B
The diagram does not say that “this is always what you should do.” It says that this is what will happen if you set the corresponding settings. And our job is to write tests that confirm this behavior, so that changing an annotation later does not turn into a silent regression.
3. Mapping in ContentHub: where cascade makes sense
We are talking about the Article → ArticleAttachment relationship here, and in this case cascade is often really useful. Attachments in our domain live as part of an article: separately, they do not make sense. It is like “a folder and the files inside it”: a file without a folder in our scenario should not just be lying around on the database desktop.
Example of a simplified mapping (the idea matters more than field details):
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
class Article {
// Collection on the inverse side (mappedBy) — a reflection of the relationship.
// The real foreign key usually lives on the owning side (in ArticleAttachment.article).
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ArticleAttachment> attachments = new ArrayList<>(); // To avoid NPE when adding attachments
}
Here we use CascadeType.ALL because this is an educational and illustrative option. In production you will often narrow cascade to what is actually needed (for example, PERSIST and REMOVE) so that things do not “flow through” accidentally. But for now it is important for us to understand the mechanism and learn how to test it.
But for the Article → Category relationship, the situation is different. Categories are a reference dictionary, they are shared, seeded by migrations, and one category belongs to many articles. Therefore cascade on a category is a dangerous thing. If you habitually put cascade = REMOVE on @ManyToOne Category category, then “delete article” can suddenly turn into “delete category,” and then... well, it will be fun, but only for the first five minutes, until you open the production logs.
In our domain, this can be stated very plainly: an attachment is a “part of an article,” a category is a “reference for articles.” Cascade and orphan removal are usually appropriate for “parts,” and usually inappropriate for “reference data.”
4. The addAttachment / removeAttachment methods as a safeguard
When you have a bidirectional relationship (Article contains a collection of attachments, and ArticleAttachment contains a reference to the article), a nasty detail appears: JPA stores the foreign key on the owning side. In our case, the owning side is almost always ArticleAttachment.article (that is, @ManyToOne on the attachment side), because that is where @JoinColumn lives and that is where the foreign key physically sits in the table.
This leads to a classic bug: the developer added the attachment to article.getAttachments().add(attachment), but did not set attachment.setArticle(article). In memory the list looks nice, tests without flush/clear sometimes “pass,” but in the database the foreign key is not set, and the row either will not be inserted, or it will be inserted with NULL (if you allowed that for some reason), or you will get a constraint violation.
That is why in practice we almost always create helper methods that synchronize both sides:
public void addAttachment(ArticleAttachment attachment) {
// Add to the collection (inverse side)
attachments.add(attachment);
// Be sure to sync the owning side, otherwise the foreign key in the DB will not be set
attachment.setArticle(this);
}
public void removeAttachment(ArticleAttachment attachment) {
// Remove from the collection (otherwise orphanRemoval may not work)
attachments.remove(attachment);
// Break the owning side — this is the key signal for JPA that the relationship is really broken
attachment.setArticle(null);
}
It looks like “two extra methods,” but this is exactly the kind of “extra” that saves hours of investigation. And, by the way, these methods improve testability: in a test you call article.addAttachment(...) and you know the foreign key will be correct, and orphan removal will work as expected.
Another important thought: orphanRemoval reacts to the fact that the child entity stopped being part of the collection. But in a bidirectional relationship, “stopped being part of it” means not only remove from the list, but also breaking the owning side. Therefore a good removeAttachment does both actions so that JPA does not get a “contradictory view of the world.”
5. Test for cascade: attachments are in the database too
Now we turn the annotation into observable behavior. We want a test that proves: if I created an Article, added new (not yet persisted) ArticleAttachment objects to it, and then saved only the article, the attachments still made it into the database. That is the effect of cascade persist.
A test skeleton in @DataJpaTest usually looks like this:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;
@DataJpaTest
class ArticleAttachmentCascadeDataJpaTest {
@Autowired TestEntityManager entityManager; // Convenient EntityManager for tests (persist/flush/clear)
}
Let’s add repositories, because we need an easy way to check the database state after clear():
import org.springframework.beans.factory.annotation.Autowired;
@Autowired ArticleRepository articleRepository; // Check deletion/presence of articles when needed
@Autowired ArticleAttachmentRepository attachmentRepository; // Check that attachments really live in the table
And now the scenario itself. Try to keep it as “thin” as possible: one category, one article, one attachment. The fewer decorations there are, the easier it is to understand why the test failed.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void persistArticle_shouldPersistAttachments_viaCascade() {
// Save the category separately: we do not expect cascade for reference data
Category java = entityManager.persist(new Category("java", "Java"));
// The article refers to an existing category
Article article = new Article("JPA basics", java);
// Add the attachment through the helper method so that the owning side (attachment.article) is definitely set
article.addAttachment(new ArticleAttachment("guide.pdf"));
// persist only the parent — the attachment should be saved by cascade
entityManager.persistAndFlush(article);
// Clear the persistence context so we read from the DB, not from the first-level cache
entityManager.clear();
// Main assertion: the attachment row really made it into the table
assertThat(attachmentRepository.count()).isEqualTo(1L);
}
What is important to notice here, specifically as test authors:
We save the category separately because we are not assuming cascade for categories. This is a normal model: the article refers to an already existing category.
We add the attachment through addAttachment so that the owning side (attachment.article) is definitely set.
We use persistAndFlush because without flush JPA may postpone the INSERT until the end of the transaction, and you will be checking not the database, but the database’s life plans.
We do clear() so that subsequent checks do not “peek” at state from the persistence context. After clear () attachmentRepository.count() will definitely go to the DB.
If you remove cascade = PERSIST (or ALL) from the mapping, depending on how persistence is set up, you will get either an exception about a transient entity or the attachment simply will not be saved. And this is not a “bad test” — it is a test that honestly shows the behavior has changed.
6. Test for orphanRemoval: the row disappeared from the database
Orphan removal is like a “hard cleanup” of the database: if the object is no longer part of the graph, it gets deleted. But to keep this from staying at the level of words, we need a test: create an article with two attachments, remove one, do flush/clear, and verify that the row disappeared from the attachments table.
To keep the test short and avoid turning it into an 80-line thriller, it is convenient to have a small helper method inside the test class that creates an article with attachments. This is not “hidden magic” if it is honest and transparent.
For example, here is a very compact helper:
private Article persistArticleWithTwoAttachments(Category category) {
// Create an article in the given category
Article article = new Article("A", category);
// Add 2 attachments so there is something to "orphan" when one is removed
article.addAttachment(new ArticleAttachment("first.txt"));
article.addAttachment(new ArticleAttachment("second.txt"));
// Save and flush immediately so the attachments definitely get an id
return entityManager.persistAndFlush(article);
}
And now the main test. Pay attention: we need to capture the id of the attachment being removed before deletion, because after deletion the object still exists in memory, but in the database it should already be gone.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void removeAttachment_shouldDeleteRow_viaOrphanRemoval() {
// Reference data (category) lives separately, without cascade
Category java = entityManager.persist(new Category("java", "Java"));
// Create an article and 2 attachments (both should be in the DB after persistAndFlush)
Article article = persistArticleWithTwoAttachments(java);
// Capture the id in advance: after orphanRemoval the row should disappear from the table
Long removedId = article.getAttachments().getFirst().getId();
// Remove the attachment using the correct method, which breaks both sides of the relationship
article.removeAttachment(article.getAttachments().getFirst());
// Synchronize the persistence context with the DB (otherwise DELETE may be delayed)
entityManager.flush();
// Clear the context so findById goes to the DB, not to a managed object
entityManager.clear();
// Check the database itself: there should be no row with removedId anymore
assertThat(attachmentRepository.findById(removedId)).isEmpty();
}
The point of the test is precisely that we prove the effect at the database level. Checking article.getAttachments().size() would be checking a Java collection, not persistence behavior. Yes, we care that the collection got smaller. But for data-layer quality, it is much more important that the row in the table really disappeared.
If you remove orphanRemoval = true, the outcome then depends on exactly how you break the relationship and what the current mapping allows. In our version, removeAttachment() does attachment.setArticle(null), and we keep article_id non-nullable, so on flush you will more likely get a constraint violation rather than a “silently left behind” row. If the FK allowed NULL or the owning side were not broken at all, the row could remain in the database as an orphan. And this is exactly where the value of orphanRemoval becomes especially visible: it turns the ambiguous fate of a child row into a predictable delete.
One more practical note: orphan removal deletes the row, but it does not delete the file from the filesystem. In ContentHub that will be the responsibility of another layer (storage/service), and the data-layer test proves only the correctness of the DB metadata. That is fine: one test — one layer; otherwise you get a layered pie made of randomness.
7. Cascade REMOVE when deleting an article
Removing an attachment from a collection is one scenario. Another real-life scenario: the article itself is deleted (or, at least, in some projects that can happen; in ContentHub we will more often archive, but it is still important to understand the mechanism). If you delete an article, it is logical to expect that its attachments will be deleted along with it. This is where cascade = REMOVE (or ALL) is useful.
But at the same time we absolutely do not want deleting an article to delete the category. The category is reference data. Therefore a good cascade remove test is one that checks both sides: attachments disappeared, category remained.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void deleteArticle_shouldDeleteAttachments_butNotCategory() {
// Create a reference category (without cascade delete from the article side)
Category java = entityManager.persist(new Category("java", "Java"));
// Create an article with attachments
Article article = persistArticleWithTwoAttachments(java);
// Remember the category id so we can verify it was not accidentally deleted
Long categoryId = java.getId();
// Delete the article: we expect attachments to go away via cascade, and the category to remain
articleRepository.delete(article);
// Force DELETE to the database
entityManager.flush();
// Clear the cache so checks below read from the database only
entityManager.clear();
// Attachments should disappear
assertThat(attachmentRepository.count()).isZero();
// The category should remain (otherwise someone enabled an extra cascade somewhere)
assertThat(entityManager.find(Category.class, categoryId)).isNotNull();
}
This test is useful as a “guard against an accidental annotation.” If someone adds cascade to the category, the test can suddenly turn red, and you will understand the reason before the “category reference data” unexpectedly disappears in production. That is a very pleasant class of regressions: better to catch them with a test than with users.
8. Nuances: remove from the list and database state
Sometimes it feels like JPA should “guess” what you wanted. But JPA is not telepathic. It honestly looks at managed objects and their state in the persistence context, and at what exactly changed. If you removed an object from the collection but the owning side still points to the parent, JPA may decide that the relationship was not broken. If you did not call flush, JPA may not have sent anything to the database yet. If you did not call clear, your subsequent findById may return the object from the first-level cache.
Because of this, data tests develop iron discipline: changed the graph — called flush(); want to verify the database itself — called clear(); verify through a repository or through entityManager.find after clearing. This is not a “ritual” — it is a way to remove “happy accidents” from the test.
It is also worth keeping in mind the nature of @DataJpaTest: the test usually runs inside a single transaction and is rolled back after completion. Therefore deletion in a test is not “forever” — and that is good. But flush is still required if you want to see constraint violations and real DELETE/INSERT statements within the current test.
9. Typical mistakes with cascade and orphanRemoval
Mistake #1: set CascadeType.ALL “because that is what the example uses,” and then be surprised by side effects.
ALL includes not only PERSIST and REMOVE, but also MERGE, and other operations. In educational code this is convenient, but in a real project it can lead to unexpected updates of child entities when the parent is save()d. The right habit is to first define what behavior is needed, and only then choose the minimal set of cascades for that behavior.
Mistake #2: cascade REMOVE to reference data and shared entities.
For the Article → Category relationship, delete cascade is almost always harmful. The category does not “belong” to the article; it is used by many articles. If you enable cascade, deleting one article can theoretically delete the category (or try to delete it and fail because of the foreign key). A test that checks “the category remained” is an excellent safeguard against that.
Mistake #3: change only the inverse side of the relationship (the collection), forgetting the owning side (foreign key).
With mappedBy = "article", the Article.attachments collection is a reflection of the relationship, but the foreign key is stored in ArticleAttachment.article. If you do article.getAttachments().add(a), but do not do a.setArticle(article), then the relationship will either not appear in the database, or you will get a constraint violation. That is exactly why addAttachment/removeAttachment methods are not a whim, but a way to avoid having to investigate “why are my attachments not linked?”
Mistake #4: verify orphan removal by Java collection size and not by checking the database.
attachments.size() can be 1 because you removed the object from the list. But the row may still remain in the table. In a data-layer test the main proof is a database query after flush() and clear(): for example, attachmentRepository.findById(id) should return empty.
Mistake #5: forget flush and expect deletion to have “already happened.”
JPA is not required to execute SQL immediately for every action you take. It accumulates changes and synchronizes them with the database on flush or at the end of the transaction. If you remove an attachment and immediately check the repository, the test can give strange results, especially if the persistence context still holds old objects. flush brings the moment of truth a little closer and makes the test honest.
GO TO FULL VERSION