How Do I Build REST APIs with Spring MVC?

Building REST APIs with Spring MVC

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

A typical REST API is organized like this:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

Each layer has a clear responsibility:

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

1. Add the Spring Web Dependency

For Maven:

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

If you need validation:

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

If you use JPA:

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

2. Create a REST Controller

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

package com.example.demo.user;

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

@RestController
public class HelloController {

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

Calling:

GET /api/hello

returns:

Hello, REST API!

3. Design Resource-Based URLs

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

Good:

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

Avoid action-style URLs like:

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

The HTTP method already describes the operation.


4. Create DTOs for Request and Response Bodies

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

package com.example.demo.user;

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

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

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

DTOs keep your API contract separate from your database model.


5. Create REST Endpoints

A controller for basic CRUD operations might look like this:

package com.example.demo.user;

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

import java.util.List;

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

    private final UserService userService;

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

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

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

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

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

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

Key annotations:

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

6. Put Business Logic in a Service

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

package com.example.demo.user;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserService {

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

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

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

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

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

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


7. Use Spring Data JPA for Persistence

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

package com.example.demo.user;

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

@Entity
@Getter
@Setter
public class User {

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

    private String name;

    private String email;
}
package com.example.demo.user;

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

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

By extending JpaRepository, you automatically get methods like:

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

8. Return Proper HTTP Status Codes

Use status codes that match the result:

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

For creation, you can also return a Location header:

package com.example.demo.user;

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

import java.net.URI;

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

    private final UserService userService;

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

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

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

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

9. Handle Errors Globally

Use @RestControllerAdvice to return consistent JSON errors.

package com.example.demo.exception;

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

public record ApiError(
        int status,
        String error,
        String message,
        String path,
        Instant timestamp,
        List<FieldErrorDetail> fieldErrors
) {
    public record FieldErrorDetail(
            String field,
            String message
    ) {
    }
}
package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.demo.exception;

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

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

@RestControllerAdvice
public class GlobalExceptionHandler {

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

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

        return new ApiError(
                400,
                "Bad Request",
                "Validation failed",
                request.getRequestURI(),
                Instant.now(),
                fieldErrors
        );
    }
}

10. Test Your API

Example using curl:

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

Get all users:

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

Get one user:

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

Update a user:

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

Delete a user:

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

11. Add Pagination for List Endpoints

For large collections, avoid returning everything at once.

package com.example.demo.user;

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

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

    private final UserService userService;

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

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

Then clients can call:

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

Recommended Checklist

When building REST APIs with Spring MVC:

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

A clean REST API usually follows this shape:

Controller → Service → Repository → Database

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

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

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

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

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

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


What Is AOP?

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

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

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

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

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

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


Common AOP Terms

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

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

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


Example Scenario

Suppose we have a service that handles orders.

package com.example.demo.order;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

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

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

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

This is a perfect use case for Spring AOP.


Adding the Spring AOP Dependency

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

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

This starter includes Spring AOP and AspectJ annotation support.

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


Creating a Simple Aspect

An aspect is a Spring bean annotated with @Aspect.

package com.example.demo.aop;

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

@Aspect
@Component
public class LoggingAspect {

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

This aspect says:

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

The important part is this expression:

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

This is called a pointcut expression.


Understanding the Pointcut Expression

The expression:

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

can be read as:

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

So it matches methods such as:

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

Running the Service

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

package com.example.demo;

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

@Component
public class DemoRunner implements CommandLineRunner {

    private final OrderService orderService;

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

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

Example output:

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

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

That is the main benefit of AOP.


Types of Advice in Spring AOP

Spring AOP provides several advice annotations.

@Before

Runs before the matched method.

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

Use this for:

  • Logging before execution
  • Security checks
  • Validating method arguments

@After

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

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

Use this for cleanup logic.


@AfterReturning

Runs only when the method completes successfully.

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

Example service method:

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

@AfterReturning can access the return value.


@AfterThrowing

Runs only when the method throws an exception.

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

Use this for:

  • Error logging
  • Auditing failed operations
  • Sending failure metrics

@Around

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

It can:

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

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

@Aspect
@Component
public class PerformanceAspect {

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

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

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

The key method here is:

joinPoint.proceed();

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

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


Reusing Pointcuts

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

package com.example.demo.aop;

import org.aspectj.lang.annotation.Pointcut;

public class CommonPointcuts {

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

Then use it in your aspects:

package com.example.demo.aop;

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

@Aspect
@Component
public class LoggingAspect {

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

This makes your code easier to maintain.


Matching Methods by Annotation

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

For example, create an annotation named @Auditable.

package com.example.demo.audit;

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

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

Now annotate a service method:

package com.example.demo.order;

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

@Service
public class OrderService {

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

Then create an aspect that reacts to this annotation:

package com.example.demo.audit;

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

@Aspect
@Component
public class AuditAspect {

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

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

You can immediately see which methods are audited:

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

Practical Example: Logging Method Arguments

You can access method arguments using JoinPoint.

package com.example.demo.aop;

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

@Aspect
@Component
public class MethodArgumentLoggingAspect {

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

Example output:

Method: createOrder
Arguments: [Laptop]

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

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

Practical Example: Measuring Service Performance

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

package com.example.demo.aop;

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

@Aspect
@Component
public class ServiceTimingAspect {

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

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

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

This pointcut:

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

matches methods inside packages containing service.

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


Using AOP with Spring MVC Controllers

You can also apply AOP to controllers.

For example:

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

This matches beans annotated with @RestController.

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

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

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


AOP and Transactions

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

For example:

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

@Service
public class PaymentService {

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

Spring applies transaction behavior around the method call.

Conceptually, it works like this:

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

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


Important Limitation: Self-Invocation

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

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

For example, this works:

@Service
public class OrderControllerService {

    private final OrderService orderService;

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

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

But this may not trigger AOP:

@Service
public class OrderService {

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

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

Why?

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

This is called self-invocation.

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

@Service
public class OrderValidationService {

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

Then inject it into OrderService.

@Service
public class OrderService {

    private final OrderValidationService validationService;

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

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

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


Best Practices for Using Spring AOP

1. Use AOP for Infrastructure Concerns

Good use cases include:

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

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


2. Prefer Annotation-Based Pointcuts for Explicit Behavior

This is clear:

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

This is less obvious:

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

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


3. Avoid Logging Sensitive Data

Be careful with this:

Arrays.toString(joinPoint.getArgs())

It may expose passwords, tokens, or personal data.

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


4. Keep Aspects Small

An aspect should focus on one concern.

For example:

  • LoggingAspect
  • AuditAspect
  • PerformanceAspect
  • SecurityAspect

Avoid creating one large aspect that does many unrelated things.


5. Understand Proxy Behavior

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

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

Complete Example

Here is a compact working example.

Service

package com.example.demo.order;

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

@Service
public class OrderService {

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

Custom Annotation

package com.example.demo.audit;

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

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

Audit Aspect

package com.example.demo.audit;

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

@Aspect
@Component
public class AuditAspect {

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

Timing Aspect

package com.example.demo.aop;

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

@Aspect
@Component
public class TimingAspect {

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

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

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

Runner

package com.example.demo;

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

@Component
public class DemoRunner implements CommandLineRunner {

    private final OrderService orderService;

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

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

Example output:

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

When Should You Not Use AOP?

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

Avoid AOP when:

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

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


Summary

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

You can use it for:

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

The basic structure is:

@Aspect
@Component
public class MyAspect {

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

The most commonly used advice types are:

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

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

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

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

How do I use events in Spring applications?

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

Typical use cases:

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

Spring has built-in support through:

  • ApplicationEventPublisher
  • @EventListener
  • ApplicationEvent
  • @TransactionalEventListener

1. Define an Event

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

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

You can also use a normal class:

public class UserRegisteredEvent {

    private final Long userId;
    private final String email;

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

    public Long getUserId() {
        return userId;
    }

    public String getEmail() {
        return email;
    }
}

2. Publish the Event

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

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

@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

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

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

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

3. Listen for the Event

Use @EventListener on a method in a Spring bean.

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

@Component
public class UserRegisteredListener {

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

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

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


4. Multiple Listeners Can React to the Same Event

You can have several independent listeners for one event.

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

@Component
public class WelcomeEmailListener {

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

@Component
public class AuditLogListener {

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

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


5. Listen Only When a Condition Matches

You can add a condition using Spring Expression Language.

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

@Component
public class CorporateUserListener {

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

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

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

6. Make Event Handling Asynchronous

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

To run listeners asynchronously, enable async execution:

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

@Configuration
@EnableAsync
public class AsyncConfig {
}

Then annotate the listener with @Async.

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

@Component
public class AsyncWelcomeEmailListener {

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

You can also configure a custom executor:

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

import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

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

Use it like this:

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

@Component
public class AsyncAuditListener {

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

7. Use Transaction-Aware Events

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

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

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

@Service
public class UserService {

    private final ApplicationEventPublisher eventPublisher;

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

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

        // Persist user here

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

@Component
public class UserRegisteredTransactionalListener {

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

By default, @TransactionalEventListener runs in the AFTER_COMMIT phase.

You can specify the phase explicitly:

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

@Component
public class UserRegisteredTransactionListener {

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

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

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

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

8. Listener Ordering

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

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

@Component
public class OrderedListeners {

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

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

Lower order values run first.


9. Returning Events from Listeners

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

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

@Component
public class ChainedEventListener {

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

Example second event:

public record AccountCreatedEvent(Long userId) {
}

Then another listener can react to it:

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

@Component
public class AccountCreatedListener {

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

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


10. Legacy ApplicationEvent Style

Older Spring code often defines events by extending ApplicationEvent.

import org.springframework.context.ApplicationEvent;

public class UserRegisteredApplicationEvent extends ApplicationEvent {

    private final Long userId;
    private final String email;

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

    public Long getUserId() {
        return userId;
    }

    public String getEmail() {
        return email;
    }
}

Publishing:

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

Listening:

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

@Component
public class LegacyUserEventListener {

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

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


Recommended Pattern

For most Spring applications:

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

Example:

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

@Service
public class OrderService {

    private final ApplicationEventPublisher eventPublisher;

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

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

        // Save order

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

@Component
public class OrderNotificationListener {

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

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

How do I write unit tests for Spring components?

Writing Unit Tests for Spring Components

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

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


1. Unit Test a Spring @Service

Example service:

package com.example.order;

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

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

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

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

Unit test:

package com.example.order;

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

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

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

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

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

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

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

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

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

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


2. Unit Test a Spring @Component

Example component:

package com.example.notification;

import org.springframework.stereotype.Component;

@Component
public class EmailValidator {

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

Test:

package com.example.notification;

import org.junit.jupiter.api.Test;

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

class EmailValidatorTest {

    private final EmailValidator emailValidator = new EmailValidator();

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

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

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


3. Unit Test a Spring MVC @Controller

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

Example controller:

package com.example.order;

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

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

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

Controller test:

package com.example.order;

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

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

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OrderService orderService;

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

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

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


4. Unit Test Repository-Using Services

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

package com.example.user;

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

import java.util.Optional;

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

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

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

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

        User result = userService.findUser(1L);

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

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


5. Test Spring Data JPA Repositories with @DataJpaTest

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

package com.example.user;

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

import java.util.Optional;

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

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

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

        userRepository.save(user);

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

        assertTrue(result.isPresent());
    }
}

Use this when you want to verify:

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

6. Recommended Dependencies

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

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

It includes commonly used testing libraries such as:

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

7. Common Testing Patterns

Arrange, Act, Assert

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

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

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

Verify interactions only when meaningful

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

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

Test exceptions

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

8. Choosing the Right Test Type

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

Rule of Thumb

Use the smallest test scope that proves the behavior:

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

Most Spring component unit tests should not need @SpringBootTest.

How do I secure a Java web application with authentication and authorization?

Securing a Java web application typically means adding:

  1. Authentication — verifying who the user is.
  2. Authorization — deciding what the authenticated user can access.
  3. Session/token protection — keeping the login state secure.
  4. Transport and application hardening — HTTPS, CSRF protection, password hashing, etc.

Since your stack includes Spring MVC / Spring Data JPA / Jakarta EE, the most common approach is Spring Security.


1. Add Spring Security

If you use Maven:

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

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>

If this is a Spring Boot app, use:

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

2. Create a Security Configuration

For modern Spring Security, define a SecurityFilterChain.

package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }
}

This configuration means:

URL Access
/, /login, static files Public
/user/** USER or ADMIN
/admin/** ADMIN only
Everything else Must be logged in

3. Store Users in the Database

A simple JPA entity could look like this:

package com.example.user;

import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

import java.util.Set;

@Entity
@Table(name = "app_users")
@Getter
@Setter
public class User {

    @Id
    private Long id;

    private String username;

    private String password;

    private boolean enabled = true;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(
            name = "app_user_roles",
            joinColumns = @JoinColumn(name = "user_id")
    )
    private Set<String> roles;
}

Example roles:

ROLE_USER
ROLE_ADMIN

Spring Security’s hasRole("ADMIN") checks for ROLE_ADMIN internally.


4. Create a Repository

package com.example.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

5. Implement UserDetailsService

Spring Security uses UserDetailsService to load users during login.

package com.example.security;

import com.example.user.User;
import com.example.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(user.getRoles().toArray(String[]::new))
                .disabled(!user.isEnabled())
                .build();
    }
}

Register it as a bean:

@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
    return new DatabaseUserDetailsService(userRepository);
}

6. Hash Passwords with BCrypt

Never store plain-text passwords.

import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

When registering a user:

user.setPassword(passwordEncoder.encode(rawPassword));

A stored password should look similar to:

$2a$10$...

7. Add Method-Level Authorization

You can also secure service methods.

Enable method security:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

Then protect methods:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteReport(Long reportId) {
        // admin-only logic
    }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Object viewReport(Long reportId) {
        // user/admin logic
        return new Object();
    }
}

8. Protect Against CSRF

For browser-based apps using sessions and forms, keep CSRF enabled.

Spring Security enables CSRF by default.

In a Thymeleaf form, include the CSRF token:

<form method="post" action="/profile">
    <input type="hidden"
           name="${_csrf.parameterName}"
           value="${_csrf.token}" />

    <button type="submit">Save</button>
</form>

For JSP:

<input type="hidden"
       name="${_csrf.parameterName}"
       value="${_csrf.token}" />

For stateless REST APIs using JWT, CSRF is often disabled because the API does not rely on cookies for authentication.


9. Use HTTPS

Always run authentication over HTTPS.

For production:

server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=changeit
server.ssl.key-store-type=PKCS12
server.ssl.key-alias=myapp

If your app is behind a reverse proxy, configure forwarded headers correctly so Spring knows the original request was HTTPS.

server.forward-headers-strategy=framework

10. Use Secure Session Cookies

Configure cookies as secure and HTTP-only.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

Use same-site=strict if your app does not require cross-site navigation flows.


11. Example REST API Authorization

For controller endpoints:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {

    @GetMapping("/admin/stats")
    @PreAuthorize("hasRole('ADMIN')")
    public String stats() {
        return "admin stats";
    }
}

Or configure URL-based access:

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

12. JWT Option for Stateless APIs

If your frontend is separate, such as React, Angular, mobile, or another client, you may use JWT or OAuth2.

Typical setup:

.oauth2ResourceServer(oauth2 -> oauth2.jwt())

Then secure routes:

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/public/**").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
)

For production, prefer a standards-based identity provider such as:

  • Keycloak
  • Auth0
  • Okta
  • Microsoft Entra ID
  • AWS Cognito

13. Important Security Checklist

Use this checklist before production:

  • Hash passwords with BCrypt, Argon2, or PBKDF2
  • Never store plain-text passwords
  • Use HTTPS only
  • Keep CSRF enabled for session-based browser apps
  • Use secure HTTP-only cookies
  • Validate all user inputs
  • Apply authorization in the service layer for sensitive business operations
  • Avoid exposing stack traces or internal errors
  • Lock down admin endpoints
  • Use least-privilege roles
  • Add audit logging for sensitive actions
  • Rate-limit login attempts
  • Use MFA for admin users
  • Keep dependencies updated
  • Add security headers
  • Do not put secrets in source code

Minimal Spring Security Setup

A compact working configuration could look like this:

package com.example.security;

import com.example.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new DatabaseUserDetailsService(userRepository);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

For most Java web applications, the recommended path is:

Spring Security + database-backed users + BCrypt password hashing + role-based authorization + HTTPS + CSRF protection.