Appearance
Spring Boot: Custom Interceptors | Intercept Incoming HTTP Requests and Custom Annotations
Introduction: The Smart Middleman
Imagine a restaurant where every order passes through a head waiter before reaching the kitchen. The head waiter verifies the table number, checks if the item is still available, logs the time, and after the food is served, notes the delivery time. The head waiter never cooks — that is the kitchen's job — but everything flows through them.
A Spring Boot interceptor is exactly this head waiter: a mediator that gets invoked before or after your actual controller code runs. It has the power to inspect, modify, short circuit, or enrich the request without touching the business logic inside your controllers.
Custom interceptors are especially valuable because caching, logging, and authentication — topics covered in later chapters — often require you to write your own interception logic. This chapter gives you the foundation for all of them.
Two Types of Custom Interceptor Scenarios
- Intercept the request before it reaches a controller — using
HandlerInterceptor - Intercept the invocation of a specific method (possibly after controller processing) — using AOP with custom annotations
Scenario 1: Intercepting Before the Controller
Step 1 — Create the Interceptor
Implement HandlerInterceptor:
java
// MyCustomInterceptor.java
@Component
public class MyCustomInterceptor implements HandlerInterceptor {
/**
* Runs BEFORE the controller method.
* Return true to continue; return false to stop processing.
*/
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
System.out.println("[PRE-HANDLE] Request URI: " + request.getRequestURI());
System.out.println("[PRE-HANDLE] Method: " + request.getMethod());
// Example: reject requests without an API key
String apiKey = request.getHeader("X-API-Key");
if (apiKey == null || apiKey.isBlank()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false; // stops processing here
}
return true;
}
/**
* Runs AFTER the controller method, but only if no exception was thrown.
*/
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
System.out.println("[POST-HANDLE] Completed successfully");
}
/**
* Runs ALWAYS after the complete request lifecycle, even if an exception occurred.
* Like a finally block.
*/
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
System.out.println("[AFTER-COMPLETION] Request fully processed");
if (ex != null) {
System.out.println("[AFTER-COMPLETION] Exception: " + ex.getMessage());
}
}
}Step 2 — Register the Interceptor
java
// AppConfig.java
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Autowired
private MyCustomInterceptor myCustomInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(myCustomInterceptor)
.addPathPatterns("/api/**") // applies to all /api/* URLs
.excludePathPatterns("/api/update-user", // these URLs are excluded
"/api/delete-user");
}
}Step 3 — The Controller
java
// UserController.java
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/get-user")
public String getUser() {
System.out.println("[CONTROLLER] getUser invoked");
return "User data returned";
}
}Output when /api/get-user is called:
[PRE-HANDLE] Request URI: /api/get-user
[PRE-HANDLE] Method: GET
[CONTROLLER] getUser invoked
[POST-HANDLE] Completed successfully
[AFTER-COMPLETION] Request fully processedHow the Dispatcher Servlet Drives This
Looking inside Spring's DispatcherServlet.doDispatch(), the flow is:
applyPreHandle() → your preHandle runs
↓
ha.handle() → your controller method runs
↓
applyPostHandle() → your postHandle runs
↓
processDispatchResult() → afterCompletion runsIf preHandle returns false, the dispatcher servlet sets a flag and returns immediately — neither the controller nor postHandle is called, but afterCompletion of already-executed interceptors still runs.
Understanding Custom Annotations
Before building the second type of interceptor, you need to understand how to create and use custom annotations — this is foundational knowledge for caching, rate limiting, and authentication patterns.
Creating a Custom Annotation
java
@interface MyCustomAnnotation { }That is the bare minimum. Two critical meta annotations must be applied to make it useful:
Meta-Annotation 1: @Target
Tells Java where this annotation can be applied.
java
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target({
ElementType.METHOD, // on methods
ElementType.TYPE, // on classes, interfaces, enums
ElementType.CONSTRUCTOR, // on constructors
ElementType.FIELD, // on fields
ElementType.PARAMETER // on method parameters
})
@interface MyCustomAnnotation { }If you try to use the annotation on a location not listed here, the compiler will reject it.
Meta-Annotation 2: @Retention
Tells Java how long the annotation is retained:
| Policy | Where it exists | Runtime accessible? |
|---|---|---|
RetentionPolicy.SOURCE | Only in source code | No — discarded by compiler |
RetentionPolicy.CLASS | Source + .class file | No — JVM ignores it at runtime |
RetentionPolicy.RUNTIME | Source + .class file + JVM memory | Yes — readable via reflection |
For interceptors and AOP you always need RUNTIME, because the framework reads annotations at runtime via reflection.
java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface MyCustomAnnotation { }@Override uses SOURCE — it is only for the programmer and compiler. It is not even recorded in the bytecode. Our custom interceptor annotations need RUNTIME.
Adding Fields to Your Annotation
Annotations can carry data. The fields are declared as methods with no parameters and restricted return types:
Allowed return types:
- All eight Java primitives (
int,long,boolean, etc.) StringenumClass<?>(class literals likeString.class)- Annotations
- Arrays of any of the above
java
// CacheKey.java — a custom annotation that carries cache configuration
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheKey {
String name() default ""; // default empty string
int ttlSeconds() default 300; // default 5 minutes
Class<?> keyType() default String.class;
boolean enabled() default true;
}Using it:
java
@CacheKey(name = "user-cache", ttlSeconds = 600, enabled = true)
public User getUser(String id) { ... }Scenario 2: Intercepting After the Controller via AOP
This approach intercepts a specific method inside a service or utility class — even after the controller has already delegated to it. AOP is the mechanism, and your custom annotation is the trigger.
Step 1 — Create the Custom Annotation
java
// MyCustomAnnotation.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyCustomAnnotation {
String name() default ""; // optional field to pass extra info
}Step 2 — Apply It to a Method
java
// User.java
@Component
public class User {
@MyCustomAnnotation(name = "user")
public String getUser() {
System.out.println("[USER] Getting user details");
return "User detail fetched";
}
}Step 3 — Write the AOP Interceptor
java
// MyCustomInterceptorAop.java
@Aspect
@Component
public class MyCustomInterceptorAop {
/**
* @annotation pointcut matches any method annotated with @MyCustomAnnotation.
* The full class path of the annotation is required.
*/
@Around("@annotation(com.example.MyCustomAnnotation)")
public Object interceptAnnotatedMethod(ProceedingJoinPoint joinPoint) throws Throwable {
// Access the method being intercepted
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// Read annotation data from the method
boolean annotationPresent = method.isAnnotationPresent(MyCustomAnnotation.class);
System.out.println("[INTERCEPTOR] Annotation present: " + annotationPresent);
if (annotationPresent) {
MyCustomAnnotation annotation = method.getAnnotation(MyCustomAnnotation.class);
System.out.println("[INTERCEPTOR] Annotation name: " + annotation.name());
}
System.out.println("[INTERCEPTOR] Before actual method");
// Invoke the actual method
Object result = joinPoint.proceed();
System.out.println("[INTERCEPTOR] After actual method");
return result;
}
}Step 4 — The Controller Triggers It
java
// UserController.java
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private User user;
@GetMapping("/get-user")
public String getUser() {
return user.getUser(); // this call triggers the AOP interceptor
}
}Output when /api/get-user is called:
[INTERCEPTOR] Annotation present: true
[INTERCEPTOR] Annotation name: user
[INTERCEPTOR] Before actual method
[USER] Getting user details
[INTERCEPTOR] After actual methodWhy These Two Conditions Are Required for aop based Interceptors
AOP interception relies on proxies. Two rules apply:
The method must be in a different class from the caller — Proxies only intercept calls that cross a class boundary. If you call an annotated method within the same class, you call the real object directly (bypassing the proxy), so no interception occurs.
The method must be public — proxy based interception (both JDK dynamic proxy and CGLIB) only works on public methods.
A More Complete Example: Logging Interceptor with Timing
This example combines both scenarios: a HandlerInterceptor for timing the request, and an AOP aspect for logging individual service calls.
java
// TimingInterceptor.java
@Component
public class TimingInterceptor implements HandlerInterceptor {
private static final String START_TIME_ATTR = "startTime";
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute(START_TIME_ATTR, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
long startTime = (Long) request.getAttribute(START_TIME_ATTR);
long elapsed = System.currentTimeMillis() - startTime;
System.out.printf("[TIMING] %s %s took %dms%n",
request.getMethod(), request.getRequestURI(), elapsed);
}
}
// Loggable.java — custom annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
String value() default "INFO"; // log level
}
// ServiceLoggingAspect.java
@Aspect
@Component
public class ServiceLoggingAspect {
@Around("@annotation(loggable)")
public Object logServiceCall(ProceedingJoinPoint pjp,
Loggable loggable) throws Throwable {
String methodName = pjp.getSignature().getName();
System.out.printf("[%s] Entering: %s%n", loggable.value(), methodName);
try {
Object result = pjp.proceed();
System.out.printf("[%s] Exiting: %s%n", loggable.value(), methodName);
return result;
} catch (Exception ex) {
System.out.printf("[ERROR] Exception in %s: %s%n", methodName, ex.getMessage());
throw ex;
}
}
}
// OrderService.java
@Service
public class OrderService {
@Loggable("DEBUG")
public Order fetchOrder(String orderId) {
// business logic
return new Order(orderId);
}
}Choosing Between HandlerInterceptor and AOP
| Dimension | HandlerInterceptor | AOP + Custom Annotation |
|---|---|---|
| Scope | Controller layer | Any Spring bean method |
| URL based filtering | Yes | No (annotation based) |
| Access to request/response | Yes | Only via @Around parameter |
| Spring MVC knowledge | Yes (knows about HandlerMethod) | Through JoinPoint only |
| method level targeting | No | Yes, with annotation targeting |
| Best for | Auth, request timing, logging | method level caching, tracing |
Interview Questions
Q1. What is a custom interceptor in Spring Boot? A custom interceptor is a user-defined class that implements HandlerInterceptor or uses AOP to intercept method invocations before or after execution. It acts as a mediator between the incoming request and the actual business logic, commonly used for authentication, logging, caching, and performance monitoring.
Q2. What are the three methods of HandlerInterceptor and when does each run?preHandle runs before the controller method and returns a boolean — returning false stops processing. postHandle runs after the controller method but only on success (no exception). afterCompletion always runs, even when an exception occurs, and is equivalent to a finally block.
Q3. What are @Target and @Retention and why are they important for custom annotations?@Target specifies where the annotation can be applied (method, class, field, etc.). @Retention specifies how long the annotation is retained. For runtime interception (AOP, Spring interceptors), RetentionPolicy.RUNTIME is mandatory because the framework reads annotations via reflection at runtime. Using SOURCE or CLASS means the annotation is invisible at runtime.
Q4. Why must two conditions be met for aop based interception to work? AOP uses proxies to intercept method calls. Proxies only intercept calls crossing a class boundary — calling a method in the same class bypasses the proxy. Additionally, proxy mechanisms (JDK dynamic proxy and CGLIB) only intercept public methods. So the annotated method must be public and must be in a different class from its caller.
Q5. What is the difference between @Retention(SOURCE) and @Retention(RUNTIME)?SOURCE annotations are discarded by the compiler and never appear in .class files. RUNTIME annotations survive compilation, appear in bytecode, and are accessible via reflection while the application is running. @Override uses SOURCE (only for the programmer); Spring's own annotations like @Autowired use RUNTIME.
Q6. How do you read annotation values inside an AOP aspect? Through the JoinPoint:
java
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
Method method = sig.getMethod();
MyAnnotation ann = method.getAnnotation(MyAnnotation.class);
String value = ann.someField();Alternatively, bind it directly as a parameter in @Around("@annotation(ann)") where ann is the annotation parameter.
Q7. What return types are allowed in annotation fields? Primitive types (int, long, boolean, etc.), String, enum, Class<?>, other annotations, and arrays of any of the above. Complex objects, generic collections, and arbitrary class instances are not allowed.
Q8. How do you exclude specific URLs from an interceptor? In WebMvcConfigurer.addInterceptors(), after adding the interceptor, chain .excludePathPatterns("/api/public", "/health"). These paths will bypass the interceptor even if they match the addPathPatterns pattern.
Q9. What is the difference between a HandlerInterceptor and an AOP @Around aspect?HandlerInterceptor works at the Spring MVC level, is URL-aware, and has access to the HTTP request and response as well as the HandlerMethod. AOP @Around works at the method invocation level, can target any Spring bean (not just controllers), and is annotation-driven or pointcut-driven. HandlerInterceptor is better for HTTP-level concerns; AOP is better for method level cross cutting concerns.
Q10. Can you apply multiple custom annotations to a single method? Yes. A method can carry any number of annotations. Each AOP aspect with a matching pointcut will independently intercept the method call. The order of aspect execution can be controlled with @Order on the aspect classes or by implementing Ordered.