JPA Inheritance Mapping and Lifecycle Callbacks

JPA Advanced 5 min read Published
In this lesson

Your Java model uses inheritance (a Payment can be a UpiPayment or CardPayment), but relational tables don’t have inheritance. JPA offers several strategies to bridge the gap, plus callbacks to run code automatically when entities are saved or updated.

@MappedSuperclass: share fields, not a table#

Most entities need id, createdAt and updatedAt. Put them in a base class:

package com.vitalstack;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@MappedSuperclass                          // not an entity: its fields are copied into each subclass table
public abstract class BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    protected Long id;
    protected LocalDateTime createdAt;
    protected LocalDateTime updatedAt;

    @PrePersist                             // runs just before INSERT
    void onCreate() { createdAt = updatedAt = LocalDateTime.now(); }

    @PreUpdate                              // runs just before UPDATE
    void onUpdate() { updatedAt = LocalDateTime.now(); }

    public Long getId() { return id; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

You can’t query a @MappedSuperclass (select b from BaseEntity b fails). It’s just a code-sharing tool.

Entity inheritance strategies#

When you do want polymorphic queries (select p from Payment p returning UPI and card payments), make the parent an entity and choose a strategy:

1. SINGLE_TABLE (default): one table for the whole hierarchy#

package com.vitalstack;

import jakarta.persistence.*;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "method")                 // which subclass is this row?
public abstract class Payment extends BaseEntity {
    protected int amount;
    public int getAmount() { return amount; }
    public abstract String describe();
}
package com.vitalstack;

import jakarta.persistence.*;

@Entity
@DiscriminatorValue("UPI")
public class UpiPayment extends Payment {
    private String vpa;
    protected UpiPayment() { }
    public UpiPayment(int amount, String vpa) { this.amount = amount; this.vpa = vpa; }
    public String describe() { return "UPI ₹" + amount + " from " + vpa; }
}
package com.vitalstack;

import jakarta.persistence.*;

@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
    private String last4;
    protected CardPayment() { }
    public CardPayment(int amount, String last4) { this.amount = amount; this.last4 = last4; }
    public String describe() { return "Card ₹" + amount + " ****" + last4; }
}
package com.vitalstack;

import jakarta.persistence.*;

public class Main {
    public static void main(String[] args) throws Exception {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("school");
        EntityManager em = emf.createEntityManager();
        em.getTransaction().begin();
        em.persist(new UpiPayment(499, "asha@okaxis"));
        CardPayment card = new CardPayment(1299, "4242");
        em.persist(card);
        em.getTransaction().commit();

        Thread.sleep(20);
        em.getTransaction().begin();
        card.amount = 999;                                   // triggers @PreUpdate at flush
        em.getTransaction().commit();
        System.out.println("updated later than created? " + card.getUpdatedAt().isAfter(card.getCreatedAt()));
        em.clear();

        em.createQuery("select p from Payment p order by p.id", Payment.class)    // polymorphic query
          .getResultList()
          .forEach(p -> System.out.println(p.describe()));
        em.close();
        emf.close();
    }
}

Generated schema and output:

create table Payment (amount integer not null, createdAt timestamp(6), id bigint generated by default as identity,
                      updatedAt timestamp(6), method varchar(31) not null, last4 varchar(255), vpa varchar(255), primary key (id))
updated later than created? true
UPI ₹499 from asha@okaxis
Card ₹999 ****4242

One table, a method discriminator column, and nullable columns for subclass fields. Fastest for queries (no joins), but subclass columns can’t be NOT NULL.

2. JOINED: one table per class, linked by primary key#

@Inheritance(strategy = InheritanceType.JOINED)

payment(id, amount…), upi_payment(id, vpa), card_payment(id, last4). Clean, normalised, subclass columns can be NOT NULL, but every query needs joins.

3. TABLE_PER_CLASS: one complete table per concrete class#

Each subclass table repeats all parent columns. Polymorphic queries need UNIONs: slow. Rarely used.

StrategyTablesPolymorphic queriesConstraintsUse when
SINGLE_TABLE1fastestsubclass fields nullablefew subclass fields (most cases)
JOINED1 + one per subclassjoinsfullmany subclass-specific fields, data integrity matters
TABLE_PER_CLASSone per concrete classunionsfullalmost never
@MappedSuperclassone per subclassnot possiblefulljust sharing fields

Before reaching for entity inheritance, ask whether a single entity with an enum type field (or an @Embeddable) would do. Inheritance in the database is harder to change later than in Java.

Lifecycle callbacks#

AnnotationRuns
@PrePersist / @PostPersistbefore / after INSERT
@PreUpdate / @PostUpdatebefore / after UPDATE
@PreRemove / @PostRemovebefore / after DELETE
@PostLoadafter loading from the database

Typical uses: timestamps, normalising data (email = email.toLowerCase()), computing derived fields.

Entity listeners: reusable callbacks#

public class AuditListener {
    @PrePersist
    public void beforeSave(Object entity) {
        System.out.println("Saving " + entity.getClass().getSimpleName());
    }
}

@Entity
@EntityListeners(AuditListener.class)
public class Course { ... }

Spring Data JPA offers ready-made auditing (@CreatedDate, @LastModifiedBy) built on this mechanism.

Callback rules#

  • Don’t call the EntityManager or run queries inside callbacks (undefined behaviour).
  • Keep them fast and side-effect free (no emails, no HTTP calls). Use events or a service layer for that.
  • @PreUpdate runs only if Hibernate detects a change.

Exercise#

  1. Create a BaseEntity with audit fields and make two entities extend it. Check both tables have the columns.
  2. Switch the Payment hierarchy to JOINED and compare the generated tables and the SQL of the polymorphic query.
  3. Add a @PrePersist that lowercases and trims emails on a Student entity.
  4. Add a NetBankingPayment subclass and query only card payments with select p from CardPayment p.
Hint for 2

You’ll see three tables and a query that left-joins the subclass tables, with a case expression to work out each row’s type.

Interview corner#

  • Inheritance strategies in JPA? SINGLE_TABLE, JOINED, TABLE_PER_CLASS (plus @MappedSuperclass for non-entity base classes).
  • @MappedSuperclass vs @Entity parent? MappedSuperclass only shares mappings (no table, not queryable); an entity parent allows polymorphic queries and associations.
  • What is a discriminator column? A column that stores which subclass a row belongs to (SINGLE_TABLE, optionally JOINED).
  • What are lifecycle callbacks? Methods annotated @PrePersist, @PostLoad etc. that JPA calls around entity operations.

Summary#

  • @MappedSuperclass shares common fields like id and audit timestamps.
  • SINGLE_TABLE is fast and simple; JOINED is normalised; TABLE_PER_CLASS is rarely worth it.
  • Callbacks and entity listeners run code automatically around persistence events.

Next: transactions and locking for safe concurrent updates.

Quick quiz

3 questions. Check what stuck and earn 2 XP for each right answer.

1Which strategy stores a hierarchy in one table?
2Can you query a @MappedSuperclass?
3Which callback runs before INSERT?

Progress is saved in this browser. Create a free account to keep it on every device.