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-spring 4.0, Jakarta EE, and Java 17). It was originally written in 2015 for Spring 4.1 and the javax.* servlet API. The configuration below now uses the jakarta.* namespace and a HikariCP data source, and the example finally delivers a transaction that actually rolls back. We keep XML configuration and web.xml so the wiring stays explicit; if you want, the same beans translate directly to a @Configuration class.

Project Architecture

Before the configuration, here is the map. The project splits into four packages, each a single layer:

  • controller handles HTTP requests and picks a view.
  • service holds business logic and transaction boundaries.
  • mapper is the MyBatis data-access layer (plain interfaces, no implementation).
  • model is the plain Java objects mapped to table rows.

Requests flow top-down (controllerservicemapper → 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:

  • SqlSessionFactoryBean builds MyBatis’s SqlSessionFactory from a DataSource.
  • MapperScannerConfigurer scans the mapper package and registers each interface as an injectable bean, so you never write a mapper implementation by hand.
  • DataSourceTransactionManager plus <tx:annotation-driven> makes @Transactional work 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.

References