How do I start unit testing in Java with JUnit?

Unit testing in Java means testing small pieces of code — usually one method or one class — in isolation. The most common testing framework for modern Java projects is JUnit 5, also known as JUnit Jupiter.

This guide shows the basic steps to start writing unit tests with JUnit.


1. Add JUnit to Your Project

If you use Maven, add JUnit 5 to your pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.4</version>
    <scope>test</scope>
</dependency>

You should also make sure Maven Surefire can run JUnit 5 tests:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.2</version>
        </plugin>
    </plugins>
</build>

If you use Gradle, add:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
}

test {
    useJUnitPlatform()
}

2. Create a Class to Test

Suppose you have a simple calculator class:

package org.kodejava;

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Divider cannot be zero");
        }
        return a / b;
    }
}

This class has two methods:

  • add() returns the sum of two numbers.
  • divide() divides two numbers and rejects division by zero.

3. Create a Test Class

JUnit test classes are usually placed under:

src/test/java

For the Calculator class, create:

src/test/java/org/kodejava/CalculatorTest.java

Example test class:

package org.kodejava;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    @Test
    void addReturnsSumOfTwoNumbers() {
        int result = calculator.add(2, 3);

        assertEquals(5, result);
    }

    @Test
    void divideReturnsQuotient() {
        int result = calculator.divide(10, 2);

        assertEquals(5, result);
    }

    @Test
    void divideThrowsExceptionWhenDividerIsZero() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.divide(10, 0)
        );

        assertEquals("Divider cannot be zero", exception.getMessage());
    }
}

4. Understand the Basic JUnit Annotations

The most important annotation is:

@Test

It marks a method as a test method.

Example:

@Test
void addReturnsCorrectResult() {
    assertEquals(4, 2 + 2);
}

Common JUnit 5 annotations include:

Annotation Purpose
@Test Marks a method as a test
@BeforeEach Runs before each test method
@AfterEach Runs after each test method
@BeforeAll Runs once before all tests
@AfterAll Runs once after all tests
@Disabled Temporarily disables a test

Example using @BeforeEach:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private Calculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }

    @Test
    void addReturnsSumOfTwoNumbers() {
        assertEquals(5, calculator.add(2, 3));
    }
}

5. Use Assertions

Assertions check whether the result is what you expect.

Common assertions:

assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertThrows(ExceptionType.class, executable);

Example:

package org.kodejava;

import org.junit.jupiter.api.Test;

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

class StringTest {

    @Test
    void stringShouldContainText() {
        String message = "Hello JUnit";

        assertNotNull(message);
        assertTrue(message.contains("JUnit"));
        assertEquals(11, message.length());
    }
}

6. Follow the Arrange, Act, Assert Pattern

A common structure for unit tests is:

  1. Arrange — prepare input data and objects.
  2. Act — call the method being tested.
  3. Assert — verify the result.

Example:

@Test
void addReturnsSumOfTwoNumbers() {
    // Arrange
    Calculator calculator = new Calculator();

    // Act
    int result = calculator.add(2, 3);

    // Assert
    assertEquals(5, result);
}

This makes tests easier to read and maintain.


7. Run the Tests

With Maven:

mvn test

With Gradle:

./gradlew test

Most IDEs also let you right-click the test class or test method and choose Run Test.


8. Naming Test Methods

Use descriptive names, so it is clear what behavior is being tested.

Good examples:

void addReturnsSumOfTwoNumbers()
void divideThrowsExceptionWhenDividerIsZero()
void loginFailsWhenPasswordIsInvalid()

Avoid vague names like:

void test1()
void testAdd()
void shouldWork()

9. What Should You Test?

Good candidates for unit tests include:

  • Business rules
  • Calculations
  • Validation logic
  • Conditional logic
  • Exception handling
  • Data transformation methods

For example, test things like:

discount is applied correctly
invalid email is rejected
zero quantity throws an exception
user cannot withdraw more than their balance

You usually do not need to unit test:

  • Simple getters and setters
  • Framework-generated behavior
  • Code with no meaningful logic
  • External services directly

10. Example: Testing a Realistic Service Class

Class to test:

package org.kodejava.order;

public class DiscountService {

    public double applyDiscount(double price, double discountPercent) {
        if (price < 0) {
            throw new IllegalArgumentException("Price cannot be negative");
        }

        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Discount must be between 0 and 100");
        }

        return price - (price * discountPercent / 100);
    }
}

Test class:

package org.kodejava.order;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class DiscountServiceTest {

    private final DiscountService discountService = new DiscountService();

    @Test
    void applyDiscountReturnsDiscountedPrice() {
        double result = discountService.applyDiscount(100.0, 10.0);

        assertEquals(90.0, result);
    }

    @Test
    void applyDiscountRejectsNegativePrice() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> discountService.applyDiscount(-100.0, 10.0)
        );

        assertEquals("Price cannot be negative", exception.getMessage());
    }

    @Test
    void applyDiscountRejectsInvalidDiscountPercent() {
        assertThrows(
                IllegalArgumentException.class,
                () -> discountService.applyDiscount(100.0, 120.0)
        );
    }
}

For floating-point values, you can also provide a delta:

assertEquals(90.0, result, 0.001);

Summary

To start unit testing in Java with JUnit:

  1. Add JUnit 5 to your project.
  2. Put test classes under src/test/java.
  3. Mark test methods with @Test.
  4. Use assertions such as assertEquals() and assertThrows().
  5. Follow the Arrange, Act, Assert pattern.
  6. Run tests with Maven, Gradle, or your IDE.

A simple JUnit test looks like this:

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    void addReturnsSum() {
        Calculator calculator = new Calculator();

        assertEquals(5, calculator.add(2, 3));
    }
}

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.