What MyBatis Does at Startup: From MappedStatement to Dynamic SQL Parsing
This article dissects MyBatis startup internals, explaining how Mapper interfaces and XML SQL are parsed into MappedStatement objects, the role of SqlSource in static vs dynamic SQL, the #{}/${} difference, and how JDK dynamic proxies enable interface method execution without implementation classes.
1. What Is MappedStatement?
MappedStatement is MyBatis's complete encapsulation of a single SQL statement. Every <select>, <insert>, <update>, <delete> tag in XML becomes a MappedStatement object containing all information needed to execute that SQL:
SQL source ( SqlSource)
Parameter mapping ( ParameterMap / ParameterMapping)
Result mapping ( ResultMap / ResultMapping)
Statement type ( StatementType: STATEMENT/PREPARED/CALLABLE)
SQL command type ( SqlCommandType: SELECT/INSERT/UPDATE/DELETE)
Timeout, fetchSize, cache settings, key generator, etc.
Two core fields:
id : unique identifier formatted as namespace + "." + tag id (e.g., com.example.UserMapper.selectById). At runtime, the Mapper interface's fully qualified name + method name forms this id to locate the MappedStatement.
sqlSource : encapsulates the SQL text and parameter mappings; the core of SQL execution.
All MappedStatements are stored in Configuration.mappedStatements (a Map<String, MappedStatement>). Configuration is MyBatis's global configuration center; startup is essentially building this object.
2. MyBatis Startup Flow (Pure MyBatis)
2.1 Entry Point
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
User user = userMapper.selectById(1L);The critical step is new SqlSessionFactoryBuilder().build(inputStream), which parses the config file, builds Configuration, and returns DefaultSqlSessionFactory.
2.2 XMLConfigBuilder Parses Global Config
XMLConfigBuilder.parse()parses <configuration> root node in fixed order: propertiesElement —
<properties> settingsAsProperties—
<settings> loadCustomVfs— custom VFS typeAliasesElement —
<typeAliases> pluginElement—
<plugins> objectFactoryElement/ objectWrapperFactoryElement /
reflectorFactoryElement settingsElement— apply settings environmentsElement — <environments> (DataSource, transaction)
databaseIdProviderElement typeHandlersElement— <typeHandlers> mappersElement — <mappers> (core!)
Earlier parsed configs (typeAliases, typeHandlers) are used by later Mapper parsing.
2.3 mappersElement Parses Mapper Config
<mappers>supports four styles:
<mappers>
<mapper resource="com/example/mapper/UserMapper.xml"/>
<mapper url="file:///var/mappers/UserMapper.xml"/>
<mapper class="com.example.mapper.UserMapper"/>
<package name="com.example.mapper"/>
</mappers> mappersElementdelegates to XMLMapperBuilder for XML files or registers the interface directly. Ultimately it does two things:
Parse Mapper XML via XMLMapperBuilder to generate MappedStatements.
Register Mapper interface into MapperRegistry for later dynamic proxy creation.
2.4 XMLMapperBuilder Parses Single Mapper XML
XMLMapperBuilder.parse():
Parse <mapper> root node via configurationElement.
Mark resource loaded to avoid duplicate parsing. bindMapperForNamespace() — bind Mapper interface by namespace.
Process pending ResultMap, CacheRef, and Statement references (retry failed parses). configurationElement parses child nodes in order:
cache-ref cache parameterMap resultMap sql(SQL fragments)
buildStatementFromContext for select|insert|update|delete (core!)
Namespace must match the Mapper interface's fully qualified name — this binds interface to XML.
2.5 XMLStatementBuilder Parses SQL Tags into MappedStatement
buildStatementFromContextiterates each SQL tag, creates an XMLStatementBuilder per tag, and calls parseStatementNode():
public class XMLStatementBuilder extends BaseBuilder {
public void parseStatementNode() {
// 1. Read tag attributes
String id = context.getStringAttribute("id");
// ... fetchSize, timeout, parameterMap, parameterType, resultMap, resultType,
// resultSetType, statementType, keyProperty, keyColumn, useCache, flushCache
// 2. Parse SQL source (core!)
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
// 3. Build MappedStatement via builder pattern
builderAssistant.addMappedStatement(
id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass,
resultMap, resultTypeClass, resultSetTypeEnum,
flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId,
langDriver, resultSets);
}
}Key point: langDriver.createSqlSource() parses XML SQL text into a SqlSource object.
2.6 MapperBuilderAssistant Builds and Registers MappedStatement
MapperBuilderAssistant.addMappedStatement()uses builder pattern:
public MappedStatement addMappedStatement(...) {
id = applyCurrentNamespace(id, false); // prefix with namespace
MappedStatement.Builder statementBuilder = new MappedStatement.Builder(
configuration, id, sqlSource, sqlCommandType)
.resource(resource)
.fetchSize(fetchSize)
.timeout(timeout)
.statementType(statementType)
.keyGenerator(keyGenerator)
.keyProperty(keyProperty)
.keyColumn(keyColumn)
.databaseId(databaseId)
.lang(lang)
.resultOrdered(resultOrdered)
.resultSets(resultSets)
.resultMaps(getStatementResultMaps(resultMap, resultType, id))
.resultSetType(resultSetType)
.flushCacheRequired(flushCache)
.useCache(useCache)
.cache(currentCache);
ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);
if (statementParameterMap != null) {
statementBuilder.parameterMap(statementParameterMap);
}
MappedStatement statement = statementBuilder.build();
configuration.addMappedStatement(statement); // register
return statement;
}After all SQL tags are parsed, Configuration holds all MappedStatements.
3. Deep Dive: SqlSource and Dynamic SQL Parsing
SqlSourceencapsulates SQL and parameter mappings. Understanding it explains #{} vs ${} and dynamic SQL execution.
3.1 Three SqlSource Types
StaticSqlSource : Static SQL; parsed at startup, parameters use ? placeholders. Scenario: Only #{} present.
RawSqlSource : Raw SQL; at startup checks for ${} or dynamic tags; if none, wraps as StaticSqlSource. Scenario: Startup check; actual use is StaticSqlSource.
DynamicSqlSource : Dynamic SQL; contains ${} or dynamic tags ( <if>, <where>, <foreach>); re-parsed every execution. Scenario: Dynamic SQL.
3.2 XMLScriptBuilder Creates SqlSource
langDriver.createSqlSource()creates XMLScriptBuilder which parses the SQL node:
public class XMLScriptBuilder extends BaseBuilder {
public SqlSource parseScriptNode() {
MixedSqlNode rootSqlNode = parseDynamicTags(context);
if (isDynamic) {
return new DynamicSqlSource(configuration, rootSqlNode);
} else {
return new RawSqlSource(configuration, rootSqlNode, parameterType);
}
}
private MixedSqlNode parseDynamicTags(XNode node) {
List<SqlNode> contents = new ArrayList<>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE
|| child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
handler.handleNode(child, contents);
isDynamic = true;
}
}
return new MixedSqlNode(contents);
}
}Dynamic SQL detection criteria: SQL text contains ${} OR dynamic tags ( <if>, <where>, <foreach>, <choose>, etc.).
Contains ${} or dynamic tags → DynamicSqlSource → re-parsed every execution .
Only #{} → RawSqlSource → parsed at startup into StaticSqlSource → execution uses it directly.
Dynamic SQL node tree consists of SqlNode implementations: IfSqlNode, WhereSqlNode, ForEachSqlNode, StaticTextSqlNode, etc.
3.3 #{} vs ${} Fundamental Difference
#{} (prepared parameter):
Parsed at startup into ? placeholder.
At execution, PreparedStatement sets parameter.
Prevents SQL injection (parameter treated as data, not SQL).
Better performance (DB caches execution plan).
${} (string substitution):
At execution, parameter value concatenated directly into SQL string.
Uses Statement (or PreparedStatement but parameter already inlined).
Cannot prevent SQL injection (value becomes part of SQL).
Worse performance (SQL differs each time, no plan reuse).
When to use ${}? Only when parameter is part of SQL structure (table name, column name, ORDER BY field) because prepared parameters cannot be used for identifiers.
<!-- Table name must use ${} because #{} becomes ?, and table name cannot be a parameter -->
SELECT * FROM ${tableName} WHERE id = #{id}
<!-- ORDER BY column also must use ${} -->
SELECT * FROM user ORDER BY ${orderBy} ${sortType}Rule: Always prefer #{} unless the parameter is a SQL structural element (table/column names). When using ${} , enforce whitelist validation to prevent injection.
3.4 Dynamic SQL Execution Process
Example with <if>:
<select id="selectUser" resultType="User">
SELECT * FROM user
<where>
<if test="name != null">
AND name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>At startup, parsed into DynamicSqlSource with a SqlNode tree:
MixedSqlNode
├── StaticTextSqlNode("SELECT * FROM user")
└── WhereSqlNode
└── MixedSqlNode
├── IfSqlNode(test="name != null")
│ └── StaticTextSqlNode("AND name = #{name}")
└── IfSqlNode(test="age != null")
└── StaticTextSqlNode("AND age = #{age}")At execution, DynamicSqlSource.getBoundSql(parameterObject):
Create DynamicContext with parameter object.
Traverse SqlNode tree; each node decides whether to append based on parameter (e.g., <if> evaluates test expression).
After concatenation, obtain full SQL text (still containing #{}).
Use SqlSourceBuilder to parse #{}, generating ? placeholders and ParameterMapping.
Wrap into BoundSql (executable SQL, parameter mappings, parameter values).
public class DynamicSqlSource implements SqlSource {
private final Configuration configuration;
private final SqlNode rootSqlNode;
@Override
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
rootSqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
}
return boundSql;
}
}This is why dynamic SQL re-parses every execution — different parameters produce different SQL. Static SQL (only #{} ) is parsed once at startup, so execution is faster.
4. Mapper Interface & Dynamic Proxy: Why Interfaces Work Without Implementation
4.1 MapperRegistry Registers Mapper Interfaces
During XML parsing, bindMapperForNamespace() finds the Mapper interface by namespace and registers it in MapperRegistry:
public class MapperRegistry {
private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();
public <T> void addMapper(Class<T> type) {
if (type.isInterface()) {
if (hasMapper(type)) {
throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
}
boolean loadCompleted = false;
try {
knownMappers.put(type, new MapperProxyFactory<>(type));
MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
parser.parse(); // parse @Select, @Insert annotations into MappedStatement
loadCompleted = true;
} finally {
if (!loadCompleted) knownMappers.remove(type);
}
}
}
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
if (mapperProxyFactory == null) {
throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
}
return mapperProxyFactory.newInstance(sqlSession);
}
}Key points:
Each Mapper interface gets a MapperProxyFactory.
Registration also parses annotations on the interface ( @Select, @Insert, etc.) to generate MappedStatements. getMapper() uses the factory to create a dynamic proxy.
4.2 MapperProxyFactory Creates Dynamic Proxy
public class MapperProxyFactory<T> {
private final Class<T> mapperInterface;
private final Map<Method, MapperMethodInvoker> methodCache = new ConcurrentHashMap<>();
public T newInstance(SqlSession sqlSession) {
MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
return newInstance(mapperProxy);
}
protected T newInstance(MapperProxy<T> mapperProxy) {
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface},
mapperProxy);
}
}Uses JDK dynamic proxy ; MapperProxy implements InvocationHandler; all method calls route to MapperProxy.invoke().
4.3 MapperProxy: Method Invocation Entry
public class MapperProxy<T> implements InvocationHandler, Serializable {
private final SqlSession sqlSession;
private final Class<T> mapperInterface;
private final Map<Method, MapperMethodInvoker> methodCache;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
} else {
return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
}
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
private MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
return methodCache.computeIfAbsent(method, m -> {
if (m.isDefault()) {
return new DefaultMethodInvoker(getMethodHandleJava8(method));
} else {
return new PlainMethodInvoker(new MapperMethod(
mapperInterface, method, sqlSession.getConfiguration()));
}
});
}
}4.4 MapperMethod: Actual SQL Execution
MapperMethodencapsulates execution logic for a Mapper method. It resolves the MappedStatement by method name and delegates to SqlSession:
public class MapperMethod {
private final SqlCommand command;
private final MethodSignature method;
public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
this.command = new SqlCommand(config, mapperInterface, method);
this.method = new MethodSignature(config, mapperInterface, method);
}
public Object execute(SqlSession sqlSession, Object[] args) {
Object result;
switch (command.getType()) {
case INSERT:
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.insert(command.getName(), param));
break;
case UPDATE:
param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.update(command.getName(), param));
break;
case DELETE:
param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.delete(command.getName(), param));
break;
case SELECT:
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else if (method.returnsCursor()) {
result = executeForCursor(sqlSession, args);
} else {
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
if (method.returnsOptional() && (result == null || !method.getReturnType().equals(result.getClass()))) {
result = Optional.ofNullable(result);
}
}
break;
case FLUSH:
result = sqlSession.flushStatements();
break;
default:
throw new BindingException("Unknown execution method for: " + command.getName());
}
return result;
}
public static class SqlCommand {
private final String name;
private final SqlCommandType type;
public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
String statementId = mapperInterface.getName() + "." + method.getName();
MappedStatement ms = configuration.getMappedStatement(statementId);
this.name = ms.getId();
this.type = ms.getSqlCommandType();
}
}
}Key points:
statementId = Mapper interface FQN + "." + method name — this binds interface method to XML SQL tag. command.getName() is the statementId used to fetch MappedStatement from Configuration.
Dispatches to SqlSession methods based on SQL type and return type. SqlSession internally uses MappedStatement's SqlSource to get BoundSql, then executes via Executor.
Full chain: Mapper interface method → MapperProxy (JDK dynamic proxy) → MapperMethod → statementId lookup → MappedStatement → SqlSession → Executor → JDBC execution.
5. Spring Integration: Startup Differences
Spring-MyBatis integration uses three core components:
SqlSessionFactoryBean : FactoryBean that creates SqlSessionFactory during Spring startup; internally uses SqlSessionFactoryBuilder to parse config.
MapperScannerConfigurer : BeanDefinitionRegistryPostProcessor that scans Mapper interface packages and registers each as a MapperFactoryBean.
MapperFactoryBean : FactoryBean that, on injection, creates the Mapper dynamic proxy via SqlSession.getMapper() (still uses MapperProxyFactory).
Spring startup flow:
Spring startup
│
├─ 1. Parse config classes, register BeanDefinitions
├─ 2. MapperScannerConfigurer scans Mapper package
│ └─ Register each Mapper interface as MapperFactoryBean (BeanDefinition)
├─ 3. Instantiate SqlSessionFactoryBean
│ ├─ Parse mybatis-config.xml (if configured)
│ ├─ Parse Mapper XML files (per mapperLocations)
│ ├─ Generate MappedStatements, register to Configuration
│ └─ Return DefaultSqlSessionFactory
├─ 4. Instantiate MapperFactoryBean (inject SqlSessionFactory)
│ └─ getObject() calls SqlSession.getMapper() to create Mapper dynamic proxy
└─ 5. Inject Mapper into Service
└─ Actual injected object is MapperProxy dynamic proxyCore logic identical to pure MyBatis: parse XML → MappedStatement → MapperProxy. Only difference is integration into Spring lifecycle.
Summary
MyBatis startup is fundamentally building the Configuration object by parsing all Mapper methods and SQL statements into MappedStatements .
Core Flow Recap
XMLConfigBuilder parses global config : mybatis-config.xml nodes (properties, settings, typeAliases, plugins, environments, mappers, etc.).
XMLMapperBuilder parses Mapper XML : namespace, resultMap, sql fragments, SQL statement tags.
XMLStatementBuilder parses SQL statements : each select|insert|update|delete tag becomes a MappedStatement registered in Configuration.mappedStatements.
SqlSource parsing : static SQL (only #{}) → StaticSqlSource at startup; dynamic SQL ( ${} or dynamic tags) → DynamicSqlSource, re-parsed per execution.
MapperRegistry registers Mapper interfaces : each interface gets a MapperProxyFactory.
MapperProxy dynamic proxy : JDK proxy intercepts calls; uses interface FQN.methodName to find MappedStatement, then executes via SqlSession.
Core Concepts
MappedStatement : complete SQL encapsulation (SqlSource, parameter/result mappings, config).
Configuration : global config center; holds all MappedStatements.
SqlSource : SQL source; StaticSqlSource (static) vs DynamicSqlSource (dynamic).
statementId : namespace + "." + id; binding key between Mapper method and SQL.
MapperProxy : JDK dynamic proxy; enables interface calls without implementation class.
Understanding MyBatis startup and MappedStatement moves you beyond "just writing XML and interfaces" to grasping internals — enabling precise debugging of issues like SQL not taking effect, parameter binding failures, dynamic SQL not executing, cache misses. MyBatis's design is elegant; mastering its startup flow reveals its core philosophy.
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.
Java Tech Enthusiast
Sharing computer programming language knowledge, focusing on Java fundamentals, data structures, related tools, Spring Cloud, IntelliJ IDEA... Book giveaways, red‑packet rewards and other perks await!
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.
