ManyToOne
@ManyToOne maps the relationship where many child rows point at one parent row through a foreign key column: many comments belong to one post, many orders to one customer. It sits on the child entity, the one whose table holds the foreign key, and that makes it the owning side. It is also the mapping that most often needs no companion: a plain @ManyToOne on the child is frequently all you need, and adding the @OneToMany collection on the parent is optional. The things to get right are the fetch type (the default is EAGER and you almost never want it), how you save and reassign references, what happens when a parent is deleted, how you read parent data into DTOs without triggering N+1 queries, and how to prove with a test that you did not introduce one.
Mapping the Relationship
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false, length = 2000)
private String body;
// Mandatory parent: owning side, comments.post_id.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_comments_post"))
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_comments_author"))
private User author;
// Optional self-reference: null means a top-level comment.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_comment_id",
foreignKey = @ForeignKey(name = "fk_comments_parent"))
private Comment parent;
protected Comment() { } // required by JPA
public Comment(String body, Post post, User author) {
this.body = body;
this.post = post;
this.author = author;
}
public void moveTo(Post newPost) { this.post = newPost; }
// Package-private: only the parent's helper methods (next section)
// should change the link when the collection side is mapped.
void setPost(Post post) { this.post = post; }
public Long getId() { return id; }
public String getBody() { return body; }
public Post getPost() { return post; }
public User getAuthor() { return author; }
}
// The parent can stay completely unaware of its comments:
@Entity
@Table(name = "posts")
public class Post {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false)
private String title;
protected Post() { }
public Post(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
}
// Migration (PostgreSQL does not index foreign keys automatically):
// CREATE INDEX idx_comments_post_id ON comments (post_id);Adding the Inverse Side Only When You Need It
@Entity
@Table(name = "posts")
public class Post {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false)
private String title;
// Inverse side: "post" is the field on Comment that owns the foreign key.
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public void addComment(Comment comment) {
comments.add(comment);
comment.setPost(this); // keep both sides in step
}
public void removeComment(Comment comment) {
comments.remove(comment);
// orphanRemoval deletes the comment. Do not call comment.setPost(null):
// the relationship is mandatory (nullable = false), so a null
// post would break the invariant if the comment is ever reused.
}
protected Post() { }
public Post(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
public List<Comment> getComments() { return Collections.unmodifiableList(comments); }
}
// Cascade belongs on the collection side, which owns the child's lifecycle.
// Never put CascadeType.ALL or REMOVE on a @ManyToOne: deleting a comment would then delete the post.Fetching: Why LAZY Matters and How to Load Parents on Purpose
public interface CommentRepository extends JpaRepository<Comment, Long> {
// No join: post_id is a column on comments. Each comment's post stays an uninitialised proxy.
List<Comment> findByPostId(Long postId);
long countByPostId(Long postId);
// (If the entity also had a field called postId, write findByPost_Id to be explicit.)
// Joins posts for the filter only; the returned comments still hold post proxies.
List<Comment> findByPostTitle(String title);
// Fetch the parents in the same SELECT, for a single comment...
@Query("select c from Comment c join fetch c.post join fetch c.author where c.id = :id")
Optional<Comment> findByIdWithParents(@Param("id") Long id);
// ...or declaratively, for a list.
@EntityGraph(attributePaths = {"post", "author"})
List<Comment> findByPostIdOrderByIdAsc(Long postId);
// For an OPTIONAL relationship use LEFT JOIN FETCH, or rows with a null parent disappear:
@Query("select c from Comment c left join fetch c.parent where c.post.id = :postId")
List<Comment> findWithOptionalParent(@Param("postId") Long postId);
}
// Safety net in application.yml: lazy loads that slip through run in batches, not one per row.
// spring:
// jpa:
// properties:
// hibernate:
// default_batch_fetch_size: 25
//
// To see the problem, log the SQL (logging.level.org.hibernate.SQL=DEBUG) and count the statements
// for one request. If the number grows with the number of rows returned, you have an N+1.Saving and Reassigning References
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional
public Long create(Long postId, CreateCommentRequest request, Long authorId) {
if (!postRepository.existsById(postId)) {
throw new PostNotFoundException(postId); // clean 404, one cheap query
}
Post post = postRepository.getReferenceById(postId); // proxy: no SELECT
User author = userRepository.getReferenceById(authorId); // from the security context
Comment saved = commentRepository.save(new Comment(request.body(), post, author));
return saved.getId(); // INSERT INTO comments (body, post_id, author_id, ...) VALUES (?, ?, ?, ...)
}
@Transactional
public void move(Long commentId, Long newPostId) {
Comment comment = commentRepository.findById(commentId).orElseThrow();
comment.moveTo(postRepository.getReferenceById(newPostId));
// No save() call needed: dirty checking issues UPDATE comments SET post_id = ? WHERE id = ?
}
}
// getReferenceById(id): proxy, no query; a missing row shows up later as a foreign key
// violation at flush, or an EntityNotFoundException when the proxy is used.
// findById(id): runs the SELECT now; use it when you need to read the parent's data.Deleting Parents and Referential Integrity
public interface CommentRepository extends JpaRepository<Comment, Long> {
// One DELETE statement, no entity loading. clearAutomatically empties the stale persistence context.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("delete from Comment c where c.post.id = :postId")
int deleteAllForPost(@Param("postId") Long postId);
}
// When the database does the cascading, tell Hibernate on the collection side:
// @OneToMany(mappedBy = "post")
// @OnDelete(action = OnDeleteAction.CASCADE) // org.hibernate.annotations
// private List<Comment> comments = new ArrayList<>();
// Deleting the Post now issues one DELETE for the post; the database removes its comments.
// Pick the rule in the migration, where the foreign key is defined:
//
// -- Children must be removed first (restrict, the default):
// ALTER TABLE comments ADD CONSTRAINT fk_comments_post
// FOREIGN KEY (post_id) REFERENCES posts (id);
//
// -- Database removes the comments with their post:
// ALTER TABLE comments ADD CONSTRAINT fk_comments_post
// FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE;
//
// -- Optional reference: deleting a parent comment leaves its replies as top-level comments:
// ALTER TABLE comments ADD CONSTRAINT fk_comments_parent
// FOREIGN KEY (parent_comment_id) REFERENCES comments (id) ON DELETE SET NULL;Reading Parent Data into DTOs and Projections
public record CommentResponse(
Long id,
String body,
Long postId,
String authorName) {
// Call inside a transaction, and with the author fetched (see the entity graph below).
public static CommentResponse from(Comment comment) {
return new CommentResponse(
comment.getId(),
comment.getBody(),
comment.getPost().getId(), // a proxy can usually answer getId() without a query
comment.getAuthor().getName()); // initialises the author: free if it was fetched
}
}
@Service
@RequiredArgsConstructor
public class CommentQueryService {
private final CommentRepository commentRepository;
@Transactional(readOnly = true)
public List<CommentResponse> findByPostId(Long postId) {
// findByPostIdOrderByIdAsc has @EntityGraph(attributePaths = {"post", "author"}):
// one SELECT with joins, no N+1.
return commentRepository.findByPostIdOrderByIdAsc(postId).stream()
.map(CommentResponse::from)
.toList();
}
}
// Projection: select only the columns the screen needs. Aliases must match the getter names.
public interface CommentSummary {
Long getId();
String getBody();
Long getPostId();
String getPostTitle();
String getAuthorName();
}
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Query("""
select c.id as id, c.body as body,
p.id as postId, p.title as postTitle,
a.name as authorName
from Comment c
join c.post p
join c.author a
where p.id = :postId
order by c.id
""")
List<CommentSummary> findSummariesByPostId(@Param("postId") Long postId);
}Joining on a Column Other Than the Primary Key
@Entity
@Table(name = "customers")
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(name = "customer_code", nullable = false, unique = true, length = 20)
private String code; // business key, unique
// ...
}
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// orders.customer_code references customers.customer_code, not customers.id
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_code", referencedColumnName = "customer_code",
nullable = false,
foreignKey = @ForeignKey(name = "fk_orders_customer_code"))
private Customer customer;
}
// The referenced column needs a unique constraint in the database,
// and an index on orders.customer_code for joins:
// ALTER TABLE customers ADD CONSTRAINT uq_customers_code UNIQUE (customer_code);
// CREATE INDEX idx_orders_customer_code ON orders (customer_code);Proving There Is No N+1: Testing Query Counts
// application-test.yml (or @DataJpaTest properties):
// spring.jpa.properties.hibernate.generate_statistics: true
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use your real DB / Testcontainers
class CommentQueryCountTest {
@Autowired private TestEntityManager em;
@Autowired private CommentRepository commentRepository;
@Test
void findByPostId_loadsAuthorsWithoutNPlusOne() {
Post post = em.persist(new Post("Hello"));
for (int i = 0; i < 5; i++) {
User author = em.persist(new User("user" + i));
em.persist(new Comment("comment " + i, post, author));
}
em.flush();
em.clear(); // empty the first-level cache
Statistics stats = em.getEntityManager().getEntityManagerFactory()
.unwrap(SessionFactory.class).getStatistics();
stats.clear();
List<Comment> comments = commentRepository.findByPostIdOrderByIdAsc(post.getId());
comments.forEach(c -> c.getAuthor().getName()); // touch the lazy parents
// One SELECT with joins (entity graph). Without the graph this would be 1 + 5.
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
assertThat(comments).hasSize(5);
}
}Common @ManyToOne Mistakes
// ── 1. EAGER by default ───────────────────────────────────────────────
// ❌ @ManyToOne (JPA default is EAGER)
// ✅ @ManyToOne(fetch = FetchType.LAZY)
// ── 2. Entity returned from a controller ──────────────────────────────
// ❌ @GetMapping("/comments/{id}") Comment get(...) // lazy loads + recursion
// ✅ @GetMapping("/comments/{id}") CommentResponse get(...)
// ── 3. Cascade on the child side ──────────────────────────────────────
// ❌ @ManyToOne(cascade = CascadeType.ALL) // deleting a comment can delete the post
// ✅ @ManyToOne(fetch = FetchType.LAZY) // cascade only on the collection side
// ── 4. Unbounded child collection on the parent ───────────────────────
// ❌ private List<Comment> comments; // 100,000 comments in memory
// ✅ Page<Comment> findByPostId(Long postId, Pageable pageable);
// ── 5. JOIN FETCH on an optional relationship ─────────────────────────
// ❌ select c from Comment c join fetch c.parent // top-level comments vanish
// ✅ select c from Comment c left join fetch c.parent
// ── 6. SELECT just to set a foreign key ───────────────────────────────
// ❌ Post post = postRepository.findById(postId).orElseThrow();
// ✅ Post post = postRepository.getReferenceById(postId); // after existsById if you need a 404
// ── 7. Missing foreign key index on PostgreSQL ────────────────────────
// ✅ CREATE INDEX idx_comments_post_id ON comments (post_id);