How do I secure a Java web application with authentication and authorization?

Securing a Java web application typically means adding:

  1. Authentication — verifying who the user is.
  2. Authorization — deciding what the authenticated user can access.
  3. Session/token protection — keeping the login state secure.
  4. Transport and application hardening — HTTPS, CSRF protection, password hashing, etc.

Since your stack includes Spring MVC / Spring Data JPA / Jakarta EE, the most common approach is Spring Security.


1. Add Spring Security

If you use Maven:

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

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>

If this is a Spring Boot app, use:

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

2. Create a Security Configuration

For modern Spring Security, define a SecurityFilterChain.

package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }
}

This configuration means:

URL Access
/, /login, static files Public
/user/** USER or ADMIN
/admin/** ADMIN only
Everything else Must be logged in

3. Store Users in the Database

A simple JPA entity could look like this:

package com.example.user;

import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

import java.util.Set;

@Entity
@Table(name = "app_users")
@Getter
@Setter
public class User {

    @Id
    private Long id;

    private String username;

    private String password;

    private boolean enabled = true;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(
            name = "app_user_roles",
            joinColumns = @JoinColumn(name = "user_id")
    )
    private Set<String> roles;
}

Example roles:

ROLE_USER
ROLE_ADMIN

Spring Security’s hasRole("ADMIN") checks for ROLE_ADMIN internally.


4. Create a Repository

package com.example.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

5. Implement UserDetailsService

Spring Security uses UserDetailsService to load users during login.

package com.example.security;

import com.example.user.User;
import com.example.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(user.getRoles().toArray(String[]::new))
                .disabled(!user.isEnabled())
                .build();
    }
}

Register it as a bean:

@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
    return new DatabaseUserDetailsService(userRepository);
}

6. Hash Passwords with BCrypt

Never store plain-text passwords.

import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

When registering a user:

user.setPassword(passwordEncoder.encode(rawPassword));

A stored password should look similar to:

$2a$10$...

7. Add Method-Level Authorization

You can also secure service methods.

Enable method security:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

Then protect methods:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteReport(Long reportId) {
        // admin-only logic
    }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Object viewReport(Long reportId) {
        // user/admin logic
        return new Object();
    }
}

8. Protect Against CSRF

For browser-based apps using sessions and forms, keep CSRF enabled.

Spring Security enables CSRF by default.

In a Thymeleaf form, include the CSRF token:

<form method="post" action="/profile">
    <input type="hidden"
           name="${_csrf.parameterName}"
           value="${_csrf.token}" />

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

For JSP:

<input type="hidden"
       name="${_csrf.parameterName}"
       value="${_csrf.token}" />

For stateless REST APIs using JWT, CSRF is often disabled because the API does not rely on cookies for authentication.


9. Use HTTPS

Always run authentication over HTTPS.

For production:

server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=changeit
server.ssl.key-store-type=PKCS12
server.ssl.key-alias=myapp

If your app is behind a reverse proxy, configure forwarded headers correctly so Spring knows the original request was HTTPS.

server.forward-headers-strategy=framework

10. Use Secure Session Cookies

Configure cookies as secure and HTTP-only.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

Use same-site=strict if your app does not require cross-site navigation flows.


11. Example REST API Authorization

For controller endpoints:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {

    @GetMapping("/admin/stats")
    @PreAuthorize("hasRole('ADMIN')")
    public String stats() {
        return "admin stats";
    }
}

Or configure URL-based access:

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

12. JWT Option for Stateless APIs

If your frontend is separate, such as React, Angular, mobile, or another client, you may use JWT or OAuth2.

Typical setup:

.oauth2ResourceServer(oauth2 -> oauth2.jwt())

Then secure routes:

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/public/**").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
)

For production, prefer a standards-based identity provider such as:

  • Keycloak
  • Auth0
  • Okta
  • Microsoft Entra ID
  • AWS Cognito

13. Important Security Checklist

Use this checklist before production:

  • Hash passwords with BCrypt, Argon2, or PBKDF2
  • Never store plain-text passwords
  • Use HTTPS only
  • Keep CSRF enabled for session-based browser apps
  • Use secure HTTP-only cookies
  • Validate all user inputs
  • Apply authorization in the service layer for sensitive business operations
  • Avoid exposing stack traces or internal errors
  • Lock down admin endpoints
  • Use least-privilege roles
  • Add audit logging for sensitive actions
  • Rate-limit login attempts
  • Use MFA for admin users
  • Keep dependencies updated
  • Add security headers
  • Do not put secrets in source code

Minimal Spring Security Setup

A compact working configuration could look like this:

package com.example.security;

import com.example.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new DatabaseUserDetailsService(userRepository);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

For most Java web applications, the recommended path is:

Spring Security + database-backed users + BCrypt password hashing + role-based authorization + HTTPS + CSRF protection.

How do I build a REST API in Java using Spring Boot the right way?

The “right way” to build a REST API in Java with Spring Boot is not just about making URLs return JSON. A good REST API should be:

  • Cleanly structured
  • Easy to test
  • Easy to maintain
  • Validated properly
  • Consistent in error handling
  • Separated into controller, service, repository, entity, and DTO layers
  • Built around HTTP semantics, not just Java methods exposed over HTTP

In this article, we will build a simple User REST API using:

  • Spring Boot
  • Spring MVC
  • Spring Data JPA
  • Jakarta Persistence
  • Jakarta Validation
  • Java records
  • Java 25
  • Lombok

The example API will support basic user operations:

GET    /api/users
GET    /api/users/{id}
POST   /api/users
PUT    /api/users/{id}
DELETE /api/users/{id}

1. Create a Spring Boot Project

You can create a Spring Boot project from Spring Initializr with these dependencies:

  • Spring Web
  • Spring Data JPA
  • Validation
  • PostgreSQL Driver, MySQL Driver, or H2 Database
  • Lombok

For Maven, the important dependencies look like this:

<dependencies>
    <!-- REST API support -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

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

    <!-- Jakarta Bean Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Example database: PostgreSQL -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </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>

If you only want an in-memory database while learning, you can use H2 instead:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

2. Use a Clean Project Structure

A common clean structure is:

com.example.demo
├── DemoApplication.java
├── user
│   ├── User.java
│   ├── UserRepository.java
│   ├── UserService.java
│   ├── UserController.java
│   ├── CreateUserRequest.java
│   ├── UpdateUserRequest.java
│   └── UserResponse.java
└── exception
    ├── ApiError.java
    ├── ResourceNotFoundException.java
    └── GlobalExceptionHandler.java

This is a feature-based structure. Instead of separating everything by technical layer only, all user-related classes stay together.

For small applications, this is easy to understand. For larger applications, it also scales well because each feature remains self-contained.


3. Create the Main Spring Boot Application Class

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

Keep this class in the root package, such as:

com.example.demo

That allows Spring Boot to automatically scan subpackages such as:

com.example.demo.user
com.example.demo.exception

4. Configure the Database

For PostgreSQL, create:

src/main/resources/application.properties

Example:

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
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

For local learning, ddl-auto=update is convenient.

For production, prefer:

spring.jpa.hibernate.ddl-auto=validate

Then manage schema changes using a migration tool such as Flyway or Liquibase.


5. Create the Entity

The entity represents the database table.

package com.example.demo.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;
}

Notice the import:

import jakarta.persistence.Entity;

Modern Spring Boot uses Jakarta EE packages, not the old javax.persistence packages.


6. Create DTOs for Requests and Responses

A common mistake is exposing entities directly from controllers.

For small demos, returning entities may seem fine. But in real applications, it is better to use DTOs because they separate your API contract from your database model.

Create User Request

package com.example.demo.user;

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

public record CreateUserRequest(
        @NotBlank(message = "Name is required")
        @Size(max = 100, message = "Name must not exceed 100 characters")
        String name,

        @NotBlank(message = "Email is required")
        @Email(message = "Email must be valid")
        @Size(max = 150, message = "Email must not exceed 150 characters")
        String email
) {
}

Update User Request

package com.example.demo.user;

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

public record UpdateUserRequest(
        @NotBlank(message = "Name is required")
        @Size(max = 100, message = "Name must not exceed 100 characters")
        String name,

        @NotBlank(message = "Email is required")
        @Email(message = "Email must be valid")
        @Size(max = 150, message = "Email must not exceed 150 characters")
        String email
) {
}

User Response

package com.example.demo.user;

public record UserResponse(
        Long id,
        String name,
        String email
) {
}

Java records are excellent for DTOs because they are concise and immutable by default.


7. Create the Repository

Spring Data JPA provides most CRUD operations automatically.

package com.example.demo.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}

By extending JpaRepository<User, Long>, you automatically get methods such as:

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

You do not need to write SQL for basic CRUD operations.


8. Create a Custom Not Found Exception

Instead of returning null or manually building error responses everywhere, create a reusable exception.

package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

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

We will handle this exception globally later.


9. Create the Service Layer

The service layer contains business logic and transaction boundaries.

package com.example.demo.user;

import com.example.demo.exception.ResourceNotFoundException;
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<UserResponse> findAll() {
        return userRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

    @Transactional(readOnly = true)
    public UserResponse findById(Long id) {
        User user = findUserById(id);
        return toResponse(user);
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new IllegalArgumentException("Email is already used");
        }

        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());

        User savedUser = userRepository.save(user);

        return toResponse(savedUser);
    }

    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request) {
        User user = findUserById(id);

        user.setName(request.name());
        user.setEmail(request.email());

        return toResponse(user);
    }

    @Transactional
    public void delete(Long id) {
        User user = findUserById(id);
        userRepository.delete(user);
    }

    private User findUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "User with id " + id + " was not found"
                ));
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }
}

A few important things are happening here:

  1. The controller will not access the repository directly.
  2. Read methods use @Transactional(readOnly = true).
  3. Write methods use @Transactional.
  4. The service maps entities to response DTOs.
  5. Missing users throw a meaningful exception.

This keeps the controller thin and the business logic centralized.


10. Create the REST Controller

The controller handles HTTP details: URLs, request bodies, response status codes, and validation.

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

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

    @GetMapping
    public List<UserResponse> findAll() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @PutMapping("/{id}")
    public UserResponse update(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request
    ) {
        return userService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

The controller is intentionally small.

It does not:

  • Contain database logic
  • Build SQL queries
  • Manage transactions
  • Know how users are persisted
  • Contain complicated business rules

Its job is HTTP handling.


11. Understand REST Endpoint Design

Good REST URLs usually identify resources using nouns.

Good:

GET    /api/users
GET    /api/users/10
POST   /api/users
PUT    /api/users/10
DELETE /api/users/10

Less ideal:

GET    /api/getUsers
POST   /api/createUser
POST   /api/deleteUser

The HTTP method already describes the action.

HTTP Method Meaning Example
GET Read data GET /api/users
POST Create new data POST /api/users
PUT Replace or update data PUT /api/users/1
PATCH Partially update data PATCH /api/users/1
DELETE Delete data DELETE /api/users/1

12. Add Global Exception Handling

A good API should return consistent error responses.

Create an API error response:

package com.example.demo.exception;

import java.time.Instant;
import java.util.List;

public record ApiError(
        int status,
        String error,
        String message,
        String path,
        Instant timestamp,
        List<FieldErrorDetail> fieldErrors
) {
    public ApiError(
            int status,
            String error,
            String message,
            String path
    ) {
        this(status, error, message, path, Instant.now(), List.of());
    }

    public ApiError(
            int status,
            String error,
            String message,
            String path,
            List<FieldErrorDetail> fieldErrors
    ) {
        this(status, error, message, path, Instant.now(), fieldErrors);
    }

    public record FieldErrorDetail(
            String field,
            String message
    ) {
    }
}

Now create the global exception handler:

package com.example.demo.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleResourceNotFoundException(
            ResourceNotFoundException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.NOT_FOUND.value(),
                HttpStatus.NOT_FOUND.getReasonPhrase(),
                ex.getMessage(),
                request.getRequestURI()
        );
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleIllegalArgumentException(
            IllegalArgumentException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                ex.getMessage(),
                request.getRequestURI()
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request
    ) {
        List<ApiError.FieldErrorDetail> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new ApiError.FieldErrorDetail(
                        error.getField(),
                        error.getDefaultMessage()
                ))
                .toList();

        return new ApiError(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                "Validation failed",
                request.getRequestURI(),
                fieldErrors
        );
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError handleException(
            Exception ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
                "An unexpected error occurred",
                request.getRequestURI()
        );
    }
}

Now, when something fails, your API returns structured JSON instead of a stack trace or inconsistent response.

Example validation error:

{
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "path": "/api/users",
  "timestamp": "2026-07-06T10:15:30Z",
  "fieldErrors": [
    {
      "field": "email",
      "message": "Email must be valid"
    }
  ]
}

13. Test the API with HTTP Requests

You can use curl, Postman, HTTPie, or IntelliJ IDEA HTTP Client.

Create a User

curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"[email protected]"}'

Expected response:

{
  "id": 1,
  "name": "Alice",
  "email": "[email protected]"
}

HTTP status:

201 Created

Get All Users

curl http://localhost:8080/api/users

Example response:

[
  {
    "id": 1,
    "name": "Alice",
    "email": "[email protected]"
  }
]

Get One User

curl http://localhost:8080/api/users/1

Example response:

{
  "id": 1,
  "name": "Alice",
  "email": "[email protected]"
}

Update a User

curl -X PUT http://localhost:8080/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice Smith","email":"[email protected]"}'

Example response:

{
  "id": 1,
  "name": "Alice Smith",
  "email": "[email protected]"
}

Delete a User

curl -X DELETE http://localhost:8080/api/users/1

Expected status:

204 No Content

14. Add Basic Controller Tests

Testing your controller helps ensure the API contract works as expected.

Here is an example using @WebMvcTest and MockMvc.

package com.example.demo.user;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private UserService userService;

    @Test
    void shouldReturnUsers() throws Exception {
        Mockito.when(userService.findAll())
                .thenReturn(List.of(
                        new UserResponse(1L, "Alice", "[email protected]"),
                        new UserResponse(2L, "Bob", "[email protected]")
                ));

        mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].name").value("Alice"))
                .andExpect(jsonPath("$[1].name").value("Bob"));
    }

    @Test
    void shouldCreateUser() throws Exception {
        CreateUserRequest request = new CreateUserRequest(
                "Alice",
                "[email protected]"
        );

        Mockito.when(userService.create(any(CreateUserRequest.class)))
                .thenReturn(new UserResponse(1L, "Alice", "[email protected]"));

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("Alice"))
                .andExpect(jsonPath("$.email").value("[email protected]"));
    }

    @Test
    void shouldRejectInvalidCreateUserRequest() throws Exception {
        CreateUserRequest request = new CreateUserRequest(
                "",
                "invalid-email"
        );

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest());
    }
}

Testing at this level verifies:

  • URL mappings
  • HTTP status codes
  • JSON request/response structure
  • Validation behavior
  • Controller-service interaction

15. Common REST API Best Practices

Use DTOs Instead of Exposing Entities

Avoid this in real APIs:

@GetMapping("/{id}")
public User findById(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

Prefer this:

@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
    return userService.findById(id);
}

DTOs give you control over what your API exposes.


Keep Controllers Thin

A controller should mostly do this:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

Avoid putting business logic directly in the controller.


Put Transactions in Services

Use:

@Transactional
public UserResponse create(CreateUserRequest request) {
    // business operation
}

Avoid placing @Transactional on controller methods in most applications.


Use Validation on Request DTOs

Use Jakarta Validation annotations:

public record CreateUserRequest(
        @NotBlank String name,
        @Email @NotBlank String email
) {
}

Then activate validation in the controller:

public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

Return Correct HTTP Status Codes

Use meaningful status codes:

Situation Status Code
Successful read 200 OK
Successful creation 201 Created
Successful delete 204 No Content
Invalid request 400 Bad Request
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden
Resource not found 404 Not Found
Conflict 409 Conflict
Server error 500 Internal Server Error

Use Plural Resource Names

Prefer:

/api/users
/api/orders
/api/products

Instead of:

/api/user
/api/order
/api/product

Use Query Parameters for Filtering

Example:

GET /api/[email protected]
GET /api/users?name=alice

Path variables are usually better for identifying a specific resource:

GET /api/users/1

Query parameters are usually better for searching, filtering, sorting, and pagination.


16. Add Pagination for Collection Endpoints

Returning all records may work during development, but it can become a problem when your table grows.

Spring Data supports pagination using Pageable.

Repository already supports it because JpaRepository includes paging methods.

Update the service:

package com.example.demo.user;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// imports omitted

@Service
public class UserService {

    private final UserRepository userRepository;

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

    @Transactional(readOnly = true)
    public Page<UserResponse> findAll(Pageable pageable) {
        return userRepository.findAll(pageable)
                .map(this::toResponse);
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }
}

Update the controller:

package com.example.demo.user;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;

// imports omitted

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

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

    @GetMapping
    public Page<UserResponse> findAll(Pageable pageable) {
        return userService.findAll(pageable);
    }
}

Now you can call:

GET /api/users?page=0&size=10

With sorting:

GET /api/users?page=0&size=10&sort=name,asc

17. A Better Response for Created Resources

For POST, you can return 201 Created with a Location header.

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

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

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Valid @RequestBody CreateUserRequest request,
            UriComponentsBuilder uriBuilder
    ) {
        UserResponse response = userService.create(request);

        URI location = uriBuilder
                .path("/api/users/{id}")
                .buildAndExpand(response.id())
                .toUri();

        return ResponseEntity
                .created(location)
                .body(response);
    }
}

This produces a response like:

HTTP/1.1 201 Created
Location: http://localhost:8080/api/users/1

This is a nice RESTful touch because the response tells the client where the new resource can be found.


18. Recommended Request Flow

A clean REST API usually follows this flow:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

And back:

Database
    ↓
Repository
    ↓
Service
    ↓
Controller
    ↓
HTTP Response

Each layer has a clear job:

Layer Responsibility
Controller Handles HTTP requests and responses
Service Contains business logic and transactions
Repository Handles database access
Entity Maps Java objects to database tables
DTO Defines API request and response shapes
Exception Handler Produces consistent error responses

19. What Makes It “The Right Way”?

A Spring Boot REST API is built the right way when it follows these principles:

  1. Use @RestController for REST endpoints
  2. Keep controllers thin
  3. Put business logic in services
  4. Use repositories only for data access
  5. Use DTOs at the API boundary
  6. Validate request bodies with Jakarta Validation
  7. Handle exceptions globally
  8. Return meaningful HTTP status codes
  9. Use transactions in the service layer
  10. Avoid exposing JPA entities directly
  11. Use pagination for collection endpoints
  12. Keep package structure clean
  13. Use Jakarta imports in modern Spring Boot applications

Complete Minimal Example

Here is the core structure again.

com.example.demo
├── DemoApplication.java
├── user
│   ├── User.java
│   ├── UserRepository.java
│   ├── UserService.java
│   ├── UserController.java
│   ├── CreateUserRequest.java
│   ├── UpdateUserRequest.java
│   └── UserResponse.java
└── exception
    ├── ApiError.java
    ├── ResourceNotFoundException.java
    └── GlobalExceptionHandler.java

That gives you a clean, maintainable foundation for a real REST API.


Summary

To build a REST API in Java using Spring Boot the right way:

  • Use Spring Web for REST controllers.
  • Use Spring Data JPA for persistence.
  • Use Jakarta Validation for request validation.
  • Use DTOs instead of exposing entities.
  • Keep your controller thin.
  • Put business logic and transactions in the service layer.
  • Use a repository for database access.
  • Use global exception handling for consistent error responses.
  • Return correct HTTP status codes such as 200, 201, 204, 400, and 404.
  • Add pagination before your API grows too large.

The clean pattern is:

Controller → Service → Repository → Database

With DTOs at the API boundary and entities at the persistence boundary, your Spring Boot REST API will be easier to maintain, test, and evolve.

How do I organize service, repository and controller layers in Spring?

Typical Spring Layer Organization

A clean Spring application usually separates code into controller, service, repository, and model/entity layers.

com.example.app
├── AppApplication.java
├── controller
│   └── UserController.java
├── service
│   └── UserService.java
├── repository
│   └── UserRepository.java
├── entity
│   └── User.java
└── dto
    ├── CreateUserRequest.java
    └── UserResponse.java

The usual request flow is:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

1. Controller Layer

The controller handles HTTP requests and responses.

Use:

  • @RestController for JSON APIs
  • @Controller for server-rendered pages such as Thymeleaf/JSP

Controllers should be thin. They should mainly:

  • Accept requests
  • Validate input
  • Call services
  • Return responses

Example:

package com.example.app.controller;

import com.example.app.dto.CreateUserRequest;
import com.example.app.dto.UserResponse;
import com.example.app.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

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

    @GetMapping
    public List<UserResponse> findAll() {
        return userService.findAll();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }
}

2. Service Layer

The service contains business logic.

Use @Service.

Services should:

  • Implement business rules
  • Coordinate multiple repositories
  • Handle transactions
  • Convert between entities and DTOs if your app is small or medium-sized

Example:

package com.example.app.service;

import com.example.app.dto.CreateUserRequest;
import com.example.app.dto.UserResponse;
import com.example.app.entity.User;
import com.example.app.repository.UserRepository;
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<UserResponse> findAll() {
        return userRepository.findAll()
                .stream()
                .map(user -> new UserResponse(
                        user.getId(),
                        user.getName(),
                        user.getEmail()
                ))
                .toList();
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());

        User savedUser = userRepository.save(user);

        return new UserResponse(
                savedUser.getId(),
                savedUser.getName(),
                savedUser.getEmail()
        );
    }
}

Use @Transactional on service methods rather than controller methods.


3. Repository Layer

The repository handles database access.

With Spring Data JPA, you usually define an interface that extends JpaRepository.

package com.example.app.repository;

import com.example.app.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

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

Spring Data JPA automatically provides common methods such as:

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

You can also add query methods:

package com.example.app.repository;

import com.example.app.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}

You generally do not need to annotate Spring Data repository interfaces with @Repository; Spring detects them automatically.


4. Entity Layer

The entity represents database tables.

Use Jakarta persistence imports:

package com.example.app.entity;

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;
}

Entities should mostly represent persistent state. Avoid putting HTTP-specific logic in entities.


5. DTO Layer

DTOs separate your API contract from your database model.

Request DTO:

package com.example.app.dto;

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

public record CreateUserRequest(
        @NotBlank String name,
        @Email @NotBlank String email
) {
}

Response DTO:

package com.example.app.dto;

public record UserResponse(
        Long id,
        String name,
        String email
) {
}

Using DTOs helps avoid exposing internal entity fields directly through your API.


Recommended Responsibilities

Layer Annotation Responsibility
Controller @RestController, @Controller HTTP request/response handling
Service @Service Business logic, transactions
Repository Spring Data JpaRepository Database access
Entity @Entity Database table mapping
DTO/Form Records/classes with validation API input/output models

Dependency Direction

Keep dependencies flowing one way:

Controller → Service → Repository → Entity

Avoid this:

Repository → Service
Service → Controller
Entity → Controller

For example:

  • A controller can inject a service.
  • A service can inject a repository.
  • A repository should not know about services or controllers.
  • Entities should not depend on web/controller classes.

Best Practices

  1. Use constructor injection
    @Service
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public OrderService(OrderRepository orderRepository) {
            this.orderRepository = orderRepository;
        }
    }
    
  2. Keep controllers thin

    Bad:

    @PostMapping
    public User create(@RequestBody User user) {
        if (user.getEmail() == null) {
            throw new IllegalArgumentException("Email is required");
        }
    
        return userRepository.save(user);
    }
    

    Better:

    @PostMapping
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }
    
  3. Put transactions in services
    @Transactional
    public UserResponse create(CreateUserRequest request) {
        // business logic and repository calls
    }
    
  4. Use DTOs for API boundaries

    Do not expose entities directly unless the application is very small or internal.

  5. Keep the main application class in the root package

    com.example.app.AppApplication
    

That way Spring can scan:

com.example.app.controller
com.example.app.service
com.example.app.repository
com.example.app.entity

Feature-Based Alternative

For larger applications, you may prefer organizing by feature instead of technical layer:

com.example.app
├── user
│   ├── UserController.java
│   ├── UserService.java
│   ├── UserRepository.java
│   ├── User.java
│   ├── CreateUserRequest.java
│   └── UserResponse.java
├── order
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   └── Order.java
└── AppApplication.java

This is often easier to maintain as the project grows because related files stay together.


Simple Rule of Thumb

Ask this when deciding where code belongs:

  • Is it about HTTP? Put it in the controller.
  • Is it business logic? Put it in the service.
  • Is it database access? Put it in the repository.
  • Is it database structure? Put it in the entity.
  • Is it request/response shape? Put it in a DTO.

For most Spring applications, the clean structure is:

Controller → Service → Repository → Database

with DTOs at the API boundary and entities at the persistence boundary.

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.