Here is the English translation of the article body:
In Japanese enterprise systems, there are still many teams that choose MyBatis over JPA. The reason is that you can write your own SQL, which makes complex searches and tuning easier to handle. However, we often hear people say, “When it comes to integrating it into Spring Boot from scratch, I don’t know where to write what.”
In this article, we’ll walk through everything step by step, focusing on working code: from adding dependencies, to defining Mappers (both annotation-based and XML-based), dynamic SQL, mapping join results, and finally @Transactional integration. We’ll leave the discussion of whether to choose JPA to the MyBatis vs. JPA comparison article, and focus on implementation here.
The Roles of MyBatis and mybatis-spring-boot-starter
MyBatis is an O/R mapper of the “write your own SQL, leave the mapping to us” type. Unlike JPA, it doesn’t automatically generate SQL. Instead, the SQL you write is executed as-is, which makes its behavior easy to follow.
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 the DataSource, all you have to do afterward is write your Mapper interfaces and it works. Think of it as a middle-ground position: you want to write your own SQL, but you also want to make the mapping easy.
Adding Dependencies and Configuring the DataSource and Connection
First, add the dependencies. With 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'
}
Consolidate the connection and MyBatis settings 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 is where your XML mappers are located. When you enable map-underscore-to-camel-case, a user_name column is automatically mapped to a userName field. If you forget this, values tend to come back as null, so we recommend setting it up right from the start.
To have Mappers recognized, you can either add @MapperScan("com.example.shop.mapper") to your main class, or add @Mapper to each interface. If you want to manage them together on a per-package basis, @MapperScan is easier.
Sample Tables and Entities
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 setup has each user owning 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
);
Plain Java classes are enough for the entities. No special annotations are required.
public class User {
private Long id;
private String userName;
private List<Order> orders;
// getter / setter
}
Defining Mappers with Annotation Syntax
For short SQL, the annotation syntax 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") automatically sets the ID that the database generated after the INSERT into user.id. After that, you just inject UserMapper via Spring’s DI and call it directly. For the flow of invoking CRUD from a REST API, the REST API CRUD tutorial is also a good reference.
Defining Mappers with XML Syntax
When SQL gets long or you start writing dynamic SQL, annotations become hard to read. In such cases, you externalize the SQL into XML. The key points are to match the namespace to the fully qualified name of the Mapper interface, and to 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 don’t write any SQL—only the method declarations remain. The criteria for choosing between them is simple: use annotations for short static SQL, and XML for dynamic SQL and complex JOINs. Keeping this in mind, you won’t go far wrong.
Dynamic SQL with <if>/<where>/<foreach>
Dynamic SQL is one of MyBatis’s strengths. You can assemble cases where the search conditions change depending on the input, right in 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> adds the WHERE keyword only when content has actually been generated, and it automatically strips the redundant leading 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 causes a syntax error. As shown in the example above, it’s safer to do an emptiness check with <if> before iterating. If you want to build dynamic SQL in Java, @SelectProvider is another option, but XML is probably easier to read to start with.
Mapping Joins and One-to-Many Relationships with resultMap
To map a JOIN result into a form where a single User has multiple Orders attached, use resultMap. <collection> corresponds to one-to-many, and <association> corresponds to 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 with a single JOIN like this, you avoid the N+1 problem of re-fetching orders for each user. If you’re the annotation type, 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 each time, so it tends to cause N+1. For joins where the number of records grows, it’s safer to choose the batch fetch with a JOIN in XML.
The Difference Between #{} and ${}, and Preventing SQL Injection
Be sure to understand this section, as it can lead to accidents. #{} is a PreparedStatement placeholder, and values are bound safely. On the other hand, ${} expands the string directly into the SQL.
// Safe: the value is bound
@Select("SELECT * FROM users WHERE user_name = #{name}")
List<User> safe(String name);
// Dangerous: name is embedded as-is, causing 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 you need ${} is when you want to make the structure of the SQL dynamic rather than a value—such as an ORDER BY column name or a table name. Even in those cases, don’t use the input as-is; validate it against an allowlist before using it.
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 ${} with an allowlist for structure only.”
Managing Transactions with @Transactional
To combine multiple Mapper calls into a single transaction, add @Transactional to the Service layer. Since MyBatis rides on Spring’s transaction management, no special configuration is required.
@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-family exception) is thrown partway through, both INSERTs are rolled back. If you want fine-grained control over propagation and isolation levels, they are summarized in the transaction management article, so please refer to that.
Common Pitfalls
The following are things people often trip over during setup. When you get a BindingException, check that the XML’s namespace matches the interface’s fully qualified name, that the method name matches the id, and that the mapper-locations path is correct. When columns come back null, suspect either a missing map-underscore-to-camel-case setting or a missing mapping in your resultMap.
For methods with multiple parameters, you can’t reference them unless you name them with @Param.
List<User> search(@Param("name") String name, @Param("ids") List<Long> ids);
Another common one is the case where the XML isn’t included in the build output and can’t be read from the classpath. Place it under src/main/resources, or configure your build so that it’s picked up as a resource.
Conclusion
MyBatis is a practical O/R mapper that lets you keep control of your own SQL while eliminating only the labor of mapping. Use annotations for short SQL, and XML for dynamic SQL and joins; always bind values with #{}. Once you’ve mastered these basics, you’ll be able to safely build even the complex SQL of enterprise systems. The quickest path to understanding is to start with a small subject like users / orders and get everything working, from CRUD to dynamic SQL to resultMap.