Part 4: Relationships - Many-To-One and Many-To-Many
Navigation: Index | Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6
Table of Contents
- Many-To-One Fundamentals
- Many-To-One in Production: Address and User
- Many-To-Many Fundamentals
- Basic Many-To-Many: User and Role
- Many-To-Many with Extra Columns: The Custom Join Entity
- User-Role with Assignment Metadata
- Product-Category Many-To-Many
- Equals and HashCode in Many-To-Many
- N+1 in Many-To-Many and Solutions
- DTO Projections for Read Operations
- Interface Projections (Spring Data)
- When to Use Each Approach
- Tips and Best Practices
- Anti-Patterns and Pitfalls
- Real-World Architecture Decisions
1. Many-To-One Fundamentals
@ManyToOne is the simplest and most performant relationship in JPA. It represents the owning side of a one-to-many relationship: many child records point to one parent via a foreign key.
Every @ManyToOne has a corresponding @OneToMany on the other side (bidirectional), or none at all (unidirectional). Unidirectional @ManyToOne is perfectly valid and often preferred.
Why @ManyToOne is Special
@ManyToOne is the OWNING SIDE. It controls the FK column.
@OneToMany is the INVERSE SIDE. It just reads the FK.
Rule: If you do not set the @ManyToOne, the FK is NULL and your data is broken.
If you set only the @OneToMany (inverse), the FK is STILL NULL.
The database cares only about what @ManyToOne says.
Always Use FetchType.LAZY for @ManyToOne
The default for @ManyToOne is FetchType.EAGER, which is wrong for production. Always override it.
// BAD - loads the customer EVERY time you load any order
@ManyToOne
@JoinColumn(name = "customer_id")
private User customer;
// GOOD - customer only loaded when accessed
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private User customer;2. Many-To-One Production Example: Address and User
A user can have multiple addresses (home, work, shipping, billing). Each address belongs to exactly one user.
Address Entity
package com.company.ecommerce.domain.user;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(
name = "addresses",
indexes = {
@Index(name = "idx_addresses_user_id", columnList = "user_id")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "user")
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/*
* OWNING SIDE of User-Address relationship.
* FK column 'user_id' lives in the addresses table.
* fetch = LAZY: We almost never need the full User when working with an Address.
* optional = false: Every address MUST belong to a user.
*/
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "user_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_addresses_user_id")
)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "address_type", nullable = false, length = 20)
private AddressType addressType;
@Column(name = "street_line1", nullable = false, length = 200)
private String streetLine1;
@Column(name = "street_line2", length = 200)
private String streetLine2;
@Column(name = "city", nullable = false, length = 100)
private String city;
@Column(name = "state_province", length = 100)
private String stateProvince;
@Column(name = "postal_code", nullable = false, length = 20)
private String postalCode;
@Column(name = "country_code", nullable = false, length = 3)
private String countryCode;
@Column(name = "is_default", nullable = false)
@Builder.Default
private Boolean isDefault = false;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Address address)) return false;
return id != null && id.equals(address.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
public enum AddressType {
HOME, WORK, SHIPPING, BILLING
}User Entity with Addresses
@Entity
@Table(name = "users")
public class User extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... other fields ...
/*
* Inverse side of User-Address relationship.
* cascade = PERSIST, MERGE: Saving a user saves new addresses.
* cascade excludes REMOVE: Deleting a user does NOT delete addresses by default
* unless you also want address deletion.
* orphanRemoval = true: Removing address from collection DELETES it from DB.
*/
@OneToMany(
mappedBy = "user",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("isDefault DESC, addressType ASC")
@Builder.Default
private List<Address> addresses = new ArrayList<>();
// Helper methods
public void addAddress(Address address) {
addresses.add(address);
address.setUser(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setUser(null);
}
public Optional<Address> getDefaultShippingAddress() {
return addresses.stream()
.filter(a -> a.getIsDefault() && a.getAddressType() == AddressType.SHIPPING)
.findFirst();
}
}Address Repository
public interface AddressRepository extends JpaRepository<Address, Long> {
List<Address> findByUserId(Long userId);
List<Address> findByUserIdAndAddressType(Long userId, AddressType addressType);
Optional<Address> findByUserIdAndIsDefaultTrue(Long userId);
// Count how many addresses a user has
long countByUserId(Long userId);
// Check if a user owns this address (for security validation)
boolean existsByIdAndUserId(Long addressId, Long userId);
}3. Many-To-Many Fundamentals
A many-to-many relationship means: one row in table A can relate to many rows in table B, AND one row in table B can relate to many rows in table A.
Real-world examples:
- Users and Roles (a user has many roles, a role belongs to many users)
- Products and Categories (a product is in many categories, a category has many products)
- Students and Courses (a student takes many courses, a course has many students)
- Authors and Books (a book can have multiple authors, an author writes multiple books)
Database Representation
users user_roles roles
+----+-------+ +----------+---------+ +----+-------+
| id | email | | user_id | role_id | | id | name |
+----+-------+ +----------+---------+ +----+-------+
| 1 | a@... | | 1 | 1 | | 1 | ADMIN |
| 2 | b@... | | 1 | 2 | | 2 | USER |
+----+-------+ | 2 | 2 | +----+-------+
+----------+---------+
A separate JOIN TABLE is required. In pure many-to-many with no extra columns, JPA can manage this automatically with @JoinTable.
4. Basic Many-To-Many: User and Role
Role Entity
package com.company.ecommerce.domain.user;
import jakarta.persistence.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "roles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "users")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "name", nullable = false, unique = true, length = 50)
private RoleName name;
@Column(name = "description", length = 200)
private String description;
/*
* mappedBy = "roles": This is the INVERSE side.
* The JOIN TABLE is managed from User.roles (the owning side).
* No cascade on this side: removing a Role should NOT remove Users.
*/
@ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
@Builder.Default
private Set<User> users = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Role role)) return false;
return name != null && name.equals(role.name);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
public enum RoleName {
ROLE_ADMIN, ROLE_CUSTOMER, ROLE_SUPPORT, ROLE_MANAGER
}User Entity with Roles
@Entity
@Table(name = "users")
public class User extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... other fields ...
/*
* OWNING SIDE of User-Role many-to-many.
* @JoinTable defines the join table structure.
*
* joinColumns: Columns from THIS entity (user_id in user_roles)
* inverseJoinColumns: Columns from the OTHER entity (role_id in user_roles)
*
* IMPORTANT: Do NOT use CascadeType.REMOVE or CascadeType.ALL on @ManyToMany.
* Deleting a User should NOT delete the ADMIN role.
* Use only PERSIST and MERGE if you need cascade at all.
*
* Use Set, NOT List for @ManyToMany.
* List causes Hibernate to delete ALL rows from user_roles for this user
* and re-insert them when any change is made. Set does targeted inserts/deletes.
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(
name = "user_id",
foreignKey = @ForeignKey(name = "fk_user_roles_user_id")
),
inverseJoinColumns = @JoinColumn(
name = "role_id",
foreignKey = @ForeignKey(name = "fk_user_roles_role_id")
)
)
@Builder.Default
private Set<Role> roles = new HashSet<>();
// Helper methods for bidirectional sync
public void addRole(Role role) {
roles.add(role);
role.getUsers().add(this);
}
public void removeRole(Role role) {
roles.remove(role);
role.getUsers().remove(this);
}
public boolean hasRole(RoleName roleName) {
return roles.stream().anyMatch(r -> r.getName() == roleName);
}
}Flyway Migration for user_roles
-- V5__create_roles_and_user_roles.sql
CREATE TABLE roles (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
PRIMARY KEY (id),
UNIQUE KEY uk_roles_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed initial roles
INSERT INTO roles (name, description) VALUES
('ROLE_ADMIN', 'System administrator'),
('ROLE_CUSTOMER', 'Regular customer'),
('ROLE_SUPPORT', 'Customer support agent'),
('ROLE_MANAGER', 'Store manager');
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
INDEX idx_user_roles_role_id (role_id),
CONSTRAINT fk_user_roles_user_id
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role_id
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
) ENGINE=InnoDB;UserRepository with Role Fetching
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
// Load user with roles in one query (needed for authentication)
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(@Param("email") String email);
// Load user with all relationships needed for the dashboard
@Query("""
SELECT u FROM User u
LEFT JOIN FETCH u.roles
LEFT JOIN FETCH u.profile
WHERE u.id = :id
""")
Optional<User> findByIdWithRolesAndProfile(@Param("id") Long id);
// Find all users with a specific role
@Query("SELECT u FROM User u JOIN u.roles r WHERE r.name = :roleName")
List<User> findByRoleName(@Param("roleName") RoleName roleName);
}5. Custom Join Entity Pattern (Industry Best Practice)
Basic @ManyToMany works well only when the join table has NO extra columns. In almost every real-world scenario, you need extra columns:
- When was this role assigned? (
assigned_at) - Who assigned it? (
assigned_by) - When does it expire? (
expires_at) - Is it active? (
is_active)
As soon as you need any of these, you MUST replace @ManyToMany with a custom join entity.
Why Replace @ManyToMany with a Custom Entity
BASIC @ManyToMany (JPA-managed join table):
- Table has only 2 FK columns
- Cannot add extra columns without breaking JPA management
- Cannot query the join table directly
- Cannot add additional constraints
CUSTOM JOIN ENTITY:
- Full control over the join table schema
- Add any extra columns
- Queryable directly via repository
- Business logic can live on the join entity
- Timestamps, audit fields, soft deletes all possible
6. User-Role with Assignment Metadata
A real-world user-role system: roles have an assigned date, assigned by whom, and an expiration date.
UserRole Entity (Custom Join Entity)
package com.company.ecommerce.domain.user;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Table(
name = "user_roles",
uniqueConstraints = {
// Prevent assigning the same role twice
@UniqueConstraint(name = "uk_user_roles_user_role",
columnNames = {"user_id", "role_id"})
},
indexes = {
@Index(name = "idx_user_roles_user_id", columnList = "user_id"),
@Index(name = "idx_user_roles_role_id", columnList = "role_id"),
@Index(name = "idx_user_roles_expires_at", columnList = "expires_at")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"user", "role"})
public class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "user_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_user_roles_user_id")
)
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "role_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_user_roles_role_id")
)
private Role role;
// Extra columns - impossible with basic @ManyToMany
@Column(name = "assigned_at", nullable = false, updatable = false)
private Instant assignedAt;
@Column(name = "assigned_by", nullable = false, length = 100, updatable = false)
private String assignedBy;
@Column(name = "expires_at")
private Instant expiresAt;
@Column(name = "is_active", nullable = false)
@Builder.Default
private Boolean isActive = true;
@Column(name = "notes", length = 500)
private String notes;
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public boolean isEffective() {
return isActive && !isExpired();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserRole ur)) return false;
return id != null && id.equals(ur.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Updated User Entity (Custom Join Entity Pattern)
@Entity
@Table(name = "users")
public class User extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... other fields ...
/*
* With custom join entity, User no longer uses @ManyToMany.
* Instead, it has @OneToMany to UserRole (which is the custom join entity).
* You then navigate: user -> userRoles -> role
*/
@OneToMany(
mappedBy = "user",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = true,
fetch = FetchType.LAZY
)
@Builder.Default
private Set<UserRole> userRoles = new HashSet<>();
// Helper methods
public void assignRole(Role role, String assignedBy) {
// Check if already has this role
boolean alreadyHasRole = userRoles.stream()
.anyMatch(ur -> ur.getRole().equals(role) && ur.getIsActive());
if (alreadyHasRole) {
throw new BusinessException("User already has role: " + role.getName());
}
UserRole userRole = UserRole.builder()
.user(this)
.role(role)
.assignedAt(Instant.now())
.assignedBy(assignedBy)
.isActive(true)
.build();
userRoles.add(userRole);
}
public void revokeRole(Role role) {
userRoles.stream()
.filter(ur -> ur.getRole().equals(role) && ur.getIsActive())
.findFirst()
.ifPresent(ur -> ur.setIsActive(false));
}
public boolean hasRole(RoleName roleName) {
return userRoles.stream()
.anyMatch(ur -> ur.isEffective() && ur.getRole().getName() == roleName);
}
public Set<RoleName> getEffectiveRoles() {
return userRoles.stream()
.filter(UserRole::isEffective)
.map(ur -> ur.getRole().getName())
.collect(Collectors.toSet());
}
}Updated Role Entity (Custom Join Entity Pattern)
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "name", nullable = false, unique = true, length = 50)
private RoleName name;
@Column(name = "description", length = 200)
private String description;
// Inverse side - navigate from Role to UserRoles
@OneToMany(mappedBy = "role", fetch = FetchType.LAZY)
@Builder.Default
private Set<UserRole> userRoles = new HashSet<>();
public long countActiveAssignments() {
return userRoles.stream().filter(UserRole::isEffective).count();
}
}UserRole Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Long> {
List<UserRole> findByUserId(Long userId);
List<UserRole> findByUserIdAndIsActiveTrue(Long userId);
// Find all active, non-expired role assignments for a user
@Query("""
SELECT ur FROM UserRole ur
JOIN FETCH ur.role
WHERE ur.user.id = :userId
AND ur.isActive = true
AND (ur.expiresAt IS NULL OR ur.expiresAt > :now)
""")
List<UserRole> findEffectiveRolesForUser(
@Param("userId") Long userId,
@Param("now") Instant now
);
// Find all users with a specific active role
@Query("""
SELECT ur FROM UserRole ur
JOIN FETCH ur.user
WHERE ur.role.name = :roleName
AND ur.isActive = true
""")
List<UserRole> findActiveAssignmentsByRoleName(@Param("roleName") RoleName roleName);
boolean existsByUserIdAndRoleNameAndIsActiveTrue(Long userId, RoleName roleName);
void deleteByUserIdAndRoleId(Long userId, Long roleId);
}7. Product-Category Many-To-Many
Products can belong to multiple categories, and categories can contain multiple products. This is a typical @ManyToMany where no extra metadata is needed on the join, so the basic approach works.
Category Entity
package com.company.ecommerce.domain.product;
import jakarta.persistence.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(
name = "categories",
indexes = {
@Index(name = "idx_categories_parent_id", columnList = "parent_id"),
@Index(name = "idx_categories_slug", columnList = "slug")
}
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = {"products", "parent", "children"})
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "slug", nullable = false, unique = true, length = 100)
private String slug;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
/*
* Self-referencing relationship: Category has a parent category.
* Electronics -> Phones -> Smartphones
* This models a category tree/hierarchy.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id", foreignKey = @ForeignKey(name = "fk_categories_parent_id"))
private Category parent;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
@Builder.Default
private Set<Category> children = new HashSet<>();
/*
* INVERSE SIDE of Product-Category many-to-many.
* The @JoinTable is defined on the Product side (owning side).
* No cascade: Removing a category should NOT remove products.
*/
@ManyToMany(mappedBy = "categories", fetch = FetchType.LAZY)
@Builder.Default
private Set<Product> products = new HashSet<>();
public boolean isRootCategory() {
return parent <mark class="obsidian-highlight"> null;
}
@Override
public boolean equals(Object o) {
if (this </mark> o) return true;
if (!(o instanceof Category cat)) return false;
return slug != null && slug.equals(cat.slug);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}Product Entity with Categories
@Entity
@Table(name = "products")
public class Product extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... other fields ...
/*
* OWNING SIDE of Product-Category many-to-many.
*
* @JoinTable:
* name: join table name
* joinColumns: FK column pointing to THIS entity (product_id)
* inverseJoinColumns: FK column pointing to the OTHER entity (category_id)
*
* Use Set to avoid Hibernate delete-all-and-reinsert behavior on modification.
* No cascade: Deleting a Product should NOT delete its Categories.
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(
name = "product_id",
foreignKey = @ForeignKey(name = "fk_product_categories_product_id")
),
inverseJoinColumns = @JoinColumn(
name = "category_id",
foreignKey = @ForeignKey(name = "fk_product_categories_category_id")
)
)
@Builder.Default
private Set<Category> categories = new HashSet<>();
// Helper methods
public void addCategory(Category category) {
categories.add(category);
category.getProducts().add(this);
}
public void removeCategory(Category category) {
categories.remove(category);
category.getProducts().remove(this);
}
public boolean isInCategory(String categorySlug) {
return categories.stream().anyMatch(c -> c.getSlug().equals(categorySlug));
}
}Product Repository with Category Queries
public interface ProductRepository extends JpaRepository<Product, Long> {
// Find products in a specific category
@Query("""
SELECT DISTINCT p FROM Product p
JOIN p.categories c
WHERE c.slug = :categorySlug
AND p.status = 'ACTIVE'
ORDER BY p.name
""")
List<Product> findByCategorySlug(@Param("categorySlug") String categorySlug);
// Find products with their categories loaded (for product detail page)
@Query("SELECT p FROM Product p LEFT JOIN FETCH p.categories WHERE p.id = :id")
Optional<Product> findByIdWithCategories(@Param("id") Long id);
// Paginated product listing with category filter
@Query("""
SELECT DISTINCT p FROM Product p
JOIN p.categories c
WHERE c.id = :categoryId
AND p.status = :status
""")
Page<Product> findByCategoryIdAndStatus(
@Param("categoryId") Long categoryId,
@Param("status") ProductStatus status,
Pageable pageable
);
// Find all active products with their categories (admin use)
@EntityGraph(attributePaths = {"categories"})
List<Product> findByStatus(ProductStatus status);
}8. Equals and HashCode in Many-To-Many
This is a critical correctness issue that causes subtle, hard-to-debug problems in production.
The Problem with Id-Based Equals in Sets
// Entity with id-based equals/hashCode
@Entity
public class Product {
@Id
private Long id;
@Override
public boolean equals(Object o) {
if (!(o instanceof Product p)) return false;
return id != null && id.equals(p.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
// Problem scenario:
Product p = new Product(); // id is null
categories.add(p); // Added to Set with hashCode from getClass()
productRepository.save(p); // id assigned: 42
categories.contains(p); // Still works because hashCode doesn't change (getClass())
// WHY getClass().hashCode() works:
// The hash code MUST be stable before and after persist.
// Objects.hashCode(id) would return different values before (null) and after (42) save.
// This would break Set behavior: item inserted with null-hashCode slot,
// but searched with 42-hashCode slot -> not found!Business Key Equals (Best for Entities in Collections)
@Entity
public class Category {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Category cat)) return false;
// Use slug as the business key - stable before and after persist
return slug != null && slug.equals(cat.slug);
}
@Override
public int hashCode() {
return getClass().hashCode(); // Constant, stable across lifecycle
}
}The Three Strategies Compared
| Strategy | Before Persist | After Persist | In Sets | Recommended |
|---|---|---|---|---|
id-based equals + Objects.hashCode(id) | Broken (null id) | Works | Broken | Never |
id-based equals + getClass().hashCode() | Works | Works | Works | Good |
Business key equals + getClass().hashCode() | Works | Works | Works | Best |
9. N+1 in Many-To-Many and Solutions
Many-to-many has its own N+1 patterns that differ from one-to-many.
The Many-To-Many N+1
// Load all products (1 query)
List<Product> products = productRepository.findAll();
for (Product product : products) {
// Each access to categories triggers:
// SELECT * FROM product_categories WHERE product_id = ?
// THEN: SELECT * FROM categories WHERE id IN (...)
// That's 2N additional queries!
Set<Category> cats = product.getCategories();
}
// Total: 1 + 2N queries for N productsSolution 1: JOIN FETCH for Single Entity
@Query("SELECT p FROM Product p LEFT JOIN FETCH p.categories WHERE p.id = :id")
Optional<Product> findByIdWithCategories(@Param("id") Long id);Solution 2: EntityGraph for Lists
@EntityGraph(attributePaths = {"categories"})
List<Product> findByStatus(ProductStatus status);
// Hibernate generates:
// SELECT p.*, pc.*, c.* FROM products p
// LEFT JOIN product_categories pc ON p.id = pc.product_id
// LEFT JOIN categories c ON pc.category_id = c.id
// WHERE p.status = ?Solution 3: Default Batch Fetch Size (Transparent)
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25With batch size 25, loading 100 products' categories goes from 200 queries to 8 queries.
Solution 4: DTO Projection (Best for Read-Only Operations)
For read-only data display, fetch a DTO directly from the database without loading entities at all. This is the most performant approach.
10. DTO Projections for Read Operations
A DTO projection fetches only the columns you need, without loading full entity objects or their relationships. This is the recommended approach for all read-only API responses.
Why DTOs Outperform Entity Loading for Reads
ENTITY LOADING: SELECT * from all mapped columns
Hibernate manages first-level cache
Lazy proxy objects created for associations
Risk of accidental lazy loading during serialization
DTO PROJECTION: SELECT only the columns you specify
No entity lifecycle management
No lazy loading proxies
No risk of accidental extra queries
No Jackson serialization issues with proxies
JPQL Constructor Expression (Class Projection)
// DTO class
package com.company.ecommerce.api.dto.response;
import lombok.Value;
import java.math.BigDecimal;
@Value // Lombok: immutable, all-args constructor, equals, hashCode, toString
public class ProductSummaryDto {
Long id;
String sku;
String name;
BigDecimal price;
String status;
// No categories here - this is a summary
}// Repository using JPQL constructor expression
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("""
SELECT new com.company.ecommerce.api.dto.response.ProductSummaryDto(
p.id, p.sku, p.name, p.price, p.status
)
FROM Product p
WHERE p.status = :status
ORDER BY p.name
""")
Page<ProductSummaryDto> findSummariesByStatus(
@Param("status") ProductStatus status,
Pageable pageable
);
}JPQL with Category Data in DTO
@Value
public class ProductWithCategoriesDto {
Long id;
String name;
BigDecimal price;
List<String> categoryNames; // We'll populate this in the service layer
// Constructor for JPQL
public ProductWithCategoriesDto(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
this.categoryNames = new ArrayList<>();
}
}Using MapStruct for Entity-to-DTO Mapping
package com.company.ecommerce.api.mapper;
import org.mapstruct.*;
@Mapper(componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface ProductMapper {
// Map Product entity to response DTO
@Mapping(target = "categoryNames",
expression = "java(product.getCategories().stream().map(c -> c.getName()).collect(java.util.stream.Collectors.toList()))")
ProductResponse toResponse(Product product);
// Map request DTO to entity
@Mapping(target = "id", ignore = true)
@Mapping(target = "categories", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
Product toEntity(CreateProductRequest request);
// Update existing entity from request (for PUT/PATCH)
@Mapping(target = "id", ignore = true)
@Mapping(target = "categories", ignore = true)
void updateEntityFromRequest(UpdateProductRequest request, @MappingTarget Product product);
List<ProductResponse> toResponseList(List<Product> products);
}11. Interface Projections (Spring Data)
Spring Data JPA supports projecting query results directly into interfaces. This is the most concise way to fetch partial data.
// Define the projection interface
package com.company.ecommerce.api.dto.projection;
public interface ProductCatalogView {
Long getId();
String getSku();
String getName();
BigDecimal getPrice();
String getStatus();
// Nested projection for category data
CategoryView getCategory(); // Not usable with @ManyToMany, works with @ManyToOne
interface CategoryView {
String getName();
String getSlug();
}
}// Repository returning interface projection
public interface ProductRepository extends JpaRepository<Product, Long> {
// Spring Data auto-generates optimized SELECT based on projection fields
List<ProductCatalogView> findByStatusOrderByName(ProductStatus status);
// With pagination
Page<ProductCatalogView> findByStatus(ProductStatus status, Pageable pageable);
}When to Use Which DTO Approach
| Approach | Code Volume | Type Safety | Performance | Flexibility |
|---|---|---|---|---|
| Entity Loading + MapStruct | Medium | High | Good | High |
| JPQL Constructor DTO | Medium | High | Best | Medium |
| Interface Projection | Low | Medium | Best | Low |
| Native Query + RowMapper | High | Low | Best | Highest |
12. When to Use Each Approach
Choosing Between @ManyToMany and Custom Join Entity
Use basic @ManyToMany when:
- The join table has ONLY the two FK columns
- No extra attributes are needed now or in the foreseeable future
- The relationship is simple (User-Role without expiry or audit)
Use Custom Join Entity when:
- You need ANY extra column on the join table
- You need auditing on the relationship itself
- You need to query the join table independently
- You need soft delete on the relationship
- You need time-bounded relationships (e.g., temporary access)
Industry Reality:
Almost always use the Custom Join Entity.
Requirements always change, and adding extra columns to @ManyToMany
requires refactoring the entire relationship approach.
Starting with a custom entity is the safer investment.
Choosing Between @ManyToOne and @OneToMany
Use @ManyToOne when:
- You navigate from child to parent (e.g., orderItem.getOrder())
- You want the owning side (FK holder)
- You need to filter by parent (WHERE order_id = ?)
Use @OneToMany when:
- You navigate from parent to collection (e.g., order.getItems())
- ALWAYS pair it with @ManyToOne on the child side (bidirectional)
- Never use unidirectional @OneToMany alone (creates unwanted join table)
13. Tips and Best Practices
Tip 1: Always Use Set for @ManyToMany
// CORRECT: Using Set
@ManyToMany
private Set<Role> roles = new HashSet<>();
// Adding or removing: one targeted INSERT or DELETE
// BAD: Using List
@ManyToMany
private List<Role> roles = new ArrayList<>();
// Any change: DELETE ALL from join table + reinsert all
// This is horrifically inefficient and can cause data lossTip 2: Initialize Collections to Avoid NullPointerException
// Always initialize - never let the collection be null
@Builder.Default
private Set<Role> roles = new HashSet<>();
@Builder.Default
private List<OrderItem> items = new ArrayList<>();Tip 3: Never Use CascadeType.ALL or CascadeType.REMOVE on @ManyToMany
// DANGEROUS: Removing a user could cascade-remove the ADMIN role
@ManyToMany(cascade = CascadeType.ALL)
private Set<Role> roles;
// SAFE: No cascade (or only PERSIST/MERGE)
@ManyToMany(fetch = FetchType.LAZY)
private Set<Role> roles;Tip 4: Use Named Queries for Complex Fetch Requirements
@NamedEntityGraph(
name = "Product.withCategoriesAndBrand",
attributeNodes = {
@NamedAttributeNode("categories"),
@NamedAttributeNode("brand")
}
)
@Entity
public class Product { ... }Tip 5: Batch Fetch for Self-Referencing Hierarchies
Category trees (parent/child) cause N+1 at every level:
@BatchSize(size = 50)
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private Set<Category> children = new HashSet<>();Tip 6: Use @OrderBy for Consistent Collection Ordering
@OneToMany(mappedBy = "user")
@OrderBy("createdAt DESC")
private List<Address> addresses = new ArrayList<>();
@ManyToMany
@OrderBy("name ASC")
private Set<Category> categories = new LinkedHashSet<>();
// LinkedHashSet preserves @OrderBy results14. Anti-Patterns and Pitfalls
Pitfall 1: Using List for @ManyToMany
// Hibernate behavior with List in @ManyToMany:
user.getRoles().add(newRole);
// Generates:
// DELETE FROM user_roles WHERE user_id = ? (deletes ALL roles)
// INSERT INTO user_roles VALUES (?, 1) (reinserts all, including new)
// INSERT INTO user_roles VALUES (?, 2)
// INSERT INTO user_roles VALUES (?, 3) <- new one
// With Set:
user.getRoles().add(newRole);
// Generates:
// INSERT INTO user_roles VALUES (?, 3) (only the new one)Pitfall 2: Bi-Directional Many-To-Many With CascadeType.ALL on Both Sides
// This is a circular cascade bomb:
@ManyToMany(cascade = CascadeType.ALL)
private Set<Role> roles; // On User
@ManyToMany(mappedBy = "roles", cascade = CascadeType.ALL) // Also on Role
private Set<User> users;
// Deleting ONE user -> cascades to ALL roles -> cascades to ALL other users -> cascade...
// Potential: wiping your entire user and role tablesPitfall 3: Loading Large Collections in Memory
// Dangerous: A top-level Category might have thousands of products
@ManyToMany(mappedBy = "categories", fetch = FetchType.EAGER)
private Set<Product> products; // Loading ALL products in memory -> OutOfMemoryError
// Correct: Never keep a reference to the large side
// Access products via ProductRepository with pagination insteadPitfall 4: Forgetting Unique Constraint on Custom Join Table
// Without unique constraint, a user can be assigned the same role multiple times
@Table(name = "user_roles")
// Missing: @UniqueConstraint on (user_id, role_id)
// Always add:
@Table(
name = "user_roles",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_user_roles_user_role",
columnNames = {"user_id", "role_id"}
)
}
)Pitfall 5: Not Setting Both Sides of Bidirectional @ManyToMany
// Missing: setting the inverse side
product.getCategories().add(electronics);
// electronics.getProducts() does NOT contain this product in memory!
// Next call to electronics.getProducts() shows stale data in same transaction
// Correct: Always use helper method
product.addCategory(electronics);
// addCategory sets both: product.categories and electronics.products15. Real-World Architecture Decisions
Decision 1: When to Flatten a Many-To-Many into One Table
Sometimes a many-to-many relationship with extra data becomes so complex that it deserves to be its own first-class entity.
Example: Student Enrollment in Course
Option A: @ManyToMany with basic join
Student <-> Course (no extra data)
Option B: Custom Join Entity
Student -> Enrollment -> Course
Enrollment has: enrolled_date, grade, status, payment_reference
Option C: Full Entity
Enrollment becomes its own domain entity
with its own service, controller, and business logic
When the join entity grows beyond 3-4 extra columns and has its own business rules (enrollment approval, payment, grading), promote it to a full entity.
Decision 2: Bidirectional vs Unidirectional
In a microservices architecture, you may intentionally make relationships unidirectional to avoid tight coupling:
// In Order Service: only know about OrderItem
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
// OrderItem references product_id (Long), NOT the Product entity
// Product lives in Product Service
@Column(name = "product_id")
private Long productId; // Just the ID, not a JPA relationship
// This prevents cross-service entity loading and keeps services independentDecision 3: Denormalizing for Read Performance
For high-read scenarios, store derived data in the parent to avoid join queries:
@Entity
public class Order {
// Derived from OrderItems, stored for fast reads
@Column(name = "item_count")
private Integer itemCount; // Updated on addItem/removeItem
@Column(name = "total_amount")
private BigDecimal totalAmount; // Recalculated on item changes
}
// Instead of:
SELECT COUNT(*) FROM order_items WHERE order_id = ?
// You query:
SELECT item_count FROM orders WHERE id = ?
// One fast lookup instead of an aggregation queryContinue to Part 5: Production Patterns and Configurations