1. Collections after a link entity
If before this you lived in the world of @ManyToMany, you might not have thought about the fact that a collection inside an entity is not just an "ordinary list." After moving to a link entity, you start working not with "product categories" but with relationship objects, and then it suddenly turns out that one awkward add() is enough for the graph in memory to split into two parallel realities. At that point Hibernate does not break. We do — when we try to explain to ourselves why SQL and object state diverge.
The main problem sounds very human: the relationship is now bidirectional and composite. We have Product with a categoryAssignments list, we have Category with a productAssignments list, and we have the ProductCategoryAssignment itself, which stores references to both ends. That is already three places that must stay in sync. If you change only one place, Hibernate may save it "somehow" (or may not), but you will almost certainly get "ghost" assignments in memory, duplicates, unexpected UPDATEs, or a situation where "it was already deleted in the database, but it is still in the collection."
Naive code usually looks something like this: someone gets access to the collection and starts poking at it directly. It seems to work until you add a second scenario and a third test.
// Bad idea: the service changes entity internals directly
// This creates an "empty" assignment with no Product/Category references set,
// and as a result the graph in memory becomes inconsistent.
product.getCategoryAssignments().add(
new ProductCategoryAssignment()
);
Everything is wrong in this line at once: the assignment is created "empty", references are not set, the Category collection was not updated, and you have also just allowed external code to "do anything it wants" with the internal state of Product. As a result, Hibernate will be forced to guess what you meant. I can guess in advance: Hibernate guesses worse than it seems. And unfortunately, it cannot ask, "Are you sure you wanted it this way?"
2. Owning side and lifecycle
Before writing helper methods, it is useful to pause for a minute and "hang a sign on the door": who is responsible for what here. This is not philosophy — it is pure ORM mechanics. If you confuse the owning side and the lifecycle owner, you will start treating symptoms (strange SQL) instead of fixing the cause (the wrong graph mutation model).
Our model after extracting ProductCategoryAssignment looks like this: the assignment has two @ManyToOne associations (this is the owning side for each FK), and Product and Category have @OneToMany(mappedBy = "...") collections (inverse side). At the same time, we want to tie the lifecycle of the assignment object to the product: the product controls which categories are assigned to it, but does not control the lifecycle of the category itself (the category is a shared reference).
For convenience, let us fix this in a small diagram:
classDiagram
class Product {
Long id
List~ProductCategoryAssignment~ categoryAssignments
}
class Category {
Long id
List~ProductCategoryAssignment~ productAssignments
}
class ProductCategoryAssignment {
Long id
Product product
Category category
int sortOrder
Instant assignedAt
}
Product "1" --> "*" ProductCategoryAssignment : "categoryAssignments (inverse)"
Category "1" --> "*" ProductCategoryAssignment : "productAssignments (inverse)"
ProductCategoryAssignment "*" --> "1" Product : "product (owning FK)"
ProductCategoryAssignment "*" --> "1" Category : "category (owning FK)"
And here is a short "responsibility map" (yes, sometimes a table saves more nerves than three hours of debugging):
| Part of the model | What it is | Who should change it | Why it matters |
|---|---|---|---|
| ProductCategoryAssignment .product | owning side (FK in the table) | created/cleared inside helper methods | without this the FK will not be controlled and the relationship will become "ghostly" |
| Product.categoryAssignments | inverse side + lifecycle owner | Product.assignCategory(...) / Product.unassignCategory(...) | this is where the rule "which categories belong to the product" lives |
| Category.productAssignments | inverse side for navigation | changed internally, but not "from the outside" | so the graph in memory stays consistent |
3. Adding: Product.assignCategory(...)
When the relationship became a separate entity, adding a category to a product is no longer "add to a category list," but "create a new assignment object." From this point on, it is convenient to think like a developer who cares about maintainability: we want one method that expresses intent. Not "put something into a collection," but "assign a category to a product", and immediately with relationship data (sortOrder, assignedAt) and with correct synchronization of both sides.
The entities themselves do not change: the same ProductCategoryAssignment remains the center of the relationship; we just stop scattering its construction and synchronization across services.
Almost always, the best option is to make a helper method on Product, because in our domain the product is what controls the set of assignments. Inside the helper method, we create a ProductCategoryAssignment, set the references, add it to Product, and carefully update Category. In other words, we do exactly what the service would have done manually, only now it is hidden in one place and not spread across the code.
Mini example of the method (for now without complex invariants — we will cover them in detail in the next lecture):
import java.time.Instant;
public void assignCategory(Category category, int sortOrder, Instant assignedAt) {
// Create the assignment immediately "complete": with both sides and relationship data
ProductCategoryAssignment assignment =
new ProductCategoryAssignment(this, category, sortOrder, assignedAt);
// Update the lifecycle owner side (Product stores the rule "which categories are assigned")
categoryAssignments.add(assignment);
// Update the inverse side (Category) for graph consistency in memory
category.addAssignment(assignment); // internal helper on Category
}
Pay attention to a couple of important details that strongly affect day-to-day life.
First, we create the assignment already "complete": we pass this (the product), the category, and the relationship fields. This is not a "technical object" that someone will fill in later. It is a small but fully-fledged part of the domain model.
Second, we add the assignment to the product collection, because that is the list that reflects "the set of categories for the product." With cascade = CascadeType.ALL (or at least PERSIST), Hibernate can automatically save the new relationship row when Product is in the managed state.
Third, we update the Category side through its helper method. Formally, this may not be strictly necessary for writing to the database (the owning side is on the assignment), but it is critical for consistency of the in-memory graph. Otherwise you immediately get a situation where product.getCategoryAssignments() already contains the relationship, but category.getProductAssignments() does not yet "know" about it. That is the perfect recipe for surprise, especially if later within the same transaction you want to walk through the category and see "which products are linked to it."
Another important engineering habit: do not expose a mutable collection to the outside. Then a raw add/remove simply becomes impossible, and the world becomes a slightly safer place.
import java.util.Collections;
import java.util.List;
public List<ProductCategoryAssignment> getCategoryAssignments() {
// Do not expose a mutable collection: changes only through helper methods.
return Collections.unmodifiableList(categoryAssignments);
}
Yes, to some people this seems like "too much strictness." But it is the kind of strictness that saves hours of investigation later. Hibernate already introduces enough implicit behavior on its own; there is no need to add "everyone can change everything as they like" on top of that.
Navigation on Category without ownership
It is very easy, inspired by helper methods, to start turning Category into a "second owner" of the relationship. That is tempting: "let category have assignProduct(...) too." But in our model this quickly creates competition between two "control centers," and therefore contradictory rules. So the strategy is this: Category stores the assignments collection for navigation, but changes to that collection happen only as part of scenarios on the Product side.
The most practical style is to keep methods on Category as small as possible and restrict their visibility. Ideally they should be package-private (without public), so they cannot be called from outside.
void addAssignment(ProductCategoryAssignment assignment) {
// Important: this method is used only as part of scenarios on the Product side
// to keep the in-memory graph consistent.
productAssignments.add(assignment);
}
void removeAssignment(ProductCategoryAssignment assignment) {
// Symmetric removal for graph consistency in memory
productAssignments.remove(assignment);
}
Why is this important specifically in a Hibernate project, and not just "because it looks nice"?
Because this protects the model from a strange scenario: someone in the service took a category and started "assigning products" through it, forgetting to add the assignment to the product. As a result, one side says "yes, the relationship exists," the other says "no," and the SQL on flush will depend on which side is actually owning. And you will spend a long time staring at the log like a cat that knocked over a vase, saying, "Why did you do that?"
At this point it is worth remembering a simple rule: Category knows about relationships, but Product controls relationships. If you keep that in mind, helper methods become natural.
4. Removing a relationship and orphanRemoval
Removing a relationship is the place where the real pains of beginner ORM code begin. Because in your head it is very easy to mix up three different actions: remove a relationship, remove an assignment object, remove a category. In our model, removing a category from a product means: delete one assignment row, while the category as an entity should continue living its own life. This is exactly why we moved away from naive ManyToMany: so we could distinguish these concepts not only in words, but in code.
We want removal to look like a clear operation: "remove the category with this id from the product." Internally we need to: find the assignment, remove it from the product collection, remove it from the category collection, and break the references inside the assignment so the in-memory graph does not remain "half alive."
One convenient option is to search for the assignment by categoryId. We compare by id on purpose, not by entity equals(), because equality rules for entities are a separate subtle world (we will cover it later).
public void unassignCategory(Long categoryId) {
// Find the specific assignment by category id (not by entity equals)
ProductCategoryAssignment assignment = categoryAssignments.stream()
.filter(a -> a.getCategory().getId().equals(categoryId))
.findFirst()
.orElseThrow();
// Remove from the product collection: with orphanRemoval this means "delete the orphan relationship row"
categoryAssignments.remove(assignment);
// Remove from the inverse side so the graph in memory stays consistent
assignment.getCategory().removeAssignment(assignment);
// Explicitly break the domain links inside the assignment itself
assignment.detach();
}
A couple of nuances that are worth saying out loud rather than hoping they are "obvious anyway."
First, the key role here is played by orphanRemoval = true on the Product.categoryAssignments collection. When Product is managed, and we remove an assignment from the collection, Hibernate interprets this as "the assignment no longer belongs to the product," which means it must be deleted (because of orphan removal and lifecycle ownership). At the SQL level this usually turns into a DELETE from the assignments table.
Second, we remove the assignment from Category.productAssignments as well. This is not so that Hibernate "saves it better" (the owning side is still the ManyToOne), but so that within the current transaction, within the current persistence context, the object graph is honest. If you do not do this, the category will keep a reference to an assignment in memory that is already scheduled for deletion. And at some point you will start debugging "why does the category have a relationship that no longer exists," when in fact you simply forgot to synchronize both sides.
Third, we call assignment.detach(). Why — we will discuss in the very next section, because it seems like a small thing, but in practice it saves nerves.
5. Breaking references inside the assignment
When you remove an assignment from a collection, Hibernate can indeed delete the row from the table correctly. But the object in memory still exists, and inside it there may still be references to Product and Category. This is similar to the situation "I moved out of the apartment, but somehow kept the keys." Formally you no longer live in the registry, but if you start analyzing the domain by objects in memory, confusion appears.
That is why it is useful to have a small method that makes the relationship break explicit at the Java graph level. It is usually called detach(), unlink(), or even markAsRemoved() — the name is not so important, the intent is.
public void detach() {
// Break the domain link: this assignment is no longer attached to anything in the object graph.
this.product = null;
this.category = null;
}
This looks too simple to be important. But in practice it gives several benefits at once.
The first benefit is debugging. When you log the assignment object (do not log the whole graph, but sometimes you still will), you can see that it has truly become an orphan, not just "sitting separately in a list."
The second benefit is protection against accidental reuse. Sometimes a developer removes an assignment from a collection and then, for example because of faulty logic, tries to add it again or modify it. If the assignment still has links to the product and category inside, that can look "reasonable enough" and produce unexpected side effects.
The third benefit is a honest domain model. We literally fix the fact in code: this relationship is broken. Not "somewhere in the database," but right here, in application memory.
Of course, it is important to remember: the entity method detach() is not the same as the Hibernate operation EntityManager.detach(entity). The name is the same, the meaning is different. Hibernate detach() is about an entity's state relative to the persistence context, while our assignment.detach() is about breaking a domain relationship. If the terminology bothers you, you can call the method unlink().
6. Using it in services
Now we have helper methods in the model. All that is left is the nice part: service code becomes shorter and more honest. Instead of spreading logic across several objects, the service performs one clear action: it loads entities and calls a domain method. Hibernate, the persistence context, and dirty checking do the rest (within a transaction).
An example of a service that assigns a category to a product might look like this:
import java.time.Instant;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogCategoryService {
@Transactional
public void assignCategory(long productId, long categoryId, int sortOrder) {
// Get Product as a managed entity so dirty checking and cascade work automatically
Product product = productRepository.findById(productId).orElseThrow();
// Category can be obtained as a reference: we need a proxy/entity by id, without a mandatory select
Category category = categoryRepository.getReferenceById(categoryId);
// All business logic for graph mutation is hidden inside the domain helper method
product.assignCategory(category, sortOrder, Instant.now());
}
}
Notice how the topics from previous days fit together nicely here.
The transaction is on the service method, which means Product is managed, changes enter the persistence context, and cascade on categoryAssignments makes it possible to save the new assignment without a manual entityManager.persist(...). And, importantly, the service does not perform a mechanical save() at all — this is exactly the style we already discussed on the day about dirty checking: if the object is managed, Hibernate will see the changes and synchronize them on flush/commit.
Removal is even simpler:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void unassignCategory(long productId, long categoryId) {
// Load managed Product inside the transaction
Product product = productRepository.findById(productId).orElseThrow();
// Removing a relationship is a domain operation, not a "remove from collection somewhere in the service"
product.unassignCategory(categoryId);
}
And this is where you start feeling the main value of helper methods: transaction, reading, graph mutation, flush — everything is predictable. You are not trying to remember, "Do I need to remove it from that other collection too?", "Do I need to set null on the owning side?", "Do I need to call save()?" — because it is encoded in the model, where this logic belongs.
Also, this is very convenient for SQL observability. When you see an INSERT into product_category_assignment in the log, you can point at assignCategory(...) and say: "That is why it appeared." And honestly, that is one of the most pleasant feelings in the Hibernate world: when SQL stops being mysticism.
7. Typical mistakes when using helper methods for a link entity
Mistake #1: changing collections directly, bypassing helper methods.
This usually starts innocently: "well, I will add the assignment in just one place." Then that "one place" becomes three, then you forget to synchronize the other side, and then the carnival begins in the style of "one thing in memory, another in the database." A simple discipline helps a lot: expose collections only as unmodifiableList, and make changes only through assign.../unassign... methods.
Mistake #2: synchronizing only one side of the relationship and assuming that is enough.
Sometimes a developer adds an assignment only to Product.categoryAssignments and does not update Category.productAssignments. The SQL may still be correct (especially if the owning side is set), but the object graph inside the transaction becomes "lying." After a couple of methods you start seeing duplicates, or "deleted" elements, or strange behavior on repeated traversals. In Hibernate code it is better to keep the graph consistent right away, otherwise debugging turns into a psychological thriller.
Mistake #3: trying to delete Category when you only need to remove the assignment.
This is the classic confusion of "remove a relationship" vs "remove an entity." If the category is a dictionary and is used by many products, then CascadeType.REMOVE or a mechanical categoryRepository.delete(category) in the "remove category from product" scenario is almost certainly a disaster. In a link entity model, removing a relationship means removing ProductCategoryAssignment, and the code should be aimed exactly at that.
Mistake #4: forgetting about orphanRemoval and expecting that "removing from the collection will delete the row by itself."
If orphanRemoval is not enabled (or you manage lifecycle differently), removing an assignment from the product collection may not lead to a DELETE, but instead to an attempt to null the FK, or even to the relationship simply "staying in the database" until you delete it manually. In our domain the assignment lives and dies together with the product, so orphanRemoval=true on Product.categoryAssignments is a sensible choice, but it still needs to be remembered and checked.
Mistake #5: leaving an assignment half-alive in memory after removal.
When you removed the assignment from the collections but did not break the references inside it, you leave an object that still looks linked to the product and category, even though the relationship has already been broken. This does not always break SQL, but it breaks reasoning and diagnosis: later someone prints a log, sees assignment.product.id, and draws the wrong conclusions. A small detach/unlink makes the model more honest and calmer.
GO TO FULL VERSION