notificationsテーブルを例に、1つのテーブルに複数の通知種別(EMAIL / PUSH)が混在するケースで discriminator タグを使った出し分けを解説します。association・collectionによる親子マッピングは resultMapのassociation/collection記事 で扱っているので、本記事では種別ごとに異なるフィールドをマッピングする discriminator に絞ります。

動作確認はSpring Boot 3系、mybatis-spring-boot-starterの3系、H2で行っています。discriminator自体はMyBatisの初期バージョンから存在する機能なので、バージョンによる挙動差はあまり気にしなくて大丈夫です。

1つのテーブルに複数種別が混在すると何が困るか

notificationsテーブルには type カラムがあり、EMAILなら subject と body_html に、PUSHなら device_token と payload_json に値が入ります。全部1行に収まっていますが、種別ごとに使うカラムが違うわけですね。

resultMapを素直に書くと、全カラムをフラットに1つのDTOへ詰め込むか、Service層で type を見てswitchしながら詰め替えるかのどちらかになりがちです。後者はエンティティへの詰め替えロジックが散らばり、種別が増えるたびに分岐を書き足す羽目になります。discriminator を使うと、この分岐をresultMap側に寄せられます。

discriminatorタグの基本構文

discriminator はresultMapの中に1つだけ置けるタグで、どの case を使うかを判定するカラムを指定します。

<discriminator column="type" javaType="string">
  <case value="EMAIL" resultMap="emailNotificationMap"/>
  <case value="PUSH" resultMap="pushNotificationMap"/>
</discriminator>

column には判定に使う実際のカラム名、javaType にはそのカラムの値をJavaのどの型として読むかを書きます。文字列で判定するなら string、数値コードなら int です。各 case の value がカラムの実際の値と一致したら、resultMap で指定したresultMapが使われます。仕組みとしてはこれだけです。

通知種別ごとにresultMapを出し分ける

ベースのresultMapに discriminator を仕込み、case ごとに専用のresultMapへ extends で継承させるのが基本形です。

<resultMap id="notificationMap" type="com.example.notification.NotificationBase">
  <id property="id" column="id"/>
  <result property="recipient" column="recipient"/>
  <result property="createdAt" column="created_at"/>
  <discriminator column="type" javaType="string">
    <case value="EMAIL" resultMap="emailNotificationMap"/>
    <case value="PUSH" resultMap="pushNotificationMap"/>
  </discriminator>
</resultMap>

<resultMap id="emailNotificationMap" type="com.example.notification.EmailNotification"
           extends="notificationMap">
  <result property="subject" column="subject"/>
  <result property="bodyHtml" column="body_html"/>
</resultMap>

<resultMap id="pushNotificationMap" type="com.example.notification.PushNotification"
           extends="notificationMap">
  <result property="deviceToken" column="device_token"/>
  <result property="payloadJson" column="payload_json"/>
</resultMap>

<select id="findAll" resultMap="notificationMap">
  SELECT id, type, recipient, created_at, subject, body_html, device_token, payload_json
  FROM notifications
</select>

extends を使うとベースresultMapの id / result を引き継げるので、emailNotificationMap や pushNotificationMap 側で id や recipient を再定義する必要はありません。種別固有のカラムだけを書き足せばOKです。全カラムを1回のSELECTで取得し、discriminatorが行ごとに適切なresultMapへ振り分けるので、SQLにJOINやサブクエリを増やす必要もありません。

caseごとに異なるフィールドをクラス階層にマッピングする

Javaのクラス階層はこうなります。

public abstract class NotificationBase {
    private Long id;
    private String recipient;
    private LocalDateTime createdAt;
    // getter/setter省略
}

public class EmailNotification extends NotificationBase {
    private String subject;
    private String bodyHtml;
    // getter/setter省略
}

public class PushNotification extends NotificationBase {
    private String deviceToken;
    private String payloadJson;
    // getter/setter省略
}

Mapperインターフェースは List<NotificationBase> を返すだけです。

public interface NotificationMapper {
    List<NotificationBase> findAll();
}

呼び出し元は instanceof やキャストをせずにポリモーフィックにリストを扱えます。どのサブクラスが返ってくるかはdiscriminatorが決めてくれるので、Mapperを呼ぶ側はもう型のことを気にしなくていいわけです。

association・collectionと組み合わせる

case で指定するresultMap自体に association や collection を含めても問題ありません。たとえばPUSH通知だけに配信ログの一覧を持たせたい場合は、こう書けます。

<resultMap id="pushNotificationMap" type="com.example.notification.PushNotification"
           extends="notificationMap">
  <result property="deviceToken" column="device_token"/>
  <result property="payloadJson" column="payload_json"/>
  <collection property="deliveryLogs" ofType="com.example.notification.DeliveryLog"
              resultMap="deliveryLogMap"/>
</resultMap>

discriminatorとcollection/associationは独立した仕組みなので、このように自然にネストできます。ただしネストが2階層を超えて複雑になってきたら、discriminatorの単位でMapperファイル自体を分割したほうが読みやすいです。

導入前後でコードはどう変わるか

discriminator導入前は、Service層でこんな分岐を書いていたはずです。

public NotificationBase toNotification(NotificationRow row) {
    switch (row.getType()) {
        case "EMAIL":
            EmailNotification email = new EmailNotification();
            email.setSubject(row.getSubject());
            email.setBodyHtml(row.getBodyHtml());
            return email;
        case "PUSH":
            PushNotification push = new PushNotification();
            push.setDeviceToken(row.getDeviceToken());
            push.setPayloadJson(row.getPayloadJson());
            return push;
        default:
            throw new IllegalStateException("unknown type: " + row.getType());
    }
}

導入後は、この詰め替えコードがそのまま不要になります。

List<NotificationBase> notifications = notificationMapper.findAll();

種別が増えてもresultMapに case を1行追加するだけで済み、Service層のコードは変更不要です。switch文がコードベースのあちこちに散らばる心配もなくなります。

ハマりやすい設定ミス

一番多いのは javaType の指定漏れです。省略すると case の value とDBの値をうまく比較できず、全件が最初の case に落ちたり、どのcaseにも当たらずベースのresultMapで処理されたりします。

javaType と resultType / ofType を混同するのもよくある間違いです。discriminatorの javaType は「判定に使うカラムの値をどの型として読むか」を指定するものであり、マッピング先のクラス(EmailNotification など)を指定するものではありません。マッピング先のクラスは各 case の resultMap 属性(または resultType)で決まります。resultType は <select> の戻り値、ofType は <collection> の要素型を指定するための属性で、discriminatorの javaType とは役割が異なるので、ここを混同すると意図しない型にマッピングされてしまいます。

value とDB上の値の表記ゆれも定番の罠です。DBには小文字で email と入っているのに value="EMAIL" と書くと一致しません。Enumを使っている場合はDB値とEnum名を揃えておくと迷いにくいです。

discriminatorには「どのcaseにも当てはまらない場合」を明示的に指定する仕組みがありません。該当する case がなければ、ベースのresultMap(notificationMap)がそのまま使われるだけです。想定外の種別を見逃さずに検知したいなら、アプリ側で返ってきたインスタンスの型を確認するバリデーションを別途入れておくと安全です。

テストで出し分けを確認する

実データを入れて、返ってきたインスタンスの型とフィールドを確認するテストを書いておきましょう。@MybatisTest のようなMyBatis専用のテストスライスは存在しないので、@SpringBootTest でアプリケーションコンテキストを起動し、テスト用のH2に投入したデータに対してMapperを実行する形にします。

@SpringBootTest
class NotificationMapperTest {

    @Autowired
    private NotificationMapper notificationMapper;

    @Test
    void 種別ごとに正しいサブクラスにマッピングされる() {
        List<NotificationBase> notifications = notificationMapper.findAll();

        assertThat(notifications)
            .filteredOn(n -> n instanceof EmailNotification)
            .extracting(n -> ((EmailNotification) n).getSubject())
            .isNotEmpty();

        assertThat(notifications)
            .anyMatch(n -> n instanceof PushNotification);
    }
}

テストデータは src/test/resources の schema.sql / data.sql でH2に投入しておけばOKです。型のアサーションを入れておくと、カラム名を変更したときに case の対応漏れへ早めに気付けます。

まとめ

discriminator を使うと、1つのテーブルに複数種別が混在していても、Java側にswitch文を書かずにresultMapだけで型ごとの出し分けができます。ポイントは column / javaType で判定方法を決め、case の resultMap に extends で専用のresultMapをぶら下げることです。

association・collectionとの組み合わせ方は resultMapのassociation/collection記事 を、Mapperの基本に不安がある方は MyBatisの実装ガイド を参考にしてください。そもそもMyBatisとJPAのどちらで単一テーブル継承的なマッピングを扱うか迷っている場合は、MyBatisとJPAの比較記事 もあわせてどうぞ。