Priya gets a support call: “Order QC-8842 failed, but I cannot find it in the logs.” She searches the logs and sees hundreds of lines that all look the same — Processing payment, Checking stock, Sending email — with no way to tell which order each line belongs to. The main web thread knew the order ID. The background worker thread did not. The ID got lost when work moved to a thread pool.
What is MDC?
MDC (Mapped Diagnostic Context) is a small map attached to the current thread. Your logger reads it and prints the values in every log line.
With SLF4J:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class OrderController {
private static final Logger log = LoggerFactory.getLogger(OrderController.class);
public void handleOrder(String orderId, String customerId) {
MDC.put("orderId", orderId);
MDC.put("customerId", customerId);
log.info("Order received");
// Output: 2026-08-03 10:15:00 [QC-8842] [cust-991] Order received
// Read a value back
String id = MDC.get("orderId");
// Remove one key when done with it
MDC.remove("orderId");
// Or clear everything at once
MDC.clear();
}
}
In your Logback pattern, use %X{orderId} to print the value:
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%X{orderId}] [%X{customerId}] %msg%n</pattern>
Now every log line for that request carries the order ID. Support can search for QC-8842 and find the full story.
Document your MDC keys so the whole team uses the same names:
/**
* QuickCart MDC keys:
* - orderId: order identifier (e.g. QC-8842)
* - customerId: customer identifier
* - requestId: unique per HTTP request
*/
The problem: thread pools do not share MDC
MDC lives in a ThreadLocal. Each thread has its own copy. When you submit work to a pool, a different thread runs your task. That thread starts with an empty MDC.
MDC.put("orderId", "QC-8842");
executor.submit(() -> {
log.info("Charging card"); // orderId is MISSING here
});
The main thread had the ID. The pool thread does not. This is the bug Priya hit.
The same problem happens with CompletableFuture.runAsync, @Async methods, and any path that moves work to another thread.
The fix: copy context before submit, set it in the worker
Three steps:
- Capture the parent’s MDC map before submitting.
- Set that map at the start of the worker thread.
- Clear MDC in a
finallyblock when the worker finishes.
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class QuickCartOrderService {
private final ExecutorService executor = Executors.newFixedThreadPool(10);
private static final Logger log = LoggerFactory.getLogger(QuickCartOrderService.class);
public void processOrderAsync(String orderId) {
MDC.put("orderId", orderId);
Map<String, String> context = MDC.getCopyOfContextMap();
executor.submit(() -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
chargeCard(orderId);
updateInventory(orderId);
sendConfirmation(orderId);
log.info("Order complete");
} finally {
MDC.clear();
}
});
MDC.clear(); // clean up the web thread too
}
private void chargeCard(String orderId) { log.info("Charging card"); }
private void updateInventory(String orderId) { log.info("Updating stock"); }
private void sendConfirmation(String orderId) { log.info("Sending email"); }
}
Now every log line inside the worker shows [QC-8842]. Support can trace the full path.
CompletableFuture path
Same pattern for async futures:
public void processOrderWithFuture(String orderId) {
MDC.put("orderId", orderId);
Map<String, String> context = MDC.getCopyOfContextMap();
CompletableFuture.runAsync(() -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
chargeCard(orderId);
log.info("Payment done");
} finally {
MDC.clear();
}
}, executor);
MDC.clear();
}
Wrap Runnable and Callable once
Copy-set-clear repeats in every async call. Wrap it once:
public class MdcUtils {
public static Runnable wrap(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
task.run();
} finally {
MDC.clear();
}
};
}
public static <T> Callable<T> wrap(Callable<T> task) {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) {
MDC.setContextMap(context);
}
return task.call();
} finally {
MDC.clear();
}
};
}
}
Usage:
MDC.put("orderId", "QC-8842");
executor.submit(MdcUtils.wrap(() -> {
log.info("Charging card"); // orderId is present
}));
executor.submit(MdcUtils.wrap(() -> {
return paymentClient.charge("QC-8842");
}));
Custom ThreadPoolTaskExecutor for Spring Boot
In Spring Boot, override the executor so every submitted task gets wrapped automatically:
import org.slf4j.MDC;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
public class MdcThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {
public static <T> Callable<T> wrap(Callable<T> callable, Map<String, String> context) {
return () -> {
if (context != null) {
MDC.setContextMap(context);
}
try {
return callable.call();
} finally {
MDC.clear();
}
};
}
@Override
public <T> Future<T> submit(Callable<T> task) {
Map<String, String> context = MDC.getCopyOfContextMap();
return super.submit(wrap(task, context));
}
@Override
public Future<?> submit(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap();
return super.submit(MdcUtils.wrap(task));
}
}
Configure it as your Spring async executor:
@Configuration
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
MdcThreadPoolTaskExecutor executor = new MdcThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("quickcart-async-");
executor.initialize();
return executor;
}
}
Now every @Async method and every executor.submit() call in QuickCart keeps the order ID in logs — no manual copy in each method.
Bad vs good — side by side
Without propagation (the bug Priya hit):
public class BadOrderService {
private ExecutorService executor = Executors.newFixedThreadPool(10);
public void processOrder(String orderId) {
MDC.put("orderId", orderId);
executor.submit(() -> {
log.info("Charging card"); // orderId missing — support cannot trace this
});
// MDC never cleared on web thread either
}
}
With manual propagation:
public class GoodOrderService {
private ExecutorService executor = Executors.newFixedThreadPool(10);
public void processOrder(String orderId) {
MDC.put("orderId", orderId);
Map<String, String> context = MDC.getCopyOfContextMap();
executor.submit(() -> {
try {
MDC.setContextMap(context);
log.info("Charging card"); // orderId present
} finally {
MDC.clear();
}
});
MDC.clear();
}
}
With custom executor (best for Spring Boot apps):
public class BestOrderService {
private MdcThreadPoolTaskExecutor executor = new MdcThreadPoolTaskExecutor();
public void processOrder(String orderId) {
MDC.put("orderId", orderId);
executor.submit(() -> {
log.info("Charging card"); // orderId present automatically
});
MDC.clear();
}
}
Why clear MDC in finally?
Thread pool threads are reused. If you set MDC and never clear it, the next task on that thread might log with the previous order’s ID. That is worse than no ID at all — wrong IDs send you on wild goose chases.
Always:
try {
MDC.setContextMap(context);
// do work
} finally {
MDC.clear();
}
Clear on the web thread too after you copy the context. The parent thread does not need to carry the ID once the copy is made.
If you forget to clear, MDC entries stay in the thread’s ThreadLocal map. In a long-running pool, that is a slow memory leak — old order IDs pile up in thread memory.
Do not share MDC directly between threads
This does not work:
MDC.put("orderId", "QC-8842");
executor.submit(() -> {
String id = MDC.get("orderId"); // null — different thread, empty MDC
});
MDC is per-thread. You must copy the map explicitly with getCopyOfContextMap() and set it in the worker with setContextMap().
Also avoid two threads writing different values to the same MDC key at the same time on the same thread — that cannot happen across threads, but nested async calls on one thread can overwrite each other’s keys if you are not careful.
What to remember
- MDC stores per-thread logging context like order ID and customer ID.
- MDC uses ThreadLocal — it does not follow work to a thread pool automatically.
- Copy with
MDC.getCopyOfContextMap()before async submit. - Set with
MDC.setContextMap(context)at the start of the worker. - Clear with
MDC.clear()infinallyon both worker and parent threads. - Wrap once with
MdcUtils.wrap()or a customThreadPoolTaskExecutorso you do not repeat the pattern everywhere. - Use consistent keys:
orderId,customerId,requestId.
What Priya does next: she wants QuickCart to handle many slow API calls without tying up threads. She looks at reactive programming — reacting to results as they arrive instead of blocking and waiting.