Using the notifications table as an example, this article explains how to use the discriminator tag to switch mappings when multiple notification types (EMAIL / PUSH) coexist in a single table. Parent-child mapping via association/collection is covered in the resultMap association/collection article, so this article focuses specifically on discriminator, which maps different fields depending on the type.

Verification was done with Spring Boot 3.x, mybatis-spring-boot-starter 3.x, and H2. The discriminator feature itself has existed since the early versions of MyBatis, so you don’t need to worry much about version-dependent behavior differences.

What goes wrong when multiple types coexist in one table

The notifications table has a type column: when it’s EMAIL, values go into subject and body_html; when it’s PUSH, values go into device_token and payload_json. Everything fits into a single row, but the columns used differ by type.

If you write a resultMap naively, you tend to end up either flattening all columns into a single DTO, or branching with a switch on type in the service layer to reassemble the data. The latter scatters the reassembly logic for the entity across the codebase, and you end up adding another branch every time a new type appears. Using discriminator lets you push this branching into the resultMap itself.

Basic syntax of the discriminator tag

discriminator is a tag you can place only once inside a resultMap, and it specifies the column used to determine which case to use.

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

column specifies the actual column name used for the determination, and javaType specifies what Java type to read that column’s value as. Use string when determining by a string, or int for a numeric code. When a given case’s value matches the column’s actual value, the resultMap specified by resultMap is used. That’s the entire mechanism.

Switching resultMaps per notification type

The basic pattern is to embed a discriminator in the base resultMap, and have each case inherit from a dedicated resultMap via 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>

Using extends lets you inherit the id / result mappings from the base resultMap, so there’s no need to redefine id or recipient in emailNotificationMap or pushNotificationMap. You only need to add the type-specific columns. Since all columns are fetched in a single SELECT and discriminator routes each row to the appropriate resultMap, there’s no need to add JOINs or subqueries to the SQL either.

Mapping different fields per case onto a class hierarchy

The Java class hierarchy looks like this.

public abstract class NotificationBase {
    private Long id;
    private String recipient;
    private LocalDateTime createdAt;
    // getters/setters omitted
}

public class EmailNotification extends NotificationBase {
    private String subject;
    private String bodyHtml;
    // getters/setters omitted
}

public class PushNotification extends NotificationBase {
    private String deviceToken;
    private String payloadJson;
    // getters/setters omitted
}

The Mapper interface simply returns a List<NotificationBase>.

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

The caller can handle the list polymorphically without instanceof checks or casts. Since discriminator decides which subclass gets returned, the caller no longer needs to worry about types at all.

Combining with association/collection

There’s no problem including association or collection within the resultMap specified by a case. For example, if you want only PUSH notifications to carry a list of delivery logs, you can write it like this.

<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>

Since discriminator and collection/association are independent mechanisms, nesting them like this works naturally. That said, once nesting goes beyond two levels and gets complex, it’s more readable to split the Mapper file itself along discriminator boundaries.

How the code changes before and after introduction

Before introducing discriminator, you’d likely have written a branch like this in the service layer.

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());
    }
}

After introducing it, this reassembly code becomes entirely unnecessary.

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

Even as new types are added, all you need is to add one case line to the resultMap — no changes are needed to the service layer code. You also no longer need to worry about switch statements scattered throughout the codebase.

Common configuration mistakes

The most common mistake is forgetting to specify javaType. If omitted, the case value can’t be compared properly against the DB value, causing either all rows to fall into the first case, or none of the cases to match and the row falls back to the base resultMap.

Confusing javaType with resultType / ofType is another common mistake. The discriminator’s javaType specifies “what type to read the determining column’s value as” — it does not specify the mapping destination class (e.g., EmailNotification). The mapping destination class is determined by each case’s resultMap attribute (or resultType). resultType is an attribute for specifying a <select>’s return type, and ofType is for specifying a <collection>’s element type — their role differs from discriminator’s javaType, so confusing them here results in mapping to an unintended type.

Mismatches between value and the actual DB value are another classic trap. If the DB stores lowercase email but you write value="EMAIL", they won’t match. If you’re using an Enum, aligning the DB value with the Enum name helps avoid confusion.

discriminator has no mechanism to explicitly specify “what happens when no case matches.” If no matching case is found, the base resultMap (notificationMap) is simply used as-is. If you want to reliably detect unexpected types rather than letting them slip through, it’s safer to add a separate validation on the application side that checks the type of the returned instance.

Verifying the switching behavior with tests

Insert real data and write a test that checks the type and fields of the returned instance. There’s no MyBatis-specific test slice like @MybatisTest, so you start the application context with @SpringBootTest and run the Mapper against data loaded into a test H2 instance.

@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);
    }
}

You can load the test data into H2 via schema.sql / data.sql under src/test/resources. Adding type assertions helps you catch missing case mappings early whenever a column name changes.

Summary

Using discriminator, you can switch mappings by type using only the resultMap, without writing a switch statement in Java, even when multiple types coexist in a single table. The key points are deciding the determination method via column / javaType, and attaching a dedicated resultMap to each case’s resultMap via extends.

For how to combine this with association/collection, see the resultMap association/collection article; if you’re unsure about the basics of Mapper, see the MyBatis implementation guide. And if you’re wondering whether to handle single-table-inheritance-style mapping with MyBatis or JPA in the first place, check out the MyBatis vs. JPA comparison article as well.