8. TypeHandlers, Plugins & Testing

MyBatis provides clean extension SPIs for custom Java value objects (BaseTypeHandler<T>), cross-cutting statement interception (Interceptor / @Intercepts), and lightweight in-memory SQL integration testing (ScriptRunner).


Custom BaseTypeHandler<T> for Domain Value Objects & JSON

Whenever your domain model uses strongly typed value objects (TenantId, Money, EmailAddress) or serialized JSON columns, extend org.apache.ibatis.type.BaseTypeHandler<T>. BaseTypeHandler automatically handles PreparedStatement.setNull(...), ResultSet.wasNull(), and TypeException wrapping.

08_typehandlers_plugins_and_testing_1
Bidirectional Conversion via BaseTypeHandler
Source Code
from drawlib.canvas import save, setup
from drawlib.diagrams.architecture import ArchitectureDiagram, Node, NodeGroup, PhosphorIcon
from drawlib.styles import Colors, Styles

setup(width=144, height=46, dpi=150)

d = ArchitectureDiagram(
    node_style=Styles.Dark,
    node_text_style=Styles.DarkBold.patch(text_size=9.2),
    edge_style=Styles.Dark.patch(line_width=1.5),
    edge_text_style=Styles.DarkBold.patch(text_size=8.8),
    node_card_style=Styles.Neutral.patch(shape_fill_color=Colors.White),
)

grp = d.add(
    NodeGroup(
        "TypeHandlerRegistry Dispatch (MappedTypes <-> BaseTypeHandler<T> <-> MappedJdbcTypes)",
        width=136,
        height=36,
        style=Styles.PrimaryNeutral,
        text_style=Styles.DarkBold.patch(text_size=10.0, halign="left", valign="top"),
    ),
    xy=(4, 5),
)
n_java = grp.add(Node("Java Value Object\nrecord TenantId(UUID value)", icon=PhosphorIcon.CUBE, width=38, height=15), xy=(23, 15))
n_th = grp.add(Node("TenantIdTypeHandler\nextends BaseTypeHandler<TenantId>", icon=PhosphorIcon.ARROWS_LEFT_RIGHT, width=44, height=15), xy=(68, 15))
n_jdbc = grp.add(Node("JDBC Column\nVARCHAR / UUID\nps.setObject / rs.getString", icon=PhosphorIcon.DATABASE, width=38, height=15), xy=(113, 15))

n_java.connect(n_th, label="setNonNullParameter")
n_th.connect(n_jdbc, label="bind / read")

d.draw(xy=(0, 0))
save()
package com.example.blog.type;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;

public record TenantId(UUID value) {}

@MappedTypes(TenantId.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class TenantIdTypeHandler extends BaseTypeHandler<TenantId> {

  @Override
  public void setNonNullParameter(
      PreparedStatement ps, int i, TenantId parameter, JdbcType jdbcType) throws SQLException {
    ps.setString(i, parameter.value().toString());
  }

  @Override
  public TenantId getNullableResult(ResultSet rs, String columnName) throws SQLException {
    String raw = rs.getString(columnName);
    return raw == null ? null : new TenantId(UUID.fromString(raw));
  }

  @Override
  public TenantId getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
    String raw = rs.getString(columnIndex);
    return raw == null ? null : new TenantId(UUID.fromString(raw));
  }

  @Override
  public TenantId getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
    String raw = cs.getString(columnIndex);
    return raw == null ? null : new TenantId(UUID.fromString(raw));
  }
}

Handling Java enums (EnumTypeHandler vs EnumOrdinalTypeHandler)

By default, MyBatis registers EnumTypeHandler for all Java enum types, storing Enum.name() ("PUBLISHED", "DRAFT"). Never use EnumOrdinalTypeHandler (0, 1) in production schemas if enum constants might ever be reordered; instead, either use EnumTypeHandler or write a small generic CodeEnumTypeHandler<E extends CodedEnum> that persists an explicit stable code.


Writing @Intercepts Plugins

MyBatis's InterceptorChain allows you to wrap any method on the four core execution interfaces:

08_typehandlers_plugins_and_testing_2
The Four Interceptable Core Targets in MyBatis 3
Source Code
from drawlib.canvas import save, setup
from drawlib.smartarts import CardList
from drawlib.styles import Colors, Styles

setup(width=144, height=40, dpi=150)

cards = CardList(
    style=Styles.Neutral.patch(shape_fill_color=Colors.White),
    text_style=Styles.DarkBold.patch(text_size=10.0),
    description_style=Styles.Dark.patch(text_size=8.5),
    badge_shape="rounded_rect",
    badge_style=Styles.PrimaryFlat,
    badge_text_style=Styles.WhiteBold.patch(text_size=9.0),
    direction="horizontal",
    spacing=2.5,
)

cards.add("1. Executor", "update, query, queryCursor,\nflushStatements, commit\nUse for: Audit / Metrics", badge_text="EXEC")
cards.add("2. StatementHandler", "prepare, parameterize,\nbatch, update, query\nUse for: SQL / Pagination", badge_text="STMT")
cards.add("3. ParameterHandler", "getParameterObject,\nsetParameters\nUse for: Auto-Audit Fields", badge_text="PARAM")
cards.add("4. ResultSetHandler", "handleResultSets,\nhandleCursorResultSets\nUse for: Row Decryption", badge_text="RESULT")

cards.draw((4.0, 4.0), width=136.0, height=32.0, align="bottom_left")
save()

Example: Slow-Query Telemetry Interceptor

package com.example.blog.plugin;

import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Intercepts({
  @Signature(
      type = Executor.class,
      method = "query",
      args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
  @Signature(
      type = Executor.class,
      method = "query",
      args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
  @Signature(
      type = Executor.class,
      method = "update",
      args = {MappedStatement.class, Object.class})
})
public class SlowQueryTelemetryPlugin implements Interceptor {

  private static final Logger log = LoggerFactory.getLogger(SlowQueryTelemetryPlugin.class);
  private long slowThresholdMs = 200L;

  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    long startNanos = System.nanoTime();
    try {
      return invocation.proceed();
    } finally {
      long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
      if (elapsedMs >= slowThresholdMs) {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        log.warn("Slow MyBatis statement [{}] took {} ms", ms.getId(), elapsedMs);
      }
    }
  }

  @Override
  public void setProperties(Properties properties) {
    String threshold = properties.getProperty("slowThresholdMs");
    if (threshold != null) {
      this.slowThresholdMs = Long.parseLong(threshold);
    }
  }
}

Fast Integration Testing with ScriptRunner & H2 / Testcontainers

Never mock AuthorMapper in unit tests when testing SQL queries—a mock cannot catch SQL syntax errors, missing column aliases, or TypeHandler mismatches! Instead, use org.apache.ibatis.jdbc.ScriptRunner to initialize your schema against an in-memory H2 database or Testcontainers PostgreSQL instance in JUnit 5:

package com.example.blog.mapper;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.io.Reader;
import java.sql.Connection;
import java.time.LocalDateTime;
import javax.sql.DataSource;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.jdbc.ScriptRunner;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

class AuthorMapperIntegrationTest {

  private static SqlSessionFactory sqlSessionFactory;

  @BeforeAll
  static void setUpDatabase() throws Exception {
    DataSource dataSource = new UnpooledDataSource(
        "org.h2.Driver",
        "jdbc:h2:mem:blog_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
        "sa",
        "");

    Environment env = new Environment("test", new JdbcTransactionFactory(), dataSource);
    Configuration config = new Configuration(env);
    config.setMapUnderscoreToCamelCase(true);
    config.addMapper(AuthorMapper.class);
    sqlSessionFactory = new SqlSessionFactoryBuilder().build(config);

    // Execute DDL schema.sql using MyBatis's built-in ScriptRunner
    try (Connection conn = dataSource.getConnection();
         Reader reader = Resources.getResourceAsReader("schema.sql")) {
      ScriptRunner runner = new ScriptRunner(conn);
      runner.setLogWriter(null);
      runner.runScript(reader);
    }
  }

  @Test
  void shouldInsertAndLoadAuthorRecord() {
    try (SqlSession session = sqlSessionFactory.openSession()) {
      AuthorMapper mapper = session.getMapper(AuthorMapper.class);

      Author created = new Author(null, "alice", "alice@example.com", LocalDateTime.now());
      mapper.insert(created);

      Author loaded = mapper.findById(1L).orElseThrow();
      assertNotNull(loaded.id());
      assertEquals("alice", loaded.username());
    } // Uncommitted session rolls back automatically on close()!
  }
}