Simple tricks to improve your application’s maintainability (Part 2): User context without breaking your design
User context without breaking your design
TL;DR: Passing user identifiers through your code works – but MDC allows your logs to carry user context without polluting business logic.
In Part 1 of this series (→ Why logs stop being useful in real systems), we saw how basic logging patterns break down once concurrency, delays, and multiple users enter the picture.
At that point, logs were still technically correct, but they no longer allowed us to reconstruct what happened to a single user or request. Let’s take the next step.
User ID as diagnostic context
First, consider the application user. They might not be our colleague. They might not work in our company – or even be reachable. Let’s assume they report a problem in a bug tracker and include their system identifier. That identifier can be:
- a username,
- a system number,
- or an email address.
In our example, it’s an email address: mamian@zoo.gov.eu
To make use of this information, we need a way to filter logs by user ID.
If problematic code can only be triggered by an authenticated user, we’re already closer to the goal. From the moment a user logs in and is authenticated, everything that happens is executed in their context.
So, let’s try to use that.
❌ Problem: passing userId everywhere
The most straightforward approach is to pass userId explicitly and include it in every log statement:
public void modifyOrder(String userId) {
try {
var priceOfItem = getPriceOfItem(userId);
var quantity = getQuantity(userId);
var totalPrice = calculateTotalPrice(userId, priceOfItem, quantity);
updateWith(totalPrice);
} catch (OrderException e) {
log.error("Failure: user (id = {})", userId);
}
}
private int getPriceOfItem(String userId) {
var priceOfItem = repository.getPriceOfItem(userId);
logger.info("User (id = {}) called: Price of item = {}", userId, priceOfItem);
return priceOfItem;
}
private int getQuantity(String userId) {
var quantity = repository.getQuantity(userId);
logger.info("User (id = {}) called: Items in order = [{}]", userId, quantity);
return quantity;
}
private int calculateTotalPrice(String userId, int priceOfItem, int quantity) {
var totalPrice = calculator.calculate(userId, price, quantity);
logger.info("User (id = {}) called: Total price = {}", userId, totalPrice);
return totalPrice;
}
This approach works, but it quickly becomes problematic.
Passing userId to every method becomes a poor design:
- what if we later want to replace a String email with a database UUID or a UserId value object?
- what if we forget to pass it to one method, or forget to log it?
- what if logging concerns start leaking into otherwise clean domain logic?
In practice, this solution becomes tedious surprisingly quickly.
✅ Solution: Mapped Diagnostic Context (MDC)
Mapped Diagnostic Context (MDC) is a small but powerful utility built into all popular Java logging libraries. It allows us to store diagnostic values in thread-local memory, separate for each execution thread.
The idea is simple:
- when execution starts, we put useful diagnostic data into MDC,
- when execution ends, we clean MDC.
Because MDC is thread-local:
- diagnostic data from one execution can’t leak into another,
- but forgetting to clean it is dangerous, because threads are reused.
Where MDC fits naturally
In Spring-based applications, the most common execution boundary is an HTTP request. That makes request handling an ideal place to initialize MDC at the beginning, and clean it up when the response is sent.
A similar pattern applies to:
- scheduled jobs,
- queue consumers,
- or any execution with a clear start and end.
Here is an example that handles MDC lifecycle correctly:
- Initialise filter that is later automatically injected into every REST API call flow.
- Collect information about logged in user.
- Put logged in user into MDC at the beginning, then clean MDC at the end.
- The finally block is critical here – it ensures MDC is always cleaned up, even when an exception occurs.
@Component
public class GlobalLoggingRequestFilter extends OncePerRequestFilter {
private final AuthenticationService authService;
public GlobalLoggingRequestFilter(AuthenticationService authService) {
this.authService = authService;
}
private Optional<String> extractUserIdFrom(HttpServletRequest request) {
// In a real application, this service call would parse JWT
// or look up session data based on the request.
return authService.extractUserId(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// init MDC with user ID
extractUserIdFrom(request).ifPresent(userId -> MDC.put("userId", userId));
// let the business logic be executed
filterChain.doFilter(request, response);
} finally {
// finish and clean MDC context
MDC.clear();
}
}
}
❌ Problem: coupling MDC with business code
At this point, user ID is available via MDC, so we no longer need to pass it through method parameters.
However, if we access MDC manually in every log statement, we introduce a new form of coupling:
public void modifyOrder() {
try {
var priceOfItem = getPriceOfItem();
var quantity = getQuantity();
var totalPrice = calculateTotalPrice(priceOfItem, quantity);
updateWith(totalPrice);
} catch (OrderException e) {
log.error("Failure: user (id = {})", MDC.get("userId"));
}
}
private int getPriceOfItem() {
var priceOfItem = repository.getPriceOfItem();
logger.info("User (id = {}) called: Price of item = {}", MDC.get("userId"), priceOfItem);
return priceOfItem;
}
private int getQuantity() {
var quantity = repository.getQuantity();
logger.info("User (id = {}) called: Items in order = [{}]", MDC.get("userId"), quantity);
return quantity;
}
private int calculateTotalPrice(int priceOfItem, int quantity) {
var totalPrice = calculator.calculate(price, quantity);
logger.info("User (id = {}) called: Total price = {}", MDC.get("userId"), totalPrice);
return totalPrice;
}
This still pollutes logging code, couples business logic to logging infrastructure, and requires discipline everywhere.
✅ Solution: let the logger read MDC for us
Fortunately, Logback can read MDC values automatically. We only need to update the logging pattern:
%d{ISO8601} %-5level ${PID} [t=%thread] %-48logger{48} %msg : [u=%X{userId}] %n%throwable
| |
| +--- access variable from MDC
| …uses conversion specifier %X
| …accessing userId in our case
|
+--- visual separator
Here, %X{userId} tells Logback to read the userId value from MDC and append it to every log line. No changes to logging calls are required.
Cleaner business code, richer logs
With this configuration in place, application code becomes clean again:
public void modifyOrder() {
try {
var priceOfItem = getPriceOfItem();
var quantity = getQuantity();
var totalPrice = calculateTotalPrice(priceOfItem, quantity);
updateWith(totalPrice);
} catch (OrderException e) {
log.error("Failure");
}
}
private int getPriceOfItem() {
var priceOfItem = repository.getPriceOfItem(userId);
logger.info("Price of item = {}", priceOfItem);
return priceOfItem;
}
private int getQuantity() {
var quantity = repository.getQuantity(userId);
logger.info("Items in order = [{}]", quantity);
return quantity;
}
private int calculateTotalPrice(int priceOfItem, int quantity) {
var totalPrice = calculator.calculate(userId, price, quantity);
logger.info("Total price = {}", totalPrice);
return totalPrice;
}
And logs automatically include user context:
2025-10-20 00:29:11,300 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 17 : [u=ferdynand@oo.pl]
2025-10-20 00:29:11,356 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [3] : [u=ferdynand@oo.pl]
2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl]
2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu]
2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu]
2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu]
2025-10-20 00:29:16,884 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 81 : [u=mr.1337@pwnd.it]
2025-10-20 00:29:16,959 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [1] : [u=mr.1337@pwnd.it]
2025-10-20 00:29:16,979 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 81 : [u=mr.1337@pwnd.it]
Filtering logs by user ID immediately reveals the problematic execution:
2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu]
2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu]
2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu]
Something is clearly wrong – and now it’s easy to spot.
User ID vs. GDPR
Depending on the industry, logging a user identifier may be restricted by GDPR or other regulations.
In such cases, you can still log non-identifying metadata, such as:
- user role,
- selected language,
- country,
- UI mode,
- device type.
For example, instead of:
2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl]
you might log:
2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [role=CUSTOMER,lang=pl_PL,lc=pl,mode=DARK,dvc=MOBILE]
In practice, this usually provides enough context to understand the issue, without identifying a specific person.
Where this gets us – and where it doesn’t
Filtering logs by user ID solves many real production problems. It allows us to:
- correlate events for a single user,
- quickly identify incorrect behaviour,
- reduce time spent manually reconstructing execution paths.
However, this approach still has limits. What happens when:
- users are not authenticated,
- execution spans multiple threads,
- work is performed asynchronously?
Let’s improve our solution again, shall we?
What comes next
About the author
Contact us in case of any questions!
RECOMMENDED ARTICLES