Once you can write MyBatis Mappers, the next thing that catches your attention is how @Transactional behaves. Articles explaining JPA talk about the “persistence context” and “dirty checking,” but MyBatis has no such mechanism to begin with. In fact, @Transactional in MyBatis is a surprisingly simple thing — it’s just controlling the commit/rollback timing of a SqlSession.

In this article, we’ll walk through, with working code, how a SqlSession gets bound to a thread, how to confirm commit/rollback timing through logs, how to mix JdbcTemplate with Mappers, and the classic mistake where a checked exception fails to trigger a rollback.

How is MyBatis’s @Transactional different from JPA’s?

In JPA, the EntityManager tracks changes to the Entities it manages and issues SQL in bulk at transaction commit time (more precisely, at flush time). That’s exactly why it’s hard to tell “when the SQL actually fired,” and understanding @Transactional requires knowledge of the persistence context.

MyBatis, on the other hand, executes SQL the moment you call a Mapper method. There’s no tracking and no automatic flush. So what is @Transactional doing in MyBatis? The answer is simple: it disables auto-commit on the connection the SqlSession is using, and issues a commit when the method finishes normally or a rollback when an exception occurs — nothing more.

For a general discussion of propagation levels and isolation levels, see the Spring Boot Transaction Management Guide. Here, we’ll focus on questions specific to MyBatis.

How SqlSessionTemplate binds a SqlSession to a thread

An interface annotated with @Mapper has a proxy generated for it by MapperFactoryBean at Spring startup. When this proxy actually executes SQL, it internally uses SqlSessionTemplate.

SqlSessionTemplate doesn’t hold a SqlSession itself. Every time it’s called, it checks TransactionSynchronizationManager: if there’s a transaction bound to the current thread, it reuses that transaction’s SqlSession (i.e., its connection); if not, it opens a new one and binds it. In other words, even if you call multiple Mappers within the same @Transactional method, the same SqlSession and the same connection are reused under the hood.

@Service
public class OrderService {

    private final OrderMapper orderMapper;
    private final OrderItemMapper orderItemMapper;

    public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper) {
        this.orderMapper = orderMapper;
        this.orderItemMapper = orderItemMapper;
    }

    @Transactional
    public void createOrder(Order order, List<OrderItem> items) {
        orderMapper.insert(order);
        items.forEach(orderItemMapper::insert);
        // Everything up to this point runs on the same SqlSession (the same connection)
    }
}

DataSourceTransactionManager configuration and role

In a MyBatis setup, instead of JPA’s JpaTransactionManager, you use DataSourceTransactionManager. Its role is the same — issuing commits/rollbacks at the boundaries of methods annotated with @Transactional — but the difference is that its target is a plain JDBC connection rather than an EntityManager.

@Configuration
public class MyBatisConfig {

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        return factoryBean.getObject();
    }

    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

The key point is that sqlSessionFactory and transactionManager reference the same DataSource bean. If these get out of sync, you’ll run into unexpected trouble when mixing in JdbcTemplate, as discussed below. If you’re using mybatis-spring-boot-starter, this configuration is done automatically for a single-DataSource setup, so writing it explicitly mainly matters when using multiple DataSources.

Confirming actual commit/rollback timing through logs

Rather than explaining it in words, it’s more convincing to actually look at the logs. Set the following log levels in application.yml:

logging:
  level:
    org.mybatis: DEBUG
    com.example.demo.mapper: TRACE
    org.springframework.jdbc.datasource: DEBUG
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

When createOrder runs successfully, the log looks like this:

Creating a new SqlSession
JDBC Connection [HikariProxyConnection@... ] will be managed by Spring
==>  Preparing: INSERT INTO orders ...
==>  Preparing: INSERT INTO order_items ...
Initiating transaction commit
Committing JDBC transaction on Connection [...]
Releasing JDBC Connection [...] after transaction

Notice that Creating a new SqlSession appears only once. Even though two Mappers are called, the SqlSession is reused. If you throw an exception to check, the last line changes from Committing to Rolling back.

Initiating transaction rollback after exception: ...
Rolling back JDBC transaction on Connection [...]

If you’re not confident a rollback is actually happening, looking at this log once should settle it.

Mixing JdbcTemplate and Mappers within the same transaction

There are cases where you might want to write just an aggregation query using JdbcTemplate. Here, too, the premise is the same: as long as the DataSource used by JdbcTemplate and MyBatis’s SqlSessionFactory reference the same bean, TransactionSynchronizationManager will share the same connection between them, and they’ll participate in the same transaction.

@Service
public class OrderReportService {

    private final OrderMapper orderMapper;
    private final JdbcTemplate jdbcTemplate;

    public OrderReportService(OrderMapper orderMapper, JdbcTemplate jdbcTemplate) {
        this.orderMapper = orderMapper;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void closeOrder(Long orderId) {
        orderMapper.updateStatus(orderId, "CLOSED");
        jdbcTemplate.update(
            "UPDATE order_summary SET closed_count = closed_count + 1 WHERE order_id = ?",
            orderId
        );
        // Both the Mapper side and the JdbcTemplate side run on the same connection
    }
}

If the DataSources end up being separate beans (for example, if you accidentally passed a read-only secondary DataSource to JdbcTemplate alone), the connections would split into two, and you could end up in an inconsistent state where one side commits and the other doesn’t roll back. If you’re going to mix the two, check that the DataSource is shared first.

The classic failure where a checked exception doesn’t trigger a rollback

By default, @Transactional’s rollback targets are only RuntimeException (unchecked exceptions) and Error. Checked exceptions are not included, so if you carelessly declare a checked custom exception with throws, the transaction will get committed even when that exception occurs.

public class OrderProcessingException extends Exception {
    public OrderProcessingException(String message) {
        super(message);
    }
}

@Transactional
public void createOrder(Order order) throws OrderProcessingException {
    orderMapper.insert(order);
    if (order.getAmount() <= 0) {
        throw new OrderProcessingException("amount must be positive");
    }
    // Even if an exception is thrown here, by default it will NOT roll back and will be committed instead
}

Run this code, and the log shows Committing instead of Rolling back. It’s a bug where invalid data ends up committed to the DB anyway.

The fix is to explicitly specify the rollback target with rollbackFor.

@Transactional(rollbackFor = OrderProcessingException.class)
public void createOrder(Order order) throws OrderProcessingException {
    orderMapper.insert(order);
    if (order.getAmount() <= 0) {
        throw new OrderProcessingException("amount must be positive");
    }
}

Setting aside the question of whether using checked exceptions in business logic is a good design in the first place, when touching existing code it’s reassuring to double-check whether the exceptions a method declares with throws really don’t need a rollback.

There are a few points this article didn’t cover. The problem where @Transactional stops working on method calls within the same class (self-invocation) is caused by a limitation of AOP proxies, and is covered in detail in Why @Transactional, @Cacheable, and @Async Don’t Work.

For the architectural question of whether to choose MyBatis or JPA, see the MyBatis vs. JPA comparison article; for the basics of writing Mappers and dynamic SQL, see the MyBatis Mapper Implementation Guide. For using JdbcTemplate on its own, refer to the Spring Boot JdbcTemplate Guide.

Summary

Since MyBatis has no persistence context, what @Transactional does is actually quite simple — it just controls the commit/rollback timing of the SqlSession, so if you’re ever unsure, checking the logs will make the behavior obvious at a glance. When mixing in JdbcTemplate, remember to share the DataSource; when using checked exceptions, remember rollbackFor.