Definition
- SLF4J – Simple Logging Facade for Java, a standard API that your code calls. It is not a logging library itself.
- Logback – the default logging implementation in Spring Boot that sits behind the SLF4J facade and handles the actual writing to console, files, or remote systems.
- Parameterized logging – using
log.info("Transfer {} to {}", fromId, toId)with curly-brace placeholders so the string is built only when the level is enabled. - MDC – Mapped Diagnostic Context, a thread-local map that attaches key-value pairs (like traceId) to every log line produced by that thread.
- AsyncAppender – wraps a file or network appender so log events are dropped into an in-memory queue and written by a background thread, eliminating I/O blocking.
- PII masking – replacing sensitive data (account numbers, PAN, Aadhaar) with masked values before writing to logs.
Think of a National Bank of India branch. SLF4J is the standard memo format that every employee uses. Logback is the printer that actually prints the memos. MDC is the stamp that marks every memo with the customer's token number so you can trace all activity for a single customer visit. AsyncAppender is putting memos in an outbox tray instead of walking to the printer each time: the office assistant picks them up in batches.
Understanding the Concepts
What is SLF4J and why is it a facade?
SLF4J stands for Simple Logging Facade for Java, and the key word is facade. A facade is a standard set of method names that your code calls. Behind the facade, a real logging engine does the actual work of writing text to a file, console, or remote system. In Spring Boot, the real engine behind the facade is Logback.
Your code only ever talks to SLF4J, so you write LoggerFactory.getLogger(SettlementService.class) and then call log.info(...). Because your code depends only on the facade, you could swap Logback for Log4j2 without changing a single line in your business classes.
package com.bank.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class TransferService {
// SLF4J facade: your code talks to this interface
private static final Logger log = LoggerFactory.getLogger(TransferService.class);
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
log.info("Transfer {} from {} to {}", amount, fromAccount, toAccount);
// Logback (the implementation) writes this to console and file
}
}
2024-01-15 10:30:45.123 INFO [main] c.b.s.TransferService : Transfer 50000.00 from SAV2001 to SAV2002
What are the log levels and when do you use each?
Logging levels form a ladder from least serious to most serious: TRACE, DEBUG, INFO, WARN, ERROR. You configure a threshold, and only messages at that level or above are written.
| Level | Purpose | Example | Production Use |
|---|---|---|---|
| TRACE | Very fine-grained debugging | Method entry/exit | Never (too verbose) |
| DEBUG | Developer detail | Balance before and after debit | Only during incidents |
| INFO | Normal business events | Transfer completed successfully | Always |
| WARN | Concerning but survivable | Retry needed, low balance threshold | Always |
| ERROR | Actual failures | Transfer failed, exception caught | Always |
package com.bank.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class AccountService {
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
public void debit(String accountId, BigDecimal amount) {
BigDecimal balanceBefore = getBalance(accountId);
log.debug("Balance before debit for {}: {}", accountId, balanceBefore);
if (balanceBefore.compareTo(amount) < 0) {
log.warn("Insufficient balance for account {}: has {}, needs {}",
accountId, balanceBefore, amount);
throw new InsufficientBalanceException(accountId);
}
performDebit(accountId, amount);
log.info("Debited {} from account {}", amount, accountId);
}
}
# application-prod.yml: only INFO and above in production
logging:
level:
root: WARN
com.bank: INFO
com.bank.service: INFO
In production (level=INFO): only the info and warn lines appear
In development (level=DEBUG): debug line also appears
The log.debug() call costs nothing in production because Logback
skips the string building when DEBUG is disabled
Why must you use parameterized logging?
Parameterized logging with curly-brace placeholders is not just a style preference. It is a performance requirement. With the placeholder form, SLF4J only builds the final string if the log level is actually enabled. With string concatenation, Java builds the entire string every single time, even when the log line will be thrown away.
package com.bank.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
public class LoggingDemo {
private static final Logger log = LoggerFactory.getLogger(LoggingDemo.class);
public void processTransfer(String transferId, BigDecimal amount) {
// WRONG: string concatenation -- built EVERY call even when DEBUG is disabled
log.debug("Processing transfer " + transferId + " for amount " + amount);
// CORRECT: parameterized -- string built ONLY when DEBUG is enabled
log.debug("Processing transfer {} for amount {}", transferId, amount);
}
}
With 10,000 transfers per second and DEBUG disabled in production:
- Concatenation: 10,000 unnecessary String objects created per second -> GC pressure
- Parameterized: zero String objects created -> no overhead
How do you log exceptions correctly?
Exception logging is the single most common mistake in Java applications. The correct form passes the exception as the last argument to log.error() without a placeholder for it. SLF4J recognizes a trailing Throwable and prints the full stack trace.
package com.bank.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
public class ExceptionLoggingDemo {
private static final Logger log = LoggerFactory.getLogger(ExceptionLoggingDemo.class);
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
try {
executeTransfer(fromAccount, toAccount, amount);
} catch (Exception e) {
// WRONG: loses the entire stack trace
log.error(e.getMessage());
// WRONG: bypasses logging framework, goes to stderr
e.printStackTrace();
// WRONG: converts stack trace to string manually
log.error("Transfer failed: " + e.toString());
// CORRECT: exception as last arg, no placeholder for it
log.error("Transfer failed from {} to {} for amount {}",
fromAccount, toAccount, amount, e);
// SLF4J sees 3 placeholders and 4 args, recognizes the
// last arg is Throwable, and prints the full stack trace
}
}
}
WRONG output (getMessage only):
ERROR Transfer failed: null
-- No stack trace, no idea WHERE the failure happened
CORRECT output (exception as last arg):
ERROR Transfer failed from SAV2001 to SAV2002 for amount 50000.00
java.lang.NullPointerException: null
at com.bank.service.TransferService.executeTransfer(TransferService.java:45)
at com.bank.service.TransferService.transfer(TransferService.java:30)
-- Full stack trace, exact line number, complete context
What is MDC and how does it work?
MDC stands for Mapped Diagnostic Context. It is a small map of key-value pairs attached to the current thread. You put a value in at the start of a request, and from that moment every log line produced by that thread automatically carries the value, as long as your Logback pattern references it.
The classic use is correlation. Rajesh needs to trace all log lines for a single customer request across multiple service classes. At the start of the request, a servlet filter sets MDC.put("traceId", traceId) and the Logback pattern includes %X{traceId}. Now every log line from TransferService, AccountRepository, and AuditService includes the same traceId.
package com.bank.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null) {
traceId = UUID.randomUUID().toString().substring(0, 8);
}
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // CRITICAL: prevent leaking to the next request on this thread
}
}
}
<!-- logback-spring.xml: include traceId in every log line --syn-kw">>
syn-kw"><configurationsyn-kw">>
syn-kw"><appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"syn-kw">>
syn-kw"><encodersyn-kw">>
syn-kw"><patternsyn-kw">>%d{HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%nsyn-kw"></patternsyn-kw">>
syn-kw"></encodersyn-kw">>
syn-kw"></appendersyn-kw">>
syn-kw"><root level="INFO"syn-kw">>
syn-kw"><appender-ref ref="CONSOLE" /syn-kw">>
syn-kw"></rootsyn-kw">>
syn-kw"></configurationsyn-kw">>
10:30:45.123 [a1b2c3d4] INFO c.b.s.TransferService - Transfer 50000 from SAV2001 to SAV2002
10:30:45.125 [a1b2c3d4] INFO c.b.r.AccountRepository - Debited SAV2001
10:30:45.127 [a1b2c3d4] INFO c.b.r.AccountRepository - Credited SAV2002
10:30:45.130 [a1b2c3d4] INFO c.b.s.AuditService - Audit entry created
All four lines share traceId=a1b2c3d4, easy to filter in ELK/Loki
Why is MDC.clear() critical and what happens without it?
MDC lives on a ThreadLocal, and servlet containers reuse threads from a pool. If you do not clear the MDC when the request finishes, the next unrelated request that reuses the same thread inherits the stale traceId. Priya's account inquiry would carry Rajesh's previous debugging traceId, making the logs impossible to trace correctly.
// WRONG: no cleanup -- traceId leaks to next request
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
MDC.put("traceId", UUID.randomUUID().toString());
chain.doFilter(req, res);
// Thread goes back to pool with stale traceId
}
// CORRECT: always clear in finally block
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
MDC.put("traceId", UUID.randomUUID().toString());
try {
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
Why does heavy logging cause latency and how does AsyncAppender fix it?
Writing a log line to disk is actual I/O. Each disk write takes 1 to 5 milliseconds depending on the storage. If the thread handling a transfer blocks for 2 milliseconds on every log line, and the transfer logs 50 lines, that is 100 milliseconds of pure waiting added to every transfer.
AsyncAppender fixes this by inserting a queue between the request thread and the file writer. When your code calls log.info(...), the event is dropped into an in-memory queue in microseconds and the thread returns immediately. A separate background thread drains the queue and performs the slow disk writes.
<!-- logback-spring.xml: async appender configuration --syn-kw">>
syn-kw"><configurationsyn-kw">>
syn-kw"><appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"syn-kw">>
syn-kw"><filesyn-kw">>logs/national-bank.logsyn-kw"></filesyn-kw">>
syn-kw"><rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"syn-kw">>
syn-kw"><fileNamePatternsyn-kw">>logs/national-bank.%d{yyyy-MM-dd}.logsyn-kw"></fileNamePatternsyn-kw">>
syn-kw"><maxHistorysyn-kw">>30syn-kw"></maxHistorysyn-kw">>
syn-kw"></rollingPolicysyn-kw">>
syn-kw"><encodersyn-kw">>
syn-kw"><patternsyn-kw">>%d{HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%nsyn-kw"></patternsyn-kw">>
syn-kw"></encodersyn-kw">>
syn-kw"></appendersyn-kw">>
syn-kw"><appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"syn-kw">>
syn-kw"><queueSizesyn-kw">>1024syn-kw"></queueSizesyn-kw">>
syn-kw"><discardingThresholdsyn-kw">>0syn-kw"></discardingThresholdsyn-kw">> <!-- 0 = never discard --syn-kw">>
syn-kw"><appender-ref ref="FILE" /syn-kw">>
syn-kw"></appendersyn-kw">>
syn-kw"><root level="INFO"syn-kw">>
syn-kw"><appender-ref ref="ASYNC" /syn-kw">>
syn-kw"></rootsyn-kw">>
syn-kw"></configurationsyn-kw">>
Without AsyncAppender:
Request thread -> log.info() -> WAIT for disk write -> continue
50 log lines x 2ms each = 100ms added to every transfer
With AsyncAppender:
Request thread -> log.info() -> drop in queue (microseconds) -> continue
Background thread -> drain queue -> write to disk
Transfer latency reduced by ~100ms
How do you mask PII in logs?
Banking applications must never log sensitive data like full account numbers, PAN numbers, or Aadhaar numbers. Regulations like GDPR and RBI guidelines require PII masking in all log output.
package com.bank.util;
public final class PiiMasker {
private PiiMasker() {}
public static String maskAccountNumber(String accountNumber) {
if (accountNumber == null || accountNumber.length() < 4) {
return "****";
}
return "****" + accountNumber.substring(accountNumber.length() - 4);
}
public static String maskPan(String pan) {
if (pan == null || pan.length() < 4) {
return "****";
}
return pan.substring(0, 4) + "****" + pan.substring(pan.length() - 2);
}
}
package com.bank.service;
import com.bank.util.PiiMasker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
public class TransferLogger {
private static final Logger log = LoggerFactory.getLogger(TransferLogger.class);
public void logTransfer(String fromAccount, String toAccount, BigDecimal amount) {
// WRONG: logs full account numbers
log.info("Transfer from {} to {} amount {}", fromAccount, toAccount, amount);
// Output: Transfer from SAV2001234567 to SAV2009876543 amount 50000
// CORRECT: masks sensitive data
log.info("Transfer from {} to {} amount {}",
PiiMasker.maskAccountNumber(fromAccount),
PiiMasker.maskAccountNumber(toAccount),
amount);
// Output: Transfer from ****4567 to ****6543 amount 50000
}
}
What should a senior engineer know?
- Use Lombok's
@Slf4jannotation to generate the logger field automatically instead of writing the static final Logger line. - For centralized logging, output JSON-formatted logs using Logstash encoder. JSON logs are parsed easily by ELK (Elasticsearch, Logstash, Kibana) and Grafana Loki.
- When using
@Asyncmethods orCompletableFuture, MDC values are not automatically propagated to the new thread. You must wrap theTaskExecutorwithMDCTaskDecoratorto copy MDC values. - In high-throughput systems, set
discardingThreshold=0on AsyncAppender to prevent discarding WARN and ERROR messages under load. The default discards DEBUG and TRACE when the queue is 80 percent full. - Pankaj configures structured JSON logging in production with fields for traceId, accountId, serviceName, and timestamp, making it possible to search and filter logs across all National Bank of India microservices.
package com.bank.config;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
What are common interview follow-up questions?
- What is the difference between SLF4J and Logback? SLF4J is the API (facade) that your code calls. Logback is the implementation that does the actual writing. You can swap Logback for Log4j2 without changing business code.
- Can you use Log4j2 with Spring Boot? Yes. Exclude
spring-boot-starter-logging(which includes Logback) and addspring-boot-starter-log4j2. - How do you configure different log levels for different packages? Use
logging.level.in application.yml. For example,=LEVEL logging.level.com.bank.service=DEBUGsets DEBUG for your service package while the rest stays at INFO. - What is structured logging? Instead of plain text lines, each log event is a JSON object with named fields. This makes logs machine-parseable for search, filtering, and alerting in centralized systems.
Visual Overview
Logging Architecture:
Your Code
│
│ log.info("Transfer {} from {}", amount, account)
▼
┌─────────────────┐
│ SLF4J │ Facade (API only)
│ (interface) │
└────────┬────────┘
▼
┌─────────────────┐
│ Logback │ Implementation
│ (engine) │
└────────┬────────┘
▼
┌─────────────────┐
│ AsyncAppender │ Queue (non-blocking)
│ [queue: 1024] │
└────────┬────────┘
│ background thread
▼
┌─────────────────┐ ┌─────────────────┐
│ Console │ │ File │
│ Appender │ │ Appender │
└─────────────────┘ └─────────────────┘
MDC Flow:
HTTP Request arrives
│
┌────┴────────────────┐
│ CorrelationIdFilter │
│ MDC.put("traceId", │
│ "a1b2c3d4") │
└────┬────────────────┘
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Controller │ │ Service │ │ Repository │
│ [a1b2c3d4] │──→│ [a1b2c3d4] │──→│ [a1b2c3d4] │
└──────────────┘ └──────────────┘ └──────────────┘
│
┌────┴────────────────┐
│ CorrelationIdFilter │
│ MDC.clear() │ ← CRITICAL: prevent leak
└─────────────────────┘
Key Takeaway
Spring Boot uses SLF4J as the logging facade with Logback as the default implementation. The critical rules are: parameterized messages with curly braces so the string is built only when the level is enabled, exception as the last argument to preserve the full stack trace, and never getMessage() or printStackTrace(). These rules apply to every log statement in the National Bank of India codebase.
MDC attaches a correlation traceId to every log line for cross-service request tracing. A servlet filter sets the traceId from the request header at the start and clears it in a finally block to prevent leaking across pooled threads. In production, Rajesh uses AsyncAppender to eliminate the 100ms latency that synchronous disk I/O was adding per transfer. PII is masked before logging using a utility class, and logs are output in JSON format for structured ingestion into ELK or Grafana Loki.
The most dangerous logging mistake is losing the stack trace. When log.error(e.getMessage()) is used instead of log.error("msg", e), the entire stack trace disappears and debugging becomes guesswork. The second most dangerous mistake is not clearing MDC, which causes unrelated requests to inherit stale trace IDs on pooled threads.
Remember one sentence: SLF4J facade, Logback engine, parameterized messages, exception as last arg, MDC for tracing, AsyncAppender for performance, and always clear MDC in finally.
---