MyBatis Deep Dive: Architecture, Source Code & Best Practices
A comprehensive 14k-word technical deep-dive into MyBatis covering its semi-automatic ORM design, four core components (Executor, StatementHandler, ParameterHandler, ResultSetHandler), dynamic SQL parsing via SqlNode trees, two-level caching, plugin mechanism, Spring integration, and source-code-level analysis of 11 key classes with practical best practices and common pitfalls.
What MyBatis Is: Positioning & Evolution
MyBatis is a semi-automatic persistence framework : it manages SQL execution (parameter binding, result mapping, resource release) while leaving SQL writing entirely to the developer. Unlike Hibernate/JPA which auto-generates SQL, MyBatis gives you full control — MyBatis handles "how SQL executes"; you decide "what SQL writes."
Core boundary: SqlSession is the execution entry; MappedStatement is a SQL's "blueprint"; the four components (Executor, StatementHandler, ParameterHandler, ResultSetHandler) form the execution pipeline.
Evolution Timeline
2001: Clinton Begin starts iBATIS as JDBC wrapper for a cryptography project
2010: Moves to Google Code, renamed MyBatis (continuation of iBATIS 3.x)
2013: Moves to GitHub, community-driven rapid iteration
3.4/3.5.x: Java 8 time types, optional parameters, improved cache & plugin APIs — becomes de facto standard
Ecosystem: MyBatis-Plus (enhancement), MyBatis-Spring/Spring Boot Starter (integration), PageHelper (pagination)
Key distinction: MyBatis logo is a red bird; MyBatis-Plus logo is a blue bird. MyBatis-Plus only enhances, never changes the underlying MyBatis execution engine.
Problems MyBatis Solves vs Raw JDBC
Boilerplate code: Takes over connection, Statement creation, parameter setting, result iteration, resource closing
Manual result mapping: ResultMap / auto camel-case mapping — one row → object directly
SQL scattered everywhere: Centralized in XML/annotations, decoupled from Java code
Dynamic SQL concatenation hard: <if>, <choose>, <foreach> tags — complex conditions readable & maintainable
Technical Analysis
Design Philosophy
Don't hide SQL: Rejects "auto-generated SQL" black box; performance predictable & optimizable
Convention + override: Default auto-mapping; use ResultMap for precise control when needed
Interface as contract: Mapper interfaces have no implementation classes — JDK dynamic proxy generates them
Chain-of-responsibility extension: Executor and three other components interceptable via plugins; pagination/auditing built this way
Zero third-party deps: Core depends only on JDBC — lightweight, readable, debuggable
Horizontal Comparison: MyBatis vs JPA vs JdbcTemplate
SQL Control: MyBatis = hand-written; JPA = auto-generated; JdbcTemplate = hand-written, heavy boilerplate
Automation Level: MyBatis = semi-auto mapping; JPA = full ORM; JdbcTemplate = fully manual
Learning Curve: MyBatis = gentle; JPA = quick start, deep tuning; JdbcTemplate = gentle
DB Portability: MyBatis = weak (dialect SQL); JPA = good; JdbcTemplate = weak
Complex Queries: MyBatis = best; JPA = average; JdbcTemplate = best
Best Fit: MyBatis = high concurrency + complex reports; JPA = complex domain models; JdbcTemplate = minimal data access
Selection advice: Want SQL control, extreme optimization, team knows SQL → MyBatis. Want dev efficiency, model-driven, DB portability → JPA. They can coexist.
Overall Architecture: Three Layers
API Layer (Interface Layer) SqlSession: CRUD APIs for developers
Mapper interfaces: interface-oriented programming entry
Data Processing Layer
Parameter mapping: ParameterHandler SQL parsing & execution: Executor / StatementHandler Result mapping: ResultSetHandler Base Support Layer
Connection management, transaction management, config parsing, caching, logging, type conversion
Dependency flows top-down : API layer calls data processing layer, which depends on base support layer. Each layer can be independently replaced or enhanced.
Four Core Components & Relationships
MyBatis splits "execute one SQL" into four specialized components forming a pipeline:
① Executor Executor: scheduling hub, cache checks, transaction start, delegates to StatementHandler
↓
② StatementHandler StatementHandler: creates Statement via DB driver, sends SQL to database
↓
③ ParameterHandler ParameterHandler: binds Java params to PreparedStatement ? placeholders
↓
④ ResultSetHandler ResultSetHandler: maps ResultSet to Java objects or collectionsTwo "foundation" components spanning the full flow: Configuration: global singleton config object, holds all MappedStatement, ResultMap, TypeHandler, plugin chains — the framework's "runtime database" MappedStatement: complete description of one SQL — id, SQL source, param type, return type, cache, timeout… the "blueprint of one SQL"
Core Process Analysis
Initialization: From Config File to Configuration
MyBatis startup does one thing: parse all XML/annotation config into a complete Configuration object , then wrap into SqlSessionFactory.
① SqlSessionFactoryBuilder receives mybatis-config.xml input stream
↓
② XMLConfigBuilder parses root configuration node
↓
③ Parses in order: properties / settings / typeAliases / plugins / environments / mappers
↓
④ mappers node triggers XMLMapperBuilder, parses each MappedStatement, registers into Configuration
↓
⑤ Returns Configuration, constructs DefaultSqlSessionFactoryKey insight: Parsing is a "one-time cost"; execution phase has almost zero XML parsing overhead. Explains why MyBatis startup is "heavier" than execution.
Mapper Interface & Dynamic Proxy: Why No Implementation Class
You only write the interface:
public interface OrderMapper {
Order selectById(Long id);
int insert(Order order);
}No implementation class, yet directly callable. Secret is MapperProxyFactory: uses JDK dynamic proxy to generate proxy object; every method call converts to one SQL execution.
① Startup: MapperRegistry records "interface Class → MapperProxyFactory" mapping
↓
② Call sqlSession.getMapper(OrderMapper.class) → proxy object
↓
③ Invoke interface method → enters MapperProxy.invoke
↓
④ Wrapped as MapperMethod, dispatched by SQL type to SqlSession select/insert/update/deleteConvention: Interface FQN = namespace, method name = MappedStatement id . Mismatch throws BindingException at startup or first call.
Complete Query Chain
Piecing fragments together gives MyBatis's core mainline. Memorize this chain:
① MapperProxy.invoke → MapperMethod.execute (determine SQL type)
↓
② DefaultSqlSession.selectOne → selectList
↓
③ CachingExecutor.query (L2 cache check, miss → delegate)
↓
④ BaseExecutor.query (L1 cache check, miss → query DB)
↓
⑤ SimpleExecutor.doQuery (create StatementHandler)
↓
⑥ StatementHandler.prepare + ParameterHandler.setParameters (SQL & params ready)
↓
⑦ Execute JDBC, ResultSetHandler.handleResultSets completes mapping
↓
⑧ Result written to L1 cache (flushed to L2 on commit), returned layer by layerThree Executor implementations: SimpleExecutor (new Statement each time, default), ReuseExecutor (reuse Statement), BatchExecutor (batch submit). With L2 cache enabled, Executor gets wrapped by CachingExecutor — so actual call stack outermost is often CachingExecutor.
Dynamic SQL Parsing Flow
Dynamic SQL essence: turn SQL into a tree of SqlNode, traverse at runtime to assemble final SQL . StaticTextSqlNode: Pure static text, direct output IfSqlNode: <if>, append only if condition true ChooseSqlNode: <choose> / <when> /
<otherwise> ForEachSqlNode: <foreach>, iterate collection, rewrite placeholders TrimSqlNode: <where> / <set> / <trim>, handle prefix/suffix TextSqlNode: Text containing ${}, direct string replacement DynamicSqlSource.getBoundSql() in two steps: first SqlNode tree assembles "SQL with #{} placeholders", then SqlSourceBuilder replaces #{} with JDBC ? and generates ParameterMapping. Product is BoundSql .
#{} vs ${} fundamental difference: #{} uses PreparedStatement pre-compilation — parameter placeholder , inherently injection-safe. ${} is direct string concatenation , introduces SQL injection risk. Only for structural info (table/column names, sort fields) must you use ${} , and must whitelist-validate.
Plugin Mechanism: How Four Components Get Intercepted
MyBatis only lets you intercept Executor, StatementHandler, ParameterHandler, ResultSetHandler . Principle: when creating these four objects, wrap each with plugin proxy.
① At parse time, each Interceptor generates an InterceptorChain node
↓
② After creating Executor/StatementHandler/etc, call interceptorChain.pluginAll(target)
↓
③ Plugin.wrap uses JDK dynamic proxy to wrap target
↓
④ On invoke, Plugin.invoke checks @Signature match; if hit → Interceptor.interceptPlugins use "layered wrapping" — later-registered plugins sit on the outside , forming a pluggable responsibility chain. PageHelper works by intercepting Executor.query, rewriting SQL to append LIMIT (and running count query first when needed).
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class})
})
public class MyInterceptor implements Interceptor { ... }Two-Level Cache Mechanism
MyBatis has two cache levels with completely different lifecycles & scopes:
L1 Cache (Local Cache)
Scope: SqlSession (within same session)
Default: ON , cannot disable
Storage: BaseExecutor.localCache (PerpetualCache)
Invalidation: on update/insert/delete, explicit clearCache, transaction commit/rollback, SqlSession close
L2 Cache (Namespace Cache)
Scope: Mapper namespace (cross SqlSession, in-process shared)
Default: OFF , enable via <cache/> in mapper or @CacheNamespace Storage: managed by CachingExecutor + TransactionalCacheManager Critical limit: only written after transaction commits , discarded on rollback
Production advice: L2 cache in distributed env is a minefield (multi-instance inconsistency, cross-namespace join dirty reads). Recommend disabling L2 cache, delegate caching to Redis etc. explicitly. This is industry standard practice.
Spring Integration Flow
Standalone MyBatis requires manual SqlSession lifecycle management. Spring integration fully automates this: SqlSessionFactoryBean: Wraps MyBatis SqlSessionFactory as Spring Bean MapperScannerConfigurer: Scans @MapperScan packages, registers interfaces as MapperFactoryBean MapperFactoryBean: getObject() returns actual Mapper proxy SqlSessionTemplate: Thread-safe SqlSession proxy; internally binds Spring transaction's connection via dynamic proxy
In Spring Boot, MybatisAutoConfiguration auto-wires above; just provide DataSource — why mybatis-spring-boot-starter is near "zero-config".
Core Source Code Analysis
Each section anchored by method name (line numbers vary by version), main path only, edge handling skipped.
4.1 SqlSessionFactoryBuilder#build
public SqlSessionFactory build(InputStream in) {
// 1. Parse config with XML parser
XMLConfigBuilder parser = new XMLConfigBuilder(in);
// 2. parse() returns Configuration, build factory
return build(parser.parse());
}Three lines reveal key design: Configuration is the "intermediate product" and framework center . Builder only translates external config into Configuration.
4.2 XMLConfigBuilder#parseConfiguration
Parse order fixed — later nodes depend on earlier results (e.g. mappers need typeAliases ready):
propertiesElement(root); // properties
settingsElement(settings); // global settings
typeAliasesElement(...); // aliases
pluginElement(...); // plugin chain ← built here
objectFactoryElement(...); // object factory
typeHandlerElement(...); // type handlers
environmentsElement(...); // dataSource + transaction
mapperElement(mappers); // ⭐ parse all mappers → MappedStatementDesign lesson: This "fixed-order child-node parsing" is the standard pattern for translating "config syntax" into "object model". Any config-driven framework's core is this step.
4.3 MapperProxy#invoke: Interface Call Landing Point
public Object invoke(Object proxy, Method method, Object[] args) {
// 1. Object built-in methods (toString/equals) go to target
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 2. Default methods execute directly
// 3. Rest → wrap MapperMethod & execute SQL
return cachedInvoker(method)
.invoke(proxy, method, args, sqlSession);
} cachedInvokercaches executor per Method via Map, avoiding repeated reflection checks — typical optimization on hot path.
4.4 MapperMethod#execute: SQL Type Dispatch
public Object execute(SqlSession sqlSession, Object[] args) {
switch (command.getType()) {
case INSERT: case UPDATE: case DELETE:
Object param = method.convertArgsToSqlCommandParam(args);
return rowCountResult(sqlSession.update(
command.getName(), param));
case SELECT:
if (method.returnsMany()) return executeForMany(...);
if (method.returnsMap()) return executeForMap(...);
if (method.returnsCursor()) return executeForCursor(...);
return sqlSession.selectOne(command.getName(),
method.convertArgsToSqlCommandParam(args));
default:
throw new BindingException("Unknown method");
}
}Combines return type (single/collection/Map/Cursor) and SQL type into dispatch table. SqlCommandType fixed at XML parse time — no runtime tag-name checks needed.
4.5 CachingExecutor#query: L2 Cache First Gate
public List query(MappedStatement ms, Object param,
RowBounds rowBounds, ResultHandler handler) {
// 1. Get BoundSql (dynamic SQL assembled here)
BoundSql boundSql = ms.getBoundSql(param);
// 2. Generate cache key: statementId + SQL + params + pagination + env
CacheKey key = createCacheKey(ms, param, rowBounds, boundSql);
return query(ms, param, rowBounds, handler, key, boundSql);
}Cache key determined by SQL statement + parameters + pagination + environment id . Explains why different SQLs never cross-hit — statementId & SQL text both participate in hash.
4.6 BaseExecutor#query: L1 Cache & DB Query
public List query(...) {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
// 1. ⭐ Hit L1 cache → return directly
List list = resultHandler == null
? (List) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
// 2. Miss → real DB query
list = queryFromDatabase(ms, parameter, rowBounds,
resultHandler, boundSql);
}
// 3. Empty collection returns empty list not null (avoid NPE)
return list;
} localCacheis PerpetualCache — bottom layer a HashMap. MyBatis provides LRU/FIFO/SOFT/WEAK decorators; default is no eviction policy (fine with short-lived SqlSession).
4.7 SimpleExecutor#doQuery: Four-Piece Suite Debut
public List doQuery(...) {
Statement stmt = null;
try {
// 1. ⭐ Create StatementHandler (already wrapped with plugin proxies)
StatementHandler handler = configuration.newStatementHandler(
this, ms, parameter, rowBounds, resultHandler, boundSql);
// 2. Create PreparedStatement & set parameters
stmt = prepareStatement(handler, ms.getStatementLog());
// 3. Execute & handle result set
return handler.query(stmt, resultHandler);
} finally {
closeStatement(stmt); // unified cleanup by SqlSession
}
} newStatementHandlerreturns RoutingStatementHandler , delegates to Simple/Prepared/Callable based on MappedStatement's StatementType — default PreparedStatementHandler.
Inside PreparedStatementHandler.query:
public List query(Statement stmt, ResultHandler rh) {
// 1. Actual JDBC execution: ps.execute()
// 2. Delegate to ResultSetHandler for object mapping
return resultSetHandler.handleResultSets(stmt);
}Mainline distilled: Executor decides "what to do" → StatementHandler decides "how to send" → ParameterHandler handles "bind params" → ResultSetHandler handles "turn into objects" .
4.8 Plugin#wrap: How Plugins Weave In
public static Object wrap(Object target, Interceptor interceptor) {
// 1. Parse @Intercepts annotation → "class → method set" map
Map<Class<?>, Set<Method>> map = getSignatureMap(interceptor);
Class<?> type = target.getClass();
// 2. Find which intercepted interfaces target implements
Class<?>[] interfaces = getAllInterfaces(type, map);
if (interfaces.length > 0) {
// 3. Generate JDK dynamic proxy
return Proxy.newProxyInstance(type.getClassLoader(),
interfaces, new Plugin(target, interceptor, map));
}
return target;
}Then in Plugin.invoke match method signature: hit → Interceptor.intercept takes over; miss → method.invoke(target, args) pass through.
Why only four interfaces? Because source code only calls pluginAll when creating those four objects. To expand intercept surface, must modify framework itself — deliberate restraint to avoid API bloat.
4.9 Dynamic SQL Source: SqlSource Hierarchy
// DynamicSqlSource#getBoundSql
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
// 1. Traverse SqlNode tree, dynamically assemble SQL with #{}
rootSqlNode.apply(context);
SqlSourceBuilder builder = new SqlSourceBuilder(configuration);
// 2. Parse #{} into ? and ParameterMapping
SqlSource sqlSource = builder.parse(
context.getSql(), parameterType, context.getBindings());
// 3. Final BoundSql: SQL + parameter mapping table
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
return boundSql;
}Detail: SQL containing ${} goes through TextSqlSource (pure string replace) , while SQL with <if> etc goes through DynamicSqlSource. Split at parse time, each runs own path at runtime — important performance trade-off.
4.10 MyBatis-Spring: MapperFactoryBean & SqlSessionTemplate
Two classes key to MyBatis joining Spring transaction system:
MapperFactoryBean implements Spring's FactoryBean; getObject() calls sqlSession.getMapper() — Mapper interface becomes injectable Bean. Also implements InitializingBean, validates interface-to-config at startup.
SqlSessionTemplate thread-safe SqlSession proxy. Each call fetches from Spring TransactionSynchronizationManager the current thread-bound SqlSession — same transaction reuses same SqlSession & Connection . This is why "Mapper calls managed by @Transactional" works underneath.
Chain it together: @Transactional starts transaction → Spring binds Connection to ThreadLocal → SqlSessionTemplate gets same SqlSession from ThreadLocal → all Mapper calls share one connection → commit at transaction end. Once this chain clicks, MyBatis-Spring integration holds no secrets.
4.11 Key Class Index
Factory Build: SqlSessionFactoryBuilder — build() Config Parsing: XMLConfigBuilder — parseConfiguration() Mapper Registration: MapperRegistry — addMapper() / getMapper() Mapper Proxy: MapperProxyFactory / MapperProxy — newInstance() / invoke() Method Dispatch: MapperMethod — execute() L2 Cache: CachingExecutor — query() L1 Cache: BaseExecutor — query() / localCache Actual Execution: SimpleExecutor — doQuery() Statement Handling: RoutingStatementHandler — query() Plugin: Plugin / InterceptorChain — wrap() / pluginAll() Dynamic SQL: DynamicSqlSource / SqlSourceBuilder — getBoundSql() / parse() Spring Integration: MapperFactoryBean / SqlSessionTemplate —
getObject()Learning Path
Four-Stage Progression
1 · Use It: Independently build standard persistence module. Key points: Mapper interface + XML binding, ResultMap, dynamic SQL, #{} vs ${}, Spring Boot integration. Graduation: write multi-table join + dynamic condition queries without tutorial.
2 · Understand Principles: Explain "why". Key points: four components' duties, Mapper dynamic proxy, two-level cache scope/invalidation, plugin mechanism, three Executor implementations. Graduation: whiteboard full query call chain.
3 · Read Source: Trace main trunk, locate any exception source. Key points: init parsing, MapperProxy, Executor chain, SqlSource, Plugin, cache. Graduation: point to source location of "why L1 cache dirty reads".
4 · Build Wheels: From user to extender. Key points: pagination plugin, SQL audit plugin, custom TypeHandler, multi-data-source routing. Graduation: implement production-ready interceptor independently.
Source Code Reading Order
SqlSessionFactoryBuilder#build→ XMLConfigBuilder#parseConfiguration Configuration construction &
MapperRegistry MapperProxyFactory#newInstance→
MapperProxy#invoke MapperMethod#executefour dispatches CachingExecutor#query →
BaseExecutor#query SimpleExecutor#doQuery→ StatementHandler trio ParameterHandler#setParameters → ResultSetHandler#handleResultSets SqlSource / SqlNode tree & DynamicContext InterceptorChain#pluginAll → Plugin#wrap / invoke MyBatis-Spring: MapperFactoryBean / SqlSessionTemplate Three practical methodologies:
Reverse Anchor Method: From familiar annotation trace back to handler — faster than reading from entry. E.g. from @Intercepts reverse to Plugin.
Breakpoint-First Method: Break at doQuery, handleResultSets; debugger "watch" flow — 5x faster than reading code.
Main-Trunk-Only Method: First pass skip all null checks & logging branches, grab only main path; second pass fill details.
High-Frequency Interview Questions
MyBatis vs Hibernate/JPA differences? Why choose MyBatis?
Mapper interface has no impl class — why callable? #{} vs ${} difference? When must use ${}?
L1 vs L2 cache scope & invalidation? Why L2 not recommended?
Executor types? Why need CachingExecutor?
Plugin implementation principle? Why only four interfaces?
How MyBatis coordinates with Spring transactions?
ResultMap auto-mapping rules?
How SqlNode tree works for dynamic SQL?
What is N+1 problem? How solve in MyBatis?
PageHelper pagination plugin principle?
MyBatis-Plus vs MyBatis relationship?
Best Practices & Common Pitfalls
Six Production Rules
Always use #{} , ban ${} — only for table/column/sort fields, with whitelist validation, else injection vuln.
Disable L2 cache — distributed inconsistency risk high; cache via Redis explicitly.
Beware N+1 queries — <collection> nested query in list amplifies to N times; prefer join + resultMap single query.
Batch ops via BatchExecutor — single-loop insert terrible; use <foreach> concat or enable BATCH mode.
Large result sets use cursor or pagination — select 100k rows blows memory; use Cursor<T> or enforce pagination.
SQL timeout + slow query logging — set timeout on statement, pair with slow SQL monitor; don't let one bad SQL kill connection pool.
Common Error Quick Reference
BindingException: Invalid bound statement— Namespace or method name mismatch with XML; or XML not scanned TooManyResultsException — Query returns multiple but used selectOne; switch to selectList or narrow condition ReflectionException / setter not found — ResultMap field name vs entity property mismatch, or missing no-arg constructor ExecutorException: Closed Connection — SqlSession closed prematurely, or cross-thread reuse; check session lifecycle
L1 cache dirty read — Same SqlSession, external update occurred; any write between two queries refreshes
One-Page Cheat Sheet
Positioning: Semi-auto ORM — you write SQL, it handles execution.
Architecture: API Layer / Data Processing Layer / Base Support Layer.
Four-piece suite: Executor decides what → StatementHandler how to send → ParameterHandler binds params → ResultSetHandler turns into objects.
Interface no impl: MapperRegistry maps → MapperProxyFactory generates JDK dynamic proxy → MapperProxy.invoke lands on MapperMethod.execute.
Dynamic SQL: SqlNode tree assembles SQL → SqlSourceBuilder turns #{} into ? → BoundSql.
Cache: L1 in SqlSession (default ON), L2 in namespace (default OFF, writes only on commit).
Plugin: Only intercepts four interfaces, via Plugin.wrap JDK proxy, later registrants outer layer.
Spring: SqlSessionTemplate pulls transaction-bound SqlSession from ThreadLocal, achieving session-transaction consistency.
Closing Thoughts
MyBatis is a "thin" framework, but thin ≠ simple. Every layer answers the same question: How to keep engineering complexity inside the framework without taking away SQL control?
Once you grasp this mainline, looking at MyBatis-Plus, PageHelper, audit plugins — they're just leveraging existing extension points. That's the real value of "reading source" — it gives your technical judgment evidence, not just experience.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
