@ManyToMany Is Almost Always Wrong: When Two @OneToMany Wins

@ManyToMany is the right answer for maybe 20% of the relationships people use it for. The other 80% look like many-to-many at the domain modelling stage, but they have one of two problems: they carry metadata on the join that @ManyToMany cannot represent, or they need behaviour (assignment rules, expiry, status) that an entity can carry but a join table row cannot. Both of those cases need two @OneToMany relationships and an explicit join entity instead.

This post covers when @ManyToMany is genuinely correct, the exact moment it becomes wrong, the join entity conversion, and the four Hibernate-specific behaviours that make the List-vs-Set choice non-negotiable.

When @ManyToMany Is Correct

The relationship is a true many-to-many and the join has no business meaning of its own: tags on a product, roles on a user, permissions on a group. The join table row is just a pair of foreign keys. Nobody ever needs to query “when was this tag applied” or “who applied this permission” β€” those questions don’t exist in the domain model. The entities on both sides are independent and shared.

For these cases @ManyToMany is clean and correct. Use it. Just use Set, not List, and use cascade = {PERSIST, MERGE}, never REMOVE.

The Moment Metadata Appears on the Join

A product has tags. Simple enough for @ManyToMany. Then the product team asks: “can we see when each tag was applied and by whom?” Now the join row needs appliedAt and appliedBy. The join has business meaning. The join is an entity.

// Wrong: @ManyToMany can't carry this metadata
@ManyToMany
@JoinTable(name = "product_tags")
private Set<Tag> tags;

// Right: explicit join entity
@Entity
@Table(name = "product_tags")
public class ProductTag {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    private Tag tag;

    private Instant appliedAt;
    private String appliedBy;
}

The rule of thumb: the moment you find yourself wishing you could add a column to a join table, convert to a join entity. Converting from @ManyToMany to two @OneToMany after the fact requires a schema migration and breaks any Spring Data query methods that traversed the old association. Do it before data accumulates.

cascade = REMOVE on @ManyToMany Is Almost Always a Bug

// This deletes every role when any user with that role is deleted
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "user_roles")
private Set<Role> roles;

cascade = ALL includes REMOVE. Deleting one user deletes every role that user had, affecting every other user who shares those roles. The correct cascade for @ManyToMany is {PERSIST, MERGE} β€” let saves propagate, but let each side manage its own lifecycle.

Set Is Mandatory β€” Here’s Why List Breaks

When you remove one element from a List-backed @ManyToMany collection, Hibernate cannot target just the affected join row. It deletes all rows for that parent, then reinserts everything except the removed element.

// List: removing one tag fires DELETE all + INSERT all remaining
product.getTags().remove(specificTag);
// DELETE FROM product_tags WHERE product_id = ?  (all rows)
// INSERT INTO product_tags VALUES (?, ?)  x (N-1 times)

// Set: removing one tag fires one targeted DELETE
product.getTags().remove(specificTag);
// DELETE FROM product_tags WHERE product_id = ? AND tag_id = ?  (one row)

On a product with 50 tags, a single tag removal fires 50 INSERTs with a List but 1 DELETE with a Set. Always use Set.


⚠️ Design Warning: The β€œMany-to-Many” Trap
Avoid using the @ManyToMany annotation in long-lived enterprise schemas unless the relationship is strictly binary.

If your relationship might ever need metadataβ€”such as an assigned_at timestamp, a role (e.g., Lead vs. Contributor), or specific permissionsβ€”the standard @ManyToMany will be insufficient.

In these cases, use a Join Entity (two @OneToMany relationships) from day one. Refactoring a @ManyToMany into a Join Entity later involves complex data migration and breaking changes to your persistence layer.


The Problem: The Complexity of Shared Relationships

In a relational database, two tables cannot directly point to each other with a simple Foreign Key when multiple records on both sides are involved.

Consider a Project table and an Employee table:

  • One employee works on many projects.
  • One project has many employees.

If you store multiple IDs in a single column (e.g., a comma-separated string), you break First Normal Form (1NF). This makes querying inefficient and indexing impossible.


The Agitation: Performance Bottlenecks and Mapping Complexity

Developers often encounter recurring challenges when implementing Many-to-Many:

  • Recursive loops – Using fetch = FetchType.EAGER accidentally loads an excessive chain of related entities.
  • Orphaned records – Misconfigured cascade settings remove shared entities.
  • Join table naming chaos – Auto-generated names produce cryptic schemas.
  • Inefficient SQL inserts – Dozens of INSERT statements instead of batched writes.

Without a disciplined approach, your persistence layer becomes fragile and difficult to tune.


The Solution: Hibernate 7 Many-to-Many Mapping

Hibernate 7 handles these relationships using the @ManyToMany annotation and manages the Join Table automatically.

1. The Database Schema

To link Employee and Project, we need three tables:

  • EMPLOYEES (id, name)
  • PROJECTS (id, title)
  • EMPLOYEE_PROJECT (employee_id, project_id) β†’ Join Table

2. Implementation Strategies

A. Unidirectional Mapping (Simple)

In a unidirectional mapping, only one side (the owner) knows about the relationship. This avoids circular dependency issues during serialization.

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany
    @JoinTable(
        name = "employee_project",
        joinColumns = @JoinColumn(name = "employee_id"),
        inverseJoinColumns = @JoinColumn(name = "project_id")
    )
    private Set<Project> projects = new HashSet<>();
}

B. Bidirectional Mapping (Robust)

A bidirectional mapping allows navigation from both sides and is standard in enterprise applications.

πŸ’‘ Rule of Thumb: Identifying the Relationship Owner

  • The Owner – The side that defines the @JoinTable. Hibernate only looks at this side to write to the join table.
  • The Inverse Side – The side with mappedBy. This side is read-only regarding relationship persistence.
// Employee.java (Owning Side)
@Entity
@Table(name = "employees")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @BatchSize(size = 20)
    @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE })
    @JoinTable(
        name = "employee_project",
        joinColumns = @JoinColumn(name = "employee_id"),
        inverseJoinColumns = @JoinColumn(name = "project_id")
    )
    private Set<Project> projects = new HashSet<>();

    // Helper methods for bidirectional sync
    public void addProject(Project project) {
        this.projects.add(project);
        project.getEmployees().add(this);
    }

    public void removeProject(Project project) {
        this.projects.remove(project);
        project.getEmployees().remove(this);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee)) return false;
        Employee employee = (Employee) o;
        return id != null && id.equals(employee.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

// Project.java (Inverse Side)
@Entity
@Table(name = "projects")
public class Project {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToMany(mappedBy = "projects")
    private Set<Employee> employees = new HashSet<>();
}

⚠️ Cascade Safety Tip
Avoid CascadeType.REMOVE on @ManyToMany.
Removing one parent entity can unintentionally delete shared entities that are still referenced elsewhere.


Why Use a Set Instead of a List?

Using a Set is strongly recommended for Many-to-Many mappings.

  • A List cannot guarantee uniqueness.
  • Hibernate often deletes all join rows and reinserts them when a List changes.
  • A Set allows Hibernate to generate a single DELETE for the exact row.

Performance Optimization: Batching

Batching is one of the most impactful performance optimizations when working with Many-to-Many relationships. Without batching, Hibernate may execute dozens or hundreds of individual SQL statements, even for a single business operation.

This section covers both write-side batching (INSERT/UPDATE) and read-side batching (SELECT).


1. JDBC Batching Configuration (Write-Side Optimization)

JDBC batching groups multiple INSERT or UPDATE statements into a single database round-trip.

# Group multiple INSERT/UPDATE statements
hibernate.jdbc.batch_size=20

# Order statements to maximize batching efficiency
hibernate.order_inserts=true
hibernate.order_updates=true

# Optional: batch fetch for lazy associations
hibernate.default_batch_fetch_size=20

How it works:

  • Without batching:
    Persisting 50 Employees with Projects β†’ 150+ SQL statements
    (50 employees + 50 projects + 50 join table rows)
  • With batching (batch_size=20):
    Hibernate sends grouped INSERTs β†’ ~8–10 round-trips instead of 150

Best practices:

  • Use a batch size between 10–50 depending on your database and driver.
  • Always enable hibernate.order_inserts and hibernate.order_updates.
  • Avoid flushing inside loops (session.flush() kills batching).

2. Using @BatchSize (Read-Side Optimization)

The @BatchSize annotation optimizes fetching, not writing. It reduces the number of SELECT statements when loading lazy collections or proxies.

@BatchSize(size = 20)
@ManyToMany
privateSet<Project> projects;

How it works:

  • Without @BatchSize:
    Loading 10 Employees β†’ 1 query for Employees + 10 queries for Projects
  • With @BatchSize(20):
    Loading 10 Employees β†’ 1 query for Employees + 1 query for Projects using IN (...)

Hibernate batches lazy collection loads into a single SELECT using an IN clause.


Deep Dive: Beyond the Basics

The mappedBy Attribute Explained

The mappedBy attribute tells Hibernate:

“I am not the owner; look at the ‘projects’ field in the other class for configuration.”

Without it, Hibernate creates two join tables, causing data synchronization issues.


Handling Extra Columns in the Join Table

If you need metadata like assigned_date, move beyond @ManyToMany:

  • Create a ProjectAssignment entity.
  • Map it with @ManyToOne to Employee and Project.
  • Replace the Many-to-Many with two @OneToMany relationships.

Advanced Performance Tuning

The N+1 Select Problem

Use a JOIN FETCH query to load associations in one round-trip:

SELECT e FROM Employee e JOIN FETCH e.projects

Filtering with JPQL

Find all employees working on a specific project:

List<Employee> result = session.createQuery(
    "SELECT DISTINCT e FROM Employee e JOIN e.projects p WHERE p.title = :title",
    Employee.class
)
.setParameter("title", "Cloud Migration")
.getResultList();

Frequently Asked Questions (People Also Ask)

1. How do I remove a Many-to-Many association without deleting entities?
Remove the object from the Set on both sides and save the parent. Hibernate deletes only the join table row.

2. Is it better to use @ManyToMany or two @OneToMany relationships?
Use @ManyToMany for simple links. For relationship-specific metadata, use a joining entity.

3. Why does Hibernate throw LazyInitializationException?
You accessed a lazy collection after the Session closed. Ensure a @Transactional boundary is in place or use JOIN FETCH to initialise the collection while the session is still open.

4. Why should I avoid CascadeType.REMOVE on @ManyToMany?

Because the entities on both sides of a Many-to-Many are shared β€” removing one Employee should not delete the Projects that other Employees are still working on. CascadeType.REMOVE propagates the delete to all associated entities, not just the join table row. The correct approach is to remove the entity from the Set on both sides (using helper methods), let Hibernate delete only the join table row, and leave both domain entities intact.

5. How do I add extra columns (metadata) to a Many-to-Many join table?

The standard @ManyToMany annotation cannot carry extra columns in the join table. To add metadata such as assigned_date, role, or status, you need to replace the Many-to-Many with an explicit Join Entity (e.g., ProjectAssignment) that has its own @Id, two @ManyToOne fields pointing to Employee and Project, and any additional fields you need. Both Employee and Project then use @OneToMany(mappedBy = "...") to navigate the collection. This pattern is the professional standard for any Many-to-Many relationship that carries business meaning.

Key Best Practices to Keep in Mind

  • Prefer Set over List to ensure efficient row targeting.
  • Always use helper methods for bidirectional sync.
  • Monitor SQL to catch N+1 issues early.
  • Use JOIN FETCH, @BatchSize, and JDBC batching together.
  • If you may need metadata, move to a Join Entity early.

Building a scalable persistence layer requires discipline and a clear understanding of how Hibernate translates Java objects into relational queries.

By following these best practices, your application will remain fast, maintainable, and robust as your data grows.

Conclusion

Hibernate 7 Many-to-Many mapping is powerful but demands discipline. Always use Set over List to ensure surgical join-table row deletes, implement helper methods to keep both sides of the bidirectional relationship in sync, configure @BatchSize for read-side optimisation and JDBC batching for write-side performance, and be honest with yourself about whether a plain @ManyToMany is sufficient β€” if the relationship will ever carry metadata, build the Join Entity from day one and save yourself a painful migration later. With these patterns in place your Many-to-Many associations will be clean, performant, and ready for production load.

Further Reading & Cross-References

For more deep dives into Java Persistence and Cloud Architecture, stay tuned to ankurm.com.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.