Spring Boot
Spring Boot Entity Mapping: @Entity, @Column, Enums, Embedded and Inheritance
Learn JPA entity mapping in Spring Boot 3: @Entity and @Table, column mapping, primary key strategies, enums, @Embedded, @ElementCollection and inheritance, with a troubleshooting table for the errors you will actually hit.
What Is Entity Mapping?
Entity mapping tells JPA how a Java class and its fields correspond to a database table and its columns. Spring Boot auto-configures Hibernate as the JPA provider, so you only write the annotated classes.
A JPA entity needs:
- `@Entity` on the class
- An `@Id` field (its position also decides field vs property access)
- A no-arg constructor (public or protected)
- A non-final class (Hibernate creates proxies by subclassing)
Java records cannot be entities. Examples use Spring Boot 3 imports (`jakarta.persistence.*`). On Spring Boot 2 the package is `javax.persistence`.
Mapping Annotations at a Glance
| Annotation | Purpose |
|---|---|
| `@Entity`, `@Table` | Declare an entity and customise its table name, schema and constraints |
| `@Id`, `@GeneratedValue` | Primary key and how it is generated |
| `@Column` | Column name, length, nullability, precision and scale |
| `@Enumerated`, `@Convert` | Store enums as strings, or convert with custom logic |
| `@Embedded`, `@Embeddable` | Reuse a value object's fields inside the owning table |
| `@ElementCollection` | Collection of basic or embeddable values in a separate table |
| `@Inheritance`, `@DiscriminatorColumn` | Map a class hierarchy to tables |
| `@MappedSuperclass` | Share fields between entities without an inheritance hierarchy |
| `@Transient` | Exclude a field from persistence |
Basic Entity Setup
Every persistent field maps to a column by default, so only fields that need non-default behaviour require `@Column`. Spring Boot's default naming strategy converts camelCase to snake_case (`createdAt` becomes `created_at`), so explicit column names are often unnecessary.
Avoid reserved words as table names. `user` and `order` are reserved in SQL, which is why the table below is `users`.
Note that `unique = true` on a column already creates an index. Don't add a separate `@Index` on the same column.
Java
@Entity
@Table(
name = "users",
uniqueConstraints = @UniqueConstraint(name = "uq_users_username", columnNames = "username"),
indexes = @Index(name = "idx_users_created_at", columnList = "created_at")
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "full_name", nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 255) // unique also creates an index
private String email;
@Column(nullable = false, length = 50)
private String username;
@Builder.Default // without this, the builder ignores "= true"
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}What @Column Attributes Actually Do
This is the most common source of confusion. `length`, `unique`, `columnDefinition` and `@Index` only matter when **Hibernate generates the schema** (`ddl-auto=create` or `update`). They do not enforce anything at runtime.
- `nullable = false` is the exception: Hibernate also checks it when persisting and throws if the value is null.
- With Flyway or Liquibase owning the schema and `ddl-auto=validate`, Hibernate checks that tables, columns and types exist and match. It does **not** verify lengths, unique constraints or indexes.
So when you use a migration tool, your migration is the source of truth, and the annotations must be kept consistent with it by hand.
Primary Key Strategies
`@GeneratedValue` has five strategies in JPA 3.1:
| Strategy | How it works | Notes |
|---|---|---|
| `IDENTITY` | Database auto-increment column | Simple and common, but disables JDBC insert batching |
| `SEQUENCE` | Database sequence | Best for PostgreSQL and Oracle; supports batching with `allocationSize` |
| `UUID` | Generated in Java (JPA 3.1, Spring Boot 3+) | Non-sequential ids, safe to expose |
| `TABLE` | Dedicated key table | Portable but slow; rarely needed |
| `AUTO` | Provider decides per database | Be explicit instead, so behaviour does not change between environments |
When using a sequence, the migration's `INCREMENT BY` must equal `allocationSize`. For natural keys such as a country code, omit `@GeneratedValue` and assign the id yourself.
Java
// IDENTITY
@Entity
@Table(name = "orders") // "order" is a reserved word
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
// SEQUENCE
@Entity
@SequenceGenerator(name = "product_seq", sequenceName = "product_id_seq", allocationSize = 50)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
private Long id;
}
// UUID
@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(updatable = false, nullable = false)
private UUID id;
}
// Assigned (natural key)
@Entity
public class Country {
@Id
@Column(length = 2)
private String isoCode;
}Column Mapping
Always set `precision` and `scale` on `BigDecimal` columns instead of relying on platform defaults. `columnDefinition` is an escape hatch for database-specific types, but it ties the entity to one database.
For JSON and array columns on Hibernate 6, use `@JdbcTypeCode` rather than `columnDefinition` on a plain `String`. A String bound to a `jsonb` column is sent as varchar and PostgreSQL rejects it.
For timestamps, prefer `Instant` or `OffsetDateTime` over `LocalDateTime` when the application runs across time zones, because `LocalDateTime` carries no zone information.
Java
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(length = 20, unique = true)
private String sku;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stockQuantity;
private LocalDate expiryDate;
// JSON column (Hibernate 6): bound as jsonb, not varchar
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> attributes;
// Database-generated value, never written by the application
@Column(insertable = false, updatable = false)
private LocalDateTime lastModifiedByTrigger;
// Not persisted at all
@Transient
private BigDecimal priceWithVat;
}Enum Mapping
Map enums with `@Enumerated`:
- `EnumType.ORDINAL` stores the position (0, 1, 2). **Avoid it.** Inserting or reordering a constant silently corrupts existing rows.
- `EnumType.STRING` stores the name. Safe against reordering, but renaming a constant breaks existing rows.
- `@Converter` stores any custom value, such as a short code or a legacy integer, and lets you rename constants freely.
With `autoApply = true`, a converter applies to every field of that type, so you don't annotate each field.
Java
public enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED }
public enum Priority {
LOW("L"), MEDIUM("M"), HIGH("H"), CRITICAL("C");
private final String code;
Priority(String code) { this.code = code; }
public String getCode() { return code; }
public static Priority fromCode(String code) {
return Arrays.stream(values())
.filter(p -> p.code.equals(code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown priority code: " + code));
}
}
@Converter(autoApply = true)
public class PriorityConverter implements AttributeConverter<Priority, String> {
@Override
public String convertToDatabaseColumn(Priority priority) {
return priority == null ? null : priority.getCode();
}
@Override
public Priority convertToEntityAttribute(String code) {
return code == null ? null : Priority.fromCode(code);
}
}
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING) // stores "PENDING", "SHIPPED", ...
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(nullable = false, length = 1) // stores "L", "M", "H", "C" via the converter
private Priority priority;
}Embedded Objects
`@Embeddable` marks a value object whose fields are stored in the owning entity's table, with no join and no extra table. `@Embedded` places it inside an entity.
If the same embeddable appears twice in one entity (billing and shipping address), the column names collide, so rename them with `@AttributeOverride`. Since JPA 2.2 the annotation is repeatable, so you don't need the `@AttributeOverrides` wrapper.
Use an embeddable when the data has no identity of its own and always lives with its owner. If it needs its own id or lifecycle, make it an entity instead.
Java
@Embeddable
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class Address {
@Column(nullable = false, length = 255)
private String street;
@Column(nullable = false, length = 100)
private String city;
@Column(length = 100)
private String state;
@Column(nullable = false, length = 20)
private String postalCode;
@Column(nullable = false, length = 2)
private String countryCode;
}
@Entity
@Table(name = "customers")
@Getter @Setter @NoArgsConstructor
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded // columns: street, city, state, ...
private Address address;
}
@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
@AttributeOverride(name = "street", column = @Column(name = "billing_street"))
@AttributeOverride(name = "city", column = @Column(name = "billing_city"))
@AttributeOverride(name = "state", column = @Column(name = "billing_state"))
@AttributeOverride(name = "postalCode", column = @Column(name = "billing_postal_code"))
@AttributeOverride(name = "countryCode", column = @Column(name = "billing_country_code"))
private Address billingAddress;
@Embedded
@AttributeOverride(name = "street", column = @Column(name = "shipping_street"))
@AttributeOverride(name = "city", column = @Column(name = "shipping_city"))
@AttributeOverride(name = "state", column = @Column(name = "shipping_state"))
@AttributeOverride(name = "postalCode", column = @Column(name = "shipping_postal_code"))
@AttributeOverride(name = "countryCode", column = @Column(name = "shipping_country_code"))
private Address shippingAddress;
}Collection Mapping with @ElementCollection
`@ElementCollection` stores a collection of basic types or embeddables in a separate table, without a dedicated entity class. Use it for simple value collections such as tags or phone numbers. For collections of full entities with their own identity, use `@OneToMany` instead.
Two things to know:
- Element collections are **lazy by default**. Accessing one outside a transaction throws `LazyInitializationException`.
- A `List` without an order column is treated as a bag, and Hibernate may delete and re-insert all rows when it changes. Prefer a `Set` when order doesn't matter.
Java
@Entity
@Table(name = "articles")
@Getter @Setter @NoArgsConstructor
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 300)
private String title;
// Collection of strings
@ElementCollection
@CollectionTable(name = "article_tags", joinColumns = @JoinColumn(name = "article_id"))
@Column(name = "tag", length = 50)
private Set<String> tags = new HashSet<>();
// Collection of embeddables
@ElementCollection
@CollectionTable(name = "article_authors", joinColumns = @JoinColumn(name = "article_id"))
private Set<Author> authors = new HashSet<>();
// Map: locale -> translated title
@ElementCollection
@CollectionTable(name = "article_translations", joinColumns = @JoinColumn(name = "article_id"))
@MapKeyColumn(name = "locale")
@Column(name = "translated_title")
private Map<String, String> translations = new HashMap<>();
}
@Embeddable
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class Author {
@Column(name = "author_name", nullable = false, length = 100)
private String name;
@Column(name = "author_email", length = 255)
private String email;
}Inheritance Strategies Compared
JPA offers three ways to map a class hierarchy. A fourth option, `@MappedSuperclass`, shares fields without making the base class an entity.
| Strategy | Tables | Polymorphic queries | Trade-off |
|---|---|---|---|
| `SINGLE_TABLE` | One table plus a discriminator column | Fast, no joins | Subclass columns must be nullable |
| `JOINED` | One table per class, joined by primary key | Joins on every query | Clean, normalised schema |
| `TABLE_PER_CLASS` | One complete table per concrete class | Supported, but uses `UNION` and is slow | No `IDENTITY` ids; use a sequence or UUID |
| `@MappedSuperclass` | Base class is not an entity | Not possible, no base-type queries | Best for shared fields such as audit columns |
**Rule of thumb:** use `SINGLE_TABLE` for small hierarchies with few extra fields, `JOINED` when subtypes have many distinct fields and you want a clean schema, and `@MappedSuperclass` when you only want to share code.
SINGLE_TABLE and JOINED Examples
With `SINGLE_TABLE`, a subclass column cannot be `NOT NULL` at the database level, because rows of other subtypes leave it empty. With `JOINED`, each subtype has its own table whose primary key references the parent.
Java
// SINGLE_TABLE: one table, discriminator column
@Entity
@Table(name = "payments")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING, length = 20)
@Getter @Setter @NoArgsConstructor
public abstract class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
}
@Entity
@DiscriminatorValue("CARD")
@Getter @Setter @NoArgsConstructor
public class CardPayment extends Payment {
@Column(length = 4) // nullable: other subtypes leave it empty
private String cardLast4;
}
@Entity
@DiscriminatorValue("BANK")
@Getter @Setter @NoArgsConstructor
public class BankPayment extends Payment {
@Column(length = 34)
private String accountNumber;
}
// JOINED: separate table per subclass
@Entity
@Table(name = "notifications")
@Inheritance(strategy = InheritanceType.JOINED)
@Getter @Setter @NoArgsConstructor
public abstract class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String recipient;
}
@Entity
@Table(name = "email_notifications")
@PrimaryKeyJoinColumn(name = "notification_id")
@Getter @Setter @NoArgsConstructor
public class EmailNotification extends Notification {
@Column(nullable = false, length = 300)
private String subject; // can be NOT NULL: own table
}Auditing Fields with @MappedSuperclass
A `@MappedSuperclass` is the usual way to share audit columns across entities. Spring Data JPA populates them when auditing is enabled.
Put `@EnableJpaAuditing` on a separate `@Configuration` class, **not** on the main application class. If it sits on the main class, `@WebMvcTest` slice tests fail with "JPA metamodel must not be empty".
If you don't need `@CreatedBy`, Hibernate's own `@CreationTimestamp` and `@UpdateTimestamp` do the same job with no Spring Data setup.
Java
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig { }
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
}
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String name;
}Lombok and Entities: Pitfalls
Lombok is convenient on entities, but a few annotations cause real bugs.
- **Avoid `@Data`.** It generates `equals`, `hashCode` and `toString` over all fields. On entities with lazy relationships this can trigger extra queries, or infinite recursion in bidirectional relationships.
- **Use `@Builder.Default`** for fields with initial values. Without it, the builder ignores them.
- **Keep `@NoArgsConstructor`.** JPA requires it. `@NoArgsConstructor(access = AccessLevel.PROTECTED)` also works.
- **Be careful with `equals` and `hashCode`.** Base them on the id (with a null check) or on a stable business key, never on all fields.
Use `@Getter` and `@Setter` explicitly, as in the examples on this page.
Troubleshooting Entity Mapping Errors
| Error | Usual cause | Fix |
|---|---|---|
| SQL syntax error near `user` or `order` | Reserved word used as table name | Set `@Table(name = "users")` or `"orders"` |
| `No default constructor for entity` | Missing no-arg constructor | Add `@NoArgsConstructor` |
| `Schema-validation: missing column [created_at]` | Column absent, or naming strategy mismatch | Add it to the migration, or set the column name explicitly |
| `Schema-validation: wrong column type` | Entity type differs from the migration | Align the Java type with the database type |
| `Repeated column in mapping for entity` | Same embeddable used twice without overrides | Add `@AttributeOverride` for each use |
| `not-null property references a null or transient value` | `nullable = false` field is null on save | Set the value before saving |
| `LazyInitializationException` | Lazy collection accessed outside a transaction | Fetch inside a transaction or use a fetch join |
| `No enum constant ...` when loading | Stored string no longer matches a renamed enum constant | Migrate the data, or use a converter |
| `JPA metamodel must not be empty` in a slice test | `@EnableJpaAuditing` on the main class | Move it to a separate `@Configuration` class |
Common Mistakes
- Using `EnumType.ORDINAL`, which breaks data when constants are reordered.
- Expecting `length` or `unique` on `@Column` to be enforced when Flyway owns the schema.
- Using `@Data` on entities.
- Letting `GenerationType.AUTO` decide, so id generation differs between environments.
- Using `LocalDateTime` for timestamps in a multi-time-zone system.
- Mapping JSON with a plain `String` and `columnDefinition = "jsonb"` on Hibernate 6.
- Using `@ElementCollection` for large or frequently changing collections. Use a real entity.
- Using `@Entity` classes directly as API request and response objects. Use DTOs.
Which Mapping Should You Use?
- **Simple value in a column** (string, number, date): plain field, add `@Column` only if you need to
- **A fixed set of values:** enum with `@Enumerated(EnumType.STRING)`
- **A small value object that always belongs to one owner** (address, money): `@Embeddable`
- **A short list of simple values** (tags, phone numbers): `@ElementCollection` with a `Set`
- **A collection of things with their own id:** `@OneToMany` and a separate entity
- **Shared audit or id fields:** `@MappedSuperclass`
- **A real "is-a" hierarchy:** `@Inheritance`, with `JOINED` for clean schemas and `SINGLE_TABLE` for speed