Spring JPAの継承マッピングは、オブジェクト指向設計のエンティティ階層をリレーショナルデータベースにマッピングするための重要な機能です。@Inheritanceアノテーションを使用することで、SINGLE_TABLE、JOINED、TABLE_PER_CLASSの3つの戦略から最適なマッピング方式を選択できます。本記事では、各継承マッピング戦略の仕組みと特性を比較し、@DiscriminatorColumnの設定方法から@MappedSuperclassとの使い分け、ポリモーフィッククエリの動作まで、実務で役立つ選定基準を解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
データベース H2 Database(開発・テスト用) / PostgreSQL(本番環境想定)
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Spring Boot REST APIプロジェクトの基本構成
  • JPA Entityの基本的な理解

JPAの継承マッピングとは

JPAの継承マッピングは、Javaのクラス継承階層をリレーショナルデータベースのテーブル構造にマッピングする機能です。オブジェクト指向設計では継承を使ってドメインモデルを表現しますが、リレーショナルデータベースには継承という概念が存在しません。この「オブジェクト指向とリレーショナルの間のインピーダンスミスマッチ」を解決するのが継承マッピングです。

サンプルドメインモデル

本記事では、決済システムの決済方法(Payment)を題材にします。抽象クラスPaymentを基底クラスとし、CreditCardPayment(クレジットカード決済)、BankTransferPayment(銀行振込)、DigitalWalletPayment(電子マネー決済)という3つの具象クラスを定義します。

classDiagram
    Payment <|-- CreditCardPayment
    Payment <|-- BankTransferPayment
    Payment <|-- DigitalWalletPayment
    
    class Payment {
        <<abstract>>
        -Long id
        -BigDecimal amount
        -LocalDateTime paymentDate
        -PaymentStatus status
    }
    
    class CreditCardPayment {
        -String cardNumber
        -String cardHolderName
        -YearMonth expiryDate
    }
    
    class BankTransferPayment {
        -String bankCode
        -String accountNumber
        -String accountHolderName
    }
    
    class DigitalWalletPayment {
        -String walletProvider
        -String walletId
    }

3つの継承マッピング戦略の比較

JPAでは、エンティティの継承階層をデータベースにマッピングするために3つの戦略が定義されています。

戦略 テーブル数 NULLカラム 正規化 ポリモーフィッククエリ
SINGLE_TABLE 1テーブル 多い 低い 高速(JOINなし)
JOINED 階層数分 なし 高い 中速(JOINが必要)
TABLE_PER_CLASS 具象クラス数分 なし 高い 低速(UNIONが必要)

各戦略には明確なトレードオフがあり、ユースケースに応じた選択が重要です。

SINGLE_TABLE戦略(単一テーブル継承)

SINGLE_TABLE戦略は、継承階層全体を単一のテーブルにマッピングする方式です。JPAのデフォルト戦略であり、@Inheritanceアノテーションを指定しない場合に適用されます。

テーブル構造

erDiagram
    PAYMENT {
        bigint id PK
        varchar dtype "識別カラム"
        decimal amount
        timestamp payment_date
        varchar status
        varchar card_number "CreditCardPayment用"
        varchar card_holder_name "CreditCardPayment用"
        varchar expiry_date "CreditCardPayment用"
        varchar bank_code "BankTransferPayment用"
        varchar account_number "BankTransferPayment用"
        varchar account_holder_name "BankTransferPayment用"
        varchar wallet_provider "DigitalWalletPayment用"
        varchar wallet_id "DigitalWalletPayment用"
    }

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.payment.entity;

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

@Entity
@Table(name = "payment")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(
    name = "payment_type",
    discriminatorType = DiscriminatorType.STRING,
    length = 20
)
public abstract class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal amount;

    @Column(name = "payment_date", nullable = false)
    private LocalDateTime paymentDate;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.payment.entity;

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

@Entity
@DiscriminatorValue("CREDIT_CARD")
public class CreditCardPayment extends Payment {

    @Column(name = "card_number", length = 16)
    private String cardNumber;

    @Column(name = "card_holder_name", length = 100)
    private String cardHolderName;

    @Column(name = "expiry_date")
    private YearMonth expiryDate;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@DiscriminatorValue("BANK_TRANSFER")
public class BankTransferPayment extends Payment {

    @Column(name = "bank_code", length = 10)
    private String bankCode;

    @Column(name = "account_number", length = 20)
    private String accountNumber;

    @Column(name = "account_holder_name", length = 100)
    private String accountHolderName;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@DiscriminatorValue("DIGITAL_WALLET")
public class DigitalWalletPayment extends Payment {

    @Column(name = "wallet_provider", length = 50)
    private String walletProvider;

    @Column(name = "wallet_id", length = 100)
    private String walletId;

    // コンストラクタ、getter、setter省略
}

生成されるDDL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE TABLE payment (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    payment_type VARCHAR(20) NOT NULL,
    amount DECIMAL(15, 2) NOT NULL,
    payment_date TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL,
    card_number VARCHAR(16),
    card_holder_name VARCHAR(100),
    expiry_date VARCHAR(7),
    bank_code VARCHAR(10),
    account_number VARCHAR(20),
    account_holder_name VARCHAR(100),
    wallet_provider VARCHAR(50),
    wallet_id VARCHAR(100)
);

CREATE INDEX idx_payment_type ON payment(payment_type);

SINGLE_TABLE戦略のメリットとデメリット

メリット:

  • ポリモーフィッククエリが最も高速(JOINが不要)
  • テーブル構造がシンプルで管理しやすい
  • INSERT/UPDATE/DELETEが単一テーブルで完結

デメリット:

  • サブクラス固有のカラムにNOT NULL制約を設定できない
  • テーブルに多くのNULLカラムが発生する(スパースカラム問題)
  • サブクラスが増えるとテーブルが肥大化する

JOINED戦略(結合テーブル継承)

JOINED戦略は、継承階層の各クラスを個別のテーブルにマッピングし、外部キーで関連付ける方式です。親クラスのテーブルには共通属性が、サブクラスのテーブルには固有属性が格納されます。

テーブル構造

erDiagram
    PAYMENT ||--o| CREDIT_CARD_PAYMENT : "id = payment_id"
    PAYMENT ||--o| BANK_TRANSFER_PAYMENT : "id = payment_id"
    PAYMENT ||--o| DIGITAL_WALLET_PAYMENT : "id = payment_id"
    
    PAYMENT {
        bigint id PK
        varchar dtype "識別カラム(オプション)"
        decimal amount
        timestamp payment_date
        varchar status
    }
    
    CREDIT_CARD_PAYMENT {
        bigint payment_id PK_FK
        varchar card_number
        varchar card_holder_name
        varchar expiry_date
    }
    
    BANK_TRANSFER_PAYMENT {
        bigint payment_id PK_FK
        varchar bank_code
        varchar account_number
        varchar account_holder_name
    }
    
    DIGITAL_WALLET_PAYMENT {
        bigint payment_id PK_FK
        varchar wallet_provider
        varchar wallet_id
    }

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.payment.entity;

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

@Entity
@Table(name = "payment")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal amount;

    @Column(name = "payment_date", nullable = false)
    private LocalDateTime paymentDate;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.payment.entity;

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

@Entity
@Table(name = "credit_card_payment")
@DiscriminatorValue("CREDIT_CARD")
@PrimaryKeyJoinColumn(name = "payment_id")
public class CreditCardPayment extends Payment {

    @Column(name = "card_number", nullable = false, length = 16)
    private String cardNumber;

    @Column(name = "card_holder_name", nullable = false, length = 100)
    private String cardHolderName;

    @Column(name = "expiry_date", nullable = false)
    private YearMonth expiryDate;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "bank_transfer_payment")
@DiscriminatorValue("BANK_TRANSFER")
@PrimaryKeyJoinColumn(name = "payment_id")
public class BankTransferPayment extends Payment {

    @Column(name = "bank_code", nullable = false, length = 10)
    private String bankCode;

    @Column(name = "account_number", nullable = false, length = 20)
    private String accountNumber;

    @Column(name = "account_holder_name", nullable = false, length = 100)
    private String accountHolderName;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "digital_wallet_payment")
@DiscriminatorValue("DIGITAL_WALLET")
@PrimaryKeyJoinColumn(name = "payment_id")
public class DigitalWalletPayment extends Payment {

    @Column(name = "wallet_provider", nullable = false, length = 50)
    private String walletProvider;

    @Column(name = "wallet_id", nullable = false, length = 100)
    private String walletId;

    // コンストラクタ、getter、setter省略
}

生成されるDDL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CREATE TABLE payment (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    payment_type VARCHAR(31) NOT NULL,
    amount DECIMAL(15, 2) NOT NULL,
    payment_date TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL
);

CREATE TABLE credit_card_payment (
    payment_id BIGINT PRIMARY KEY,
    card_number VARCHAR(16) NOT NULL,
    card_holder_name VARCHAR(100) NOT NULL,
    expiry_date VARCHAR(7) NOT NULL,
    FOREIGN KEY (payment_id) REFERENCES payment(id)
);

CREATE TABLE bank_transfer_payment (
    payment_id BIGINT PRIMARY KEY,
    bank_code VARCHAR(10) NOT NULL,
    account_number VARCHAR(20) NOT NULL,
    account_holder_name VARCHAR(100) NOT NULL,
    FOREIGN KEY (payment_id) REFERENCES payment(id)
);

CREATE TABLE digital_wallet_payment (
    payment_id BIGINT PRIMARY KEY,
    wallet_provider VARCHAR(50) NOT NULL,
    wallet_id VARCHAR(100) NOT NULL,
    FOREIGN KEY (payment_id) REFERENCES payment(id)
);

JOINED戦略のメリットとデメリット

メリット:

  • データベースの正規化が保たれる
  • サブクラス固有のカラムにNOT NULL制約を設定できる
  • NULLカラムが発生しない
  • 各テーブルのサイズが適切に保たれる

デメリット:

  • ポリモーフィッククエリでJOINが必要となりパフォーマンスが低下
  • 継承階層が深い場合、複数のJOINが発生する
  • INSERT時に複数テーブルへの書き込みが必要

TABLE_PER_CLASS戦略(具象クラステーブル継承)

TABLE_PER_CLASS戦略は、各具象クラスを独立したテーブルにマッピングする方式です。各テーブルには、継承した属性を含むすべてのカラムが含まれます。

テーブル構造

erDiagram
    CREDIT_CARD_PAYMENT {
        bigint id PK
        decimal amount
        timestamp payment_date
        varchar status
        varchar card_number
        varchar card_holder_name
        varchar expiry_date
    }
    
    BANK_TRANSFER_PAYMENT {
        bigint id PK
        decimal amount
        timestamp payment_date
        varchar status
        varchar bank_code
        varchar account_number
        varchar account_holder_name
    }
    
    DIGITAL_WALLET_PAYMENT {
        bigint id PK
        decimal amount
        timestamp payment_date
        varchar status
        varchar wallet_provider
        varchar wallet_id
    }

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.payment.entity;

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

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.TABLE)
    private Long id;

    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal amount;

    @Column(name = "payment_date", nullable = false)
    private LocalDateTime paymentDate;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.payment.entity;

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

@Entity
@Table(name = "credit_card_payment")
public class CreditCardPayment extends Payment {

    @Column(name = "card_number", nullable = false, length = 16)
    private String cardNumber;

    @Column(name = "card_holder_name", nullable = false, length = 100)
    private String cardHolderName;

    @Column(name = "expiry_date", nullable = false)
    private YearMonth expiryDate;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "bank_transfer_payment")
public class BankTransferPayment extends Payment {

    @Column(name = "bank_code", nullable = false, length = 10)
    private String bankCode;

    @Column(name = "account_number", nullable = false, length = 20)
    private String accountNumber;

    @Column(name = "account_holder_name", nullable = false, length = 100)
    private String accountHolderName;

    // コンストラクタ、getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.payment.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "digital_wallet_payment")
public class DigitalWalletPayment extends Payment {

    @Column(name = "wallet_provider", nullable = false, length = 50)
    private String walletProvider;

    @Column(name = "wallet_id", nullable = false, length = 100)
    private String walletId;

    // コンストラクタ、getter、setter省略
}

TABLE_PER_CLASS戦略の注意点

TABLE_PER_CLASS戦略を使用する場合、ID生成戦略に注意が必要です。GenerationType.IDENTITYは使用できません。これは、各テーブルが独立しているため、テーブル間でIDの一意性を保証できないためです。代わりにGenerationType.TABLEまたはGenerationType.SEQUENCEを使用します。

生成されるDDL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
CREATE TABLE credit_card_payment (
    id BIGINT PRIMARY KEY,
    amount DECIMAL(15, 2) NOT NULL,
    payment_date TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL,
    card_number VARCHAR(16) NOT NULL,
    card_holder_name VARCHAR(100) NOT NULL,
    expiry_date VARCHAR(7) NOT NULL
);

CREATE TABLE bank_transfer_payment (
    id BIGINT PRIMARY KEY,
    amount DECIMAL(15, 2) NOT NULL,
    payment_date TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL,
    bank_code VARCHAR(10) NOT NULL,
    account_number VARCHAR(20) NOT NULL,
    account_holder_name VARCHAR(100) NOT NULL
);

CREATE TABLE digital_wallet_payment (
    id BIGINT PRIMARY KEY,
    amount DECIMAL(15, 2) NOT NULL,
    payment_date TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL,
    wallet_provider VARCHAR(50) NOT NULL,
    wallet_id VARCHAR(100) NOT NULL
);

-- ID生成用テーブル(GenerationType.TABLEの場合)
CREATE TABLE hibernate_sequences (
    sequence_name VARCHAR(255) NOT NULL PRIMARY KEY,
    next_val BIGINT
);

TABLE_PER_CLASS戦略のメリットとデメリット

メリット:

  • 各具象クラスのデータが完全に独立
  • 特定の具象クラスへのクエリが高速
  • サブクラス固有のカラムにNOT NULL制約を設定できる

デメリット:

  • ポリモーフィッククエリでUNIONが必要となり最も低速
  • 共通カラムの変更時に全テーブルの修正が必要
  • JPAの仕様ではオプショナル機能であり、移植性に注意が必要

@DiscriminatorColumnと@DiscriminatorValueの設定

識別子カラム(Discriminator Column)は、単一テーブルに格納されたレコードがどのサブクラスのインスタンスであるかを識別するために使用されます。

@DiscriminatorColumnの属性

属性 説明 デフォルト値
name 識別子カラムの名前 “DTYPE”
discriminatorType カラムの型(STRING, CHAR, INTEGER) STRING
columnDefinition DDL生成時のカラム定義 指定なし
length STRING型の場合の長さ 31

設定例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// STRING型(推奨)
@DiscriminatorColumn(
    name = "payment_type",
    discriminatorType = DiscriminatorType.STRING,
    length = 30
)
public abstract class Payment { }

// INTEGER型(ストレージ効率が良い)
@DiscriminatorColumn(
    name = "type_id",
    discriminatorType = DiscriminatorType.INTEGER
)
public abstract class Payment { }

// CHAR型(単一文字で識別)
@DiscriminatorColumn(
    name = "type_code",
    discriminatorType = DiscriminatorType.CHAR
)
public abstract class Payment { }

@DiscriminatorValueの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// STRING型の場合
@Entity
@DiscriminatorValue("CREDIT_CARD")
public class CreditCardPayment extends Payment { }

// INTEGER型の場合
@Entity
@DiscriminatorValue("1")
public class CreditCardPayment extends Payment { }

// CHAR型の場合
@Entity
@DiscriminatorValue("C")
public class CreditCardPayment extends Payment { }

識別子の命名規則

識別子の値を明示的に指定しない場合、JPAプロバイダはデフォルトでエンティティ名を使用します。可読性と保守性のために、以下の命名規則を推奨します。

  • 大文字のスネークケースを使用(例: CREDIT_CARD
  • ビジネス上の意味が分かる名前を使用
  • 短すぎる略語は避ける

@MappedSuperclassとの使い分け

@MappedSuperclassは継承マッピング戦略とは異なる概念です。両者の違いを理解することが重要です。

@MappedSuperclassの特徴

  • エンティティではなく、マッピング情報のみを提供するクラス
  • テーブルは作成されない
  • ポリモーフィッククエリの対象にならない
  • リポジトリで直接操作できない

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.common.entity;

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

@MappedSuperclass
public abstract class BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    // getter、setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.payment.entity;

import com.example.common.entity.BaseEntity;
import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "payment")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")
public abstract class Payment extends BaseEntity {

    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;

    // getter、setter省略
}

使い分けの判断基準

観点 @Inheritance @MappedSuperclass
ポリモーフィッククエリ 必要 不要
親クラスのリポジトリ 必要 不要
共通カラムの定義 エンティティ間で共有 各エンティティに複製
ユースケース 同一ドメインの派生型 監査情報など共通属性

ポリモーフィッククエリの動作

ポリモーフィッククエリとは、親クラス(または抽象クラス)を対象としたクエリで、すべてのサブクラスのインスタンスを取得できる機能です。

リポジトリの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.payment.repository;

import com.example.payment.entity.Payment;
import com.example.payment.entity.PaymentStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.math.BigDecimal;
import java.util.List;

public interface PaymentRepository extends JpaRepository<Payment, Long> {

    // ポリモーフィッククエリ(全サブクラスが対象)
    List<Payment> findByStatus(PaymentStatus status);

    // 金額での検索(全サブクラスが対象)
    List<Payment> findByAmountGreaterThan(BigDecimal amount);

    // JPQLでの明示的なポリモーフィッククエリ
    @Query("SELECT p FROM Payment p WHERE p.status = :status ORDER BY p.paymentDate DESC")
    List<Payment> findAllPaymentsByStatus(@Param("status") PaymentStatus status);
}

各戦略での生成SQL

SINGLE_TABLE戦略:

1
2
3
4
5
6
7
-- findByStatus実行時
SELECT p.id, p.payment_type, p.amount, p.payment_date, p.status,
       p.card_number, p.card_holder_name, p.expiry_date,
       p.bank_code, p.account_number, p.account_holder_name,
       p.wallet_provider, p.wallet_id
FROM payment p
WHERE p.status = ?

JOINED戦略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- findByStatus実行時
SELECT p.id, p.payment_type, p.amount, p.payment_date, p.status,
       cc.card_number, cc.card_holder_name, cc.expiry_date,
       bt.bank_code, bt.account_number, bt.account_holder_name,
       dw.wallet_provider, dw.wallet_id
FROM payment p
LEFT JOIN credit_card_payment cc ON p.id = cc.payment_id
LEFT JOIN bank_transfer_payment bt ON p.id = bt.payment_id
LEFT JOIN digital_wallet_payment dw ON p.id = dw.payment_id
WHERE p.status = ?

TABLE_PER_CLASS戦略:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- findByStatus実行時
SELECT id, amount, payment_date, status,
       card_number, card_holder_name, expiry_date,
       NULL as bank_code, NULL as account_number, NULL as account_holder_name,
       NULL as wallet_provider, NULL as wallet_id,
       1 as clazz_
FROM credit_card_payment WHERE status = ?
UNION ALL
SELECT id, amount, payment_date, status,
       NULL, NULL, NULL,
       bank_code, account_number, account_holder_name,
       NULL, NULL,
       2 as clazz_
FROM bank_transfer_payment WHERE status = ?
UNION ALL
SELECT id, amount, payment_date, status,
       NULL, NULL, NULL,
       NULL, NULL, NULL,
       wallet_provider, wallet_id,
       3 as clazz_
FROM digital_wallet_payment WHERE status = ?

特定のサブクラスのみを取得するクエリ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.example.payment.repository;

import com.example.payment.entity.CreditCardPayment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface CreditCardPaymentRepository extends JpaRepository<CreditCardPayment, Long> {

    // サブクラス固有のクエリ(ポリモーフィックではない)
    List<CreditCardPayment> findByCardHolderName(String cardHolderName);
}

各戦略のパフォーマンス特性

各継承マッピング戦略のパフォーマンス特性を操作別に整理します。

操作別パフォーマンス比較

操作 SINGLE_TABLE JOINED TABLE_PER_CLASS
INSERT(単一) 高速 中速(2テーブル) 高速
SELECT(特定サブクラス) 高速 中速(JOIN) 高速
SELECT(ポリモーフィック) 高速 低速(複数JOIN) 最低速(UNION)
UPDATE 高速 中速(2テーブル) 高速
DELETE 高速 中速(2テーブル) 高速

インデックス戦略

SINGLE_TABLE戦略:

1
2
3
4
5
-- 識別子カラムへのインデックスが重要
CREATE INDEX idx_payment_type ON payment(payment_type);

-- 複合インデックス
CREATE INDEX idx_payment_type_status ON payment(payment_type, status);

JOINED戦略:

1
2
3
4
-- 親テーブルの識別子カラム
CREATE INDEX idx_payment_type ON payment(payment_type);

-- 子テーブルの主キー(外部キー)は自動的にインデックス

ユースケース別の選定基準

SINGLE_TABLEを選択すべきケース

  • サブクラスの数が少ない(3〜5個程度)
  • サブクラス固有の属性が少ない
  • ポリモーフィッククエリが頻繁に発生する
  • サブクラス固有属性のNOT NULL制約が不要
  • シンプルな構造を優先する場合

JOINEDを選択すべきケース

  • データの正規化が重要
  • サブクラス固有の属性にNOT NULL制約が必要
  • サブクラスの数が多い、または将来増える可能性がある
  • 各サブクラスのデータ量が大きい
  • ポリモーフィッククエリの頻度が低い

TABLE_PER_CLASSを選択すべきケース

  • ポリモーフィッククエリがほぼ不要
  • 各サブクラスが完全に独立している
  • 特定のサブクラスへのクエリが圧倒的に多い
  • 将来的にサブクラスを別システムに分離する可能性がある

決定フローチャート

flowchart TD
    A[継承マッピング戦略の選択] --> B{ポリモーフィッククエリが必要?}
    B -->|はい| C{サブクラス固有属性に\nNOT NULL制約が必要?}
    B -->|いいえ| D{サブクラス固有属性に\nNOT NULL制約が必要?}
    C -->|はい| E[JOINED]
    C -->|いいえ| F{サブクラスの数は?}
    F -->|3-5個| G[SINGLE_TABLE]
    F -->|6個以上| E
    D -->|はい| H[TABLE_PER_CLASS\nまたは@MappedSuperclass]
    D -->|いいえ| I{データの独立性は?}
    I -->|高い| H
    I -->|低い| G

よくある誤解とアンチパターン

アンチパターン1: 無条件にSINGLE_TABLEを選択

SINGLE_TABLEはデフォルト戦略ですが、すべてのケースに最適というわけではありません。特に以下の場合は他の戦略を検討すべきです。

  • サブクラス固有の必須属性が多い
  • サブクラスの数が今後大幅に増える見込みがある
  • テーブルのカラム数がデータベースの制限に近づいている

アンチパターン2: 深すぎる継承階層

3階層以上の継承は、どの戦略でもパフォーマンスと保守性に悪影響を与えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// アンチパターン: 深すぎる継承
@Entity
public abstract class Payment { }

@Entity
public abstract class OnlinePayment extends Payment { }

@Entity
public abstract class CardPayment extends OnlinePayment { }

@Entity
public class CreditCardPayment extends CardPayment { }

代わりに、コンポジション(埋め込み)を検討してください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 推奨: コンポジションを活用
@Entity
public class Payment {
    @Embedded
    private PaymentMethod paymentMethod;
}

@Embeddable
public class CardPaymentMethod {
    private String cardNumber;
    private String cardHolderName;
}

アンチパターン3: @MappedSuperclassでポリモーフィッククエリを期待

@MappedSuperclassはエンティティではないため、ポリモーフィッククエリの対象になりません。

1
2
3
4
5
6
// コンパイルエラーにはならないが、期待どおりに動作しない
@MappedSuperclass
public abstract class BasePayment { }

// このリポジトリは作成できない
// public interface BasePaymentRepository extends JpaRepository<BasePayment, Long> { }

アンチパターン4: TABLE_PER_CLASSでIDENTITY生成戦略

TABLE_PER_CLASS戦略では、GenerationType.IDENTITYを使用するとテーブル間でIDの一意性が保証されません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// アンチパターン
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 問題あり
    private Long id;
}

// 推奨
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE) // または SEQUENCE
    private Long id;
}

まとめと実践Tips

戦略選択のまとめ

戦略 推奨ケース 避けるべきケース
SINGLE_TABLE サブクラス少数、ポリモーフィッククエリ頻繁 NOT NULL必須、カラム数制限
JOINED 正規化重視、NOT NULL必須 深い階層、ポリモーフィッククエリ超頻繁
TABLE_PER_CLASS 独立性高、ポリモーフィック不要 ポリモーフィッククエリ必須

実践Tips

  1. デフォルトの選択: 迷った場合はSINGLE_TABLEから始め、問題が発生したらJOINEDへ移行を検討
  2. 識別子の設計: STRING型でビジネス上意味のある値を使用し、将来の拡張に備える
  3. インデックスの活用: 識別子カラムには必ずインデックスを作成
  4. テストの重要性: 各戦略でのクエリパフォーマンスを実データで測定
  5. ドキュメント化: 戦略選択の理由をADR(Architecture Decision Record)に記録

移行時の注意点

継承戦略の変更は大規模なデータベース変更を伴います。以下の手順を推奨します。

  1. 新テーブル構造の作成
  2. データ移行スクリプトの作成とテスト
  3. アプリケーションコードの更新
  4. ステージング環境での検証
  5. メンテナンスウィンドウでの本番移行
  6. 旧テーブルの削除(一定期間後)

参考リンク