How do I manage transactions in Spring?

In Spring, transactions are usually managed with the @Transactional annotation.

A transaction makes sure that a group of database operations is either:

  • all succeed, or
  • all fail and roll back

This is important when one business operation changes multiple records or tables.


1. Enable Transaction Management

If you are using Spring Boot with Spring Data JPA, transaction management is usually configured automatically.

In most Spring Boot applications, you do not need to manually enable it.

If you are using plain Spring configuration, you may need:

import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {
}

With Spring Boot, this is normally unnecessary.


2. Use @Transactional on Service Methods

The most common place to put transactions is the service layer, not the controller or repository.

Example:

package com.example.app.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    public OrderService(
            OrderRepository orderRepository,
            PaymentRepository paymentRepository
    ) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void placeOrder(Order order, Payment payment) {
        orderRepository.save(order);
        paymentRepository.save(payment);
    }
}

If paymentRepository.save(payment) fails, Spring rolls back the earlier orderRepository.save(order) operation.


3. Use readOnly = true for Query Methods

For methods that only read data, use:

@Transactional(readOnly = true)

Example:

package com.example.app.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public List<Order> findAllOrders() {
        return orderRepository.findAll();
    }
}

readOnly = true can help performance and communicates that the method should not modify data.


4. Rollback Behavior

By default, Spring rolls back a transaction for:

  • RuntimeException
  • Error

By default, Spring does not roll back for checked exceptions.

Example:

@Transactional
public void updateOrder() {
    throw new IllegalStateException("Something failed");
}

This transaction rolls back because IllegalStateException is a runtime exception.


5. Roll Back for Checked Exceptions

If you want rollback for a checked exception, specify rollbackFor.

@Transactional(rollbackFor = Exception.class)
public void importOrders() throws Exception {
    // database changes
    throw new Exception("Import failed");
}

You can also target a specific exception:

@Transactional(rollbackFor = OrderImportException.class)
public void importOrders() throws OrderImportException {
    // database changes
    throw new OrderImportException("Import failed");
}

6. Avoid Catching Exceptions Without Rethrowing

This can prevent rollback:

@Transactional
public void placeOrder(Order order) {
    try {
        orderRepository.save(order);
        paymentService.charge(order);
    } catch (Exception ex) {
        // Bad if you swallow the exception
    }
}

If the exception is caught and not rethrown, Spring may think the method completed successfully and commit the transaction.

Prefer:

@Transactional
public void placeOrder(Order order) {
    try {
        orderRepository.save(order);
        paymentService.charge(order);
    } catch (Exception ex) {
        throw new OrderProcessingException("Could not place order", ex);
    }
}

7. Transaction Boundaries Should Match Business Operations

A transaction should usually wrap one complete business action.

Good examples:

@Transactional
public void transferMoney(Long fromAccountId, Long toAccountId, BigDecimal amount) {
    debitAccount(fromAccountId, amount);
    creditAccount(toAccountId, amount);
}
@Transactional
public void registerUser(RegisterUserRequest request) {
    createUser(request);
    createDefaultSettings(request);
    sendWelcomeEvent(request);
}

Avoid making transactions too large, especially if they include slow external calls.


8. Be Careful with External API Calls

Avoid doing slow network calls inside a database transaction when possible.

Less ideal:

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    paymentGateway.charge(order); // external network call inside transaction
    order.setStatus(OrderStatus.PAID);
}

Better pattern:

@Transactional
public Order createPendingOrder(Order order) {
    order.setStatus(OrderStatus.PENDING_PAYMENT);
    return orderRepository.save(order);
}

@Transactional
public void markOrderPaid(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow();

    order.setStatus(OrderStatus.PAID);
}

Then call the payment gateway between those operations.


9. Common @Transactional Options

@Transactional(
        readOnly = false,
        rollbackFor = Exception.class,
        timeout = 10
)
public void processOrder() {
    // database work
}

Common options:

Option Meaning
readOnly Marks the transaction as read-only
rollbackFor Exceptions that should trigger rollback
noRollbackFor Exceptions that should not trigger rollback
timeout Maximum transaction duration in seconds
propagation How this method joins or creates transactions
isolation How isolated this transaction is from other transactions

10. Propagation Basics

Propagation controls what happens if a transactional method calls another transactional method.

The default is:

Propagation.REQUIRED

That means:

  • join the existing transaction if one exists
  • otherwise create a new transaction

Example:

@Transactional
public void checkout() {
    reserveInventory();
    chargePayment();
}

If reserveInventory() and chargePayment() are also transactional with default propagation, they participate in the same transaction.

Common propagation values:

Propagation Meaning
REQUIRED Use current transaction or create one
REQUIRES_NEW Always start a new transaction
MANDATORY Must already have a transaction
SUPPORTS Use a transaction if one exists
NOT_SUPPORTED Run without a transaction
NEVER Fail if a transaction exists
NESTED Use a nested transaction if supported

Example using a separate transaction for audit logging:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String message) {
    auditLogRepository.save(new AuditLog(message));
}

11. Isolation Basics

Isolation controls how much one transaction can see changes from another transaction.

Example:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void processPayment() {
    // database work
}

Common isolation levels:

Isolation Meaning
DEFAULT Use the database default
READ_UNCOMMITTED May read uncommitted changes
READ_COMMITTED Only read committed data
REPEATABLE_READ Same row read twice stays consistent
SERIALIZABLE Strongest isolation, lowest concurrency

Most applications use the database by default unless there is a specific consistency problem.


12. Important Limitation: Self-Invocation

Spring transactions are usually applied through proxies.

That means this may not start a transaction as expected:

@Service
public class UserService {

    public void outerMethod() {
        innerMethod();
    }

    @Transactional
    public void innerMethod() {
        // database work
    }
}

Because innerMethod() is called from the same class, the call may bypass Spring’s transactional proxy.

Prefer calling transactional methods from another Spring bean, or put @Transactional on the outer method:

@Service
public class UserService {

    @Transactional
    public void outerMethod() {
        innerMethod();
    }

    public void innerMethod() {
        // database work
    }
}

13. Recommended Structure

A typical Spring application uses transactions like this:

Controller
    ↓
Service  ← @Transactional here
    ↓
Repository
    ↓
Database

Example:

@RestController
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/orders")
    public void createOrder(@RequestBody Order order) {
        orderService.createOrder(order);
    }
}
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }
}

Quick Rules

Use these defaults for most Spring applications:

  1. Put @Transactional on service methods.
  2. Use @Transactional(readOnly = true) for query methods.
  3. Use @Transactional for create, update, and delete methods.
  4. Do not swallow exceptions inside transactional methods.
  5. Use rollbackFor if you need rollback for checked exceptions.
  6. Keep transactions short.
  7. Avoid slow external API calls inside transactions.
  8. Be aware that self-invocation can bypass transactional behavior.

For most Spring Boot + Spring Data JPA applications, this is enough:

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(readOnly = true)
    public List<User> findUsers() {
        return userRepository.findAll();
    }

    @Transactional
    public User createUser(User user) {
        return userRepository.save(user);
    }
}

How do I use JDBC with Spring?

You can use JDBC with Spring through Spring’s JDBC support, especially JdbcTemplate. It removes much of the repetitive JDBC boilerplate such as opening connections, closing resources, handling PreparedStatement, iterating ResultSet, and translating SQLException into Spring’s DataAccessException hierarchy.

The typical setup is:

  1. Configure a DataSource
  2. Create a JdbcTemplate
  3. Inject it into a repository/DAO class
  4. Use it to run queries and updates

1. Add Spring JDBC and a database driver

For a Maven project, you usually need spring-jdbc and your database driver.

Example for PostgreSQL:

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>6.2.8</version>
    </dependency>

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.7</version>
    </dependency>
</dependencies>

If you use Spring Boot, you would usually use:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

plus the database driver.


2. Configure a DataSource

In plain Spring Java configuration, you can define a DataSource bean.

A common choice is HikariCP:

package org.kodejava.spring;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;
import java.time.Duration;

@Configuration
public class DatabaseConfig {

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();

        config.setJdbcUrl("jdbc:postgresql://localhost:5432/app");
        config.setUsername("postgres");
        config.setPassword("postgres");

        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);
        config.setConnectionTimeout(Duration.ofSeconds(5).toMillis());
        config.setPoolName("AppHikariPool");

        return new HikariDataSource(config);
    }
}

You would also need the HikariCP dependency if you are not using Spring Boot:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.3.0</version>
</dependency>

3. Create a JdbcTemplate bean

Spring can create JdbcTemplate from the configured DataSource.

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class JdbcConfig {

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

If you are using Spring Boot, Boot usually autoconfigures JdbcTemplate for you as long as a DataSource exists.


4. Create a model class

For example, suppose you have a users table:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL
);

You can map rows to a Java object:

package org.kodejava.spring;

public class User {
    private Long id;
    private String name;
    private String email;

    public User() {
    }

    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

5. Use JdbcTemplate in a repository

A repository class can receive JdbcTemplate through constructor injection.

package org.kodejava.spring;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = ?
                """;

        return jdbcTemplate.queryForObject(
                sql,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                ),
                id
        );
    }

    public List<User> findAll() {
        String sql = """
                SELECT id, name, email
                FROM users
                ORDER BY id
                """;

        return jdbcTemplate.query(
                sql,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                )
        );
    }

    public int insert(User user) {
        String sql = """
                INSERT INTO users (id, name, email)
                VALUES (?, ?, ?)
                """;

        return jdbcTemplate.update(
                sql,
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }

    public int update(User user) {
        String sql = """
                UPDATE users
                SET name = ?, email = ?
                WHERE id = ?
                """;

        return jdbcTemplate.update(
                sql,
                user.getName(),
                user.getEmail(),
                user.getId()
        );
    }

    public int deleteById(Long id) {
        String sql = "DELETE FROM users WHERE id = ?";

        return jdbcTemplate.update(sql, id);
    }
}

6. Enable component scanning

If you are using plain Spring, your configuration class should scan for repositories and services.

package org.kodejava.spring;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("org.kodejava.spring")
public class AppConfig {
}

Then you can bootstrap Spring:

package org.kodejava.spring;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class SpringJdbcExample {
    public static void main(String[] args) {
        try (AnnotationConfigApplicationContext context =
                     new AnnotationConfigApplicationContext(AppConfig.class, DatabaseConfig.class, JdbcConfig.class)) {

            UserRepository userRepository = context.getBean(UserRepository.class);

            User user = new User(1L, "Alice", "[email protected]");
            userRepository.insert(user);

            User savedUser = userRepository.findById(1L);
            System.out.println(savedUser.getName());
        }
    }
}

7. Handling query results safely

queryForObject() is convenient, but it throws an exception when no row is found. You can handle that explicitly:

package org.kodejava.spring;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;

import java.util.Optional;

public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Optional<User> findOptionalById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = ?
                """;

        try {
            User user = jdbcTemplate.queryForObject(
                    sql,
                    (rs, rowNum) -> new User(
                            rs.getLong("id"),
                            rs.getString("name"),
                            rs.getString("email")
                    ),
                    id
            );

            return Optional.ofNullable(user);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}

8. Using NamedParameterJdbcTemplate

For more readable SQL parameters, use NamedParameterJdbcTemplate.

package org.kodejava.spring;

import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class NamedUserRepository {
    private final NamedParameterJdbcTemplate jdbcTemplate;

    public NamedUserRepository(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = :id
                """;

        MapSqlParameterSource params = new MapSqlParameterSource()
                .addValue("id", id);

        return jdbcTemplate.queryForObject(
                sql,
                params,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                )
        );
    }
}

You can define it as a bean:

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class NamedJdbcConfig {

    @Bean
    public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) {
        return new NamedParameterJdbcTemplate(dataSource);
    }
}

9. Transactions

For multiple database operations that should succeed or fail together, use Spring transactions.

Add a transaction manager:

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.TransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {

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

Then use @Transactional in a service:

package org.kodejava.spring;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public void registerUser(User user) {
        userRepository.insert(user);

        // Other related database operations can go here.
        // If a RuntimeException occurs, the transaction is rolled back.
    }
}

10. Typical Spring Boot configuration

If you are using Spring Boot, the configuration is simpler.

application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.hikari.maximum-pool-size=10

Repository:

package org.kodejava.spring;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public int countUsers() {
        return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
    }
}

Summary

To use JDBC with Spring:

  1. Add spring-jdbc and your database driver.
  2. Configure a DataSource.
  3. Create or autoconfigure JdbcTemplate.
  4. Inject JdbcTemplate into repository classes.
  5. Use query(), queryForObject(), and update() for database operations.
  6. Use @Transactional for operations that need transaction boundaries.

For most applications, prefer JdbcTemplate over raw JDBC because it keeps the code shorter, safer, and easier to test.

How do I connect Spring to a database?

To connect Spring to a database, the usual modern approach is:

  1. Add database-related dependencies.
  2. Configure the database connection properties.
  3. Create an entity.
  4. Create a repository.
  5. Use the repository from a service or controller.

The simplest way is with Spring Boot + Spring Data JPA.


1. Add Maven Dependencies

For a Spring Boot application using JPA, add:

<dependencies>
    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Example: PostgreSQL driver -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

For MySQL instead:

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

If you are also building REST endpoints, you usually include:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

2. Configure application.properties

Create or update:

src/main/resources/application.properties

Example for PostgreSQL:

spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=postgres
spring.datasource.password=postgres

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Example for MySQL:

spring.datasource.url=jdbc:mysql://localhost:3306/app
spring.datasource.username=root
spring.datasource.password=secret

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Spring Boot will automatically create a DataSource, configure Hibernate, and connect Spring Data JPA to the database.


3. Create an Entity

Example:

package com.example.app.user;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;
}

The @Entity annotation tells JPA that this class maps to a database table.


4. Create a Repository

package com.example.app.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

This gives you built-in methods such as:

findAll()
findById(id)
save(entity)
deleteById(id)

You do not need to manually open JDBC connections for common CRUD operations.


5. Use the Repository in a Service

package com.example.app.user;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(readOnly = true)
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @Transactional
    public User save(User user) {
        return userRepository.save(user);
    }
}

Use @Transactional for methods that interact with the database.


6. Optional REST Controller Example

package com.example.app.user;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/users")
    public List<User> users() {
        return userService.findAll();
    }
}

When you visit:

http://localhost:8080/users

Spring will query the database through the repository and return the users as JSON.


7. Recommended ddl-auto Values

The property:

spring.jpa.hibernate.ddl-auto=update

controls how Hibernate manages tables.

Common values:

Value Meaning
none Do not change the schema
validate Check that tables match entities
update Update tables automatically
create Drop and recreate tables on startup
create-drop Create on startup, drop on shutdown

For learning, update is convenient.

For production, prefer:

spring.jpa.hibernate.ddl-auto=validate

and use a migration tool such as Flyway or Liquibase.


8. Typical Spring Database Flow

Controller
    ↓
Service
    ↓
Repository
    ↓
Spring Data JPA / Hibernate
    ↓
DataSource
    ↓
Database

Quick Checklist

To connect Spring to a database:

  1. Add spring-boot-starter-data-jpa.
  2. Add the database driver, such as PostgreSQL or MySQL.
  3. Configure spring.datasource.url, username, and password.
  4. Create an @Entity.
  5. Create a JpaRepository.
  6. Inject the repository into a service.
  7. Use @Transactional for database operations.

For most Spring applications, you should let Spring Boot configure the DataSource automatically instead of manually creating JDBC connections.

How do I handle exceptions globally in Spring MVC?

In Spring MVC, handle exceptions globally by creating a class annotated with @ControllerAdvice or @RestControllerAdvice and adding methods annotated with @ExceptionHandler.

For REST APIs, prefer @RestControllerAdvice, because it combines @ControllerAdvice and @ResponseBody, so returned objects are serialized as JSON automatically.

package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex
    ) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex
    ) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred"
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Example error response DTO:

package com.example.demo.exception;

import java.time.Instant;

public record ErrorResponse(
        int status,
        String message,
        Instant timestamp
) {
    public ErrorResponse(int status, String message) {
        this(status, message, Instant.now());
    }
}

Example custom exception:

package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Then you can throw exceptions from controllers or services:

throw new ResourceNotFoundException("User not found");

Spring will automatically route that exception to the matching @ExceptionHandler.

Common handlers you may want to add:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
        MethodArgumentNotValidException ex
) {
    ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed"
    );

    return ResponseEntity.badRequest().body(error);
}

Use:

  • @RestControllerAdvice for REST APIs returning JSON.
  • @ControllerAdvice for MVC apps returning views or when you manually use ResponseEntity.
  • Specific exception handlers before generic ones.
  • A final @ExceptionHandler(Exception.class) as a fallback.

How do I validate form data in Spring?

In Spring MVC, the standard way to validate form data is to use Jakarta Bean Validation annotations on a form/DTO object, then check validation results in your controller with BindingResult.

Since your project uses Jakarta EE, use jakarta.validation.* imports.

1. Add validation annotations to your form object

Example form/DTO:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class UserForm {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name must be at most 100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Please enter a valid email address")
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;

    // getters and setters
}

With Lombok:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserForm {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name must be at most 100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Please enter a valid email address")
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
}

2. Use @Valid in your controller

import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class UserController {

    @GetMapping("/register")
    public String showRegisterForm(Model model) {
        model.addAttribute("userForm", new UserForm());
        return "register";
    }

    @PostMapping("/register")
    public String register(
            @Valid UserForm userForm,
            BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return "register";
        }

        // Save user or call service layer here
        return "redirect:/register/success";
    }
}

Important: BindingResult must come immediately after the validated object.

Correct:

public String register(@Valid UserForm userForm, BindingResult bindingResult)

Incorrect:

public String register(@Valid UserForm userForm, Model model, BindingResult bindingResult)

3. Display errors in Thymeleaf

If you use Thymeleaf:

<form th:action="@{/register}" th:object="${userForm}" method="post">
    <div>
        <label>Name</label>
        <input type="text" th:field="*{name}">
        <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </div>

    <div>
        <label>Email</label>
        <input type="email" th:field="*{email}">
        <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
    </div>

    <div>
        <label>Password</label>
        <input type="password" th:field="*{password}">
        <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
    </div>

    <button type="submit">Register</button>
</form>

4. Common validation annotations

@NotNull
@NotBlank
@NotEmpty
@Size(min = 2, max = 100)
@Min(18)
@Max(120)
@Email
@Pattern(regexp = "...")
@Past
@Future
@Positive
@PositiveOrZero

Use:

  • @NotNull for any value that must not be null
  • @NotBlank for strings that must contain non-whitespace text
  • @NotEmpty for strings, collections, arrays, or maps that must not be empty
  • @Size for string length or collection size
  • @Email for email format validation

5. Maven dependency

If you use Spring Boot, add:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

For Gradle:

implementation("org.springframework.boot:spring-boot-starter-validation")

6. Service-layer validation

You can also validate method parameters in Spring services:

import jakarta.validation.Valid;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class UserService {

    public void createUser(@Valid UserForm userForm) {
        // business logic
    }
}

7. REST API validation example

For JSON request bodies:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserRestController {

    @PostMapping("/api/users")
    public String createUser(@Valid @RequestBody UserForm userForm) {
        return "User created";
    }
}

For REST APIs, invalid input usually results in a 400 Bad Request.

Summary

Use this pattern:

@PostMapping("/submit")
public String submit(@Valid MyForm form, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "form-page";
    }

    return "redirect:/success";
}

That is the typical Spring MVC form validation flow.