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

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

Good uses of Streams

Streams are great when you are doing simple collection transformations:

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

This reads almost like a sentence:

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

Prefer method references when they are obvious

Readable:

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

Less readable:

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

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

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

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

Name complex predicates

If your filter condition gets complicated, extract it.

Hard to read:

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

Better:

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

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

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

Avoid deeply nested streams

This is usually a readability warning sign:

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

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

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

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

Do not use streams for a complicated control flow

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

Less readable:

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

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

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

A plain loop may be clearer:

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

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

    ship(order);
}

Rule of thumb:

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

Keep stream operations on separate lines

Prefer this:

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

Avoid cramming everything into one line:

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

Vertical formatting makes each step visible.

Avoid side effects inside streams

This is usually a bad sign:

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

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

Prefer collecting the result directly:

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

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

Use collect only when needed

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

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

Use Collectors when you need something more specific:

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

Or grouping:

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

Avoid overly clever collectors

This may be technically impressive but hard to maintain:

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

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

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

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

Use meaningful variable names

Bad:

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

Better:

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

Readable streams depend heavily on meaningful names.

Be careful with Optional.stream()

This can be elegant:

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

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

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

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

Use loops when they are clearer

Streams are not inherently better than loops.

Readable stream:

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

Readable loop:

boolean hasExpiredInvoice = false;

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

For simple matching, the stream is excellent:

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

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

Practical rules of thumb

Use streams when:

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

Avoid streams when:

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

A good readable stream style

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

This is readable because:

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

Bottom line

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