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.

Architecture Pony
Architecture Pony
Architecture Pony
MyBatis Deep Dive: Architecture, Source Code & Best Practices

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 collections

Two "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 DefaultSqlSessionFactory
Key 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/delete

Convention: 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 layer
Three 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.intercept

Plugins 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 → MappedStatement
Design 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);
}
cachedInvoker

caches 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;
}
localCache

is 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
    }
}
newStatementHandler

returns 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#execute

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Plugin ArchitectureSource Code AnalysisCachingMyBatisDynamic SQLSpring IntegrationJava PersistenceORM Framework
Architecture Pony
Written by

Architecture Pony

Learning notes

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.