This comprehensive guide will walk you through creating a complete RESTful API with Spring Boot, MyBatis with XML mapping, and PostgreSQL. We'll cover everything from project setup to advanced topics like Postman testing and unit testing.
Table of Contents
- Project Setup
- Database Configuration
- Entity Class
- MyBatis Mapper (XML)
- Repository Layer
- Service Layer
- Controller Layer
- Exception Handling
- Postman Testing
- Unit Testing
- Integration Testing
Project Setup
First, let's create a new Spring Boot project using Spring Initializr or your IDE.
Dependencies (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-demo</name>
<description>Demo project for Spring Boot with MyBatis</description>
<properties>
<java.version>17</java.version>
<mybatis-spring-boot.version>3.0.1</mybatis-spring-boot.version>
<postgresql.version>42.5.4</postgresql.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Database Configuration
application.properties
# Server port
server.port=8080
# Database configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/mybatis_demo
spring.datasource.username=postgres
spring.datasource.password=yourpassword
spring.datasource.driver-class-name=org.postgresql.Driver
# MyBatis configuration
mybatis.mapper-locations=classpath:mappers/*.xml
mybatis.type-aliases-package=com.example.mybatisdemo.model
# HikariCP configuration (optional)
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.maximum-pool-size=5
Create Database Table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Entity Class
package com.example.mybatisdemo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
MyBatis Mapper (XML)
Create src/main/resources/mappers/UserMapper.xml
:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatisdemo.mapper.UserMapper">
<resultMap id="userResultMap" type="User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="email" column="email"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="userResultMap">
SELECT * FROM users
</select>
<select id="findById" parameterType="Long" resultMap="userResultMap">
SELECT * FROM users WHERE id = #{id}
</select>
<insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (username, email, created_at, updated_at)
VALUES (#{username}, #{email}, #{createdAt}, #{updatedAt})
</insert>
<update id="update" parameterType="User">
UPDATE users
SET username = #{username},
email = #{email},
updated_at = #{updatedAt}
WHERE id = #{id}
</update>
<delete id="deleteById" parameterType="Long">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>
Repository Layer
Mapper Interface
package com.example.mybatisdemo.mapper;
import com.example.mybatisdemo.model.User;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface UserMapper {
List<User> findAll();
User findById(Long id);
void insert(User user);
void update(User user);
void deleteById(Long id);
}
Service Layer
Service Interface
package com.example.mybatisdemo.service;
import com.example.mybatisdemo.model.User;
import java.util.List;
public interface UserService {
List<User> getAllUsers();
User getUserById(Long id);
User createUser(User user);
User updateUser(Long id, User user);
void deleteUser(Long id);
}
Service Implementation
package com.example.mybatisdemo.service.impl;
import com.example.mybatisdemo.exception.ResourceNotFoundException;
import com.example.mybatisdemo.mapper.UserMapper;
import com.example.mybatisdemo.model.User;
import com.example.mybatisdemo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
@Override
public List<User> getAllUsers() {
return userMapper.findAll();
}
@Override
public User getUserById(Long id) {
return userMapper.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
}
@Override
public User createUser(User user) {
LocalDateTime now = LocalDateTime.now();
user.setCreatedAt(now);
user.setUpdatedAt(now);
userMapper.insert(user);
return user;
}
@Override
public User updateUser(Long id, User userDetails) {
User user = getUserById(id);
user.setUsername(userDetails.getUsername());
user.setEmail(userDetails.getEmail());
user.setUpdatedAt(LocalDateTime.now());
userMapper.update(user);
return user;
}
@Override
public void deleteUser(Long id) {
User user = getUserById(id);
userMapper.deleteById(user.getId());
}
}
Controller Layer
package com.example.mybatisdemo.controller;
import com.example.mybatisdemo.model.User;
import com.example.mybatisdemo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.getAllUsers();
return new ResponseEntity<>(users, HttpStatus.OK);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
return new ResponseEntity<>(user, HttpStatus.OK);
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
User createdUser = userService.createUser(user);
return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
User updatedUser = userService.updateUser(id, user);
return new ResponseEntity<>(updatedUser, HttpStatus.OK);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
Exception Handling
Custom Exception
package com.example.mybatisdemo.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
Global Exception Handler
package com.example.mybatisdemo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Object> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleGlobalException(
Exception ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "An error occurred");
return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Postman Testing
Here are the Postman requests you can use to test your API:
1. Create User (POST)
- URL:Â
http://localhost:8080/api/v1/users
- Method: POST
- Body (raw JSON):
{
"username": "john_doe",
"email": "john@example.com"
}
2. Get All Users (GET)
- URL:Â
http://localhost:8080/api/v1/users
- Method: GET
3. Get User by ID (GET)
- URL:Â
http://localhost:8080/api/v1/users/1
- Method: GET
4. Update User (PUT)
- URL:Â
http://localhost:8080/api/v1/users/1
- Method: PUT
- Body (raw JSON):
{
"username": "john_doe_updated",
"email": "john.updated@example.com"
}
5. Delete User (DELETE)
- URL:Â
http://localhost:8080/api/v1/users/1
- Method: DELETE
Unit Testing
Service Layer Test
package com.example.mybatisdemo.service;
import com.example.mybatisdemo.exception.ResourceNotFoundException;
import com.example.mybatisdemo.mapper.UserMapper;
import com.example.mybatisdemo.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
private UserMapper userMapper;
@InjectMocks
private UserServiceImpl userService;
private User user1;
private User user2;
@BeforeEach
void setUp() {
user1 = new User(1L, "user1", "user1@example.com",
LocalDateTime.now(), LocalDateTime.now());
user2 = new User(2L, "user2", "user2@example.com",
LocalDateTime.now(), LocalDateTime.now());
}
@Test
void getAllUsers_ShouldReturnAllUsers() {
// Arrange
List<User> expectedUsers = Arrays.asList(user1, user2);
when(userMapper.findAll()).thenReturn(expectedUsers);
// Act
List<User> actualUsers = userService.getAllUsers();
// Assert
assertEquals(2, actualUsers.size());
assertEquals(expectedUsers, actualUsers);
verify(userMapper, times(1)).findAll();
}
@Test
void getUserById_WithValidId_ShouldReturnUser() {
// Arrange
when(userMapper.findById(1L)).thenReturn(Optional.of(user1));
// Act
User actualUser = userService.getUserById(1L);
// Assert
assertNotNull(actualUser);
assertEquals(user1.getId(), actualUser.getId());
verify(userMapper, times(1)).findById(1L);
}
@Test
void getUserById_WithInvalidId_ShouldThrowException() {
// Arrange
when(userMapper.findById(99L)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
userService.getUserById(99L);
});
verify(userMapper, times(1)).findById(99L);
}
@Test
void createUser_ShouldSaveAndReturnUser() {
// Arrange
User newUser = new User(null, "newUser", "new@example.com", null, null);
when(userMapper.insert(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(3L);
return null;
});
// Act
User createdUser = userService.createUser(newUser);
// Assert
assertNotNull(createdUser.getId());
assertEquals("newUser", createdUser.getUsername());
assertNotNull(createdUser.getCreatedAt());
verify(userMapper, times(1)).insert(any(User.class));
}
@Test
void updateUser_WithValidId_ShouldUpdateUser() {
// Arrange
User existingUser = new User(1L, "oldUser", "old@example.com",
LocalDateTime.now(), LocalDateTime.now());
User updatedDetails = new User(null, "newUser", "new@example.com", null, null);
when(userMapper.findById(1L)).thenReturn(Optional.of(existingUser));
doNothing().when(userMapper).update(any(User.class));
// Act
User updatedUser = userService.updateUser(1L, updatedDetails);
// Assert
assertEquals("newUser", updatedUser.getUsername());
assertEquals("new@example.com", updatedUser.getEmail());
assertNotNull(updatedUser.getUpdatedAt());
verify(userMapper, times(1)).findById(1L);
verify(userMapper, times(1)).update(any(User.class));
}
@Test
void deleteUser_WithValidId_ShouldDeleteUser() {
// Arrange
when(userMapper.findById(1L)).thenReturn(Optional.of(user1));
doNothing().when(userMapper).deleteById(1L);
// Act
userService.deleteUser(1L);
// Assert
verify(userMapper, times(1)).findById(1L);
verify(userMapper, times(1)).deleteById(1L);
}
}
Controller Layer Test
package com.example.mybatisdemo.controller;
import com.example.mybatisdemo.model.User;
import com.example.mybatisdemo.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
private User user1;
private User user2;
@BeforeEach
void setUp() {
user1 = new User(1L, "user1", "user1@example.com",
LocalDateTime.now(), LocalDateTime.now());
user2 = new User(2L, "user2", "user2@example.com",
LocalDateTime.now(), LocalDateTime.now());
}
@Test
void getAllUsers_ShouldReturnAllUsers() {
// Arrange
List<User> expectedUsers = Arrays.asList(user1, user2);
when(userService.getAllUsers()).thenReturn(expectedUsers);
// Act
ResponseEntity<List<User>> response = userController.getAllUsers();
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(2, response.getBody().size());
verify(userService, times(1)).getAllUsers();
}
@Test
void getUserById_WithValidId_ShouldReturnUser() {
// Arrange
when(userService.getUserById(1L)).thenReturn(user1);
// Act
ResponseEntity<User> response = userController.getUserById(1L);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(user1, response.getBody());
verify(userService, times(1)).getUserById(1L);
}
@Test
void createUser_ShouldReturnCreatedUser() {
// Arrange
User newUser = new User(null, "newUser", "new@example.com", null, null);
User savedUser = new User(3L, "newUser", "new@example.com",
LocalDateTime.now(), LocalDateTime.now());
when(userService.createUser(newUser)).thenReturn(savedUser);
// Act
ResponseEntity<User> response = userController.createUser(newUser);
// Assert
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertEquals(savedUser, response.getBody());
verify(userService, times(1)).createUser(newUser);
}
@Test
void updateUser_WithValidId_ShouldReturnUpdatedUser() {
// Arrange
User updatedDetails = new User(null, "updatedUser", "updated@example.com", null, null);
User updatedUser = new User(1L, "updatedUser", "updated@example.com",
user1.getCreatedAt(), LocalDateTime.now());
when(userService.updateUser(1L, updatedDetails)).thenReturn(updatedUser);
// Act
ResponseEntity<User> response = userController.updateUser(1L, updatedDetails);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(updatedUser, response.getBody());
verify(userService, times(1)).updateUser(1L, updatedDetails);
}
@Test
void deleteUser_WithValidId_ShouldReturnNoContent() {
// Arrange
doNothing().when(userService).deleteUser(1L);
// Act
ResponseEntity<Void> response = userController.deleteUser(1L);
// Assert
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
verify(userService, times(1)).deleteUser(1L);
}
}
Integration Testing
Create an integration test to test the entire flow from controller to database:
package com.example.mybatisdemo;
import com.example.mybatisdemo.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class UserControllerIntegrationTest {
@Container
public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void testCreateAndGetUser() throws Exception {
// Create a new user
User newUser = new User();
newUser.setUsername("testuser");
newUser.setEmail("test@example.com");
MvcResult createResult = mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newUser)))
.andExpect(status().isCreated())
.andReturn();
User createdUser = objectMapper.readValue(
createResult.getResponse().getContentAsString(), User.class);
// Get the created user
mockMvc.perform(get("/api/v1/users/" + createdUser.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(createdUser.getId()))
.andExpect(jsonPath("$.username").value("testuser"))
.andExpect(jsonPath("$.email").value("test@example.com"));
}
@Test
void testUpdateUser() throws Exception {
// First create a user
User newUser = new User();
newUser.setUsername("original");
newUser.setEmail("original@example.com");
MvcResult createResult = mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newUser)))
.andExpect(status().isCreated())
.andReturn();
User createdUser = objectMapper.readValue(
createResult.getResponse().getContentAsString(), User.class);
// Update the user
User updatedUser = new User();
updatedUser.setUsername("updated");
updatedUser.setEmail("updated@example.com");
mockMvc.perform(put("/api/v1/users/" + createdUser.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updatedUser)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("updated"))
.andExpect(jsonPath("$.email").value("updated@example.com"));
}
@Test
void testDeleteUser() throws Exception {
// First create a user
User newUser = new User();
newUser.setUsername("todelete");
newUser.setEmail("delete@example.com");
MvcResult createResult = mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newUser)))
.andExpect(status().isCreated())
.andReturn();
User createdUser = objectMapper.readValue(
createResult.getResponse().getContentAsString(), User.class);
// Delete the user
mockMvc.perform(delete("/api/v1/users/" + createdUser.getId()))
.andExpect(status().isNoContent());
// Verify the user is deleted
mockMvc.perform(get("/api/v1/users/" + createdUser.getId()))
.andExpect(status().isNotFound());
}
}
Additional Configuration
MyBatis Configuration Class (Optional)
package com.example.mybatisdemo.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.example.mybatisdemo.mapper")
public class MyBatisConfig {
// Additional MyBatis configuration can go here
}
Application Main Class
package com.example.mybatisdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MybatisDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MybatisDemoApplication.class, args);
}
}
Conclusion
This comprehensive guide has covered:
- Setting up a Spring Boot project with MyBatis and PostgreSQL
- Configuring MyBatis with XML mappings
- Implementing CRUD operations
- Proper layering (Controller-Service-Repository)
- Exception handling
- Postman testing
- Unit testing with Mockito
- Integration testing with Testcontainers
The application follows RESTful principles and includes proper error handling and validation. You can extend this foundation by adding:
- Pagination for GET all endpoints
- DTOs for request/response separation
- Validation annotations
- Authentication and authorization
- Swagger/OpenAPI documentation
- More complex business logic as needed