Intermediate Reading time: ~12 min

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-jpa starter 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:

  • @Entity annotation 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 @Id is on the field): reads fields directly via reflection
  • Property access (when @Id is 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