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.
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:
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()!
}
}