Spring Boot
Table Mapping
Table mapping controls how JPA entities translate to database tables — table names, schemas, indexes, unique constraints, and join tables for relationships. This entry covers @Table configuration, schema management, join tables for many-to-many relationships, secondary tables, and naming strategies.
@Table Configuration
@Table customises the database table that an entity maps to. Without it, Hibernate derives the table name from the class name using the configured naming strategy. Use @Table when the class name and table name differ, when the entity lives in a non-default schema, or when you need to declare table-level unique constraints or indexes.
Java
// ── Basic name override ───────────────────────────────────────────────
@Entity
@Table(name = "tbl_users") // maps to tbl_users, not users
@Getter @Setter @NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 255)
private String email;
}
// ── Schema qualification ───────────────────────────────────────────────
@Entity
@Table(name = "orders", schema = "sales") // sales.orders
@Getter @Setter @NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private LocalDateTime placedAt;
}
// ── Catalog qualification (MySQL / SQL Server) ─────────────────────────
@Entity
@Table(name = "products",
catalog = "inventory_db", // inventory_db.dbo.products
schema = "dbo")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String name;
}Unique Constraints
Single-column uniqueness can be declared with @Column(unique = true). Multi-column unique constraints — where the combination of values must be unique — require @Table(uniqueConstraints = ...). Always name constraints explicitly so migrations can reference them by name rather than by a database-generated identifier.
Java
// ── Single-column unique — inline on @Column ─────────────────────────
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email; // single-column unique
@Column(nullable = false, unique = true, length = 50)
private String username; // single-column unique
}
// ── Multi-column unique — declared at table level ─────────────────────
@Entity
@Table(
name = "team_members",
uniqueConstraints = {
// A user can belong to a team only once
@UniqueConstraint(
name = "uq_team_members_user_team",
columnNames = {"user_id", "team_id"}
),
// A user can have only one role per team
@UniqueConstraint(
name = "uq_team_members_user_team_role",
columnNames = {"user_id", "team_id", "role"}
)
}
)
@Getter @Setter @NoArgsConstructor
public class TeamMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "team_id", nullable = false)
private Long teamId;
@Column(nullable = false, length = 30)
private String role;
}
// ── Composite unique key on a natural identifier ───────────────────────
@Entity
@Table(
name = "product_translations",
uniqueConstraints = @UniqueConstraint(
name = "uq_product_translations_product_locale",
columnNames = {"product_id", "locale"}
)
)
@Getter @Setter @NoArgsConstructor
public class ProductTranslation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(nullable = false, length = 10)
private String locale; // "en", "de", "fr"
@Column(nullable = false, length = 300)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
}Indexes
Declare indexes at the @Table level using the indexes attribute. Hibernate uses these declarations to generate CREATE INDEX statements during schema auto-generation. Always name indexes explicitly. Composite indexes specify column order — put the most selective column first. Index foreign key columns and any column that appears in a WHERE or ORDER BY clause in frequent queries.
Java
@Entity
@Table(
name = "orders",
indexes = {
// ── Single-column indexes ──────────────────────────────────────
@Index(name = "idx_orders_customer_id",
columnList = "customer_id"),
@Index(name = "idx_orders_created_at",
columnList = "created_at"),
// ── Composite index — column order matters ─────────────────────
// Supports: WHERE status = ? AND created_at > ?
// Also supports: WHERE status = ? (prefix match)
@Index(name = "idx_orders_status_created_at",
columnList = "status, created_at"),
// ── Unique index — enforces uniqueness and speeds up lookups ──
@Index(name = "idx_orders_reference",
columnList = "reference",
unique = true),
// ── Covering index for a frequent query pattern ────────────────
// SELECT id, status FROM orders WHERE customer_id = ? ORDER BY created_at DESC
@Index(name = "idx_orders_customer_status_created",
columnList = "customer_id, status, created_at")
}
)
@Getter @Setter @NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_id", nullable = false)
private Long customerId;
@Column(nullable = false, unique = true, length = 30)
private String reference;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal total;
}Join Tables for Many-to-Many
A many-to-many relationship requires a join table. @JoinTable names the table and declares the foreign key columns that reference each side. Always name the join table and its constraints explicitly. If the join table needs additional columns (such as a role or a date), promote it to a full entity with a composite or surrogate key.
Java
// ── Basic many-to-many with @JoinTable ───────────────────────────────
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id",
foreignKey = @ForeignKey(
name = "fk_user_roles_user")),
inverseJoinColumns = @JoinColumn(name = "role_id",
foreignKey = @ForeignKey(
name = "fk_user_roles_role"))
)
private Set<Role> roles = new HashSet<>();
}
@Entity
@Table(name = "roles")
@Getter @Setter @NoArgsConstructor
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
@ManyToMany(mappedBy = "roles") // User owns the relationship
private Set<User> users = new HashSet<>();
}
// ── Join table with extra columns — promote to entity ─────────────────
@Entity
@Table(
name = "project_members",
uniqueConstraints = @UniqueConstraint(
name = "uq_project_members_project_user",
columnNames = {"project_id", "user_id"}
)
)
@Getter @Setter @NoArgsConstructor
public class ProjectMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_project_members_project"))
private Project project;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_project_members_user"))
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProjectRole role; // OWNER, EDITOR, VIEWER
@Column(name = "joined_at", nullable = false, updatable = false)
private LocalDateTime joinedAt;
}Secondary Tables
@SecondaryTable splits a single entity's columns across two or more tables sharing the same primary key. Use it to map a legacy schema that has already been split, or to isolate infrequently accessed columns (such as large text or audit fields) without introducing a separate entity and relationship.
Java
// ── Entity split across two tables sharing the same PK ───────────────
@Entity
@Table(name = "employees")
@SecondaryTable(
name = "employee_details",
pkJoinColumns = @PrimaryKeyJoinColumn(
name = "employee_id",
foreignKey = @ForeignKey(name = "fk_employee_details_employee")
)
)
@Getter @Setter @NoArgsConstructor
public class Employee {
// ── Primary table: employees ───────────────────────────────────────
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(name = "department", nullable = false, length = 100)
private String department;
// ── Secondary table: employee_details ─────────────────────────────
@Column(table = "employee_details",
name = "bio",
columnDefinition = "TEXT")
private String bio;
@Column(table = "employee_details",
name = "phone_number",
length = 20)
private String phoneNumber;
@Column(table = "employee_details",
name = "linkedin_url",
length = 255)
private String linkedinUrl;
@Column(table = "employee_details",
name = "date_of_birth")
private LocalDate dateOfBirth;
}
// ── Multiple secondary tables ─────────────────────────────────────────
@Entity
@Table(name = "articles")
@SecondaryTables({
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "article_id",
foreignKey = @ForeignKey(name = "fk_article_content_article"))
),
@SecondaryTable(
name = "article_seo",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "article_id",
foreignKey = @ForeignKey(name = "fk_article_seo_article"))
)
})
@Getter @Setter @NoArgsConstructor
public class Article {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 300)
private String title; // articles table
@Column(table = "article_content",
columnDefinition = "TEXT")
private String body; // article_content table
@Column(table = "article_seo", name = "meta_title", length = 70)
private String metaTitle; // article_seo table
@Column(table = "article_seo", name = "meta_description", length = 160)
private String metaDescription; // article_seo table
}Naming Strategies
Hibernate's naming strategy controls how entity and field names translate to table and column names when @Table and @Column are omitted. Spring Boot defaults to SpringPhysicalNamingStrategy, which converts camelCase to snake_case. Override it globally in application.yml or implement a custom strategy for legacy schemas.
yaml
# ── application.yml — switch naming strategy ──────────────────────────
spring:
jpa:
hibernate:
naming:
# Physical: applied last — transforms the logical name to the DB name
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
# Implicit: applied first — derives a name when none is explicitly given
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
# ── Spring Boot defaults (no config needed) ───────────────────────────
# physical: SpringPhysicalNamingStrategy → camelCase → snake_case
# implicit: SpringImplicitNamingStrategy
// ── What SpringPhysicalNamingStrategy does ────────────────────────────
// Class UserAccount → table user_account
// Field createdAt → column created_at
// Field firstName → column first_name
// ── Custom strategy — prefix every table with the app name ───────────
public class PrefixedNamingStrategy
extends SpringPhysicalNamingStrategy {
private static final String PREFIX = "myapp_";
@Override
public Identifier toTableName(Identifier logicalName,
JdbcEnvironment env) {
Identifier snake = super.toTableName(logicalName, env);
return Identifier.toIdentifier(PREFIX + snake.getText());
}
}
// Register in application.yml:
// spring.jpa.hibernate.naming.physical-strategy:
// com.myapp.config.PrefixedNamingStrategy
// ── Custom strategy — map to an existing legacy schema (UPPER_CASE) ──
public class LegacyUpperCaseNamingStrategy
extends PhysicalNamingStrategyStandardImpl {
@Override
public Identifier toTableName(Identifier logicalName,
JdbcEnvironment env) {
return Identifier.toIdentifier(
logicalName.getText().toUpperCase());
}
@Override
public Identifier toColumnName(Identifier logicalName,
JdbcEnvironment env) {
return Identifier.toIdentifier(
logicalName.getText().toUpperCase());
}
}Schema Generation and Validation
Hibernate can create, update, validate, or leave the schema alone based on the spring.jpa.hibernate.ddl-auto property. Use create-drop in tests, validate in production (with Flyway or Liquibase managing the schema), and never use update in production — it cannot drop columns or reverse migrations.
yaml
# ── application.yml — ddl-auto values and when to use each ──────────
spring:
jpa:
hibernate:
ddl-auto: validate # production: validate schema matches entities
# ddl-auto options:
# none — do nothing (use for production with external migrations)
# validate — validate schema matches entities; fail on mismatch
# update — add missing tables/columns; NEVER use in production
# create — drop and recreate on startup; dev only
# create-drop — create on startup, drop on shutdown; tests only
show-sql: false # true in dev only — logs every SQL statement
properties:
hibernate:
format_sql: true # pretty-print SQL when show-sql is true
use_sql_comments: true
# ── application-test.yml — create schema fresh for each test run ───────
spring:
jpa:
hibernate:
ddl-auto: create-drop
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
// ── Validate entity against schema at startup ─────────────────────────
// With ddl-auto: validate, Hibernate checks on startup that:
// - every @Entity has a matching table
// - every @Column maps to a matching column with a compatible type
// - every @UniqueConstraint and @Index is present
// If any check fails, the application refuses to start — catching
// schema drift before it causes a runtime failure.
// ── Recommended production setup ──────────────────────────────────────
// 1. Manage schema with Flyway or Liquibase (versioned migration scripts)
// 2. Set ddl-auto: validate so Hibernate confirms the migration ran
// 3. Set ddl-auto: none to skip validation in CI if migrations aren't run