Spring MVC MyBatis Integration Tutorial
A while back I built a project on Spring MVC and Hibernate, then wanted to see how MyBatis compares. This article shows how to wire MyBatis into Spring MVC, and how to let Spring manage MyBatis transactions for you.
A database transaction is an all-or-nothing unit of work: either every statement in it takes effect, or none of them does. We lean on that property at the very end, where a single bad row makes an entire batch insert roll back.
Note: This tutorial has been updated for Spring 7 and MyBatis 3.5 (using
mybatis-spring4.0, Jakarta EE, and Java 17). It was originally written in 2015 for Spring 4.1 and thejavax.*servlet API. The configuration below now uses thejakarta.*namespace and a HikariCP data source, and the example finally delivers a transaction that actually rolls back. We keep XML configuration andweb.xmlso the wiring stays explicit; if you want, the same beans translate directly to a@Configurationclass.
Project Architecture
Before the configuration, here is the map. The project splits into four packages, each a single layer:
controllerhandles HTTP requests and picks a view.serviceholds business logic and transaction boundaries.mapperis the MyBatis data-access layer (plain interfaces, no implementation).modelis the plain Java objects mapped to table rows.
Requests flow top-down (controller → service → mapper → database); each layer only talks to the one below it.
Maven Dependencies
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.verwandlung</groupId>
<artifactId>voj</artifactId>
<packaging>war</packaging>
<version>1.0.0-SNAPSHOT</version>
<name>Verwandlung Online Judge</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>7.0.8</spring.version>
<mybatis.version>3.5.19</mybatis.version>
<mybatis.spring.version>4.0.0</mybatis.spring.version>
</properties>
<dependencies>
<!-- Spring MVC, plus spring-jdbc for the DataSourceTransactionManager
(it transitively pulls in spring-tx) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- MyBatis and its Spring integration -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>${mybatis.spring.version}</version>
</dependency>
<!-- Connection pool and JDBC driver -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.0.2</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.7.0</version>
</dependency>
<!-- Provided by the servlet container (Tomcat 11+) -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>voj</finalName>
</build>
</project>
Two things changed from the original 2015 dependency list. First, mybatis-spring 4.0 requires Spring 7 and Java 17, so the whole stack moves to the jakarta.* namespace. Second, the connection pool is now HikariCP instead of Druid. HikariCP needs only a handful of properties, and it avoids dragging a javax.servlet monitoring servlet into a Jakarta container. The old AspectJ dependencies are gone too: declarative transactions use Spring’s proxy-based AOP, which needs nothing extra.
Web Application Descriptor
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0">
<display-name>Verwandlung Online Judge</display-name>
<!-- A single DispatcherServlet loads the whole Spring context -->
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/dispatcher-servlet.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
Note the namespace: it moved from java.sun.com/xml/ns/javaee to jakarta.ee/xml/ns/jakartaee, and the descriptor version is now 6.0. The original loaded the same XML file twice (once as the root context via ContextLoaderListener, once as the servlet context). For a single-servlet app that is needless, so here one DispatcherServlet owns the only context.
Spring Configuration
This file is where the integration actually happens. It lives at /WEB-INF/dispatcher-servlet.xml:
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc
https://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/tx
https://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/context
https://www.springframework.org/schema/context/spring-context.xsd">
<!-- Discover @Controller / @Service beans -->
<context:component-scan base-package="org.verwandlung.voj" />
<!-- Spring MVC and static assets -->
<mvc:annotation-driven />
<mvc:resources mapping="/assets/**" location="/assets/" />
<!-- Resolve a view name like "index" to /WEB-INF/views/index.jsp -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" />
<property name="suffix" value=".jsp" />
</bean>
<!-- jdbc.* values come from src/main/resources/jdbc.properties -->
<context:property-placeholder location="classpath:jdbc.properties" />
<!-- HikariCP connection pool -->
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource" destroy-method="close">
<property name="driverClassName" value="${jdbc.driverClassName}" />
<property name="jdbcUrl" value="${jdbc.url}" />
<property name="username" value="${jdbc.username}" />
<property name="password" value="${jdbc.password}" />
<property name="maximumPoolSize" value="${jdbc.maxPoolSize}" />
</bean>
<!-- One SqlSessionFactory, fed the SAME DataSource as the tx manager -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
</bean>
<!-- Turn every interface under the mapper package into a Spring bean -->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="org.verwandlung.voj.mapper" />
</bean>
<!-- Declarative transactions over the SAME DataSource -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" />
</bean>
<tx:annotation-driven transaction-manager="transactionManager" />
</beans>
Three beans do the real work:
SqlSessionFactoryBeanbuilds MyBatis’sSqlSessionFactoryfrom aDataSource.MapperScannerConfigurerscans themapperpackage and registers each interface as an injectable bean, so you never write a mapper implementation by hand.DataSourceTransactionManagerplus<tx:annotation-driven>makes@Transactionalwork through Spring’s proxies.
The one rule that is easy to miss: the SqlSessionFactoryBean and the transaction manager must share the same DataSource. That shared data source is how MyBatis-Spring keeps one SqlSession bound to the active transaction and commits or rolls it back with everything else. Wire two different data sources and your @Transactional boundaries silently do nothing.
The jdbc.* placeholders resolve from src/main/resources/jdbc.properties:
jdbc.driverClassName=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/voj?useSSL=false&serverTimezone=UTC
jdbc.username=root
jdbc.password=
jdbc.maxPoolSize=10
Database Schema
The original table had AUTO_INCREMENT=1 on a column that was never declared auto-incrementing, and no key at all. Here it is corrected, with a UNIQUE constraint on username that the transaction demo relies on:
CREATE TABLE IF NOT EXISTS `voj_users` (
`uid` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(16) NOT NULL,
`password` VARCHAR(32) NOT NULL,
`email` VARCHAR(64) NOT NULL,
PRIMARY KEY (`uid`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed one row so the homepage has a user to display
INSERT INTO voj_users (username, password, email)
VALUES ('alice', 'secret', 'alice@voj.test');
The Model
A plain Java object, one field per column:
package org.verwandlung.voj.model;
import java.io.Serializable;
public class User implements Serializable {
private long uid;
private String username;
private String password;
private String email;
private static final long serialVersionUID = 2264535351943252507L;
// getters and setters
}
The Mapper
The mapper is a bare interface; MyBatis generates the implementation. Annotations carry the SQL, and @Options(useGeneratedKeys = true) writes the new primary key back onto the object after an insert:
package org.verwandlung.voj.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.verwandlung.voj.model.User;
/**
* Data-access interface for the voj_users table.
* @author Haozhe Xie
*/
public interface UserMapper {
@Select("SELECT * FROM voj_users WHERE uid = #{uid}")
User getUserByUid(@Param("uid") long uid);
@Select("SELECT COUNT(*) FROM voj_users")
long countUsers();
@Insert("INSERT INTO voj_users (username, password, email) "
+ "VALUES (#{username}, #{password}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "uid")
int createUser(User user);
}
The Service
The service is where transactions live. registerAll inserts a whole batch inside one @Transactional method:
package org.verwandlung.voj.service;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.verwandlung.voj.mapper.UserMapper;
import org.verwandlung.voj.model.User;
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getUserByUid(long uid) {
return userMapper.getUserByUid(uid);
}
/**
* Inserts every user in one transaction. Because voj_users.username is
* UNIQUE, a duplicate username makes one INSERT throw, and Spring rolls
* the whole method back, so none of the users are persisted.
*/
@Transactional
public void registerAll(List<User> users) {
for (User user : users) {
userMapper.createUser(user);
}
}
}
By default Spring rolls back only on unchecked exceptions (RuntimeException and Error). A duplicate key surfaces as Spring’s DataIntegrityViolationException, which is unchecked, so the rollback happens automatically. If you want to roll back on a checked exception too, add @Transactional(rollbackFor = ...).
The Controller
The controller injects the service and exposes two endpoints: the homepage, and the rollback demo below. Note the modern @GetMapping, and that @Autowired is now imported (the original snippet used it without importing it):
package org.verwandlung.voj.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import org.verwandlung.voj.mapper.UserMapper;
import org.verwandlung.voj.model.User;
import org.verwandlung.voj.service.UserService;
@Controller
public class DefaultController {
@Autowired
private UserService userService;
@Autowired
private UserMapper userMapper;
@GetMapping("/")
public ModelAndView indexView() {
ModelAndView view = new ModelAndView("index");
view.addObject("user", userService.getUserByUid(1));
return view;
}
@GetMapping("/demo/rollback")
@ResponseBody
public String rollbackDemo() {
long before = userMapper.countUsers();
// "alice" already exists, so the second insert violates the UNIQUE key.
List<User> batch = List.of(
newUser("carol", "carol@voj.test"),
newUser("alice", "alice2@voj.test"));
String outcome;
try {
userService.registerAll(batch);
outcome = "committed";
} catch (DataIntegrityViolationException e) {
outcome = "rolled back (" + e.getClass().getSimpleName() + ")";
}
long after = userMapper.countUsers();
return String.format("rows before=%d, after=%d -> %s", before, after, outcome);
}
private static User newUser(String username, String email) {
User user = new User();
user.setUsername(username);
user.setPassword("secret");
user.setEmail(email);
return user;
}
}
Seeing the Transaction Roll Back
This is the payoff the intro promised. Visit http://localhost:8080/voj/demo/rollback. The batch tries to insert carol (valid) and then alice (a duplicate username). The second insert throws, and you get:
rows before=1, after=1 -> rolled back (DataIntegrityViolationException)
The row count is unchanged. carol was a perfectly valid insert, but because she shared a transaction with the failing alice, Spring discarded her too. That is atomicity in action, and you did not write a single line of commit or rollback code. Remove the duplicate alice from the batch and rerun: the count goes up, and the outcome reads committed.
Running the Application
Build the WAR with mvn package and deploy it to Tomcat 11+ (Jakarta EE 11, Servlet 6.1, which is what Spring 7 targets). Visiting the homepage at http://localhost:8080/voj renders the seeded user in index.jsp, and /demo/rollback exercises the transaction.
Conclusion
The whole integration comes down to one idea: give MyBatis and Spring’s transaction manager the same DataSource, and Spring’s declarative transactions cover your mapper calls for free. From here you would split the configuration into JSP views and real forms, add more mappers, and move long SQL out of annotations into XML mapper files. The full setup powers Verwandlung Online Judge, if you want to see it in a larger project.

The Disqus comment system is loading ...
If the message does not appear, please check your Disqus configuration.