Relationships

Map many-to-one, one-to-many and many-to-many associations, choose the owning side, and decide what cascades.

Syntax@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") PurchaseOrder order; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) List<OrderLine> lines = new ArrayList<>();

Associations let an entity hold other entities instead of raw foreign keys. They are convenient, but every one is a decision about loading, ownership and deletion. Map fewer of them than you think you need.

@ManyToOne: the one you will use most

An order line belongs to one order; a book has one publisher. @ManyToOne maps the foreign key column on this table. Its default fetch type is EAGER, which means every query for lines also loads their orders, often with a query per row. Always write @ManyToOne(fetch = FetchType.LAZY) and load the parent explicitly when a use case needs it.

@OneToMany and the owning side

A bidirectional pair has two Java references but one foreign key. The @ManyToOne side owns it; the collection side says @OneToMany(mappedBy = "order") and is ignored when Hibernate writes. Adding a line to order.getLines() without setting line.setOrder(order) saves nothing. Give the parent addLine and removeLine methods that set both sides, and do not expose the mutable collection.

A @OneToMany without mappedBy or @JoinColumn makes Hibernate create a join table, which is rarely what anyone intended.

Cascades and orphans

cascade = CascadeType.ALL passes persist, merge and remove from parent to children. Use it only when the children are part of the parent, as order lines are part of an order. orphanRemoval = true deletes a child removed from the collection. Never cascade from a child to its parent, and never cascade to shared reference data: cascading remove from a book to its publisher tries to delete a publisher that other books still reference, which fails on the foreign key or, with more cascades configured, deletes far more than one book.

@ManyToMany

A join table with only two foreign keys maps cleanly with @ManyToMany; use a Set, since for a List Hibernate deletes every join row of that owner and inserts them again on any change. The day the link needs a column such as added_at or a role, replace it with a join entity holding two @ManyToOne fields. Starting with the join entity is often simpler.

Keep entities out of JSON

Serialising an entity with a bidirectional association recurses until the stack overflows, and lazy fields either trigger queries during serialisation or fail. Return DTOs from controllers. An association that is only ever followed one way does not need the other side mapped at all: a @ManyToOne on the line is enough until you actually need order.getLines().

Example

Example · java
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Entity
class OrderLine {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id")
    private PurchaseOrder order;

    private String sku;
    private int quantity;
    private BigDecimal unitPrice;

    protected OrderLine() { }

    OrderLine(String sku, int quantity, BigDecimal unitPrice) {
        this.sku = sku;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    void setOrder(PurchaseOrder order) { this.order = order; }

    BigDecimal total() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); }
}

@Entity
public class PurchaseOrder {
    @Id
    @GeneratedValue
    private Long id;

    private String reference;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();

    protected PurchaseOrder() { }

    public PurchaseOrder(String reference) { this.reference = reference; }

    public void addLine(String sku, int quantity, BigDecimal unitPrice) {
        OrderLine line = new OrderLine(sku, quantity, unitPrice);
        line.setOrder(this);
        lines.add(line);
    }

    public void removeLine(OrderLine line) {
        lines.remove(line);
        line.setOrder(null);
    }

    public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); }

    public BigDecimal total() {
        return lines.stream().map(OrderLine::total).reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

When to use it

  • Model an order with lines that are saved and deleted together with it.
  • Track project members with a role and join date instead of a bare link table.
  • Attach a comment to a post by id without loading the post.

More examples

A join entity instead of @ManyToMany

Project membership started as a plain many-to-many and soon needed a role and a join date. As its own entity it has a repository, can be queried directly (all projects where this member is an owner) and can be removed without loading either side. The unique constraint stops a member being added twice.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;

enum ProjectRole { OWNER, EDITOR, VIEWER }

@Entity
class Project {
    @Id
    @GeneratedValue
    private Long id;
    private String name;

    protected Project() { }
}

@Entity
class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String email;

    protected Member() { }
}

@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"project_id", "member_id"}))
public class Membership {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Project project;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Member member;

    @Enumerated(EnumType.STRING)
    private ProjectRole role;

    private Instant joinedAt;

    protected Membership() { }

    public Membership(Project project, Member member, ProjectRole role, Instant joinedAt) {
        this.project = project;
        this.member = member;
        this.role = role;
        this.joinedAt = joinedAt;
    }
}

Setting a foreign key without loading the parent

Comment only maps the many-to-one side; Post has no comments collection because nothing needs to walk from a post to all of its comments in memory. getReferenceById gives a proxy carrying just the id, enough to write the foreign key. A missing post then fails as a foreign key violation when the insert is flushed at commit, surfacing as a DataIntegrityViolationException. If the API must answer 404 for an unknown post, check posts.existsById(postId) first rather than treating every integrity violation as not found.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Post {
    @Id
    private Long id;
    private String title;

    protected Post() { }
}

@Entity
class Comment {
    @Id
    @GeneratedValue
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Post post;
    private String body;

    protected Comment() { }

    Comment(Post post, String body) {
        this.post = post;
        this.body = body;
    }

    Long getId() { return id; }
}

interface PostRepository extends JpaRepository<Post, Long> { }

interface CommentRepository extends JpaRepository<Comment, Long> { }

@Service
public class CommentService {
    private final PostRepository posts;
    private final CommentRepository comments;

    public CommentService(PostRepository posts, CommentRepository comments) {
        this.posts = posts;
        this.comments = comments;
    }

    @Transactional
    public Long add(Long postId, String body) {
        Post post = posts.getReferenceById(postId); // proxy, no SELECT
        return comments.save(new Comment(post, body)).getId();
    }
}

The schema these mappings expect

The plain @GeneratedValue ids use one sequence per entity with an allocation size of 50, so the sequences are created with a matching increment; with ddl-auto=validate a missing sequence fails startup. PostgreSQL does not index foreign keys automatically, and without the index every lazy load of an order's lines scans the whole order_line table. The on delete cascade mirrors the JPA cascade so a delete issued outside Hibernate cannot leave orphan lines behind.

Example · sql
create sequence purchase_order_seq increment by 50;
create sequence order_line_seq increment by 50;

create table purchase_order (
    id         bigint primary key,
    reference  varchar(40) not null unique
);

create table order_line (
    id          bigint primary key,
    order_id    bigint not null references purchase_order (id) on delete cascade,
    sku         varchar(40) not null,
    quantity    int not null check (quantity > 0),
    unit_price  numeric(10, 2) not null
);

create index order_line_order_id_idx on order_line (order_id);

Discussion

  • Be the first to comment on this lesson.