How do I use events in Spring applications?

In Spring, events let one part of your application publish something that happened, while other parts react to it without being tightly coupled.

Typical use cases:

  • Send an email after user registration
  • Clear a cache after data changes
  • Audit an action
  • Trigger async background processing
  • React to transaction completion

Spring has built-in support through:

  • ApplicationEventPublisher
  • @EventListener
  • ApplicationEvent
  • @TransactionalEventListener

1. Define an Event

Modern Spring applications often use a plain Java object as an event. You do not have to extend ApplicationEvent.

public record UserRegisteredEvent(
        Long userId,
        String email
) {
}

You can also use a normal class:

public class UserRegisteredEvent {

    private final Long userId;
    private final String email;

    public UserRegisteredEvent(Long userId, String email) {
        this.userId = userId;
        this.email = email;
    }

    public Long getUserId() {
        return userId;
    }

    public String getEmail() {
        return email;
    }
}

2. Publish the Event

Inject ApplicationEventPublisher into a Spring-managed bean and call publishEvent.

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

    public UserService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void registerUser(String email) {
        // Save user, validate data, etc.
        Long userId = 42L;

        eventPublisher.publishEvent(new UserRegisteredEvent(userId, email));
    }
}

3. Listen for the Event

Use @EventListener on a method in a Spring bean.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class UserRegisteredListener {

    @EventListener
    public void handleUserRegistered(UserRegisteredEvent event) {
        System.out.println("User registered: " + event.email());

        // Send welcome email, write audit log, etc.
    }
}

Spring automatically detects listener methods and invokes them when a matching event is published.


4. Multiple Listeners Can React to the Same Event

You can have several independent listeners for one event.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class WelcomeEmailListener {

    @EventListener
    public void sendWelcomeEmail(UserRegisteredEvent event) {
        System.out.println("Sending welcome email to " + event.email());
    }
}
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AuditLogListener {

    @EventListener
    public void audit(UserRegisteredEvent event) {
        System.out.println("Audit log for user " + event.userId());
    }
}

This keeps the registration logic separate from email, auditing, and other side effects.


5. Listen Only When a Condition Matches

You can add a condition using Spring Expression Language.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class CorporateUserListener {

    @EventListener(condition = "#event.email().endsWith('@company.com')")
    public void handleCorporateUser(UserRegisteredEvent event) {
        System.out.println("Corporate user registered: " + event.email());
    }
}

For a JavaBean-style event class, you might use:

@EventListener(condition = "#event.email.endsWith('@company.com')")
public void handleCorporateUser(UserRegisteredEvent event) {
    // ...
}

6. Make Event Handling Asynchronous

By default, Spring event listeners run synchronously in the same thread as the publisher.

To run listeners asynchronously, enable async execution:

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {
}

Then annotate the listener with @Async.

import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncWelcomeEmailListener {

    @Async
    @EventListener
    public void sendWelcomeEmail(UserRegisteredEvent event) {
        System.out.println("Sending email asynchronously to " + event.email());
    }
}

You can also configure a custom executor:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "applicationEventExecutor")
    public Executor applicationEventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("app-event-");
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.initialize();
        return executor;
    }
}

Use it like this:

import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncAuditListener {

    @Async("applicationEventExecutor")
    @EventListener
    public void audit(UserRegisteredEvent event) {
        System.out.println("Async audit for user " + event.userId());
    }
}

7. Use Transaction-Aware Events

If you publish an event inside a database transaction, a normal @EventListener runs immediately, even before the transaction commits.

If you want the listener to run only after the transaction commits, use @TransactionalEventListener.

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

@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

    public UserService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void registerUser(String email) {
        Long userId = 42L;

        // Persist user here

        eventPublisher.publishEvent(new UserRegisteredEvent(userId, email));
    }
}
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class UserRegisteredTransactionalListener {

    @TransactionalEventListener
    public void afterCommit(UserRegisteredEvent event) {
        System.out.println("Transaction committed for user " + event.userId());
    }
}

By default, @TransactionalEventListener runs in the AFTER_COMMIT phase.

You can specify the phase explicitly:

import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class UserRegisteredTransactionListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void afterCommit(UserRegisteredEvent event) {
        System.out.println("After commit: " + event.email());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void afterRollback(UserRegisteredEvent event) {
        System.out.println("After rollback: " + event.email());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void afterCompletion(UserRegisteredEvent event) {
        System.out.println("Transaction completed: " + event.email());
    }

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void beforeCommit(UserRegisteredEvent event) {
        System.out.println("Before commit: " + event.email());
    }
}

8. Listener Ordering

If multiple listeners handle the same event, you can control their order with @Order.

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class OrderedListeners {

    @Order(1)
    @EventListener
    public void first(UserRegisteredEvent event) {
        System.out.println("First listener");
    }

    @Order(2)
    @EventListener
    public void second(UserRegisteredEvent event) {
        System.out.println("Second listener");
    }
}

Lower order values run first.


9. Returning Events from Listeners

A synchronous listener can return another event, and Spring will publish it.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ChainedEventListener {

    @EventListener
    public AccountCreatedEvent handleUserRegistered(UserRegisteredEvent event) {
        return new AccountCreatedEvent(event.userId());
    }
}

Example second event:

public record AccountCreatedEvent(Long userId) {
}

Then another listener can react to it:

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AccountCreatedListener {

    @EventListener
    public void handleAccountCreated(AccountCreatedEvent event) {
        System.out.println("Account created for user " + event.userId());
    }
}

Avoid this pattern for complex workflows, though. It can become hard to trace.


10. Legacy ApplicationEvent Style

Older Spring code often defines events by extending ApplicationEvent.

import org.springframework.context.ApplicationEvent;

public class UserRegisteredApplicationEvent extends ApplicationEvent {

    private final Long userId;
    private final String email;

    public UserRegisteredApplicationEvent(Object source, Long userId, String email) {
        super(source);
        this.userId = userId;
        this.email = email;
    }

    public Long getUserId() {
        return userId;
    }

    public String getEmail() {
        return email;
    }
}

Publishing:

eventPublisher.publishEvent(
        new UserRegisteredApplicationEvent(this, userId, email)
);

Listening:

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class LegacyUserEventListener {

    @EventListener
    public void handle(UserRegisteredApplicationEvent event) {
        System.out.println(event.getEmail());
    }
}

This still works, but plain objects or records are usually simpler.


Recommended Pattern

For most Spring applications:

  1. Use a simple immutable event type, often a record.
  2. Publish it from a service using ApplicationEventPublisher.
  3. Listen with @EventListener.
  4. Use @TransactionalEventListener for database-related side effects.
  5. Use @Async only for work that does not need to complete before the caller continues.

Example:

public record OrderPlacedEvent(
        Long orderId,
        Long customerId
) {
}
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final ApplicationEventPublisher eventPublisher;

    public OrderService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void placeOrder(Long customerId) {
        Long orderId = 100L;

        // Save order

        eventPublisher.publishEvent(new OrderPlacedEvent(orderId, customerId));
    }
}
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class OrderNotificationListener {

    @TransactionalEventListener
    public void sendConfirmation(OrderPlacedEvent event) {
        System.out.println("Send confirmation for order " + event.orderId());
    }
}

This ensures the confirmation runs only after the order transaction successfully commits.

How do I write unit tests for Spring components?

Writing Unit Tests for Spring Components

For Spring components, you usually want to test business logic without starting the full Spring application context. That means using JUnit 5 and Mockito for most unit tests.

Use Spring’s test support only when you need Spring-specific behavior such as dependency injection, MVC request handling, configuration binding, or persistence integration.


1. Unit Test a Spring @Service

Example service:

package com.example.order;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public Order createOrder(String customerEmail) {
        if (customerEmail == null || customerEmail.isBlank()) {
            throw new IllegalArgumentException("Customer email is required");
        }

        Order order = new Order(customerEmail);
        return orderRepository.save(order);
    }
}

Unit test:

package com.example.order;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

    @Test
    void createOrderSavesOrder() {
        Order savedOrder = new Order("[email protected]");

        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

        Order result = orderService.createOrder("[email protected]");

        assertEquals("[email protected]", result.getCustomerEmail());
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void createOrderRejectsBlankEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> orderService.createOrder(" ")
        );

        assertEquals("Customer email is required", exception.getMessage());
    }
}

This is a true unit test because no Spring context is started.


2. Unit Test a Spring @Component

Example component:

package com.example.notification;

import org.springframework.stereotype.Component;

@Component
public class EmailValidator {

    public boolean isValid(String email) {
        return email != null && email.contains("@");
    }
}

Test:

package com.example.notification;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class EmailValidatorTest {

    private final EmailValidator emailValidator = new EmailValidator();

    @Test
    void returnsTrueForValidEmail() {
        assertTrue(emailValidator.isValid("[email protected]"));
    }

    @Test
    void returnsFalseForInvalidEmail() {
        assertFalse(emailValidator.isValid("invalid-email"));
        assertFalse(emailValidator.isValid(null));
    }
}

If a component has no dependencies, just instantiate it directly.


3. Unit Test a Spring MVC @Controller

For controllers, use @WebMvcTest. This loads only the MVC layer, not the whole application.

Example controller:

package com.example.order;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/orders/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        return orderService.getOrder(id);
    }
}

Controller test:

package com.example.order;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void getOrderReturnsOrder() throws Exception {
        when(orderService.getOrder(1L))
                .thenReturn(new OrderResponse(1L, "[email protected]"));

        mockMvc.perform(get("/orders/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.customerEmail").value("[email protected]"));
    }
}

In newer Spring Boot versions, prefer @MockitoBean over the older @MockBean.


4. Unit Test Repository-Using Services

If your service depends on a Spring Data JPA repository, mock the repository in a unit test.

package com.example.user;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void findUserReturnsUser() {
        User user = new User(1L, "[email protected]");

        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        User result = userService.findUser(1L);

        assertEquals("[email protected]", result.getEmail());
    }
}

Do not use a real database for a unit test. If you want to test repository mappings or queries, use an integration/slice test such as @DataJpaTest.


5. Test Spring Data JPA Repositories with @DataJpaTest

This is not a pure unit test, but it is the standard way to test repositories.

package com.example.user;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertTrue;

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmailReturnsUser() {
        User user = new User();
        user.setEmail("[email protected]");

        userRepository.save(user);

        Optional<User> result = userRepository.findByEmail("[email protected]");

        assertTrue(result.isPresent());
    }
}

Use this when you want to verify:

  • JPA mappings
  • repository query methods
  • custom JPQL/native queries
  • database constraints

6. Recommended Dependencies

For Maven, the common Spring Boot test starter is usually enough:

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

It includes commonly used testing libraries such as:

  • JUnit Jupiter
  • AssertJ
  • Mockito
  • Spring Test
  • MockMvc support

7. Common Testing Patterns

Arrange, Act, Assert

@Test
void methodNameExpectedBehavior() {
    // Arrange
    when(repository.findById(1L)).thenReturn(Optional.of(entity));

    // Act
    Result result = service.doSomething(1L);

    // Assert
    assertEquals(expectedValue, result.value());
}

Verify interactions only when meaningful

verify(repository).save(any(Order.class));

Avoid verifying every single method call. Prefer verifying observable behavior.

Test exceptions

@Test
void throwsExceptionWhenUserNotFound() {
    assertThrows(
            UserNotFoundException.class,
            () -> userService.findUser(999L)
    );
}

8. Choosing the Right Test Type

Component Recommended test style
Plain utility/component Instantiate directly
@Service with dependencies JUnit 5 + Mockito
@Controller @WebMvcTest + MockMvc
Repository @DataJpaTest
Full application flow @SpringBootTest

Rule of Thumb

Use the smallest test scope that proves the behavior:

  • Business logic: plain JUnit + Mockito
  • Web layer: @WebMvcTest
  • Persistence layer: @DataJpaTest
  • End-to-end Spring wiring: @SpringBootTest

Most Spring component unit tests should not need @SpringBootTest.

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 use Java Streams without making my code hard to read?

Using Java Streams readably is mostly about using them where they express intent clearly and avoiding “clever” pipelines that hide business logic.

Good uses of Streams

Streams are great when you are doing simple collection transformations:

List<String> activeUserEmails = users.stream()
        .filter(User::isActive)
        .map(User::getEmail)
        .toList();

This reads almost like a sentence:

From users, keep active ones, get their emails, collect to a list.

Prefer method references when they are obvious

Readable:

List<Long> ids = orders.stream()
        .map(Order::getId)
        .toList();

Less readable:

List<Long> ids = orders.stream()
        .map(order -> order.getId())
        .toList();

Both are valid, but the method reference is simpler here.

However, do not force method references if a lambda is clearer:

List<Order> expensiveOrders = orders.stream()
        .filter(order -> order.total().compareTo(BigDecimal.valueOf(1000)) > 0)
        .toList();

Name complex predicates

If your filter condition gets complicated, extract it.

Hard to read:

List<Customer> customers = customers.stream()
        .filter(customer -> customer.isActive()
                && customer.getBalance().compareTo(BigDecimal.ZERO) > 0
                && customer.getLastOrderDate().isAfter(cutoffDate))
        .toList();

Better:

List<Customer> eligibleCustomers = customers.stream()
        .filter(customer -> isEligible(customer, cutoffDate))
        .toList();

private boolean isEligible(Customer customer, LocalDate cutoffDate) {
    return customer.isActive()
            && customer.getBalance().compareTo(BigDecimal.ZERO) > 0
            && customer.getLastOrderDate().isAfter(cutoffDate);
}

The stream now says what you are doing, and the helper explains how.

Avoid deeply nested streams

This is usually a readability warning sign:

List<String> productNames = orders.stream()
        .flatMap(order -> order.getLineItems().stream()
                .filter(item -> item.getQuantity() > 0)
                .map(item -> item.getProduct().getName()))
        .distinct()
        .sorted()
        .toList();

This is not terrible, but if it grows more complex, extract the inner logic:

List<String> productNames = orders.stream()
        .flatMap(order -> validProductNames(order).stream())
        .distinct()
        .sorted()
        .toList();

private List<String> validProductNames(Order order) {
    return order.getLineItems().stream()
            .filter(item -> item.getQuantity() > 0)
            .map(item -> item.getProduct().getName())
            .toList();
}

Do not use streams for a complicated control flow

Streams are not ideal when you need lots of branching, mutation, logging, exception handling, or early exits.

Less readable:

orders.stream()
        .filter(order -> {
            if (order.isCancelled()) {
                log.info("Skipping cancelled order {}", order.getId());
                return false;
            }

            if (!order.hasValidPayment()) {
                log.warn("Skipping unpaid order {}", order.getId());
                return false;
            }

            return true;
        })
        .forEach(this::ship);

A plain loop may be clearer:

for (Order order : orders) {
    if (order.isCancelled()) {
        log.info("Skipping cancelled order {}", order.getId());
        continue;
    }

    if (!order.hasValidPayment()) {
        log.warn("Skipping unpaid order {}", order.getId());
        continue;
    }

    ship(order);
}

Rule of thumb:

If the stream needs block lambdas with several statements, a loop may be better.

Keep stream operations on separate lines

Prefer this:

List<ProductDto> products = products.stream()
        .filter(Product::isVisible)
        .sorted(Comparator.comparing(Product::getName))
        .map(ProductDto::from)
        .toList();

Avoid cramming everything into one line:

List<ProductDto> products = products.stream().filter(Product::isVisible).sorted(Comparator.comparing(Product::getName)).map(ProductDto::from).toList();

Vertical formatting makes each step visible.

Avoid side effects inside streams

This is usually a bad sign:

List<String> names = new ArrayList<>();

users.stream()
        .filter(User::isActive)
        .forEach(user -> names.add(user.getName()));

Prefer collecting the result directly:

List<String> names = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .toList();

Side effects inside streams can make code harder to reason about, especially if someone later changes it to parallelStream().

Use collect only when needed

In modern Java, prefer toList() when you just need a list:

List<String> emails = users.stream()
        .map(User::getEmail)
        .toList();

Use Collectors when you need something more specific:

Map<Long, User> usersById = users.stream()
        .collect(Collectors.toMap(User::getId, Function.identity()));

Or grouping:

Map<Department, List<Employee>> employeesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment));

Avoid overly clever collectors

This may be technically impressive but hard to maintain:

Map<Department, Set<String>> namesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(
                        Employee::getName,
                        Collectors.toCollection(TreeSet::new)
                )
        ));

This is acceptable if your team is comfortable with collectors. Otherwise, consider extracting it:

Map<Department, Set<String>> namesByDepartment = employees.stream()
        .collect(groupEmployeeNamesByDepartment());

private Collector<Employee, ?, Map<Department, Set<String>>> groupEmployeeNamesByDepartment() {
    return Collectors.groupingBy(
            Employee::getDepartment,
            Collectors.mapping(
                    Employee::getName,
                    Collectors.toCollection(TreeSet::new)
            )
    );
}

Use meaningful variable names

Bad:

List<String> result = list.stream()
        .filter(x -> x.isActive())
        .map(x -> x.getName())
        .toList();

Better:

List<String> activeUserNames = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .toList();

Readable streams depend heavily on meaningful names.

Be careful with Optional.stream()

This can be elegant:

List<Address> addresses = users.stream()
        .map(User::getAddress)
        .flatMap(Optional::stream)
        .toList();

But if your team is unfamiliar with it, this may be clearer:

List<Address> addresses = users.stream()
        .map(User::getAddress)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .toList();

The first version is more idiomatic; the second may be easier for some teams. Prefer consistency with your codebase.

Use loops when they are clearer

Streams are not inherently better than loops.

Readable stream:

boolean hasExpiredInvoice = invoices.stream()
        .anyMatch(Invoice::isExpired);

Readable loop:

boolean hasExpiredInvoice = false;

for (Invoice invoice : invoices) {
    if (invoice.isExpired()) {
        hasExpiredInvoice = true;
        break;
    }
}

For simple matching, the stream is excellent:

boolean hasExpiredInvoice = invoices.stream()
        .anyMatch(Invoice::isExpired);

But for multistep logic, logging, error handling, or mutation, use a loop.

Practical rules of thumb

Use streams when:

  • You are filtering, mapping, sorting, grouping, or matching.
  • The pipeline has about 2–5 clear steps.
  • Each lambda is short and clear.
  • The result is a transformed collection, map, count, boolean, or optional.

Avoid streams when:

  • You need complex branching.
  • You need many side effects.
  • You need checked exception handling in lambdas.
  • The pipeline becomes deeply nested.
  • The stream is harder to debug than a loop.
  • You are using streams just to avoid writing for.

A good readable stream style

List<OrderSummary> summaries = orders.stream()
        .filter(Order::isCompleted)
        .filter(order -> order.placedAfter(startDate))
        .sorted(Comparator.comparing(Order::getPlacedAt).reversed())
        .map(OrderSummary::from)
        .toList();

This is readable because:

  • Each operation has one job.
  • The order of operations is clear.
  • The variable name explains the result.
  • Lambdas are short.
  • Business logic can be extracted if it grows.

Bottom line

Use Java Streams to make simple data transformations read like a pipeline. If the stream starts needing complex lambdas, nested streams, side effects, or lots of comments to explain it, switch to helper methods or a plain loop. Readability matters more than using Streams everywhere.

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.