Spring Boot + MyBatis: XML vs Annotations – Which to Pick?
Learn how to integrate MyBatis with Spring Boot using XML mappings and annotations.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+
- ✓Spring Boot 3.x
- ✓Basic knowledge of SQL and JDBC
- ✓Familiarity with Maven or Gradle
- Use MyBatis when you need fine-grained SQL control and complex mappings that JPA struggles with.
- Annotations are great for simple CRUD; XML mappings shine for dynamic SQL and legacy databases.
- Always define Mapper interfaces as Spring beans and use @MapperScan or @Mapper.
- For dynamic SQL, MyBatis's
and in XML beat JPA's criteria API hands down. - Beware of N+1 queries: MyBatis doesn't auto-fetch lazy associations like Hibernate.
Think of MyBatis as a smart assistant that helps you write SQL and map results to Java objects. Unlike JPA which tries to hide SQL, MyBatis puts you in control. XML mappings are like detailed blueprints for complex queries; annotations are sticky notes for simple ones.
If you've ever fought with Hibernate's lazy loading exceptions or watched in horror as it generated 10+ SQL queries for a simple join, you're not alone. JPA is powerful, but sometimes you need to take the wheel. That's where MyBatis comes in.
MyBatis is a persistence framework that gives you full control over SQL. You write the queries, you decide the mappings. No magic, no surprises. It's the go-to choice for teams working with legacy databases, complex reporting, or performance-critical systems where every millisecond counts.
In this article, I'll show you how to integrate MyBatis with Spring Boot 3.2, covering both XML mappings and annotations. You'll learn when to use each, how to avoid common pitfalls, and what the official documentation conveniently leaves out. By the end, you'll know exactly which approach fits your project and how to debug it when things go sideways.
Why MyBatis Still Matters in 2025
Let's get one thing straight: JPA is not the answer to everything. I've seen teams spend weeks trying to tune Hibernate's second-level cache, only to rip it out and replace it with MyBatis. MyBatis gives you raw SQL power without the overhead of an ORM. You write the SQL, you control the mapping.
Here's the hard truth: most teams get this wrong. They start with JPA because it's the 'standard,' then hit a wall with complex queries or legacy schemas. MyBatis shines when: - You have a legacy database with weird column names and no control over schema. - You need to write dynamic SQL with conditional WHERE clauses, pagination, and batch updates. - Performance is critical and you can't afford Hibernate's query generation overhead. - You're working with stored procedures or database-specific features.
Spring Boot 3.2 makes integration trivial. Add the starter, configure a datasource, define mappers, and you're off. But the devil is in the details—especially when mixing XML and annotations.
Getting Started: Dependencies and Configuration
First, add the MyBatis Spring Boot Starter. I recommend version 3.0.3 with Spring Boot 3.2. If you're still on Boot 2.x, use 2.3.x. Don't mix versions—I've seen ClassNotFoundException because of incompatible MyBatis core versions.
In your application.properties (or YAML), configure the datasource and MyBatis settings:
```properties spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=secret spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
mybatis.mapper-locations=classpath:mapper/*/.xml mybatis.configuration.map-underscore-to-camel-case=true mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl ```
The map-underscore-to-camel-case setting is a lifesaver. It automatically maps user_name to userName in your Java POJO. Without it, you'll spend hours writing explicit mappings.
Now create a Mapper interface. You can use either XML or annotations—or both. I'll show you both approaches.
Annotation-Based Mappings: Simple and Direct
For straightforward CRUD operations, annotations are perfect. You define SQL directly on the method using @Select, @Insert, @Update, @Delete. No XML files needed.
Here's an example for a Payment entity:
```java @Mapper public interface PaymentMapper { @Select("SELECT * FROM payments WHERE id = #{id}") Payment findById(Long id);
@Insert("INSERT INTO payments(amount, currency, status) VALUES(#{amount}, #{currency}, #{status})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(Payment payment);
@Update("UPDATE payments SET status = #{status} WHERE id = #{id}") int update(Payment payment);
@Delete("DELETE FROM payments WHERE id = #{id}") int delete(Long id); } ```
Notice @Options(useGeneratedKeys = true). This is critical for auto-increment IDs. If you forget it, the generated key won't be populated in your object.
For complex queries with joins, annotations can get messy. You end up with long strings and no syntax highlighting. That's when XML shines.
XML Mappings: Power and Flexibility
XML mappings give you the full power of MyBatis: dynamic SQL, result maps, and reusable fragments. They're essential for complex queries that would be a nightmare in annotations.
Create an XML file in src/main/resources/mapper/PaymentMapper.xml:
```xml
Notice the and tags. This is dynamic SQL—one of MyBatis's strongest features. You can build queries conditionally without string concatenation. The tag automatically handles the first AND.
Also note the resultMap. This gives you explicit control over column-to-field mapping. It's verbose but bulletproof. For simple cases, you can use resultType instead, but I always prefer resultMap for clarity.
When to Use Annotations vs XML: A Practical Guide
Here's my rule of thumb:
- Annotations: Use for simple CRUD, single-table operations, and when you have fewer than 10 queries per mapper. The code is self-contained and easy to read.
- XML: Use for complex queries with joins, dynamic SQL, stored procedures, and when you need result mappings that differ from column names. Also use XML when you want to keep SQL separate from Java code for DBA review.
But here's the twist: you can mix both in the same mapper. MyBatis allows it. I often define simple queries with annotations and complex ones in XML. Just be consistent within your team.
One thing the docs don't tell you: if you have both an annotation and an XML entry for the same method, the XML takes precedence. I've seen confusion when someone adds an annotation and wonders why it's not being used because an old XML file is still on the classpath.
What the Official Docs Won't Tell You
Let me save you hours of debugging with these hard-earned lessons:
1. MyBatis Does Not Manage Transactions The official docs assume you know this, but many don't. MyBatis does not start or commit transactions. It delegates to the underlying JDBC connection. If you're using Spring Boot, you must use @Transactional or a TransactionTemplate. Without it, each statement auto-commits. The production incident earlier is a perfect example.
2. The 'Invalid Bound Statement' Error Is Usually a Classpath Issue You've added the XML file, but MyBatis can't find it. Check: - The namespace matches the interface FQN. - The XML file is in the path specified by mybatis.mapper-locations. - The XML file is not filtered by Maven resource plugin. Add in your POM if needed.
3. Lazy Loading in MyBatis Is Not Like Hibernate MyBatis supports lazy loading for associations, but it's not enabled by default. You need to configure lazyLoadingEnabled=true and aggressiveLazyLoading=false. Even then, lazy loading requires a proxy and can lead to N+1 queries if you're not careful. I recommend using join queries instead.
4. Type Handlers Are Your Friend for Custom Types Need to map a PostgreSQL jsonb column to a Java Map? Write a custom TypeHandler. The docs cover it, but the real gotcha is that you must register it in MyBatis configuration. Use @MappedTypes and @MappedJdbcTypes annotations, or register globally via mybatis.type-handlers-package.
5. XML Mapper Files Are Not Hot-Reloaded Unlike JPA's automatic DDL, MyBatis XML files are loaded at startup. If you change an XML file, you must restart the application. In development, you can use Spring Boot DevTools with mybatis.configuration.cache-enabled=false to force reload, but it's not perfect. For production, treat XML files like code—they should be version-controlled and deployed with the app.
Advanced: Dynamic SQL with ") List searchPayments(@Param("status") String status, @Param("minAmount") BigDecimal minAmount); ``
Notice the > for >—XML escaping inside a Java string is a nightmare. This is why I prefer XML files for dynamic SQL. But if you must keep everything in annotations, this works.
Another advanced feature is @ResultMap which lets you reuse a result map defined in XML from an annotation. This is useful when you want the clarity of annotations for simple queries but the power of XML result maps for complex mappings.
XML Escaping in Annotations📊 Production InsightI've seen SQL injection vulnerabilities from poorly escaped dynamic SQL in annotations. Always use parameterized queries ( #{} ) never string concatenation.🎯 Key TakeawayDynamic SQL in annotations is possible but ugly. Stick to XML for anything beyond simple conditions.Testing MyBatis Mappers
Testing MyBatis mappers is straightforward with Spring Boot's test slice @MybatisTest. It auto-configures an embedded database and MyBatis components.
```java @MybatisTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) class PaymentMapperTest {
@Autowired private PaymentMapper paymentMapper;
@Test void testInsertAndFindById() { Payment payment = new Payment(); payment.setAmount(new BigDecimal("100.00")); payment.setCurrency("USD"); payment.setStatus("PENDING");
int rows = paymentMapper.insert(payment); assertThat(rows).isEqualTo(1); assertThat(payment.getId()).isNotNull();
Payment found = paymentMapper.findById(payment.getId()); assertThat(found.getAmount()).isEqualByComparingTo(new BigDecimal("100.00")); } } ```
Note: @MybatisTest does not roll back transactions by default. Use @Transactional on your test class or method to ensure cleanup. Also, if you use XML mappers, ensure they are on the test classpath. A common mistake is forgetting to copy XML files to test resources.
For integration tests with a real database, use @SpringBootTest and @Testcontainers. I'll cover that in a future article.
Test XML Mapper Discovery📊 Production InsightI've seen CI pipelines fail because XML mappers were missing from test resources. Always verify with a simple test that loads the mapper context.🎯 Key TakeawayUse @MybatisTest for slice tests and ensure XML files are on the test classpath.
Notice the > for >—XML escaping inside a Java string is a nightmare. This is why I prefer XML files for dynamic SQL. But if you must keep everything in annotations, this works.
Another advanced feature is @ResultMap which lets you reuse a result map defined in XML from an annotation. This is useful when you want the clarity of annotations for simple queries but the power of XML result maps for complex mappings.
Testing MyBatis Mappers
Testing MyBatis mappers is straightforward with Spring Boot's test slice @MybatisTest. It auto-configures an embedded database and MyBatis components.
```java @MybatisTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) class PaymentMapperTest {
@Autowired private PaymentMapper paymentMapper;
@Test void testInsertAndFindById() { Payment payment = new Payment(); payment.setAmount(new BigDecimal("100.00")); payment.setCurrency("USD"); payment.setStatus("PENDING");
int rows = paymentMapper.insert(payment); assertThat(rows).isEqualTo(1); assertThat(payment.getId()).isNotNull();
Payment found = paymentMapper.findById(payment.getId()); assertThat(found.getAmount()).isEqualByComparingTo(new BigDecimal("100.00")); } } ```
Note: @MybatisTest does not roll back transactions by default. Use @Transactional on your test class or method to ensure cleanup. Also, if you use XML mappers, ensure they are on the test classpath. A common mistake is forgetting to copy XML files to test resources.
For integration tests with a real database, use @SpringBootTest and @Testcontainers. I'll cover that in a future article.
The Case of the Vanishing Transactions
- Never rely on auto-commit for multi-statement operations. Always use @Transactional.
- MyBatis does not manage transactions; it delegates to Spring's transaction manager.
- Test batch jobs with failure scenarios to ensure rollback works as expected.
- Enable SQL logging (mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl) during development to catch unexpected behavior.
- Use a transaction template for programmatic transaction control when annotations aren't enough.
mvn dependency:tree | grep mybatisgrep -r 'namespace' src/main/resources/mapper/| File | Command / Code | Purpose |
|---|---|---|
| pom.xml | Why MyBatis Still Matters in 2025 | |
| application.properties | spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false | Getting Started |
| PaymentMapper.java | @Mapper | Annotation-Based Mappings |
| PaymentMapper.xml | XML Mappings | |
| HybridMapper.java | @Mapper | When to Use Annotations vs XML |
| CustomTypeHandler.java | @MappedTypes(Map.class) | What the Official Docs Won't Tell You |
| AdvancedMapper.java | @Mapper | Advanced |
| PaymentMapperTest.java | @MybatisTest | Testing MyBatis Mappers |
Key takeaways
Interview Questions on This Topic
Explain the difference between MyBatis and JPA/Hibernate. When would you choose MyBatis over JPA?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Hibernate & JPA. Mark it forged?
5 min read · try the examples if you haven't