Spring Boot
Exception Handling
MDC Attributes
Logging
Java

Preserve custom MDC attributes during exception-handling in Spring Boot

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Mapped Diagnostic Context (MDC) is a thread-local map in SLF4J/Logback that attaches contextual information (request ID, user ID, correlation ID) to every log statement. In Spring Boot, MDC values set during request processing are often lost by the time they reach @ControllerAdvice exception handlers, because the exception handling may run in a different context or the MDC is cleared prematurely. Preserving MDC attributes through the exception-handling chain requires careful placement of filters, proper @ControllerAdvice configuration, and awareness of thread boundaries.

How MDC Works

java
1import org.slf4j.MDC;
2import org.slf4j.Logger;
3import org.slf4j.LoggerFactory;
4
5public class OrderService {
6    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
7
8    public void processOrder(String orderId) {
9        MDC.put("orderId", orderId);
10        MDC.put("requestId", UUID.randomUUID().toString());
11
12        log.info("Processing order");  // Log includes orderId and requestId
13        // ... business logic ...
14
15        MDC.clear();  // Clean up after request
16    }
17}

In logback-spring.xml, reference MDC values:

xml
<pattern>%d{HH:mm:ss} [%X{requestId}] [%X{orderId}] %-5level %logger{36} - %msg%n</pattern>

%X{requestId} outputs the MDC value. If the MDC is empty at log time, the value is blank.

The Problem: MDC Lost in Exception Handlers

java
1@RestController
2public class OrderController {
3
4    @PostMapping("/orders")
5    public Order createOrder(@RequestBody OrderRequest request) {
6        MDC.put("orderId", request.getId());
7        // Business logic throws an exception...
8        throw new OrderNotFoundException(request.getId());
9    }
10}
11
12@ControllerAdvice
13public class GlobalExceptionHandler {
14
15    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
16
17    @ExceptionHandler(OrderNotFoundException.class)
18    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
19        // MDC may be empty here if cleared before exception handling
20        log.error("Order not found: {}", ex.getMessage());
21        // Log output: [  ] [  ] ERROR ... — MDC values are missing!
22        return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
23    }
24}

This happens when MDC is cleared in a filter's finally block before the exception handler runs, or when async processing switches threads.

Solution 1: Set MDC in a Filter (Correct Order)

Place the MDC filter at the outermost level so it wraps the entire request lifecycle, including exception handling:

java
1@Component
2@Order(Ordered.HIGHEST_PRECEDENCE)
3public class MdcFilter extends OncePerRequestFilter {
4
5    @Override
6    protected void doFilterInternal(HttpServletRequest request,
7                                     HttpServletResponse response,
8                                     FilterChain filterChain) throws ServletException, IOException {
9        try {
10            MDC.put("requestId", getOrCreateRequestId(request));
11            MDC.put("userId", extractUserId(request));
12            MDC.put("path", request.getRequestURI());
13
14            filterChain.doFilter(request, response);
15            // Exception handlers run INSIDE filterChain.doFilter()
16        } finally {
17            MDC.clear();  // Clean up AFTER everything, including exception handling
18        }
19    }
20
21    private String getOrCreateRequestId(HttpServletRequest request) {
22        String id = request.getHeader("X-Request-ID");
23        return id != null ? id : UUID.randomUUID().toString();
24    }
25
26    private String extractUserId(HttpServletRequest request) {
27        // Extract from JWT, session, etc.
28        return request.getHeader("X-User-ID");
29    }
30}

The key insight: filterChain.doFilter() includes the entire Spring MVC dispatch cycle, including @ControllerAdvice handlers. So MDC values set before doFilter() and cleared in finally after doFilter() are available throughout exception handling.

Solution 2: HandlerInterceptor with afterCompletion

java
1@Component
2public class MdcInterceptor implements HandlerInterceptor {
3
4    @Override
5    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
6                             Object handler) {
7        MDC.put("requestId", UUID.randomUUID().toString());
8        MDC.put("handler", handler.toString());
9        return true;
10    }
11
12    @Override
13    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
14                                 Object handler, Exception ex) {
15        // afterCompletion runs AFTER exception handlers
16        MDC.clear();
17    }
18}

Register it:

java
1@Configuration
2public class WebConfig implements WebMvcConfigurer {
3    @Autowired
4    private MdcInterceptor mdcInterceptor;
5
6    @Override
7    public void addInterceptors(InterceptorRegistry registry) {
8        registry.addInterceptor(mdcInterceptor);
9    }
10}

afterCompletion is called after the response is fully rendered, including after @ExceptionHandler methods run.

Solution 3: MDC-Aware Exception Handler

If you cannot control the filter order, copy MDC values into the exception itself:

java
1public class AppException extends RuntimeException {
2    private final Map<String, String> mdcContext;
3
4    public AppException(String message) {
5        super(message);
6        this.mdcContext = MDC.getCopyOfContextMap();  // Capture at throw time
7    }
8
9    public Map<String, String> getMdcContext() {
10        return mdcContext;
11    }
12}
13
14@ControllerAdvice
15public class GlobalExceptionHandler {
16    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
17
18    @ExceptionHandler(AppException.class)
19    public ResponseEntity<ErrorResponse> handleAppException(AppException ex) {
20        // Restore MDC from the exception
21        Map<String, String> ctx = ex.getMdcContext();
22        if (ctx != null) {
23            MDC.setContextMap(ctx);
24        }
25        try {
26            log.error("Application error: {}", ex.getMessage());
27            return ResponseEntity.status(500).body(new ErrorResponse(ex.getMessage()));
28        } finally {
29            MDC.clear();
30        }
31    }
32}

Async Thread Propagation

MDC is thread-local, so it is lost when processing moves to a different thread:

java
1// MDC is NOT automatically copied to async threads
2@Async
3public CompletableFuture<Result> processAsync() {
4    log.info("Processing");  // MDC is empty here
5}
6
7// Fix: use a TaskDecorator
8@Configuration
9@EnableAsync
10public class AsyncConfig implements AsyncConfigurer {
11
12    @Override
13    public Executor getAsyncExecutor() {
14        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
15        executor.setTaskDecorator(new MdcTaskDecorator());
16        executor.initialize();
17        return executor;
18    }
19}
20
21public class MdcTaskDecorator implements TaskDecorator {
22    @Override
23    public Runnable decorate(Runnable runnable) {
24        Map<String, String> context = MDC.getCopyOfContextMap();
25        return () -> {
26            if (context != null) MDC.setContextMap(context);
27            try {
28                runnable.run();
29            } finally {
30                MDC.clear();
31            }
32        };
33    }
34}

Common Pitfalls

  • Clearing MDC too early in a filter: If MDC.clear() runs in a finally block inside a controller or service method rather than in the outermost filter, exception handlers lose the MDC context. Always clear MDC in the outermost filter's finally block.
  • Filter ordering: If your MDC filter does not have @Order(Ordered.HIGHEST_PRECEDENCE), other filters may run first and the MDC may not be set when exceptions occur in those filters. Ensure the MDC filter wraps everything.
  • Losing MDC across async boundaries: @Async methods, CompletableFuture.supplyAsync(), and reactive streams run on different threads where MDC is empty. Use TaskDecorator or manually copy the MDC context map.
  • Not cleaning up MDC: Servlet containers reuse threads. If you do not call MDC.clear() after each request, MDC values from a previous request leak into the next request on the same thread, producing incorrect log context.
  • Assuming @ControllerAdvice runs on the same thread: While synchronous Spring MVC typically uses the same thread for the entire request (including exception handlers), reactive (WebFlux) and async scenarios may switch threads, dropping MDC values.

Summary

  • MDC values are thread-local and must be set before and cleared after the entire request lifecycle
  • Place MDC setup in a servlet filter with @Order(Ordered.HIGHEST_PRECEDENCE) so it wraps exception handling
  • filterChain.doFilter() includes @ControllerAdvice execution, so MDC set before it is available in exception handlers
  • For async processing, use MdcTaskDecorator to copy MDC context to worker threads
  • Always call MDC.clear() in a finally block to prevent context leaking between requests
  • Consider capturing MDC in custom exceptions as a fallback when filter ordering cannot be controlled

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.