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 REST APIs with Spring MVC?

Building REST APIs with Spring MVC

In Spring MVC, you build REST APIs by defining controller classes that map HTTP requests to Java methods. In modern Spring Boot applications, this is usually done with @RestController.

A typical REST API is organized like this:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

Each layer has a clear responsibility:

Layer Responsibility
Controller Handles HTTP requests and responses
Service Contains business logic
Repository Handles database access
Entity Represents database tables
DTO Represents API request/response data

1. Add the Spring Web Dependency

For Maven:

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

If you need validation:

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

If you use JPA:

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

2. Create a REST Controller

Use @RestController for REST APIs. It combines @Controller and @ResponseBody, meaning returned objects are written directly to the HTTP response, usually as JSON.

package com.example.demo.user;

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

@RestController
public class HelloController {

    @GetMapping("/api/hello")
    public String hello() {
        return "Hello, REST API!";
    }
}

Calling:

GET /api/hello

returns:

Hello, REST API!

3. Design Resource-Based URLs

REST APIs should use nouns for resources and HTTP methods for actions.

Good:

GET    /api/users
GET    /api/users/1
POST   /api/users
PUT    /api/users/1
DELETE /api/users/1

Avoid action-style URLs like:

/api/getUsers
/api/createUser
/api/deleteUser

The HTTP method already describes the operation.


4. Create DTOs for Request and Response Bodies

Avoid exposing database entities directly from your API. Use DTOs instead.

package com.example.demo.user;

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

public record CreateUserRequest(
        @NotBlank String name,
        @NotBlank @Email String email
) {
}
package com.example.demo.user;

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

DTOs keep your API contract separate from your database model.


5. Create REST Endpoints

A controller for basic CRUD operations might look like this:

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 CreateUserRequest request
    ) {
        return userService.update(id, request);
    }

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

Key annotations:

Annotation Purpose
@RestController Marks the class as a REST controller
@RequestMapping Defines a base URL
@GetMapping Handles HTTP GET
@PostMapping Handles HTTP POST
@PutMapping Handles HTTP PUT
@DeleteMapping Handles HTTP DELETE
@PathVariable Reads values from the URL path
@RequestBody Reads JSON from the request body
@Valid Triggers Jakarta Bean Validation
@ResponseStatus Sets the HTTP response status

6. Put Business Logic in a Service

Controllers should stay thin. Put business rules in a service class.

package com.example.demo.user;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserService {

    public List<UserResponse> findAll() {
        // Load users from repository
        return List.of();
    }

    public UserResponse findById(Long id) {
        // Find user by id
        return new UserResponse(id, "Alice", "[email protected]");
    }

    public UserResponse create(CreateUserRequest request) {
        // Create user
        return new UserResponse(1L, request.name(), request.email());
    }

    public UserResponse update(Long id, CreateUserRequest request) {
        // Update user
        return new UserResponse(id, request.name(), request.email());
    }

    public void delete(Long id) {
        // Delete user
    }
}

In a real application, the service would call a repository.


7. Use Spring Data JPA for Persistence

If your API stores data in a database, create an entity and repository.

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

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

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

By extending JpaRepository, you automatically get methods like:

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

8. Return Proper HTTP Status Codes

Use status codes that match the result:

Situation Status
Successful read 200 OK
Created resource 201 Created
Deleted resource 204 No Content
Invalid request 400 Bad Request
Missing resource 404 Not Found
Conflict 409 Conflict
Server error 500 Internal Server Error

For creation, you can also return 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);
    }
}

9. Handle Errors Globally

Use @RestControllerAdvice to return consistent JSON errors.

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 record FieldErrorDetail(
            String field,
            String message
    ) {
    }
}
package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
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.time.Instant;
import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                404,
                "Not Found",
                ex.getMessage(),
                request.getRequestURI(),
                Instant.now(),
                List.of()
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleValidation(
            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(
                400,
                "Bad Request",
                "Validation failed",
                request.getRequestURI(),
                Instant.now(),
                fieldErrors
        );
    }
}

10. Test Your API

Example using curl:

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

Get all users:

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

Get one user:

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

Update a user:

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

Delete a user:

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

11. Add Pagination for List Endpoints

For large collections, avoid returning everything at once.

package com.example.demo.user;

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

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

Then clients can call:

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

Recommended Checklist

When building REST APIs with Spring MVC:

  • Use @RestController.
  • Use resource-based URLs like /api/users.
  • Use HTTP methods correctly: GET, POST, PUT, PATCH, DELETE.
  • Keep controllers thin.
  • Put business logic in services.
  • Use repositories for database access.
  • Use DTOs instead of exposing entities.
  • Validate request bodies with jakarta.validation.
  • Handle errors globally with @RestControllerAdvice.
  • Return correct HTTP status codes.
  • Add pagination for collection endpoints.
  • Use jakarta.* imports in modern Spring Boot applications.

A clean REST API usually follows this shape:

Controller → Service → Repository → Database

That structure keeps your Spring MVC API easier to test, maintain, and evolve.

How do I use AOP in Spring for cross-cutting concerns?

In a Spring application, some logic does not belong to just one business feature. For example:

  • Logging method calls
  • Measuring execution time
  • Checking security rules
  • Managing transactions
  • Auditing user actions
  • Handling repeated exception logic

These are called cross-cutting concerns because they “cut across” many parts of your application.

Instead of copying the same logging, auditing, or timing code into many services, Spring allows you to separate that logic using AOP, or Aspect-Oriented Programming.


What Is AOP?

AOP, or Aspect-Oriented Programming, is a programming technique that lets you apply reusable behavior around your normal application logic.

In Spring, AOP is commonly used to run extra code:

  • Before a method runs
  • After a method finishes
  • After a method throws an exception
  • Around the entire method execution

For example, instead of writing logging code inside every service method:

public void createOrder() {
    System.out.println("Creating order...");
    // business logic
}

You can define the logging behavior once in an aspect, and Spring applies it automatically to matching methods.


Common AOP Terms

Before writing code, it helps to understand a few important AOP terms.

Term Meaning
Aspect A class that contains cross-cutting logic
Advice The action that runs, such as before or after a method
Join Point A point during program execution, usually a method call
Pointcut An expression that selects which methods the advice applies to
Target Object The Spring bean being advised
Proxy The object Spring creates to wrap the original bean and apply the aspect

In Spring AOP, join points are usually method executions on Spring-managed beans.


Example Scenario

Suppose we have a service that handles orders.

package com.example.demo.order;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    public void createOrder(String productName) {
        System.out.println("Creating order for: " + productName);
    }

    public void cancelOrder(Long orderId) {
        System.out.println("Cancelling order: " + orderId);
    }
}

We want to log whenever service methods are called, but we do not want to put logging code inside every method.

This is a perfect use case for Spring AOP.


Adding the Spring AOP Dependency

If you are using Maven with Spring Boot, add spring-boot-starter-aop.

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

This starter includes Spring AOP and AspectJ annotation support.

If you are not using Spring Boot, you typically need Spring AOP and AspectJ Weaver dependencies manually.


Creating a Simple Aspect

An aspect is a Spring bean annotated with @Aspect.

package com.example.demo.aop;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.example.demo.order.*.*(..))")
    public void logBeforeMethodCall(JoinPoint joinPoint) {
        System.out.println("Calling method: " + joinPoint.getSignature().getName());
    }
}

This aspect says:

Before executing any method in com.example.demo.order, print the method name.

The important part is this expression:

execution(* com.example.demo.order.*.*(..))

This is called a pointcut expression.


Understanding the Pointcut Expression

The expression:

execution(* com.example.demo.order.*.*(..))

can be read as:

Part Meaning
execution Match method execution
* Any return type
com.example.demo.order.* Any class in this package
.* Any method name
(..) Any number of parameters

So it matches methods such as:

OrderService.createOrder(String productName)
OrderService.cancelOrder(Long orderId)

Running the Service

You can call the service from a controller, command-line runner, or another Spring bean.

package com.example.demo;

import com.example.demo.order.OrderService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class DemoRunner implements CommandLineRunner {

    private final OrderService orderService;

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

    @Override
    public void run(String... args) {
        orderService.createOrder("Laptop");
        orderService.cancelOrder(1001L);
    }
}

Example output:

Calling method: createOrder
Creating order for: Laptop
Calling method: cancelOrder
Cancelling order: 1001

The OrderService class does not contain logging logic, but logging still happens.

That is the main benefit of AOP.


Types of Advice in Spring AOP

Spring AOP provides several advice annotations.

@Before

Runs before the matched method.

@Before("execution(* com.example.demo.order.*.*(..))")
public void beforeMethod(JoinPoint joinPoint) {
    System.out.println("Before: " + joinPoint.getSignature().getName());
}

Use this for:

  • Logging before execution
  • Security checks
  • Validating method arguments

@After

Runs after the method finishes, whether it succeeds or throws an exception.

@After("execution(* com.example.demo.order.*.*(..))")
public void afterMethod(JoinPoint joinPoint) {
    System.out.println("After: " + joinPoint.getSignature().getName());
}

Use this for cleanup logic.


@AfterReturning

Runs only when the method completes successfully.

@AfterReturning(
        pointcut = "execution(* com.example.demo.order.*.*(..))",
        returning = "result"
)
public void afterReturning(JoinPoint joinPoint, Object result) {
    System.out.println("Method returned successfully: " + joinPoint.getSignature().getName());
    System.out.println("Result: " + result);
}

Example service method:

public String findOrderStatus(Long orderId) {
    return "PROCESSING";
}

@AfterReturning can access the return value.


@AfterThrowing

Runs only when the method throws an exception.

@AfterThrowing(
        pointcut = "execution(* com.example.demo.order.*.*(..))",
        throwing = "exception"
)
public void afterThrowing(JoinPoint joinPoint, Exception exception) {
    System.out.println("Method failed: " + joinPoint.getSignature().getName());
    System.out.println("Exception: " + exception.getMessage());
}

Use this for:

  • Error logging
  • Auditing failed operations
  • Sending failure metrics

@Around

@Around is the most powerful advice type. It wraps the method execution completely.

It can:

  • Run code before the method
  • Run code after the method
  • Change arguments
  • Change the return value
  • Prevent the method from running
  • Measure execution time
package com.example.demo.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class PerformanceAspect {

    @Around("execution(* com.example.demo.order.*.*(..))")
    public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();

        try {
            return joinPoint.proceed();
        } finally {
            long end = System.nanoTime();
            long durationInMillis = (end - start) / 1_000_000;

            System.out.println(
                    joinPoint.getSignature().getName()
                            + " executed in "
                            + durationInMillis
                            + " ms"
            );
        }
    }
}

The key method here is:

joinPoint.proceed();

This tells Spring to continue and execute the original target method.

If you do not call proceed(), the original method will not run.


Reusing Pointcuts

If you use the same pointcut expression in multiple advice methods, it is better to define it once.

package com.example.demo.aop;

import org.aspectj.lang.annotation.Pointcut;

public class CommonPointcuts {

    @Pointcut("execution(* com.example.demo.order.*.*(..))")
    public void orderServiceMethods() {
    }
}

Then use it in your aspects:

package com.example.demo.aop;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    @Before("com.example.demo.aop.CommonPointcuts.orderServiceMethods()")
    public void logBeforeMethodCall(JoinPoint joinPoint) {
        System.out.println("Calling: " + joinPoint.getSignature().getName());
    }
}

This makes your code easier to maintain.


Matching Methods by Annotation

A very common and clean approach is to create a custom annotation and apply AOP only to methods annotated with it.

For example, create an annotation named @Auditable.

package com.example.demo.audit;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();
}

Now annotate a service method:

package com.example.demo.order;

import com.example.demo.audit.Auditable;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @Auditable(action = "CREATE_ORDER")
    public void createOrder(String productName) {
        System.out.println("Creating order for: " + productName);
    }
}

Then create an aspect that reacts to this annotation:

package com.example.demo.audit;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class AuditAspect {

    @Before("@annotation(auditable)")
    public void audit(JoinPoint joinPoint, Auditable auditable) {
        System.out.println("Audit action: " + auditable.action());
        System.out.println("Method: " + joinPoint.getSignature().getName());
    }
}

This is often better than matching methods by package name because it is more explicit.

You can immediately see which methods are audited:

@Auditable(action = "CREATE_ORDER")
public void createOrder(String productName) {
    // business logic
}

Practical Example: Logging Method Arguments

You can access method arguments using JoinPoint.

package com.example.demo.aop;

import java.util.Arrays;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class MethodArgumentLoggingAspect {

    @Before("execution(* com.example.demo.order.*.*(..))")
    public void logArguments(JoinPoint joinPoint) {
        System.out.println("Method: " + joinPoint.getSignature().getName());
        System.out.println("Arguments: " + Arrays.toString(joinPoint.getArgs()));
    }
}

Example output:

Method: createOrder
Arguments: [Laptop]

Be careful when logging arguments. Do not accidentally log sensitive information such as:

  • Passwords
  • Access tokens
  • Credit card numbers
  • Personal identity information

Practical Example: Measuring Service Performance

Here is a slightly cleaner performance aspect using Java’s Duration.

package com.example.demo.aop;

import java.time.Duration;
import java.time.Instant;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ServiceTimingAspect {

    @Around("execution(* com.example.demo..service..*(..))")
    public Object measureServiceTime(ProceedingJoinPoint joinPoint) throws Throwable {
        Instant start = Instant.now();

        try {
            return joinPoint.proceed();
        } finally {
            Duration duration = Duration.between(start, Instant.now());

            System.out.println(
                    joinPoint.getSignature().toShortString()
                            + " took "
                            + duration.toMillis()
                            + " ms"
            );
        }
    }
}

This pointcut:

execution(* com.example.demo..service..*(..))

matches methods inside packages containing service.

The .. means “this package and its subpackages.”


Using AOP with Spring MVC Controllers

You can also apply AOP to controllers.

For example:

@Around("within(@org.springframework.web.bind.annotation.RestController *)")
public Object logRestControllerCalls(ProceedingJoinPoint joinPoint) throws Throwable {
    System.out.println("REST call: " + joinPoint.getSignature().toShortString());
    return joinPoint.proceed();
}

This matches beans annotated with @RestController.

However, for HTTP request logging, a Spring MVC HandlerInterceptor or servlet filter is sometimes a better fit.

Use AOP when you want to intercept method-level application behavior.

Use filters or interceptors when you want to work directly with HTTP requests and responses.


AOP and Transactions

If you have used @Transactional, you have already used a form of AOP.

For example:

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

@Service
public class PaymentService {

    @Transactional
    public void processPayment(Long orderId) {
        // database operations
    }
}

Spring applies transaction behavior around the method call.

Conceptually, it works like this:

begin transaction
try {
    processPayment(orderId)
    commit transaction
} catch (Exception ex) {
    rollback transaction
    throw ex
}

You do not usually write this logic yourself. Spring applies it as a cross-cutting concern.


Important Limitation: Self-Invocation

Spring AOP is proxy-based. This means Spring creates a proxy object around your bean.

Because of this, AOP usually works when one Spring bean calls another Spring bean.

For example, this works:

@Service
public class OrderControllerService {

    private final OrderService orderService;

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

    public void run() {
        orderService.createOrder("Keyboard");
    }
}

But this may not trigger AOP:

@Service
public class OrderService {

    public void createOrder(String productName) {
        validateOrder(productName);
        System.out.println("Creating order for: " + productName);
    }

    @Auditable(action = "VALIDATE_ORDER")
    public void validateOrder(String productName) {
        System.out.println("Validating: " + productName);
    }
}

Why?

Because createOrder() calls validateOrder() directly inside the same class. The call does not go through the Spring proxy.

This is called self-invocation.

A common solution is to move the advised method to another Spring bean.

@Service
public class OrderValidationService {

    @Auditable(action = "VALIDATE_ORDER")
    public void validateOrder(String productName) {
        System.out.println("Validating: " + productName);
    }
}

Then inject it into OrderService.

@Service
public class OrderService {

    private final OrderValidationService validationService;

    public OrderService(OrderValidationService validationService) {
        this.validationService = validationService;
    }

    public void createOrder(String productName) {
        validationService.validateOrder(productName);
        System.out.println("Creating order for: " + productName);
    }
}

Now the method call goes through a Spring-managed bean, so AOP can be applied.


Best Practices for Using Spring AOP

1. Use AOP for Infrastructure Concerns

Good use cases include:

  • Logging
  • Auditing
  • Metrics
  • Tracing
  • Security checks
  • Transaction boundaries
  • Retry handling

Avoid using AOP to hide important business rules that developers need to see clearly.


2. Prefer Annotation-Based Pointcuts for Explicit Behavior

This is clear:

@Auditable(action = "CREATE_ORDER")
public void createOrder(String productName) {
    // business logic
}

This is less obvious:

@Before("execution(* com.example.demo.order.*.*(..))")

Package-based pointcuts are useful, but annotation-based pointcuts are often easier to understand in large projects.


3. Avoid Logging Sensitive Data

Be careful with this:

Arrays.toString(joinPoint.getArgs())

It may expose passwords, tokens, or personal data.

For production systems, use structured logging and sanitize sensitive values.


4. Keep Aspects Small

An aspect should focus on one concern.

For example:

  • LoggingAspect
  • AuditAspect
  • PerformanceAspect
  • SecurityAspect

Avoid creating one large aspect that does many unrelated things.


5. Understand Proxy Behavior

Spring AOP works through proxies, so keep these in mind:

  • The target class should be a Spring bean.
  • Calls should usually come from outside the bean.
  • Self-invocation does not usually trigger advice.
  • Final classes and final methods can be problematic depending on proxy type.

Complete Example

Here is a compact working example.

Service

package com.example.demo.order;

import com.example.demo.audit.Auditable;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @Auditable(action = "CREATE_ORDER")
    public String createOrder(String productName) {
        System.out.println("Creating order for: " + productName);
        return "Order created for " + productName;
    }
}

Custom Annotation

package com.example.demo.audit;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();
}

Audit Aspect

package com.example.demo.audit;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class AuditAspect {

    @Before("@annotation(auditable)")
    public void audit(JoinPoint joinPoint, Auditable auditable) {
        System.out.println("Audit action: " + auditable.action());
        System.out.println("Method: " + joinPoint.getSignature().toShortString());
    }
}

Timing Aspect

package com.example.demo.aop;

import java.time.Duration;
import java.time.Instant;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimingAspect {

    @Around("execution(* com.example.demo..*(..))")
    public Object timeMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        Instant start = Instant.now();

        try {
            return joinPoint.proceed();
        } finally {
            Duration duration = Duration.between(start, Instant.now());

            System.out.println(
                    joinPoint.getSignature().toShortString()
                            + " took "
                            + duration.toMillis()
                            + " ms"
            );
        }
    }
}

Runner

package com.example.demo;

import com.example.demo.order.OrderService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class DemoRunner implements CommandLineRunner {

    private final OrderService orderService;

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

    @Override
    public void run(String... args) {
        String result = orderService.createOrder("Laptop");
        System.out.println(result);
    }
}

Example output:

Audit action: CREATE_ORDER
Method: OrderService.createOrder(..)
Creating order for: Laptop
OrderService.createOrder(..) took 3 ms
Order created for Laptop

When Should You Not Use AOP?

AOP is powerful, but it should not be used everywhere.

Avoid AOP when:

  • The logic is core business logic
  • The behavior is hard to discover
  • A simple method call would be clearer
  • You need direct control over HTTP request/response details
  • The aspect makes debugging confusing

For example, calculating an order discount is business logic. It should probably stay in a normal service method, not hidden inside an aspect.


Summary

Spring AOP helps you separate cross-cutting concerns from business logic.

You can use it for:

  • Logging
  • Auditing
  • Performance monitoring
  • Security checks
  • Exception tracking
  • Transaction-like behavior

The basic structure is:

@Aspect
@Component
public class MyAspect {

    @Before("execution(* com.example.demo..*(..))")
    public void doSomethingBefore() {
        // cross-cutting logic
    }
}

The most commonly used advice types are:

Advice Runs When
@Before Before the method
@After After the method finishes or fails
@AfterReturning After successful return
@AfterThrowing After an exception
@Around Around the full method execution

For many real-world applications, annotation-based AOP is the cleanest approach because it makes the behavior explicit:

@Auditable(action = "CREATE_ORDER")
public void createOrder(String productName) {
    // business logic
}

Used carefully, Spring AOP keeps your application cleaner, reduces duplication, and makes infrastructure concerns easier to manage.

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 build a REST API in Java using Spring Boot the right way?

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

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

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

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

The example API will support basic user operations:

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

1. Create a Spring Boot Project

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

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

For Maven, the important dependencies look like this:

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

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

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

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

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

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

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

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

2. Use a Clean Project Structure

A common clean structure is:

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

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

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


3. Create the Main Spring Boot Application Class

package com.example.demo;

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

@SpringBootApplication
public class DemoApplication {

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

Keep this class in the root package, such as:

com.example.demo

That allows Spring Boot to automatically scan subpackages such as:

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

4. Configure the Database

For PostgreSQL, create:

src/main/resources/application.properties

Example:

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

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

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

For production, prefer:

spring.jpa.hibernate.ddl-auto=validate

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


5. Create the Entity

The entity represents the database table.

package com.example.demo.user;

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

@Entity
@Getter
@Setter
public class User {

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

    private String name;

    private String email;
}

Notice the import:

import jakarta.persistence.Entity;

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


6. Create DTOs for Requests and Responses

A common mistake is exposing entities directly from controllers.

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

Create User Request

package com.example.demo.user;

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

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

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

Update User Request

package com.example.demo.user;

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

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

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

User Response

package com.example.demo.user;

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

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


7. Create the Repository

Spring Data JPA provides most CRUD operations automatically.

package com.example.demo.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}

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

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

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


8. Create a Custom Not Found Exception

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

package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

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

We will handle this exception globally later.


9. Create the Service Layer

The service layer contains business logic and transaction boundaries.

package com.example.demo.user;

import com.example.demo.exception.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;

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

    @Transactional(readOnly = true)
    public List<UserResponse> findAll() {
        return userRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

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

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

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

        User savedUser = userRepository.save(user);

        return toResponse(savedUser);
    }

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

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

        return toResponse(user);
    }

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

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

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

A few important things are happening here:

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

This keeps the controller thin and the business logic centralized.


10. Create the REST Controller

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

package com.example.demo.user;

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

import java.util.List;

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

    private final UserService userService;

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

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

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

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

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

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

The controller is intentionally small.

It does not:

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

Its job is HTTP handling.


11. Understand REST Endpoint Design

Good REST URLs usually identify resources using nouns.

Good:

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

Less ideal:

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

The HTTP method already describes the action.

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

12. Add Global Exception Handling

A good API should return consistent error responses.

Create an API error response:

package com.example.demo.exception;

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

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

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

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

Now create the global exception handler:

package com.example.demo.exception;

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

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

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

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

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

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

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

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

Example validation error:

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

13. Test the API with HTTP Requests

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

Create a User

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

Expected response:

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

HTTP status:

201 Created

Get All Users

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

Example response:

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

Get One User

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

Example response:

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

Update a User

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

Example response:

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

Delete a User

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

Expected status:

204 No Content

14. Add Basic Controller Tests

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

Here is an example using @WebMvcTest and MockMvc.

package com.example.demo.user;

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

import java.util.List;

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

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private UserService userService;

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

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

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

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

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

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

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

Testing at this level verifies:

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

15. Common REST API Best Practices

Use DTOs Instead of Exposing Entities

Avoid this in real APIs:

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

Prefer this:

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

DTOs give you control over what your API exposes.


Keep Controllers Thin

A controller should mostly do this:

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

Avoid putting business logic directly in the controller.


Put Transactions in Services

Use:

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

Avoid placing @Transactional on controller methods in most applications.


Use Validation on Request DTOs

Use Jakarta Validation annotations:

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

Then activate validation in the controller:

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

Return Correct HTTP Status Codes

Use meaningful status codes:

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

Use Plural Resource Names

Prefer:

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

Instead of:

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

Use Query Parameters for Filtering

Example:

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

Path variables are usually better for identifying a specific resource:

GET /api/users/1

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


16. Add Pagination for Collection Endpoints

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

Spring Data supports pagination using Pageable.

Repository already supports it because JpaRepository includes paging methods.

Update the service:

package com.example.demo.user;

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

// imports omitted

@Service
public class UserService {

    private final UserRepository userRepository;

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

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

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

Update the controller:

package com.example.demo.user;

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

// imports omitted

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

    private final UserService userService;

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

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

Now you can call:

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

With sorting:

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

17. A Better Response for Created Resources

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

package com.example.demo.user;

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

import java.net.URI;

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

    private final UserService userService;

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

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

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

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

This produces a response like:

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

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


18. Recommended Request Flow

A clean REST API usually follows this flow:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

And back:

Database
    ↓
Repository
    ↓
Service
    ↓
Controller
    ↓
HTTP Response

Each layer has a clear job:

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

19. What Makes It “The Right Way”?

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

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

Complete Minimal Example

Here is the core structure again.

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

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


Summary

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

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

The clean pattern is:

Controller → Service → Repository → Database

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

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

Typical Spring Layer Organization

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

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

The usual request flow is:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

1. Controller Layer

The controller handles HTTP requests and responses.

Use:

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

Controllers should be thin. They should mainly:

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

Example:

package com.example.app.controller;

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

import java.util.List;

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

    private final UserService userService;

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

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

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

2. Service Layer

The service contains business logic.

Use @Service.

Services should:

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

Example:

package com.example.app.service;

import com.example.app.dto.CreateUserRequest;
import com.example.app.dto.UserResponse;
import com.example.app.entity.User;
import com.example.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;

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

    @Transactional(readOnly = true)
    public List<UserResponse> findAll() {
        return userRepository.findAll()
                .stream()
                .map(user -> new UserResponse(
                        user.getId(),
                        user.getName(),
                        user.getEmail()
                ))
                .toList();
    }

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

        User savedUser = userRepository.save(user);

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

Use @Transactional on service methods rather than controller methods.


3. Repository Layer

The repository handles database access.

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

package com.example.app.repository;

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

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

Spring Data JPA automatically provides common methods such as:

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

You can also add query methods:

package com.example.app.repository;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}

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


4. Entity Layer

The entity represents database tables.

Use Jakarta persistence imports:

package com.example.app.entity;

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

@Entity
@Getter
@Setter
public class User {

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

    private String name;

    private String email;
}

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


5. DTO Layer

DTOs separate your API contract from your database model.

Request DTO:

package com.example.app.dto;

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

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

Response DTO:

package com.example.app.dto;

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

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


Recommended Responsibilities

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

Dependency Direction

Keep dependencies flowing one way:

Controller → Service → Repository → Entity

Avoid this:

Repository → Service
Service → Controller
Entity → Controller

For example:

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

Best Practices

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

    Bad:

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

    Better:

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

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

  5. Keep the main application class in the root package

    com.example.app.AppApplication
    

That way Spring can scan:

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

Feature-Based Alternative

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

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

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


Simple Rule of Thumb

Ask this when deciding where code belongs:

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

For most Spring applications, the clean structure is:

Controller → Service → Repository → Database

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

How do I manage transactions in Spring?

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

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

  • all succeed, or
  • all fail and roll back

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


1. Enable Transaction Management

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

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

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

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

@Configuration
@EnableTransactionManagement
public class TransactionConfig {
}

With Spring Boot, this is normally unnecessary.


2. Use @Transactional on Service Methods

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

Example:

package com.example.app.order;

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

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

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

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

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


3. Use readOnly = true for Query Methods

For methods that only read data, use:

@Transactional(readOnly = true)

Example:

package com.example.app.order;

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

import java.util.List;

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

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

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

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


4. Rollback Behavior

By default, Spring rolls back a transaction for:

  • RuntimeException
  • Error

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

Example:

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

This transaction rolls back because IllegalStateException is a runtime exception.


5. Roll Back for Checked Exceptions

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

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

You can also target a specific exception:

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

6. Avoid Catching Exceptions Without Rethrowing

This can prevent rollback:

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

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

Prefer:

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

7. Transaction Boundaries Should Match Business Operations

A transaction should usually wrap one complete business action.

Good examples:

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

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


8. Be Careful with External API Calls

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

Less ideal:

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

Better pattern:

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

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

    order.setStatus(OrderStatus.PAID);
}

Then call the payment gateway between those operations.


9. Common @Transactional Options

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

Common options:

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

10. Propagation Basics

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

The default is:

Propagation.REQUIRED

That means:

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

Example:

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

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

Common propagation values:

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

Example using a separate transaction for audit logging:

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

11. Isolation Basics

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

Example:

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

Common isolation levels:

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

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


12. Important Limitation: Self-Invocation

Spring transactions are usually applied through proxies.

That means this may not start a transaction as expected:

@Service
public class UserService {

    public void outerMethod() {
        innerMethod();
    }

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

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

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

@Service
public class UserService {

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

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

13. Recommended Structure

A typical Spring application uses transactions like this:

Controller
    ↓
Service  ← @Transactional here
    ↓
Repository
    ↓
Database

Example:

@RestController
public class OrderController {

    private final OrderService orderService;

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

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

    private final OrderRepository orderRepository;

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

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

Quick Rules

Use these defaults for most Spring applications:

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

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

@Service
public class UserService {

    private final UserRepository userRepository;

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

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

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

How do I use JDBC with Spring?

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

The typical setup is:

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

1. Add Spring JDBC and a database driver

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

Example for PostgreSQL:

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

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

If you use Spring Boot, you would usually use:

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

plus the database driver.


2. Configure a DataSource

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

A common choice is HikariCP:

package org.kodejava.spring;

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

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

@Configuration
public class DatabaseConfig {

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

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

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

        return new HikariDataSource(config);
    }
}

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

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

3. Create a JdbcTemplate bean

Spring can create JdbcTemplate from the configured DataSource.

package org.kodejava.spring;

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

import javax.sql.DataSource;

@Configuration
public class JdbcConfig {

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

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


4. Create a model class

For example, suppose you have a users table:

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

You can map rows to a Java object:

package org.kodejava.spring;

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

    public User() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

5. Use JdbcTemplate in a repository

A repository class can receive JdbcTemplate through constructor injection.

package org.kodejava.spring;

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

import java.util.List;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

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

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

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

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

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

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

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

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

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

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

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

6. Enable component scanning

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

package org.kodejava.spring;

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

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

Then you can bootstrap Spring:

package org.kodejava.spring;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

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

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

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

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

7. Handling query results safely

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

package org.kodejava.spring;

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

import java.util.Optional;

public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

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

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

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

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

8. Using NamedParameterJdbcTemplate

For more readable SQL parameters, use NamedParameterJdbcTemplate.

package org.kodejava.spring;

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

@Repository
public class NamedUserRepository {
    private final NamedParameterJdbcTemplate jdbcTemplate;

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

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

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

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

You can define it as a bean:

package org.kodejava.spring;

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

import javax.sql.DataSource;

@Configuration
public class NamedJdbcConfig {

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

9. Transactions

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

Add a transaction manager:

package org.kodejava.spring;

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

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {

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

Then use @Transactional in a service:

package org.kodejava.spring;

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

@Service
public class UserService {
    private final UserRepository userRepository;

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

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

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

10. Typical Spring Boot configuration

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

application.properties:

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

Repository:

package org.kodejava.spring;

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

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

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

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

Summary

To use JDBC with Spring:

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

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