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.
@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
| Strategy | How it works | Trade-off |
|---|---|---|
IDENTITY | Database auto-increment column | Simple; Hibernate must insert immediately to learn the id, so JDBC insert batching is disabled |
SEQUENCE | Database sequence, ids fetched in blocks | Best for PostgreSQL bulk inserts; allocationSize must match the sequence's increment |
UUID | Generated in the application | Safe 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
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 }
(name = "products")
public class Product {
(strategy = GenerationType.SEQUENCE, generator = "product_seq")
(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
private Long id;
(nullable = false, unique = true, length = 40)
private String sku;
(nullable = false, length = 200)
private String name;
(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
(EnumType.STRING)
(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.
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.
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.
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