Entities and Identifiers

Map a class to a table with @Entity, choose an id strategy that fits your database, and avoid the equals and hashCode traps.

Syntax@Entity @Table(name = "books") class Book { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) Long id; @Column(nullable = false, length = 200) String title; @Enumerated(EnumType.STRING) Status status; protected Book() {} }

An entity is a class whose instances are rows. It needs @Entity, a field marked @Id, and a no-argument constructor that Hibernate can call. Make that constructor protected so application code goes through a real constructor that enforces your rules.

Entities are mutable classes, not records. Hibernate creates proxies for lazy loading by subclassing, so do not make the class or its methods final. Records are the right shape for DTOs and projections, covered later.

Columns

Field names map to snake_case columns by default: createdAt becomes created_at. Use @Table(name = "...") and @Column when names differ or when you want the mapping to state constraints. @Column(nullable = false, length = 200) mainly shapes generated DDL. It is not input validation: the length is never checked by Hibernate, and the null check before insert is switched off when Bean Validation is on the classpath, leaving it to the database constraint. Validate the request DTO with Bean Validation and keep the constraint in the schema.

Map enums with @Enumerated(EnumType.STRING). The default, ORDINAL, stores the position, so inserting a new constant in the middle silently changes the meaning of every existing row.

Choosing an id strategy

StrategyHow it worksTrade-off
IDENTITYDatabase auto-increment columnSimple; Hibernate must insert immediately to learn the id, so JDBC insert batching is disabled
SEQUENCEDatabase sequence, ids fetched in blocksBest for PostgreSQL bulk inserts; allocationSize must match the sequence's increment
UUIDGenerated in the applicationSafe to expose and to create offline; 16 bytes per key and index

Plain @GeneratedValue (AUTO) lets Hibernate pick. For a numeric id on PostgreSQL or H2 it picks a sequence named after the entity (product_seq) with an allocation size of 50, so your migration must create that sequence with INCREMENT BY 50; on MySQL, which has no sequences, it falls back to a slow table generator. Stating the strategy explicitly makes the mapping say what the schema needs. Numeric ids in URLs reveal how many rows you have and invite guessing; many teams keep a Long primary key and add a separate public UUID or slug.

equals and hashCode

A generated id is null until the entity is persisted. If hashCode uses the id, an entity put into a HashSet before saving ends up in the wrong bucket afterwards. Either compare on a natural business key that never changes (an ISBN, an email), or compare on the id while treating unsaved instances as unequal and returning a constant hashCode. Never generate them from all fields: that triggers lazy loading and changes whenever a field does.

Example

Example · java
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import java.math.BigDecimal;

enum ProductStatus { DRAFT, ACTIVE, RETIRED }

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;

    @Column(nullable = false, unique = true, length = 40)
    private String sku;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.DRAFT;

    protected Product() { }

    public Product(String sku, String name, BigDecimal price) {
        if (price.signum() < 0) throw new IllegalArgumentException("price must not be negative");
        this.sku = sku;
        this.name = name;
        this.price = price;
    }

    public Long getId() { return id; }
    public String getSku() { return sku; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public ProductStatus getStatus() { return status; }

    public void activate() { this.status = ProductStatus.ACTIVE; }
}

When to use it

  • Map a products table with a SKU that must be unique and a status that must survive new enum values.
  • Expose orders by UUID in URLs so customers cannot count or guess other orders.
  • Store entities in a Set on a parent without them disappearing after the first save.

More examples

A UUID primary key

GenerationType.UUID is part of Jakarta Persistence, so the mapping is portable. On PostgreSQL the column should be of type uuid, not varchar, to keep the index at 16 bytes. Use this when ids appear in URLs or are created by clients that work offline.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.util.UUID;

@Entity
public class ApiKey {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String label;

    protected ApiKey() { }

    public ApiKey(String label) { this.label = label; }

    public UUID getId() { return id; }
    public String getLabel() { return label; }
}

equals and hashCode that survive persisting

Two unsaved customers are never equal, and two loaded customers are equal when their ids match. The constant hashCode keeps the instance in the same HashSet bucket before and after the id is assigned. Calling getId() on the other side, rather than reading other.id, matters when the other object is a lazy proxy whose fields are empty.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.Objects;

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

    private String email;

    protected Customer() { }

    public Customer(String email) { this.email = email; }

    public Long getId() { return id; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return id != null && Objects.equals(id, other.getId());
    }

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

A natural key alongside the surrogate id

The id stays a compact internal key for joins, while the ISBN is what the API exposes and what equals could be based on. Hibernate's @NaturalId marks the field immutable and adds a unique constraint to generated DDL. Keep the unique constraint in your Flyway migration as well, since that is what actually protects the data.

Example · java
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.hibernate.annotations.NaturalId;

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

    @NaturalId
    @Column(nullable = false, unique = true, length = 13)
    private String isbn;

    private String title;

    protected Book() { }

    public Book(String isbn, String title) {
        this.isbn = isbn;
        this.title = title;
    }

    public String getIsbn() { return isbn; }
}

Discussion

  • Be the first to comment on this lesson.