Why MyBatis Is Still a Common Choice
In many Japanese enterprise systems, MyBatis is still chosen over JPA more often than you might expect. The reason is that you write your own SQL, which makes complex queries and tuning much easier to control. That said, a common complaint is: “When I try to wire this into Spring Boot from scratch, I’m not sure what goes where.”
This article walks through, step by step, with working code: adding the dependency, defining Mappers (both annotation-based and XML-based), dynamic SQL, mapping joined results, and integration with @Transactional. For the question of MyBatis vs. JPA, see the MyBatis vs. JPA comparison article — here, let’s focus on the implementation.
The Role of MyBatis and mybatis-spring-boot-starter
MyBatis is an O/R mapper of the type where “you write the SQL yourself, and let the framework handle the result mapping.” Unlike JPA, it doesn’t automatically generate SQL for you — instead, the SQL you write is executed as-is, which makes the behavior easy to reason about.
In Spring Boot, simply adding mybatis-spring-boot-starter auto-configures the creation of the SqlSessionFactory and the scanning of Mappers. As long as you configure a DataSource, all you need to do afterward is write Mapper interfaces. Think of it as occupying a middle ground: you want to write your own SQL, but you’d rather not deal with mapping by hand.
Adding the Dependency and DataSource/Connection Configuration
First, add the dependency. For Gradle, it looks like this. Choose the JDBC driver to match the database you’re using.
dependencies {
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
runtimeOnly 'com.mysql:mysql-connector-j'
}
Put the connection and MyBatis configuration together in application.yml.
spring:
datasource:
url: jdbc:mysql://localhost:3306/shop
username: app
password: secret
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
mapper-locations specifies where XML mappers live. Enabling map-underscore-to-camel-case automatically maps a user_name column to the userName field. Forgetting this setting tends to leave values as null, so it’s best to set it up from the start.
For Spring to recognize your Mappers, you can either put @MapperScan("com.example.shop.mapper") on your main class, or put @Mapper on each interface individually. If you want to manage Mappers collectively at the package level, @MapperScan is more convenient.
Sample Tables and Entities
From here on, we’ll use two tables, users and orders, as our example. Since we want to work with a one-to-many relationship, a user has multiple orders.
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_name VARCHAR(100) NOT NULL
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
amount INT NOT NULL
);
A plain Java class is sufficient for the entity. No special annotations are required.
public class User {
private Long id;
private String userName;
private List<Order> orders;
// getter / setter
}
Defining Mappers with Annotations
For short SQL, the annotation style is convenient. You write the SQL directly on an interface annotated with @Mapper.
@Mapper
public interface UserMapper {
@Select("SELECT id, user_name FROM users WHERE id = #{id}")
User findById(Long id);
@Insert("INSERT INTO users(user_name) VALUES(#{userName})")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insert(User user);
@Update("UPDATE users SET user_name = #{userName} WHERE id = #{id}")
void update(User user);
@Delete("DELETE FROM users WHERE id = #{id}")
void delete(Long id);
}
#{id} is a parameter placeholder. Adding @Options(useGeneratedKeys = true, keyProperty = "id") means that after the INSERT, the ID generated by the database is automatically set into user.id. From there, you just inject UserMapper via Spring’s DI and call it directly. For the flow of hitting CRUD operations from a REST API, the REST API CRUD tutorial is also a useful reference.
Defining Mappers with XML
As SQL grows longer or you start writing dynamic SQL, the annotation style becomes harder to read. In that case, move the SQL out into XML. The key point is to match the namespace to the fully qualified name of the Mapper interface, and match the id to the method name.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.shop.mapper.UserMapper">
<select id="findById" resultType="com.example.shop.User">
SELECT id, user_name FROM users WHERE id = #{id}
</select>
</mapper>
On the interface side, you leave out the SQL and just keep the method declaration. The rule of thumb is simple: use annotations for short, static SQL, and XML for dynamic SQL or complex JOINs — following that will rarely lead you astray.
Dynamic SQL with <if>/<where>/<foreach>
Dynamic SQL is one of MyBatis’s strengths. You can assemble queries in XML for cases where the search conditions vary depending on the input.
<select id="search" resultType="com.example.shop.User">
SELECT id, user_name FROM users
<where>
<if test="userName != null">
AND user_name LIKE CONCAT('%', #{userName}, '%')
</if>
<if test="ids != null and !ids.isEmpty()">
AND id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</if>
</where>
</select>
<where> only adds the WHERE clause when content is actually generated inside it, and it automatically strips any leading extraneous AND. <foreach> is the standard way to build an IN clause from a list. One thing to watch out for here is the behavior with an empty list: if ids is empty, you get IN (), which is a syntax error. As in the example above, it’s safer to check for emptiness with <if> before iterating. If you’d rather build dynamic SQL in Java, @SelectProvider is also an option, but XML is likely more readable to start with.
Mapping Joins and One-to-Many Relationships with resultMap
To map a JOIN result where a single User has multiple Orders hanging off of it, use resultMap. <collection> handles one-to-many, and <association> handles one-to-one.
<resultMap id="userWithOrders" type="com.example.shop.User">
<id property="id" column="id"/>
<result property="userName" column="user_name"/>
<collection property="orders" ofType="com.example.shop.Order">
<id property="id" column="order_id"/>
<result property="amount" column="amount"/>
</collection>
</resultMap>
<select id="findWithOrders" resultMap="userWithOrders">
SELECT u.id, u.user_name, o.id AS order_id, o.amount
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = #{id}
</select>
Fetching everything in a single JOIN like this avoids the N+1 problem of looking up orders separately for each user. If you prefer the annotation style, you can also write this with @Results and @Many.
@Select("SELECT id, user_name FROM users WHERE id = #{id}")
@Results({
@Result(property = "id", column = "id"),
@Result(property = "orders", column = "id",
many = @Many(select = "findOrdersByUserId"))
})
User findWithOrders(Long id);
However, the select in @Many issues a separate subquery, so it tends to result in N+1 queries. For joins where the row count grows, fetching everything at once with a JOIN in XML is the safer choice.
The Difference Between #{} and ${}, and SQL Injection Prevention
This is a point you must get right, since mistakes here lead to real incidents. #{} is a PreparedStatement placeholder, and values are bound safely. ${}, on the other hand, expands the string directly into the SQL as-is.
// Safe: the value is bound
@Select("SELECT * FROM users WHERE user_name = #{name}")
List<User> safe(String name);
// Dangerous: name is embedded directly, leading to SQL injection
@Select("SELECT * FROM users WHERE user_name = '${name}'")
List<User> danger(String name);
Passing user input into ${} is strictly forbidden. The only time ${} is actually needed is when you want to dynamically change the structure of the SQL itself — not a value — such as an ORDER BY column name or table name. Even in that case, don’t use the input as-is; validate it against an allowlist first.
private static final Set<String> SORTABLE = Set.of("id", "user_name");
public List<User> list(String sortColumn) {
if (!SORTABLE.contains(sortColumn)) {
throw new IllegalArgumentException("invalid column");
}
return mapper.listOrderBy(sortColumn);
}
The principle is: “Always use #{} for values; use ${} only for structure, and only with an allowlist.”
Managing Transactions with @Transactional
Testing Mapper Behavior
As your implementation grows, you’ll want to verify Mapper behavior against an actual database connection. Spring Boot doesn’t have a dedicated test slice for MyBatis, but combining @SpringBootTest with an embedded database (such as H2) lets you write integration tests that go through the real SQL.
@SpringBootTest
@Transactional
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
void findById_canRetrieve() {
User user = userMapper.findById(1L);
assertThat(user.getUserName()).isNotNull();
}
}
Adding @Transactional to the test class automatically rolls back after each test, so you don’t need to worry about test data piling up in the database. The more branching a piece of SQL has — as with dynamic SQL — the more reassuring it is to verify its behavior early with integration tests like this.
To bundle multiple Mapper calls into a single transaction, put @Transactional on the Service layer. MyBatis rides on top of Spring’s transaction management, so no special configuration is needed.
@Service
public class OrderService {
private final UserMapper userMapper;
private final OrderMapper orderMapper;
@Transactional
public void register(User user, Order order) {
userMapper.insert(user);
order.setUserId(user.getId());
orderMapper.insert(order);
}
}
If an unchecked exception (a RuntimeException subtype) is thrown partway through, both INSERTs are rolled back. If you want finer control over propagation and isolation levels, see the transaction management article for details.
Common Pitfalls
Here are the issues that tend to trip people up during setup. When you see a BindingException, check whether the XML’s namespace matches the interface’s fully qualified name, whether the method name matches the id, and whether the mapper-locations path is correct. If a column comes back null, suspect either a missing map-underscore-to-camel-case setting or a missing mapping in resultMap.
For methods with multiple parameters, you must name them with @Param, or you won’t be able to reference them.
List<User> search(@Param("name") String name, @Param("ids") List<Long> ids);
Another common issue is that the XML isn’t included in the build output and can’t be read from the classpath. Make sure to place it under src/main/resources, or configure your build so it’s picked up as a resource.
Conclusion
MyBatis is a practical O/R mapper that lets you keep full control over your SQL while still saving effort on mapping. Use annotations for short SQL, XML for dynamic SQL and joins, and always bind values with #{}. Once you have these basics down, you can safely build even the complex SQL that enterprise systems require. The fastest path to understanding is to try it out on a small example like users / orders, working through CRUD, dynamic SQL, and resultMap one by one.
If you’re using Maven, add the following dependency to pom.xml.
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
The groupId and artifactId combination corresponds to the Gradle dependency notation, so adapt it to whichever build tool you’re using.
By the way, you’ll want to avoid committing username and password to Git in plaintext inside application.yml. For a method of encrypting sensitive information like database passwords, see How to Encrypt Sensitive Configuration with Jasypt in Spring Boot.
Also, note that we executed CREATE TABLE directly here, but in production you’ll want to track schema changes with version history. For migration management using Liquibase, see Managing Database Migrations with Liquibase in Spring Boot.
Since it would be problematic for these exceptions to leak directly to the API caller, if you want to handle them centrally with @RestControllerAdvice, see also Implementing Spring Boot’s GlobalExceptionHandler for Production Use.