Entity Mapping
@Entity, relationship mapping, inheritance, @Embeddable, @Converter, equals/hashCode
Entity Mapping
JPA entity mapping defines the rules for mapping Java objects to relational database tables: annotations, relationships, inheritance strategies, and value objects.
1. Definition
- What is it? â Entity mapping is the process of annotating Java classes with JPA annotations so that Hibernate (or another JPA provider) automatically synchronizes them with relational database tables. This is the foundation of ORM (Object-Relational Mapping).
- Why does it exist? â Instead of writing manual SQL and processing ResultSets, developers work with Java objects while JPA automatically generates SQL. This reduces boilerplate code and enables database portability.
- Where does it fit? â Entity mapping is the lowest layer of the Spring Data JPA stack. The
spring-boot-starter-data-jpastarter auto-configures Hibernate, the DataSource, and the EntityManagerFactory.
Java Object ââ JPA Annotations ââ Hibernate ââ SQL DDL/DML ââ Database
@Entity @Column ORM CREATE TABLE users
2. Core Concepts
The @Entity annotation and requirements
A JPA entity must meet the following requirements:
@Entityannotation on the class- Primary key marked with
@Id - No-arg constructor (can be
protected) - The class must not be
final(due to CGLIB proxies) - Persistent fields must not be
final
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(columnNames = {"email"})
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_sequence", allocationSize = 50)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private UserStatus status;
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;
protected User() {} // for JPA
public User(String name, String email) {
this.name = name;
this.email = email;
}
}
@Column customization
| Attribute | Default | Description |
|---|---|---|
name |
field name | Column name in DB |
nullable |
true |
NULL allowed |
unique |
false |
Unique constraint |
length |
255 | VARCHAR length |
precision/scale |
0/0 | For BigDecimal |
columnDefinition |
â | Raw DDL (e.g., "TEXT") |
insertable/updatable |
true |
Included in INSERT/UPDATE |
ID generation strategies
| Strategy | Mechanism | Batch insert | Note |
|---|---|---|---|
| IDENTITY | DB auto-increment | â | Simple but not batchable |
| SEQUENCE | DB sequence | â (allocationSize) | Best practice for relational DBs |
| TABLE | Sequence table | â | Avoid due to slow locks |
| UUID | Application-generated | â | No DB roundtrip |
// SEQUENCE â recommended for production
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
private Long id;
// UUID â for distributed systems
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
Relationship types
| Annotation | Cardinality | Default fetch |
|---|---|---|
@OneToOne |
1:1 | EAGER |
@OneToMany |
1:N | LAZY |
@ManyToOne |
N:1 | EAGER |
@ManyToMany |
N:M | LAZY |
â ïž Golden rule: Always set @ManyToOne and @OneToOne fetch to LAZY explicitly!
3. Practical Usage
Bidirectional @OneToMany / @ManyToOne
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// â
Convenience methods â MANDATORY for bidirectional relationships
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
}
@Entity
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private String productName;
private int quantity;
}
@ManyToMany
@Entity
public class Student {
@Id @GeneratedValue
private Long id;
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
@Id @GeneratedValue
private Long id;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}
â ïž Always use Set instead of List for @ManyToMany â List can be problematic in Hibernate (extra DELETE + INSERT on the join table).
@OneToOne with shared primary key
@Entity
public class UserProfile {
@Id
private Long id; // not generated, matches User's id
@OneToOne(fetch = FetchType.LAZY)
@MapsId // uses User's PK as PK
@JoinColumn(name = "id")
private User user;
private String bio;
private String avatarUrl;
}
4. Code Examples
Inheritance mapping â SINGLE_TABLE
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Payment {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private BigDecimal amount;
private LocalDateTime createdAt;
}
@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
@Column(length = 4)
private String lastFourDigits;
private String cardBrand;
}
@Entity
@DiscriminatorValue("BANK_TRANSFER")
public class BankTransfer extends Payment {
private String iban;
private String bankName;
}
Inheritance mapping â JOINED
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Vehicle {
@Id @GeneratedValue
private Long id;
private String manufacturer;
private int year;
}
@Entity
public class Car extends Vehicle {
private int numberOfDoors;
private String fuelType;
}
@Entity
public class Truck extends Vehicle {
private double payloadCapacity;
private int numberOfAxles;
}
| Strategy | Polymorphic query | NULL columns | Schema |
|---|---|---|---|
| SINGLE_TABLE | â Fastest | â Many | 1 table |
| JOINED | â ïž JOIN required | â None | N+1 tables |
| TABLE_PER_CLASS | â UNION ALL | â None | N tables |
@Embeddable â Value Object mapping
@Embeddable
public class Address {
private String street;
private String city;
@Column(name = "zip_code")
private String zipCode;
private String country;
protected Address() {}
public Address(String street, String city, String zipCode, String country) {
this.street = street;
this.city = city;
this.zipCode = zipCode;
this.country = country;
}
}
@Entity
public class Customer {
@Id @GeneratedValue
private Long id;
private String name;
@Embedded
private Address shippingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "billing_street")),
@AttributeOverride(name = "city", column = @Column(name = "billing_city")),
@AttributeOverride(name = "zipCode", column = @Column(name = "billing_zip")),
@AttributeOverride(name = "country", column = @Column(name = "billing_country"))
})
private Address billingAddress;
}
@Converter â custom type mapping
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Long> {
@Override
public Long convertToDatabaseColumn(Money money) {
return money == null ? null : money.getAmountInCents();
}
@Override
public Money convertToEntityAttribute(Long cents) {
return cents == null ? null : Money.ofCents(cents);
}
}
// Usage â converter auto-applies to every Money-typed field
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private Money price; // autoApply=true â automatic conversion
}
5. Trade-offs
| Aspect | Advantage | Disadvantage |
|---|---|---|
| ORM mapping | Automatic SQL generation, less boilerplate | Generated SQL is not always optimal |
| Relationships | Natural Java object navigation | Cascade, orphanRemoval can get complex |
| Inheritance | Polymorphism at DB level | Every strategy has trade-offs |
| @Embeddable | DDD value object â clean domain model | @AttributeOverride verbose with multiple embeds |
| @Converter | Transparent mapping of custom types | Conversion not visible during debugging |
| DB-agnostic | Database portable via dialect switch | DB-specific features (JSON, array) are lost |
When @Embeddable vs separate @Entity?
- @Embeddable: No own identity (value object) â Address, Money, DateRange
- Separate @Entity: Own lifecycle and ID needed â Category, Tag
6. Common Mistakes
â Missing mappedBy on bidirectional
// BAD â both sides are owners â 2 foreign key tables
@Entity
public class Order {
@OneToMany(cascade = CascadeType.ALL) // no mappedBy!
private List<OrderItem> items;
}
// GOOD â inverse side declares mappedBy
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
â Missing bidirectional synchronization
// BAD â only setting one side
order.getItems().add(item);
// item.order stays NULL â foreign key will be NULL in DB!
// GOOD â helper method updates both sides
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
â equals/hashCode with generated ID
// BAD â new entity id=null, problematic in Set/Map
@Override
public boolean equals(Object o) {
return id != null && id.equals(((User) o).id);
}
// GOOD â use business key (natural key)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return email != null && email.equals(other.email);
}
@Override
public int hashCode() {
return email != null ? email.hashCode() : 0;
}
â Leaving EAGER on @ManyToOne
// BAD â @ManyToOne defaults to EAGER
@ManyToOne
private Customer customer; // Always loaded!
// GOOD â explicit LAZY
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
â List instead of Set for @ManyToMany
Hibernate with List-based @ManyToMany deletes the entire join table and re-inserts on element modification. With Set, it's more efficient: individual DELETE + INSERT.
â CascadeType.ALL in wrong direction
// BAD â deleting Customer deletes ALL Orders too!
@ManyToOne(cascade = CascadeType.ALL)
private Customer customer;
// GOOD â cascade only in "parent â child" direction
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
7. Deep Dive
Access strategy: field vs property
JPA can look for annotations in two places:
- Field access (default when
@Idis on the field): reads fields directly via reflection - Property access (when
@Idis on the getter): reads via getter/setter
@Entity
@Access(AccessType.FIELD) // Explicit declaration
public class User {
@Id
private Long id; // field access â Hibernate reads the field directly
}
Best practice: field access â no need for getters/setters on every field, proxy behavior is better.
@NaturalId â secondary lookup key
@Entity
public class Country {
@Id @GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, length = 2)
private String code; // "HU", "US"
}
// Usage:
Country hu = session.byNaturalId(Country.class)
.using("code", "HU")
.load();
@NaturalId is Hibernate-specific. Both L1 and L2 cache support it, making it more efficient than a plain findByCode().
@Formula â computed field
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@Formula("(SELECT SUM(i.price * i.quantity) FROM order_items i WHERE i.order_id = id)")
private BigDecimal totalAmount;
}
@Formula runs on every SELECT (as a subquery), it's not persisted. Alternative: calculate in a @PostLoad callback.
Hibernate-specific annotations
| Annotation | Description |
|---|---|
@DynamicUpdate |
Only UPDATEs changed columns |
@DynamicInsert |
Only INSERTs non-null columns |
@BatchSize(size=N) |
Batched loading of lazy collections |
@Where(clause="...") |
Soft delete filter |
@Filter |
Dynamic, parameterized filter |
@SQLRestriction |
Hibernate 6.3+ (replaces old @Where) |
8. Interview Questions
Q: What is the difference between SINGLE_TABLE, JOINED, and TABLE_PER_CLASS inheritance strategies? A: SINGLE_TABLE puts everything in one table with a discriminator column â fastest but many NULLs. JOINED is normalized: each class has its own table, JOINs required for SELECT. TABLE_PER_CLASS has a separate table per class, polymorphic queries require UNION ALL.
Q: Why are bidirectional helper methods important? A: JPA only manages the owner side's foreign key. If you only add an element on the inverse side (mappedBy), the foreign key will be NULL in the DB. The helper method synchronizes both sides.
Q: How would you implement equals/hashCode on a JPA entity?
A: Based on a business key (e.g., email, ISBN), not the generated ID. The ID is null before persist, which causes issues in Sets and Maps. Hibernate's @NaturalId provides a specific solution.
Q: What is @Embeddable and when do you use it instead of @Entity? A: @Embeddable is for value objects (Address, Money) â no own ID, embedded in the parent table. If the object needs its own lifecycle and ID, use a separate @Entity.
Q: Why is IDENTITY GenerationType not batchable? A: Because the database auto-increment only returns the ID after INSERT. Hibernate must wait for the ID after each INSERT, so it cannot batch them together.
Q: What is @Converter and when should you use it?
A: The AttributeConverter interface maps custom Java types (Money, enum, JSON) to DB column types. autoApply=true automatically applies it to every field of the matching type.
9. Glossary
| Term | Meaning |
|---|---|
| Entity | JPA-managed Java class corresponding to a database row |
| @Table | Entity-to-table name mapping |
| @Column | Column-level customization (name, nullable, unique, length) |
| @Id | Primary key marker |
| @GeneratedValue | ID generation strategy |
| @OneToMany | One-to-many relationship (default LAZY) |
| @ManyToOne | Many-to-one relationship (default EAGER!) |
| @ManyToMany | Many-to-many, requires join table |
| mappedBy | Inverse side marker in bidirectional relationship |
| @Embeddable | Value object without its own identity |
| @Converter | Custom type conversion between DB column and Java type |
| @Inheritance | Inheritance strategy (SINGLE_TABLE, JOINED, TABLE_PER_CLASS) |
| @DiscriminatorColumn | SINGLE_TABLE type discriminator column |
| @NaturalId | Hibernate-specific business key annotation |
10. Cheatsheet
ENTITY REQUIREMENTS:
@Entity + @Id + no-arg constructor
Not final class, not final persistent fields
ID GENERATION:
IDENTITY auto-increment â not batchable
SEQUENCE DB sequence â
allocationSize=50
UUID app-generated â
no DB roundtrip
RELATIONSHIPS:
@OneToOne 1:1 default EAGER â set to LAZY
@ManyToOne N:1 default EAGER â set to LAZY
@OneToMany 1:N default LAZY â
@ManyToMany N:M default LAZY â
â use Set
BIDIRECTIONAL RULES:
owner side â @JoinColumn (FK is here)
inverse side â mappedBy = "ownerField"
helper method â synchronize both sides
INHERITANCE:
SINGLE_TABLE 1 table + discriminator (fastest)
JOINED N+1 tables (cleanest schema)
TABLE_PER_CLASS N tables (polymorphic query slow)
@EMBEDDABLE:
Value object, no ID
Embedded in parent's table
@AttributeOverride for multiple embeds
EQUALS/HASHCODE:
â
Business key (email, code, ISBN)
â Generated @Id (null before persist)
đź Games
10 questions