Spring Boot
Entity Mapping
JPA entity mapping defines how Java classes and their fields translate to database tables and columns. Spring Boot auto-configures Hibernate as the JPA provider. This entry covers @Entity setup, column mapping, primary key strategies, embedded objects, enum mapping, collection mapping, and inheritance strategies.
Basic Entity Setup
An entity class is annotated with @Entity and must have a no-arg constructor (Lombok's @NoArgsConstructor covers this). @Table customises the table name, schema, and unique constraints. Every persistent field maps to a column by default — only fields that need non-default behaviour require @Column.
Java
@Entity
@Table(
name = "users",
schema = "app",
uniqueConstraints = {
@UniqueConstraint(name = "uq_users_email",
columnNames = "email"),
@UniqueConstraint(name = "uq_users_username",
columnNames = "username")
},
indexes = {
@Index(name = "idx_users_email", columnList = "email"),
@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)
private String email;
@Column(nullable = false, length = 50)
private String username;
@Column(name = "is_active",
nullable = false,
columnDefinition = "boolean default true")
private boolean active = true;
@Column(name = "created_at",
nullable = false,
updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}Primary Key Strategies
JPA offers four @GeneratedValue strategies. IDENTITY delegates to a database auto-increment column — the most common choice for MySQL and PostgreSQL. SEQUENCE uses a database sequence — preferred for PostgreSQL when batch inserts are needed. TABLE uses a dedicated key table — portable but slow. AUTO lets Hibernate choose. UUID primary keys avoid sequential ID exposure.
Java
// ── IDENTITY — auto-increment column (MySQL, PostgreSQL SERIAL) ───────
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
// ── SEQUENCE — database sequence (preferred for PostgreSQL) ───────────
@Entity
@SequenceGenerator(
name = "product_seq",
sequenceName = "product_id_seq",
allocationSize = 50 // fetch 50 IDs per round-trip — improves batch perf
)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "product_seq")
private Long id;
}
// ── UUID — application-generated, non-sequential ──────────────────────
@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.UUID) // Hibernate 6+ / JPA 3.1
@Column(updatable = false, nullable = false,
columnDefinition = "uuid")
private UUID id;
}
// ── Assigned — application sets the ID manually ───────────────────────
@Entity
public class Country {
@Id
@Column(length = 2)
private String isoCode; // "US", "GB", "DE" — set by caller
}
// ── Composite key via @IdClass ─────────────────────────────────────────
@Data
public class OrderItemId implements Serializable {
private Long orderId;
private Long productId;
}
@Entity
@IdClass(OrderItemId.class)
public class OrderItem {
@Id private Long orderId;
@Id private Long productId;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
}Column Mapping
@Column attributes control the DDL generated by Hibernate and enforce constraints at the database level. Precision and scale are required for BigDecimal to avoid platform-specific defaults. columnDefinition is an escape hatch for database-specific types such as jsonb, text[], or point.
Java
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ── String columns ─────────────────────────────────────────────────
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT") // unbounded text
private String description;
@Column(length = 20, unique = true)
private String sku;
// ── Numeric columns ────────────────────────────────────────────────
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false, precision = 5, scale = 2)
private BigDecimal vatRate;
@Column(nullable = false)
private int stockQuantity;
// ── Boolean ────────────────────────────────────────────────────────
@Column(nullable = false,
columnDefinition = "boolean default true")
private boolean published = true;
// ── Date / time ────────────────────────────────────────────────────
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "expiry_date")
private LocalDate expiryDate;
// ── Database-specific types ────────────────────────────────────────
@Column(columnDefinition = "jsonb") // PostgreSQL JSONB
private String attributes;
@Column(columnDefinition = "uuid") // PostgreSQL UUID
private UUID externalRef;
@Column(columnDefinition = "text[]") // PostgreSQL text array
private String[] tags;
// ── Read-only / computed column ────────────────────────────────────
@Column(insertable = false, updatable = false)
private LocalDateTime deletedAt;
}Enum Mapping
Map enums with @Enumerated. ORDINAL stores the ordinal integer — fragile because inserting a new enum value at any position corrupts existing data. STRING stores the name — safe but verbose. For a custom database value (a short code, a legacy integer), use @Converter with AttributeConverter.
Java
// ── Enum definition ────────────────────────────────────────────────────
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// ── ORDINAL — avoid: inserting new values breaks existing data ─────────
@Enumerated(EnumType.ORDINAL) // stores 0, 1, 2, 3, 4
private OrderStatus status;
// ── STRING — preferred: stores "PENDING", "CONFIRMED", etc. ───────────
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
// ── Custom converter — store a short code ─────────────────────────────
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) // applies to all Priority fields globally
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 using the converter ────────────────────────────────────────
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20)
private OrderStatus status; // STRING via @Enumerated
@Column(nullable = false, length = 1)
private Priority priority; // "L"/"M"/"H"/"C" via converter
}Embedded Objects
@Embeddable marks a class whose fields are stored in the owning entity's table — no join, no extra table. @Embedded places the embeddable inside an entity. @AttributeOverride renames the columns when the same embeddable is used more than once in one entity.
Java
// ── Embeddable ─────────────────────────────────────────────────────────
@Embeddable
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class Address {
@Column(name = "street", nullable = false, length = 255)
private String street;
@Column(name = "city", nullable = false, length = 100)
private String city;
@Column(name = "state", length = 100)
private String state;
@Column(name = "postal_code", nullable = false, length = 20)
private String postalCode;
@Column(name = "country_code", nullable = false, length = 2)
private String countryCode;
}
// ── Entity using the embeddable once ──────────────────────────────────
@Entity
@Table(name = "customers")
@Getter @Setter @NoArgsConstructor
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Embedded // columns: street, city, state, ...
private Address address;
}
// ── Entity using the same embeddable twice — needs @AttributeOverride ─
@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "billing_street")),
@AttributeOverride(name = "city", column = @Column(name = "billing_city")),
@AttributeOverride(name = "postalCode", column = @Column(name = "billing_postal_code")),
@AttributeOverride(name = "countryCode", column = @Column(name = "billing_country_code"))
})
private Address billingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "shipping_street")),
@AttributeOverride(name = "city", column = @Column(name = "shipping_city")),
@AttributeOverride(name = "postalCode", column = @Column(name = "shipping_postal_code")),
@AttributeOverride(name = "countryCode", column = @Column(name = "shipping_country_code"))
})
private Address shippingAddress;
}Collection Mapping
@ElementCollection stores a collection of basic types or embeddables in a separate table without a dedicated entity class. It is appropriate for simple value collections (tags, phone numbers, addresses). For collections of full entities, use @OneToMany instead.
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 List<Author> authors = new ArrayList<>();
// ── 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 Mapping
JPA supports three inheritance strategies. SINGLE_TABLE stores all subclasses in one table with a discriminator column — fastest queries, but nullable columns for subtype-specific fields. JOINED stores each subclass in its own table joined to the parent — clean schema, extra joins. TABLE_PER_CLASS gives each subclass its own complete table — no joins, but no polymorphic queries across subtypes.
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;
@Column(nullable = false)
private LocalDateTime createdAt;
}
@Entity
@DiscriminatorValue("CARD")
@Getter @Setter @NoArgsConstructor
public class CardPayment extends Payment {
@Column(name = "card_last4", length = 4)
private String cardLast4;
@Column(name = "card_brand", length = 20)
private String cardBrand;
}
@Entity
@DiscriminatorValue("BANK")
@Getter @Setter @NoArgsConstructor
public class BankPayment extends Payment {
@Column(name = "account_number", length = 34)
private String accountNumber;
@Column(name = "bank_code", length = 11)
private String bankCode;
}
// ── JOINED — separate table per subclass, joined to parent ────────────
@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;
@Column(nullable = false) private LocalDateTime sentAt;
}
@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;
@Column(columnDefinition = "TEXT") private String htmlBody;
}
@Entity
@Table(name = "sms_notifications")
@PrimaryKeyJoinColumn(name = "notification_id")
@Getter @Setter @NoArgsConstructor
public class SmsNotification extends Notification {
@Column(nullable = false, length = 160) private String message;
@Column(nullable = false, length = 20) private String phoneNumber;
}Auditing with @CreatedDate and @LastModifiedDate
Spring Data JPA's auditing support populates created and updated timestamps automatically. Enable it with @EnableJpaAuditing, extend a base entity or use @EntityListeners directly, and annotate the timestamp fields. This eliminates manual timestamp management across every entity.
Java
// ── Enable auditing on the application class or a @Configuration ──────
@SpringBootApplication
@EnableJpaAuditing
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// ── Auditable base entity — extend to share across all entities ────────
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class AuditableEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false, length = 100)
private String createdBy;
@LastModifiedBy
@Column(name = "updated_by", length = 100)
private String updatedBy;
}
// ── Provide current user for @CreatedBy / @LastModifiedBy ─────────────
@Component
public class SpringSecurityAuditorAware
implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(
SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
// ── Entity extending the base ─────────────────────────────────────────
@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;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
}
// Hibernate automatically sets createdAt and updatedAt on persist/merge.
// No manual LocalDateTime.now() calls needed anywhere.