- Implement one-to-many relationships between entities using JPA annotations
- Understand the difference between unidirectional and bidirectional one-to-many mappings
- Configure cascade operations and orphan removal for dependent entities
- Query and manage collections of related entities using Spring Data JPA
We've covered one-to-one relationships, but real applications rarely stop there. A user doesn't just have one profile—they create posts, write comments, place orders, build playlists. These are one-to-many relationships, where a single entity relates to multiple instances of another entity.
One-to-many is probably the most common relationship you'll implement. A blog has many posts. A post has many comments. A customer has many orders. The pattern repeats everywhere. JPA makes this reasonably straightforward, but the details matter. How you configure fetching, cascading, and collection management directly impacts performance and data integrity.
We'll extend our existing User-Profile system by adding posts. Each user can write multiple blog posts, but each post belongs to exactly one user. That's a classic one-to-many from User to Post, and a many-to-one from Post to User. Two sides of the same relationship.
The database structure is simpler than you might think. The "many" side holds the foreign key. In our case, the posts table will have a user_id column that references the users table. When you query for a user, you can join to posts and get all posts where user_id matches. That's it. No junction table needed—that's for many-to-many relationships.
In Java, the "one" side (User) has a collection of the "many" side (Post). Usually a List, Set, or Collection. The "many" side (Post) has a reference back to the "one" side (User). This bidirectional setup lets you navigate from user to posts and from post back to user.
You could make it unidirectional—just the collection on User without the reference on Post, or vice versa. But bidirectional is more useful in practice. When you have a Post object, you typically need to know which user wrote it. When you have a User, you want to see their posts. Both directions matter.
Here's the Post entity with the many-to-one side of the relationship:
package javapro.academy.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
public Post() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public Post(String title, String content) {
this();
this.title = title;
this.content = content;
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}The @ManyToOne annotation defines this side of the relationship. Multiple posts can belong to one user. The @JoinColumn specifies the foreign key column name in the posts table. Setting nullable = false enforces referential integrity—every post must have a user.
Notice fetch = FetchType.LAZY. This is critical for performance. When you load a Post, you don't automatically load the entire User object. Hibernate creates a proxy instead. The User data only loads when you actually call post.getUser(). Eager fetching would load the user immediately, which sounds convenient but causes performance problems when you're loading collections of posts.
The @PreUpdate annotation hooks into Hibernate's lifecycle callbacks. Before updating a post, this method fires and sets the updated timestamp. It's a cleaner approach than manually calling setUpdatedAt() everywhere in your code.
Using LocalDateTime instead of Date or Timestamp reflects modern Java practices. The java.time package is clearer, more powerful, and doesn't have the weird quirks of the old Date API. JPA handles the mapping to database timestamp columns automatically.
Now we add the one-to-many side to the User entity:
package javapro.academy.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private Profile profile;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
public User() {}
public User(String username, String email) {
this.username = username;
this.email = email;
}
// Convenience method for adding posts
public void addPost(Post post) {
posts.add(post);
post.setUser(this);
}
public void removePost(Post post) {
posts.remove(post);
post.setUser(null);
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Profile getProfile() {
return profile;
}
public void setProfile(Profile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
public List<Post> getPosts() {
return posts;
}
public void setPosts(List<Post> posts) {
this.posts = posts;
}
}The @OneToMany annotation mirrors the @ManyToOne on Post. The mappedBy = "user" attribute tells JPA that Post owns the relationship through its user field. User is the inverse side—it doesn't control the foreign key.
Initializing the posts list to a new ArrayList in the field declaration prevents NullPointerException when you start adding posts. Without this, calling user.getPosts().add(post) on a newly created user would fail because posts is null.
The addPost() and removePost() convenience methods maintain bidirectional consistency. When you add a post to a user, it also sets that user on the post. When you remove a post, it nulls out the user reference. This prevents the object graph from getting out of sync with the database state.
Cascade and orphan removal work the same way as with one-to-one. Delete a user, all their posts get deleted. Remove a post from the user's collection without explicitly deleting it, orphan removal takes care of it. These settings make sense for a composition relationship where posts can't exist without a user.
The repository follows the same pattern as before:
package javapro.academy.repository;
import javapro.academy.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByUserId(Long userId);
List<Post> findByUserUsername(String username);
@Query("SELECT p FROM Post p WHERE p.user.id = :userId ORDER BY p.createdAt DESC")
List<Post> findRecentPostsByUser(@Param("userId") Long userId);
@Query("SELECT p FROM Post p WHERE LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Post> searchPosts(@Param("keyword") String keyword);
}Spring Data JPA's method name parsing handles findByUserId automatically. It generates a query that filters posts by the user_id foreign key. findByUserUsername is more interesting—it navigates the relationship, joining to the users table to filter by username. You don't write the JOIN; Spring Data figures it out from the entity relationships.
Custom JPQL queries give you more control. The @Query annotation lets you write queries in JPQL (Java Persistence Query Language), which is object-oriented SQL. You reference entity names and fields, not table and column names. Hibernate translates JPQL to SQL based on your entity mappings.
The search query demonstrates pattern matching with LIKE. The CONCAT function builds the wildcard pattern, and LOWER makes it case-insensitive. This is simpler than setting up full-text search for basic filtering needs.
Let's add posts to our existing seed data:
package javapro.academy.config;
import javapro.academy.entity.Post;
import javapro.academy.entity.Profile;
import javapro.academy.entity.User;
import javapro.academy.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DataSeeder {
@Bean
CommandLineRunner initDatabase(UserRepository userRepository) {
return args -> {
User user1 = new User("john_doe", "john@example.com");
Profile profile1 = new Profile(
"Software developer with a passion for Java and Spring Boot",
"https://example.com/avatars/john.jpg",
"San Francisco, CA"
);
user1.setProfile(profile1);
Post post1 = new Post(
"Getting Started with Spring Boot",
"Spring Boot has revolutionized Java application development by providing sensible defaults and auto-configuration..."
);
Post post2 = new Post(
"Understanding JPA Relationships",
"JPA relationships can be tricky, especially when you're dealing with bidirectional mappings and cascade operations..."
);
user1.addPost(post1);
user1.addPost(post2);
User user2 = new User("jane_smith", "jane@example.com");
Profile profile2 = new Profile(
"Full-stack engineer specializing in microservices architecture",
"https://example.com/avatars/jane.jpg",
"Austin, TX"
);
user2.setProfile(profile2);
Post post3 = new Post(
"Microservices Best Practices",
"Building microservices requires careful consideration of service boundaries, communication patterns, and data consistency..."
);
Post post4 = new Post(
"Docker for Java Developers",
"Containerizing Java applications with Docker simplifies deployment and ensures consistency across environments..."
);
Post post5 = new Post(
"API Design Principles",
"Designing clean, intuitive APIs is crucial for building maintainable systems. REST principles provide a solid foundation..."
);
user2.addPost(post3);
user2.addPost(post4);
user2.addPost(post5);
User user3 = new User("bob_wilson", "bob@example.com");
Profile profile3 = new Profile(
"DevOps enthusiast and cloud infrastructure expert",
"https://example.com/avatars/bob.jpg",
"Seattle, WA"
);
user3.setProfile(profile3);
Post post6 = new Post(
"Kubernetes Essentials",
"Kubernetes orchestrates containerized applications at scale. Understanding pods, services, and deployments is fundamental..."
);
user3.addPost(post6);
userRepository.save(user1);
userRepository.save(user2);
userRepository.save(user3);
System.out.println("Database seeded with " + userRepository.count() + " users");
};
}
}We're using the addPost() convenience method instead of directly manipulating the collection. This ensures both sides of the relationship stay synchronized. When you save the user, cascade operations persist all the posts automatically because of CascadeType.ALL.
The order matters here. You set up the entire object graph in memory first, then save just the root entity (User). Hibernate walks the relationships, determines what needs to be inserted, and handles the foreign key references. It inserts the user first to get the generated ID, then inserts each post with that user_id.
A REST controller to interact with posts:
package javapro.academy.controller;
import javapro.academy.entity.Post;
import javapro.academy.entity.User;
import javapro.academy.repository.PostRepository;
import javapro.academy.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/posts")
public class PostController {
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@GetMapping
public List<Post> getAllPosts() {
return postRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Post> getPostById(@PathVariable Long id) {
return postRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/user/{userId}")
public ResponseEntity<List<Post>> getPostsByUser(@PathVariable Long userId) {
return userRepository.findById(userId)
.map(user -> ResponseEntity.ok(postRepository.findByUserId(userId)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/search")
public List<Post> searchPosts(@RequestParam String keyword) {
return postRepository.searchPosts(keyword);
}
@PostMapping("/user/{userId}")
public ResponseEntity<Post> createPost(@PathVariable Long userId, @RequestBody Post post) {
return userRepository.findById(userId)
.map(user -> {
user.addPost(post);
userRepository.save(user);
return ResponseEntity.ok(post);
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
return postRepository.findById(id)
.map(post -> {
postRepository.delete(post);
return ResponseEntity.ok().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
}The search endpoint demonstrates query parameter binding with @RequestParam. Hit /api/posts/search?keyword=spring and you'll get all posts containing "spring" in the title or content.
Creating a new post requires finding the user first, then using the addPost() method to maintain relationship integrity. You could directly set the user on the post and save it through the PostRepository, but going through the User entity and relying on cascade is more consistent with how the relationship is designed.
Deleting a post directly through PostRepository works fine. If you wanted to delete a post through the user, you'd call user.removePost(post) and save the user. Orphan removal would handle the actual deletion.
One-to-many relationships introduce the infamous N+1 query problem. Load a list of users, then access each user's posts, and Hibernate issues a separate SELECT for each user's posts. One query for users, N queries for posts where N is the number of users. This kills performance.
The solution is fetch joins in your repository queries:
package javapro.academy.repository;
import javapro.academy.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.posts WHERE u.id = :id")
Optional<User> findByIdWithPosts(Long id);
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.posts")
List<User> findAllWithPosts();
}The JOIN FETCH keyword tells Hibernate to fetch the posts in the same query as the users. One query with a LEFT JOIN instead of N+1 separate queries. The DISTINCT keyword handles duplicates that result from the join—without it, you might get the same user multiple times if they have multiple posts.
Use these specialized methods when you know you'll need the posts. For endpoints that only need user data, stick with the regular findById() or findAll() to avoid loading unnecessary data.
By default, @OneToMany uses lazy fetching. When you load a User, the posts collection is a Hibernate proxy. It looks like a List, but it's actually a lazy-loading wrapper. The first time you call any method on it—size(), isEmpty(), iterator()—Hibernate executes a SELECT to load the posts.
This sounds great until you run into LazyInitializationException. The Hibernate session closes after your repository method returns. If you try to access a lazy collection outside the session—say, in a controller or during JSON serialization—Hibernate can't fetch the data anymore and throws an exception.
Solutions include:
- Use
@Transactionalon your service or controller methods to keep the session open longer - Use JOIN FETCH queries to eagerly load what you need
- Initialize collections explicitly within the session:
user.getPosts().size() - Use DTOs instead of entities in your API layer
The DTO approach is cleanest for REST APIs. Create data transfer objects that contain only the fields you want to serialize, populate them within a transactional service method, and return those instead of entities. This decouples your API contract from your JPA entities and avoids lazy loading issues entirely.
We set CascadeType.ALL on the User's posts relationship. This means persist, merge, remove, refresh, and detach operations cascade from user to posts. Save a user with new posts? The posts get saved too. Delete a user? All their posts get deleted.
Orphan removal is more subtle. It handles the case where you remove a post from the user's collection without explicitly deleting the post:
User user = userRepository.findById(1L).orElseThrow();
user.getPosts().remove(0); // Remove first post from collection
userRepository.save(user); // Post gets deleted from databaseWithout orphanRemoval = true, that post would stay in the database with a null user_id (or fail if the foreign key is NOT NULL). Orphan removal detects that the post is no longer in the collection and deletes it automatically.
This is powerful but dangerous. Make sure it matches your domain logic. If posts should be soft-deleted or archived instead of hard-deleted, orphan removal isn't appropriate. You'd need to handle removal manually or use lifecycle callbacks to set a deleted flag instead.
One-to-many relationships are fundamental to relational database design and JPA makes them relatively painless. The "many" side holds the foreign key and uses @ManyToOne. The "one" side has a collection and uses @OneToMany with mappedBy pointing to the owning field.
Bidirectional relationships require maintaining both sides of the association. Convenience methods like addPost() and removePost() encapsulate this logic and prevent the object graph from getting out of sync with the database state. Always initialize collections in field declarations to avoid NullPointerException.
Fetch strategies matter enormously for performance. Lazy loading defers loading related entities until you access them, but introduces LazyInitializationException if you're not careful about session boundaries. JOIN FETCH queries solve the N+1 query problem by loading related entities in a single SQL statement.
Cascade operations and orphan removal determine how persistence operations flow through relationships. They're convenient for composition scenarios where child entities don't make sense without their parent, but they can cause unintended deletions if you're not careful about your domain model's semantics.
The key is understanding that JPA relationships map to database foreign keys and joins. The annotations configure how Hibernate generates SQL and manages the persistence lifecycle. When things don't work as expected, check the SQL logs to see what Hibernate is actually doing, then adjust your mappings, fetch strategies, or queries accordingly.
Did you find this helpful? You'll love our Building Production-Ready REST APIs with Spring Boot course. We take these JPA fundamentals and show you how to build complete, scalable systems—with validation, error handling, business logic, and everything else tutorials skip but employers demand. Learn to create APIs that work in the real world: https://www.javapro.academy/bootcamp/building-production-ready-rest-apis-with-spring-boot/