By the end of this session, you will be able to:
- Explain the three JPA inheritance mapping strategies (Single Table, Table per Class, Joined)
- Implement the JOINED strategy using
@Inheritanceand@PrimaryKeyJoinColumn - Create embeddable value objects with
@Embeddableand@Embedded - Use
@AttributeOverrideswhen embedding the same class multiple times - Build a full API with inheritance-based entities and embedded components
In Java, inheritance is natural:
public class Mammal extends Animal { ... }But relational databases have no concept of "extends". A table can't inherit from another table. So how do we store a class hierarchy in a database?
JPA provides three strategies to solve this:
All classes in the hierarchy are stored in one table. A discriminator column tells JPA which type each row is.
| Pros | Cons |
|---|---|
| Fast queries — no joins | Lots of NULL columns |
| Simple schema | Not normalized |
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "animal_type")
public class Animal { ... }Each concrete class gets its own complete table with all fields (inherited + specific). No shared parent table exists.
| Pros | Cons |
|---|---|
| Simple, flat tables | Duplicated columns across tables |
| No joins needed | Polymorphic queries are slow (UNION) |
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public class Animal { ... }Each class gets its own table. Subclass tables only store their unique fields and reference the parent table via a foreign key.
| Pros | Cons |
|---|---|
| Clean, normalized — no redundancy | Requires joins to read data |
| Easy to add new subclasses | Slightly slower for deep hierarchies |
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class Animal { ... }| Strategy | Best for |
|---|---|
| Single Table | Few subclasses, few unique fields, read-heavy |
| Table per Class | Subclasses are very different, rarely queried together |
| Joined | Normalized designs, many subclasses, data integrity |
Sometimes you have a value object (like an address, coordinates, or contact info) that belongs inside another entity, not in its own table.
JPA handles this with @Embeddable and @Embedded:
@Embeddable
public class GpsLocation {
private Double latitude;
private Double longitude;
}@Entity
public class Zoo {
@Id
private Long id;
private String name;
@Embedded
private GpsLocation location; // latitude and longitude stored in the zoo table
}If you embed the same class twice, use @AttributeOverrides to rename the columns:
@Embedded
private GpsLocation mainEntrance;
@AttributeOverrides({
@AttributeOverride(name = "latitude", column = @Column(name = "exit_lat")),
@AttributeOverride(name = "longitude", column = @Column(name = "exit_lng"))
})
@Embedded
private GpsLocation exitGate;We'll build an API for a zoo that tracks different types of animals using JPA inheritance (JOINED strategy) and embeds GPS coordinates into the enclosure entity.
CREATE DATABASE zoo_management;
USE zoo_management;
CREATE TABLE animal (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
species VARCHAR(100),
age INT,
weight DOUBLE,
PRIMARY KEY (id)
);
CREATE TABLE mammal (
id BIGINT NOT NULL,
fur_color VARCHAR(50),
gestation_days INT,
PRIMARY KEY (id),
FOREIGN KEY (id) REFERENCES animal(id)
);
CREATE TABLE bird (
id BIGINT NOT NULL,
wingspan_cm DOUBLE,
can_fly BOOLEAN,
PRIMARY KEY (id),
FOREIGN KEY (id) REFERENCES animal(id)
);
CREATE TABLE reptile (
id BIGINT NOT NULL,
is_venomous BOOLEAN,
scale_type VARCHAR(50),
PRIMARY KEY (id),
FOREIGN KEY (id) REFERENCES animal(id)
);
CREATE TABLE enclosure (
id BIGINT NOT NULL AUTO_INCREMENT,
enclosure_name VARCHAR(100) NOT NULL,
habitat_type VARCHAR(50),
capacity INT,
latitude DOUBLE,
longitude DOUBLE,
PRIMARY KEY (id)
);Notice how mammal, bird, and reptile only store their unique columns — they reference animal(id) for shared fields. The enclosure table stores GPS coordinates directly (no separate table).
import jakarta.persistence.*;
@Entity
@Table(name = "animal")
@Inheritance(strategy = InheritanceType.JOINED)
public class Animal {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String species;
private Integer age;
private Double weight;
public Animal() {}
public Animal(String name, String species, Integer age, Double weight) {
this.name = name;
this.species = species;
this.age = age;
this.weight = weight;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSpecies() { return species; }
public void setSpecies(String species) { this.species = species; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public Double getWeight() { return weight; }
public void setWeight(Double weight) { this.weight = weight; }
}@Inheritance(strategy = InheritanceType.JOINED)tells JPA to use the JOINED strategy.- This class maps to the
animaltable with shared fields.
@Entity
@Table(name = "mammal")
@PrimaryKeyJoinColumn(name = "id")
public class Mammal extends Animal {
@Column(name = "fur_color")
private String furColor;
@Column(name = "gestation_days")
private Integer gestationDays;
public Mammal() {}
public Mammal(String name, String species, Integer age, Double weight,
String furColor, Integer gestationDays) {
super(name, species, age, weight);
this.furColor = furColor;
this.gestationDays = gestationDays;
}
// Getters and setters
public String getFurColor() { return furColor; }
public void setFurColor(String furColor) { this.furColor = furColor; }
public Integer getGestationDays() { return gestationDays; }
public void setGestationDays(Integer gestationDays) { this.gestationDays = gestationDays; }
}@Entity
@Table(name = "bird")
@PrimaryKeyJoinColumn(name = "id")
public class Bird extends Animal {
@Column(name = "wingspan_cm")
private Double wingspanCm;
@Column(name = "can_fly")
private Boolean canFly;
public Bird() {}
public Bird(String name, String species, Integer age, Double weight,
Double wingspanCm, Boolean canFly) {
super(name, species, age, weight);
this.wingspanCm = wingspanCm;
this.canFly = canFly;
}
// Getters and setters
public Double getWingspanCm() { return wingspanCm; }
public void setWingspanCm(Double wingspanCm) { this.wingspanCm = wingspanCm; }
public Boolean getCanFly() { return canFly; }
public void setCanFly(Boolean canFly) { this.canFly = canFly; }
}@Entity
@Table(name = "reptile")
@PrimaryKeyJoinColumn(name = "id")
public class Reptile extends Animal {
@Column(name = "is_venomous")
private Boolean isVenomous;
@Column(name = "scale_type")
private String scaleType;
public Reptile() {}
public Reptile(String name, String species, Integer age, Double weight,
Boolean isVenomous, String scaleType) {
super(name, species, age, weight);
this.isVenomous = isVenomous;
this.scaleType = scaleType;
}
// Getters and setters
public Boolean getIsVenomous() { return isVenomous; }
public void setIsVenomous(Boolean isVenomous) { this.isVenomous = isVenomous; }
public String getScaleType() { return scaleType; }
public void setScaleType(String scaleType) { this.scaleType = scaleType; }
}@PrimaryKeyJoinColumn(name = "id")tells JPA that the child'sidis also a foreign key to the parent'sid.- Each child calls
super(...)to set shared fields.
import jakarta.persistence.Embeddable;
@Embeddable
public class GpsLocation {
private Double latitude;
private Double longitude;
public GpsLocation() {}
public GpsLocation(Double latitude, Double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
// Getters and setters
public Double getLatitude() { return latitude; }
public void setLatitude(Double latitude) { this.latitude = latitude; }
public Double getLongitude() { return longitude; }
public void setLongitude(Double longitude) { this.longitude = longitude; }
}@Embeddablemarks this as a value object — it has no@Id, no table of its own.
import jakarta.persistence.*;
@Entity
@Table(name = "enclosure")
public class Enclosure {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "enclosure_name")
private String enclosureName;
@Column(name = "habitat_type")
private String habitatType;
private Integer capacity;
@Embedded
private GpsLocation location;
public Enclosure() {}
public Enclosure(String enclosureName, String habitatType, Integer capacity, GpsLocation location) {
this.enclosureName = enclosureName;
this.habitatType = habitatType;
this.capacity = capacity;
this.location = location;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEnclosureName() { return enclosureName; }
public void setEnclosureName(String enclosureName) { this.enclosureName = enclosureName; }
public String getHabitatType() { return habitatType; }
public void setHabitatType(String habitatType) { this.habitatType = habitatType; }
public Integer getCapacity() { return capacity; }
public void setCapacity(Integer capacity) { this.capacity = capacity; }
public GpsLocation getLocation() { return location; }
public void setLocation(GpsLocation location) { this.location = location; }
}@Embeddedtells JPA to map theGpsLocationfields (latitude,longitude) directly into theenclosuretable — no join, no extra table.
@Repository
public interface AnimalRepository extends JpaRepository<Animal, Long> {}
@Repository
public interface MammalRepository extends JpaRepository<Mammal, Long> {}
@Repository
public interface BirdRepository extends JpaRepository<Bird, Long> {}
@Repository
public interface ReptileRepository extends JpaRepository<Reptile, Long> {}
@Repository
public interface EnclosureRepository extends JpaRepository<Enclosure, Long> {}You can create a repository for the parent and for each child. Querying AnimalRepository.findAll() returns all animals (mammals, birds, reptiles) — JPA handles the joins automatically.
@Service
public class MammalService {
@Autowired
private MammalRepository mammalRepository;
public List<Mammal> getAllMammals() { return mammalRepository.findAll(); }
public Optional<Mammal> getMammalById(Long id) { return mammalRepository.findById(id); }
public Mammal createMammal(Mammal mammal) { return mammalRepository.save(mammal); }
public void deleteMammal(Long id) { mammalRepository.deleteById(id); }
}@RestController
@RequestMapping("/mammals")
public class MammalController {
@Autowired
private MammalService mammalService;
@GetMapping
public List<Mammal> getAll() { return mammalService.getAllMammals(); }
@GetMapping("/{id}")
public Optional<Mammal> getById(@PathVariable Long id) { return mammalService.getMammalById(id); }
@PostMapping
public Mammal create(@RequestBody Mammal mammal) { return mammalService.createMammal(mammal); }
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { mammalService.deleteMammal(id); }
}Repeat the same pattern for Bird, Reptile, and Enclosure.
| Method | URL | Body (JSON) | What it does |
|---|---|---|---|
| POST | /mammals |
{"name":"Leo","species":"Lion","age":8,"weight":190.5,"furColor":"Golden","gestationDays":110} |
Creates a mammal |
| POST | /birds |
{"name":"Kiwi","species":"Kiwi","age":3,"weight":2.5,"wingspanCm":0,"canFly":false} |
Creates a bird |
| POST | /reptiles |
{"name":"Slinky","species":"King Cobra","age":5,"weight":6.0,"isVenomous":true,"scaleType":"Smooth"} |
Creates a reptile |
| GET | /mammals |
— | All mammals (JPA joins animal + mammal tables) |
| POST | /enclosures |
{"enclosureName":"Savanna Zone","habitatType":"Grassland","capacity":12,"location":{"latitude":40.7128,"longitude":-74.0060}} |
Creates enclosure with embedded GPS |
Check the console — you'll see JPA generating INSERT INTO animal followed by INSERT INTO mammal for a single POST /mammals request. That's the JOINED strategy in action.
Build an API for a vehicle dealership using JPA inheritance and embedded components.
A dealership sells cars, trucks, and motorcycles. All vehicles share common fields, but each type has unique attributes. Each vehicle also has a warranty (start date and end date) that should be embedded, not stored in a separate table.
-
Database:
vehicle_dealership -
Inheritance hierarchy (JOINED strategy):
- Parent:
vehicle— columns:id(BIGINT, PK, auto-increment),make(VARCHAR),model(VARCHAR),year(INT),price(DECIMAL),color(VARCHAR) - Child:
car— extra columns:num_doors(INT),trunk_capacity_liters(INT) - Child:
truck— extra columns:payload_capacity_kg(DOUBLE),num_axles(INT) - Child:
motorcycle— extra columns:engine_cc(INT),has_sidecar(BOOLEAN)
- Parent:
-
Embeddable class:
Warrantywith fields:warrantyStart(DATE),warrantyEnd(DATE)- Embed it into each vehicle entity
-
Build the full stack (Entity, Repository, Service, Controller) for each vehicle type.
-
Endpoints:
GET /cars,POST /cars,GET /cars/{id},DELETE /cars/{id}- Same for
/trucksand/motorcycles
-
Test with Postman — create at least 2 of each vehicle type and verify that the JOINED strategy creates rows in both the parent and child tables.
- Databases don't support inheritance — JPA provides three strategies to map it.
- Single Table = one table, discriminator column, lots of NULLs.
- Table per Class = separate complete tables, no joins but data redundancy.
- Joined = parent table + child tables linked by FK — clean and normalized.
- Use
@Inheritance(strategy = InheritanceType.JOINED)on the parent entity. - Use
@PrimaryKeyJoinColumn(name = "id")on each child entity. @Embeddablemarks a value object (no@Id, no own table).@Embeddedputs the embeddable's fields directly into the parent entity's table.- Use
@AttributeOverrideswhen embedding the same class more than once. - Querying the parent repository returns all subtypes — JPA handles the joins.