How do I use profiles for different Spring environments?

Spring profiles allow you to run the same application with different configurations depending on the environment, such as development, testing, staging, or production.

For example, your development environment may use an in-memory database, while production uses MySQL or PostgreSQL.


1. What Is a Spring Profile?

A profile is a named set of configuration settings that Spring loads only when that profile is active.

Common profile names include:

  • dev
  • test
  • staging
  • prod

Profiles help you avoid hardcoding environment-specific values directly in your application code.


2. Creating Profile-Specific Configuration Files

In a Spring Boot application, you usually define configuration in application.properties or application.yml.

You can create separate files for each environment:

src/main/resources/
├── application.properties
├── application-dev.properties
├── application-test.properties
└── application-prod.properties

Spring Boot automatically loads the file that matches the active profile.


3. Example Using application.properties

The default configuration file:

spring.application.name=my-spring-app

server.port=8080

Development profile:

spring.datasource.url=jdbc:h2:mem:devdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop

logging.level.org.springframework=DEBUG

Production profile:

spring.datasource.url=jdbc:postgresql://prod-db-server:5432/appdb
spring.datasource.username=app_user
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

logging.level.org.springframework=WARN

Here:

  • application-dev.properties is used for development.
  • application-prod.properties is used for production.
  • ${DB_PASSWORD} reads the value from an environment variable.

4. Example Using YAML

You can also use application.yml:

spring:
  application:
    name: my-spring-app

server:
  port: 8080

Profile-specific YAML files can be created like this:

application-dev.yml
application-prod.yml

Example application-dev.yml:

spring:
  datasource:
    url: jdbc:h2:mem:devdb
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop

logging:
  level:
    org.springframework: DEBUG

Example application-prod.yml:

spring:
  datasource:
    url: jdbc:postgresql://prod-db-server:5432/appdb
    username: app_user
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate

logging:
  level:
    org.springframework: WARN

5. Activating a Profile

There are several ways to activate a Spring profile.


Option 1: In application.properties

spring.profiles.active=dev

This is simple, but usually best for local development only.

Avoid committing spring.profiles.active=prod into shared configuration unless you are sure it is appropriate.


Option 2: From the Command Line

java -jar my-spring-app.jar --spring.profiles.active=prod

You can also pass it as a JVM system property:

java -Dspring.profiles.active=prod -jar my-spring-app.jar

Option 3: Using an Environment Variable

On macOS/Linux:

export SPRING_PROFILES_ACTIVE=prod
java -jar my-spring-app.jar

On Windows PowerShell:

$env:SPRING_PROFILES_ACTIVE="prod"
java -jar my-spring-app.jar

This is commonly used in Docker, Kubernetes, CI/CD pipelines, and cloud platforms.


6. Using Profiles with Beans

Profiles are not limited to configuration files. You can also create beans that only exist in certain environments.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
public class DataSourceConfig {

    @Bean
    @Profile("dev")
    public String devDatabaseMessage() {
        return "Using development database";
    }

    @Bean
    @Profile("prod")
    public String prodDatabaseMessage() {
        return "Using production database";
    }
}

When the dev profile is active, only the devDatabaseMessage bean is registered. When the prod profile is active, only the prodDatabaseMessage bean is registered.


7. Using Profiles on Classes

You can also place @Profile on an entire configuration class or component:

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

@Configuration
@Profile("dev")
public class DevConfiguration {

    // Beans here are loaded only when the dev profile is active
}

Another example:

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

@Service
@Profile("test")
public class MockEmailService implements EmailService {

    @Override
    public void sendEmail(String to, String subject, String body) {
        System.out.println("Pretending to send email in test environment");
    }
}

A production implementation could look like this:

import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

@Service
@Profile("prod")
public class SmtpEmailService implements EmailService {

    @Override
    public void sendEmail(String to, String subject, String body) {
        // Send real email using SMTP provider
    }
}

8. Using Multiple Profiles

Spring allows more than one profile to be active at the same time.

java -jar my-spring-app.jar --spring.profiles.active=prod,metrics

You can then annotate beans like this:

@Profile("metrics")
@Bean
public MeterRegistryCustomizer<?> metricsCustomizer() {
    return registry -> registry.config().commonTags("application", "my-spring-app");
}

9. Setting a Default Profile

If no profile is active, Spring uses the default profile.

You can define a default profile like this:

spring.profiles.default=dev

Or in YAML:

spring:
  profiles:
    default: dev

This means the application uses dev settings unless another profile is explicitly activated.


10. Profile Expressions

The @Profile annotation also supports expressions.

@Profile("dev | test")

This bean is active when either dev or test is active.

@Profile("!prod")

This bean is active when the prod profile is not active.

@Profile("prod & metrics")

This bean is active only when both prod and metrics are active.


11. Using Profiles in Tests

For tests, you can activate a profile with @ActiveProfiles.

import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest {

    @Test
    void shouldLoadApplicationContext() {
        // test code here
    }
}

Then create:

src/test/resources/application-test.properties

Example:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop

12. Common Use Case: Database Per Environment

Development:

spring.datasource.url=jdbc:h2:mem:devdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop

Testing:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop

Production:

spring.datasource.url=jdbc:postgresql://localhost:5432/proddb
spring.datasource.username=prod_user
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

A good rule is:

spring.jpa.hibernate.ddl-auto=validate

for production, instead of create, create-drop, or update.


13. Best Practices

  • Use profiles for environment-specific configuration.
  • Keep secrets out of committed files.
  • Use environment variables for passwords, tokens, and API keys.
  • Prefer prod configuration to be strict and safe.
  • Use validate or a migration tool like Flyway/Liquibase in production.
  • Avoid hardcoding spring.profiles.active=prod in source control.
  • Use @Profile only when bean behavior really differs by environment.
  • Prefer external configuration for values like URLs, credentials, and feature flags.

Summary

Spring profiles let you run the same application with different settings for each environment.

Typical setup:

application.properties
application-dev.properties
application-test.properties
application-prod.properties

Activate a profile like this:

java -jar my-spring-app.jar --spring.profiles.active=dev

Use @Profile when certain beans should only be available in specific environments:

@Profile("prod")
@Bean
public SomeService productionService() {
    return new SomeService();
}

In short, profiles make your Spring application easier to configure, safer to deploy, and cleaner to maintain across different environments.

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 to Use Virtual Threads in Java (Project Loom) with Spring Boot

Virtual threads are one of the most exciting additions to modern Java. They make it much easier to write highly concurrent applications without the complexity of managing large thread pools, callbacks, or reactive pipelines.

If you build applications with Spring Boot, virtual threads can help your app handle many more concurrent tasks with simpler code.

In this post, we’ll cover:

  • What virtual threads are
  • Why they matter
  • When to use them
  • How to enable them in Spring Boot
  • A few important best practices and warnings

What are virtual threads?

Traditional Java threads are often called platform threads. They are mapped closely to operating system threads. That means they are relatively expensive in terms of memory and scheduling.

Virtual threads are lightweight threads managed by the JVM, not directly by the operating system. They are designed to make blocking code cheap again.

In simple terms:

  • Platform threads = heavier, fewer, more expensive
  • Virtual threads = lightweight, many more, cheaper to create

This means you can write code in the usual imperative style, but still support high concurrency.


Why virtual threads matter

For years, Java developers had two main choices for handling concurrency:

  1. Use thread pools and blocking code
  2. Use asynchronous/reactive programming

Virtual threads give you a third option:

  • Keep the simple blocking style
  • Avoid the complexity of reactive code
  • Scale better under lots of concurrent I/O operations

This is especially useful for applications that spend a lot of time waiting on:

  • Database calls
  • HTTP requests
  • File I/O
  • Remote service calls

If your application is mostly I/O-bound, virtual threads can be a great fit.


Virtual threads vs platform threads

Here’s a simple comparison:

Feature Platform Threads Virtual Threads
Cost to create Higher Very low
Memory usage Higher Lower
Number you can run Limited Much larger
Good for blocking code Yes, but expensive Yes, and efficient
Managed by OS Yes Mostly by JVM

The biggest win is that virtual threads let you run many blocking tasks concurrently without exhausting thread resources as quickly.


When should you use virtual threads?

Virtual threads are a strong choice when your app does lots of blocking I/O and you want simple code.

Good use cases:

  • REST APIs with many simultaneous requests
  • Service-to-service communication
  • Database-heavy applications
  • Background jobs that wait on I/O
  • File processing or batch operations

Less ideal use cases:

  • CPU-intensive tasks
  • Work that depends heavily on thread-local assumptions
  • Libraries that block while holding locks in a problematic way

Virtual threads do not magically make CPU-bound code faster. They help most when threads spend time waiting.


Project Loom in Java

Virtual threads are part of Project Loom, a long-running effort to modernize concurrency in Java.

Loom also introduced other improvements around structured concurrency and scoped values, but the headline feature most developers use today is virtual threads.

Virtual threads became a standard Java feature in recent releases, so you no longer need special preview settings in modern Java versions.


How Spring Boot supports virtual threads

Spring Boot has first-class support for virtual threads.

If you are using Spring Boot 3.2+, you can usually enable them with a simple configuration property:

spring.threads.virtual.enabled=true

That tells Spring Boot to use virtual threads in places where it manages request handling and task execution.

This is one of the nicest parts: in many cases, you can get the benefits of virtual threads with very little code change.


Basic setup in Spring Boot

1. Use a recent Java version

Virtual threads require a modern JDK, for example Java 25.

2. Use a recent Spring Boot version

Make sure your Spring Boot version supports virtual threads well. Spring Boot 3.2 or later is recommended.

3. Enable virtual threads

Add this to your application configuration:

spring.threads.virtual.enabled=true

Or in YAML:

spring:
  threads:
    virtual:
      enabled: true

That’s often enough for web applications.


What happens when you enable them?

When virtual threads are enabled, Spring can use them for tasks such as:

  • Handling incoming HTTP requests
  • Running @Async methods
  • Executing some scheduled or background tasks

The exact behavior depends on the Spring component and configuration, but the overall idea is that work can run on virtual threads instead of a small pool of platform threads.


Example: a Spring Boot REST controller

Here is a simple example of how your code can stay clean and blocking while still benefiting from virtual threads.

package com.example.demo;

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

@RestController
public class DemoController {

    private final DemoService demoService;

    public DemoController(DemoService demoService) {
        this.demoService = demoService;
    }

    @GetMapping("/hello")
    public String hello() {
        return demoService.fetchMessage();
    }
}
package com.example.demo;

import org.springframework.stereotype.Service;

@Service
public class DemoService {

    public String fetchMessage() {
        // Simulate a blocking operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Hello from a virtual thread!";
    }
}

With virtual threads enabled, each request can be handled with a lightweight thread, even though the service method blocks.


Example: using virtual threads for background tasks

You can also create virtual threads manually when needed.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExample {

    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> System.out.println("Task 1 on virtual thread"));
            executor.submit(() -> System.out.println("Task 2 on another virtual thread"));
        }
    }
}

This is useful when you want a simple executor that creates a new virtual thread for each task.


Using @Async with virtual threads

Spring’s @Async support can also benefit from virtual threads.

For example:

package com.example.demo;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class EmailService {

    @Async
    public CompletableFuture<String> sendEmail() {
        // Simulate work
        return CompletableFuture.completedFuture("Email sent");
    }
}

If Spring is configured to use virtual threads for task execution, these async methods can run on virtual threads, making blocking work much cheaper.


Virtual threads do not replace everything

It’s important not to oversell virtual threads. They solve one problem very well: cheap blocking concurrency.

They do not replace:

  • Proper database indexing
  • Efficient network design
  • Good application architecture
  • Caching
  • Load balancing
  • Performance tuning

Virtual threads are a concurrency tool, not a performance silver bullet.


Best practices when using virtual threads

1. Keep code simple

One of the main advantages of virtual threads is that you can keep using normal blocking code. Don’t add unnecessary complexity.

2. Avoid long synchronized blocks

Virtual threads can behave poorly if they spend too much time blocked inside synchronized sections. Prefer shorter critical sections and consider alternatives where appropriate.

3. Watch out for thread-local usage

Some older code relies heavily on ThreadLocal. Virtual threads support it, but large-scale usage can still create complexity and memory overhead.

4. Test your dependencies

Most modern libraries work well, but some older libraries may assume platform-thread behavior or may not play nicely with high concurrency.

5. Don’t use virtual threads for CPU-heavy work expecting miracles

If your bottleneck is CPU, virtual threads will not fix it. For CPU-bound tasks, focus on algorithms, parallelism, and profiling.


Common questions

Are virtual threads faster?

Not always in a direct “single task runs faster” sense. Their big advantage is that they allow more concurrency with less overhead, especially for blocking I/O.

Do I need reactive programming anymore?

Not necessarily. Virtual threads reduce the need for reactive programming in many applications, especially if you prefer imperative code.

Can I use virtual threads with Spring MVC?

Yes. Spring MVC is a great fit because it already uses a blocking request model, which maps naturally to virtual threads.

Can I use them with Spring WebFlux?

You can, but WebFlux is built around a reactive model. Virtual threads are often more valuable in traditional blocking stacks like Spring MVC.


When virtual threads are a great fit in Spring Boot

Virtual threads are especially attractive if:

  • You already have a Spring MVC application
  • You use JDBC and blocking database access
  • Your codebase is imperative and you want to keep it that way
  • You want to handle more concurrent requests with less thread-pool tuning

In many cases, virtual threads let you modernize your app without rewriting it.


A practical migration strategy

If you want to adopt virtual threads in an existing Spring Boot app, here’s a simple approach:

  1. Upgrade to a compatible Java and Spring Boot version
  2. Enable virtual threads in configuration
  3. Test your main request paths
  4. Watch application metrics under load
  5. Check library compatibility
  6. Tune only where needed

You usually do not need to rewrite your whole application.


Final thoughts

Virtual threads are a major step forward for Java concurrency. They make it possible to write straightforward, blocking-style code while still supporting high throughput and scalability.

For Spring Boot developers, this is especially valuable because it means:

  • Less concurrency boilerplate
  • Easier-to-read code
  • Better scaling for I/O-heavy workloads
  • A smoother path than fully reactive programming for many apps

If your application spends a lot of time waiting on I/O, virtual threads are absolutely worth trying.


Summary

Use virtual threads in Spring Boot when you want:

  • Simple blocking code
  • High concurrency
  • Less thread-pool management
  • Better scalability for I/O-bound workloads

Enable them with:

spring.threads.virtual.enabled=true

Then test, measure, and enjoy a much simpler concurrency model in Java.

Testing Spring Boot Applications with JUnit and Mockito

Testing Spring Boot applications with JUnit and Mockito is a good practice to ensure the correctness and reliability of your application. Below, I’ll present a brief overview of how to write such test cases with explanations and examples.

1. JUnit Basics in Spring Boot

Spring Boot provides out-of-the-box support for JUnit through the spring-boot-starter-test dependency, which includes:

  • JUnit 5 (Jupiter) for writing test cases.
  • Mockito for mocking dependencies.
  • Additional libraries like Hamcrest and AssertJ for assertions.

Add the dependency in your pom.xml (if it’s not already present):

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

2. Structural Overview

Testing can be divided into:

  • Unit Tests: Independent and isolated tests of individual classes, written using JUnit + Mockito.
  • Integration Tests: Testing multiple layers/classes, typically with Spring’s @SpringBootTest.

3. Writing Unit Tests with JUnit and Mockito

Below is an example of unit testing a Service class.

Example Scenario:

We have a class that depends on UserRepository. UserService
UserService.java:

@Service
public class UserService {

    private final UserRepository userRepository;

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

    public User findUserById(Long id) {
        return userRepository.findById(id).orElseThrow(() -> new RuntimeException("User not found"));
    }
}

UserRepository.java:

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

Corresponding Test Case

@ExtendWith(MockitoExtension.class) // Enables Mockito in JUnit 5
class UserServiceTest {

    @Mock // Creates a mock instance of UserRepository
    private UserRepository userRepository;

    @InjectMocks // Creates UserService and injects the mock UserRepository
    private UserService userService;

    @Test
    void testFindUserById_UserExists() {
        // Arrange
        Long userId = 1L;
        User mockUser = new User(userId, "John Doe", "[email protected]");

        // Mock the behavior of userRepository
        Mockito.when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));

        // Act
        User result = userService.findUserById(userId);

        // Assert
        assertNotNull(result);
        assertEquals(mockUser.getName(), result.getName());
        Mockito.verify(userRepository).findById(userId); // Verify method call
    }

    @Test
    void testFindUserById_UserNotFound() {
        // Arrange
        Long userId = 1L;

        // Mock the behavior of userRepository
        Mockito.when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // Act & Assert
        Exception exception = assertThrows(RuntimeException.class, () -> userService.findUserById(userId));
        assertEquals("User not found", exception.getMessage());
    }
}

4. Integration Tests with @SpringBootTest

Integration tests are used to test the entire Spring application context.

Testing UserController

UserController.java:

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

    private final UserService userService;

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

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findUserById(id));
    }
}

Test Case for UserController

Use @SpringBootTest with for integration-like testing. MockMvc

@SpringBootTest
@AutoConfigureMockMvc // Configures MockMvc
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean // Mock a bean in the Spring context
    private UserService userService;

    @Test
    void testGetUserById() throws Exception {
        // Arrange
        Long userId = 1L;
        User mockUser = new User(userId, "John Doe", "[email protected]");

        Mockito.when(userService.findUserById(userId)).thenReturn(mockUser);

        // Act and Assert
        mockMvc.perform(get("/users/{id}", userId))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.name").value("John Doe"))
               .andExpect(jsonPath("$.email").value("[email protected]"));

        Mockito.verify(userService).findUserById(userId);
    }
}

5. Tips for Effective Testing

  1. Mock dependencies: Mock all external dependencies to isolate the unit being tested.
  2. Use Assertions effectively: Leverage libraries like AssertJ or Hamcrest for expressive assertions.
  3. Test exceptions: Always test boundary cases and exceptions.
  4. Verify mock behavior: Use Mockito.verify() to ensure mocked methods were invoked correctly.
  5. Spring utilities: @MockBean is useful for overriding beans in the application context for integration testing.

Summary

Spring Boot testing with JUnit and Mockito allows you to:

  • Write isolated unit tests for business logic.
  • Write integration tests to validate Spring components working together.

Implementing Global Exception Handling with @ControllerAdvice

To implement global exception handling in a Spring application, the @ControllerAdvice annotation is used. It allows you to centralize exception handling across multiple controllers.

Below is an example of how you can implement global exception handling with @ControllerAdvice in your application:


1. Define a Global Exception Handler

Create a class with the @ControllerAdvice annotation to handle exceptions globally. Within this class, use the @ExceptionHandler annotation on methods to define specific exception handling logic.

package org.kodejava;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

// Marks this class as a global exception handler
@ControllerAdvice
public class GlobalExceptionHandler {

    // Handle specific exceptions
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                request.getDescription(false));
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    // Handle global exceptions (for all other exceptions)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleGlobalException(Exception ex, WebRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred",
                request.getDescription(false));
        return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

2. Create a Custom Exception Class (Optional)

Define specific exception classes for your business use cases. For example, a ResourceNotFoundException for handling “not found” errors.

package org.kodejava;

public class ResourceNotFoundException extends RuntimeException {

    private static final long serialVersionUID = 1L;

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

3. Create an Error Response Model

Create a class to structure the error response data consistently.

package org.kodejava;

public class ErrorResponse {

    private int statusCode;
    private String message;
    private String details;

    public ErrorResponse(int statusCode, String message, String details) {
        this.statusCode = statusCode;
        this.message = message;
        this.details = details;
    }

    // Getters and Setters
    public int getStatusCode() {
        return statusCode;
    }

    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getDetails() {
        return details;
    }

    public void setDetails(String details) {
        this.details = details;
    }
}

4. Throw Custom Exceptions in Your Controller

You can now throw the ResourceNotFoundException or other exceptions in your controllers, and let the global exception handler process them.

package org.kodejava;

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

@RestController
@RequestMapping("/api")
public class TestController {

    @GetMapping("/resource")
    public String getResource() {
        throw new ResourceNotFoundException("Resource not found with ID");
    }
}

5. Test the Application

When you access the /api/resource endpoint, the global exception handler will catch the ResourceNotFoundException and return a structured error response. Example response:

{
  "statusCode": 404,
  "message": "Resource not found with ID",
  "details": "uri=/api/resource"
}

Advantages of Using @ControllerAdvice

  1. Centralized Exception Handling: Removes the need to write exception handling in multiple controllers.
  2. Improved Readability: Controllers are cleaner as they no longer handle exceptions.
  3. Reusability: Reuse the exception handler for different types of exceptions across the application.
  4. Custom Responses: Provides flexibility to return consistent error responses.

This implementation ensures your application has a robust and maintainable error-handling mechanism using Spring’s @ControllerAdvice.