In Japanese corporate systems, MyBatis is still chosen over JPA at many sites. The reason is that you can write SQL yourself, which makes complex queries and tuning easier to handle. That said, a common complaint is: “When integrating it into Spring Boot from scratch, it’s unclear what to write where.”
In this article, we’ll walk through working code covering everything from adding dependencies, defining Mappers (both annotation-based and XML-based), dynamic SQL, mapping join results, and integration with @Transactional. We’ll leave the question of choosing between MyBatis and JPA to the MyBatis vs. JPA comparison article and focus on implementation here.
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 result mapping.” Unlike JPA, it doesn’t automatically generate SQL; 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 SqlSessionFactory and Mapper scanning. As long as you configure the DataSource, all you need to do is write Mapper interfaces. Think of it as occupying a middle ground: you want to write SQL yourself, but you’d rather not deal with mapping manually.
Adding dependencies and configuring the DataSource/connection
First, add the dependency. For Gradle, it looks like this. Adjust 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'
}
Connection and MyBatis settings go 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 are placed. Enabling map-underscore-to-camel-case automatically maps a user_name column to the userName field. Forgetting this tends to result in null values, so it’s a good idea to set it up from the start.
For Mapper recognition, you can either add @MapperScan("com.example.shop.mapper") to the main class, or add @Mapper to each interface individually. If you want to manage things collectively by package, @MapperScan is more convenient.
Sample table and entity
From here on, we’ll use two tables, users and orders, as our subject. Since we want to handle a one-to-many relationship, this is a setup where 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 a Mapper with annotation notation
For short SQL, annotation notation is convenient. 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") automatically sets the ID generated by the DB after the INSERT into user.id. From there, you just inject UserMapper via Spring’s DI and call it directly. For a walkthrough of hitting CRUD operations from a REST API, the REST API CRUD tutorial is also a useful reference.
Defining a Mapper with XML notation
As SQL grows longer or you start writing dynamic SQL, annotations become harder to read. In such cases, move the SQL out into XML. The key point is to match the namespace to the fully qualified name of the Mapper interface, and 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 don’t write SQL — just leave the method declaration. The rule of thumb is simple: short static SQL goes in annotations, dynamic SQL or complex JOINs go in XML, and you won’t be far off.
Dynamic SQL with <if>/<where>/<foreach>
Dynamic SQL is one of MyBatis’s strengths. You can assemble cases where search conditions vary depending on input, using XML.
<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 WHERE when content is actually generated, and automatically strips a 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, it becomes IN (), which causes a syntax error. As in the example above, it’s safer to check for emptiness with <if> before looping. If you’d rather build dynamic SQL in Java, @SelectProvider is an option, but XML is probably 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 attached, 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>
By fetching everything in a single JOIN like this, you can avoid the N+1 problem of re-querying orders for each user. If you prefer the annotation style, you can also write it 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 cause N+1 problems. For joins where the row count grows, it’s safer to choose a single-batch JOIN fetch in XML.
The difference between #{} and ${}, and SQL injection prevention
This is a point you must absolutely get right, since mistakes here lead to real incidents. #{} is a PreparedStatement placeholder, and the value is 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 as-is, resulting in SQL injection
@Select("SELECT * FROM users WHERE user_name = '${name}'")
List<User> danger(String name);
Passing user input into ${} is strictly forbidden. ${} is only needed in situations where you want to dynamically alter the structure of the SQL itself, not a value — for example, 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, and only use ${} with an allowlist for structure.”
Managing transactions with @Transactional
To group multiple Mapper calls into a single transaction, add @Transactional at the Service layer. MyBatis rides on 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
These are the points people most often get stuck on 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 XML not being included in the build output and therefore unreadable from the classpath. Make sure to place it under src/main/resources, or configure your build so it’s picked up as a resource.
Summary
MyBatis is a practical O/R mapper that lets you keep control of your SQL while still saving the effort of mapping results. Use annotations for short SQL and XML for dynamic SQL or joins, and always bind values with #{}. Once you’ve got these basics down, you can safely build even the complex SQL that business systems require. The quickest path to understanding is to try it out end-to-end on a small example like users/orders — from CRUD through dynamic SQL to resultMap.
If you’re using Maven, add the following dependencies 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 translate it according to whichever build tool you’re using.
By the way, you’ll want to avoid committing username and password to Git in plain text in application.yml as shown here. For a method of encrypting sensitive information such as DB passwords, see How to Encrypt Sensitive Configuration Information Using Jasypt in Spring Boot.
Also, while we’re running CREATE TABLE directly here, in production you’ll want to keep a history of schema changes. For migration management using Liquibase, see Managing Database Migrations with Liquibase in Spring Boot.
Since it would be problematic if these exceptions leaked straight through to API callers, if you want to handle them collectively with @RestControllerAdvice, see also Implementing Spring Boot’s GlobalExceptionHandler for Production Use.