SLF4J is a facade — your code compiles against it, never the logger directly
Logback provides the native SLF4J binding: no adapter jars needed
Parameterised logging avoids garbage when log level is disabled, saving CPU in hot paths
MDC attaches request-scoped keys (customerId) to every log line automatically
Biggest mistake: not clearing MDC in a finally block — it poisons logs on thread reuse
Plain-English First
Imagine you're a pilot flying a plane. You don't write down every instrument reading yourself — you have a black box that automatically records everything: speed, altitude, engine status. If something goes wrong, you rewind the tape and see exactly what happened. Java logging is that black box for your application. SLF4J is the dashboard interface your code talks to, and Logback is the actual recorder underneath — and you can swap the recorder out without touching the dashboard.
Every real production application breaks at some point. A user can't check out, an API call silently fails at 2 AM, or a subtle data bug corrupts records for three weeks before anyone notices. The only way you find out what actually happened — not what you think happened — is your logs. Logging isn't a nice-to-have. It's your flight recorder, your audit trail, and your first responder toolkit all in one.
The Java ecosystem has historically been a mess for logging. You had java.util.logging baked into the JDK, then Log4j came along, then Commons Logging tried to abstract over them, then SLF4J did it properly, and now Logback exists as the spiritual successor to Log4j written by the same author. The problem this whole stack solves is simple: your code shouldn't be coupled to a specific logging implementation. Libraries you depend on might use Log4j, your framework might use JUL, and your own code uses Logback — SLF4J bridges them all so everything funnels into one consistent output stream.
By the end of this article you'll understand why the SLF4J facade pattern exists and why it matters, how to set up Logback from scratch with a real configuration file, how to use structured logging patterns that make log searching practical, how to configure rolling file appenders so your server disk doesn't fill up overnight, and the exact mistakes that trip up intermediate developers in interviews and in production.
Why SLF4J Exists — The Facade Pattern in Plain English
Here's a scenario that plays out constantly in enterprise Java. Your team writes a library and you pick Log4j 1.x. Six months later the consuming application uses Logback. Now you have two logging frameworks fighting each other in the same JVM, producing duplicate output, different formats, and no unified way to control log levels. This is the dependency hell SLF4J was designed to end.
SLF4J — Simple Logging Facade for Java — is deliberately just an API. It ships as a thin jar with interfaces and no real implementation. Your code calls LoggerFactory.getLogger() and logs via the Logger interface. At runtime, whichever SLF4J-compatible implementation is on the classpath — Logback, Log4j2, java.util.logging — picks up those calls. Think of SLF4J like a power outlet standard. You design your appliance (your code) to plug into the standard outlet shape. Whether the power behind that wall is hydro, solar, or nuclear is not your appliance's concern.
Logback is the natural default choice because its author, Ceki Gülcü, also wrote SLF4J. Logback implements SLF4J natively — no adapter bridge needed — and it's faster, more configurable, and actively maintained. When you add logback-classic to your project, it automatically provides the SLF4J binding. That's why you'll see both on the classpath together.
pom.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- Add these dependencies to your Maven pom.xml -->
<dependencies>
<!-- SLF4JAPI — the facade your code compiles against.
No logging actually happens from this jar alone. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<!-- LogbackClassic — the actual implementation.
This jar ALSO provides the SLF4J binding automatically,
so you doNOT need a separate slf4j-logback-binding artifact. -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
<!-- logback-classic already pulls in logback-core transitively,
so you don't need to declare logback-core explicitly. -->
</dependencies>
Output
No runtime output — this is your build file. After running `mvn install`, both jars appear in your classpath and SLF4J auto-discovers Logback as its provider.
Watch Out: The Duplicate Binding Trap
If you accidentally include two SLF4J bindings (e.g. logback-classic AND slf4j-log4j12) on the classpath, SLF4J prints a loud warning and picks one arbitrarily. Run mvn dependency:tree | grep slf4j to check. Use Maven exclusions to boot the unwanted binding out.
Production Insight
Production teams often discover the binding trap during deployments when log output changes format or stops appearing.
Dependency conflict usually comes from transitive deps — run mvn dependency:tree before every major release.
Rule: always explicitly manage SLF4J bindings; never rely on transitive resolution alone.
Key Takeaway
SLF4J breaks coupling between your code and the logging engine.
Your library should never depend on Logback or Log4j directly.
Only ever import from org.slf4j — everything else is a runtime concern.
Which Logging Stack Should You Use?
IfNew project with control over dependencies
→
UseSLF4J API + Logback classic — the standard choice.
IfExisting project uses Log4j2 internally
→
UseUse log4j-slf4j2-impl if you must keep Log4j2 runtime; but prefer migrating to Logback.
IfYou're writing a library / shared module
→
UseDepend ONLY on slf4j-api. Never pull in a concrete logger.
Your First Real Logback Configuration — logback.xml Explained Line by Line
Logback's configuration lives in logback.xml, placed in src/main/resources. If it can't find this file, Logback falls back to a default configuration that only logs WARN and above to the console. That default will bite you in development when you can't see your DEBUG output and wonder why your code appears to do nothing.
The configuration has three building blocks you need to understand before you write a single line of XML. An Appender is a destination — console, file, socket, database. An Encoder (or Layout) controls the format of each log line. A Logger is a named channel tied to your class hierarchy — you set its level and point it at one or more appenders.
Logback's logger hierarchy is one of its most powerful features. A logger named com.theforge.order is automatically a child of com.theforge, which is a child of the root logger. Set the level on com.theforge to DEBUG and every class in that package inherits it, unless explicitly overridden. This lets you turn on fine-grained debug output for one package in production without drowning in noise from your entire application — a trick that's saved countless late-night debugging sessions.
logback.xmlXML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- ─────────────────────────────────────────────
APPENDER: CONSOLEWrites formatted log lines to System.out.
Goodfor local dev and containerised apps
where stdout is captured by the platform.
───────────────────────────────────────────── -->
<appender name="CONSOLE"class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- Pattern breakdown:
%d{HH:mm:ss.SSS} — timestamp
[%thread] — thread name (vital for async apps)
%-5level — log level, left-padded to 5 chars
%logger{36} — logger name, truncated to 36 chars
- %msg%n — the actual message, then newline -->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- ─────────────────────────────────────────────
APPENDER: ROLLINGFILEWrites to a file. When the file hits 10MB,
it rolls over to a new file. Keeps30 days
of history and caps total size at 1GB so
your disk doesn't silently fill up.
───────────────────────────────────────────── -->
<appender name="ROLLING_FILE"class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- The active log file always has this name -->
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- Archive pattern: one file per day, gzip compressed -->
<fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<!-- Roll over when a single file reaches 10MB -->
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<!-- Keep30 days of rolled files -->
<maxHistory>30</maxHistory>
<!-- Hard cap on total log storage across all rolled files -->
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<!-- File logs include the full logger name for easier grepping -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<!-- ─────────────────────────────────────────────
PACKAGE-LEVELLOGGEROVERRIDEOnly log DEBUG and above for our own code.
This won't affect Hibernate, Spring, or any
other library — they stay at their own levels.
───────────────────────────────────────────── -->
<logger name="com.theforge" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ROLLING_FILE"/>
</logger>
<!-- ─────────────────────────────────────────────
ROOTLOGGERCatches everything not matched by a more
specific logger above. Set to WARN in prod
to suppress noisy INFO from libraries.
───────────────────────────────────────────── -->
<root level="WARN">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ROLLING_FILE"/>
</root>
</configuration>
Output
When the application starts, Logback prints:
logback.xml found on classpath.
Log output then flows to both console and logs/application.log simultaneously.
Why additivity="false" Matters
Without additivity="false" on your package logger, a log event bubbles up to the root logger too — and you see every message twice. Set additivity="false" on any logger that has its own appender-ref to prevent that double-logging.
Production Insight
I've seen production incidents where logs duplicated because the root logger was also attached to the same file appender.
Using additivity="false" is the simplest fix, but many developers miss it.
Rule: always set additivity="false" on every custom logger that defines appender-refs.
Key Takeaway
Logback's logger hierarchy is a tree — set levels on parent packages to control entire subtrees.
Never use additivity=true on a logger that directly writes to an appender.
Production must always have rolling policy; a single growing file will kill your disk.
Choosing Appender Configuration
IfNeed real-time tailing in development
→
UseConsoleAppender with short pattern (no date if not needed).
IfProduction deployment with 24x7 logging
→
UseRollingFileAppender with daily roll, size cap, and compression.
IfContainerised (Docker/K8s) stdout aggregation
→
UseConsoleAppender only — platform collects stdout.
Writing Logging Code That Actually Helps in Production
The way most developers log is wrong, and you'll only discover that when you're staring at useless log lines at midnight trying to diagnose a live incident. The three biggest practical mistakes are: using string concatenation instead of parameterised messages, logging at the wrong level, and missing contextual information that would make the log line self-contained.
SLF4J's parameterised logging — logger.debug("Order {} placed by user {}", orderId, userId) — isn't just stylistic. It's a performance optimisation. The string is only assembled if DEBUG is actually enabled. With concatenation, "Order " + orderId + " placed by user " + userId builds the string regardless of level — which inside a hot loop is expensive garbage creation for log lines that are never written.
Log level discipline matters too. Use TRACE for developer-only deep dives you'd never want in production. DEBUG for diagnostic info useful during development and targeted prod debugging. INFO for key business events — order placed, payment processed, user logged in. WARN for recoverable problems that need attention — a retry succeeded, a config value fell back to default. ERROR for failures that require immediate action. The rule of thumb: INFO logs should tell the story of a successful request; WARN and ERROR logs should make the on-call engineer's next steps obvious.
OrderService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.theforge.order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
/**
* Real-world service class showing correct SLF4J logging patterns.
* Notice the Logger is staticfinal — one instance per class, shared
* across all method calls. Creating a newLogger per method call is
* wasteful and unnecessary.
*/
public class OrderService {\n\n // Best practice: logger is static (one per class), final (never reassigned),\n // and named after the class itself for clear log output.\n private static final Logger logger = LoggerFactory.getLogger(OrderService.class);\n\n public Order placeOrder(String customerId, String productSku, int quantity) {\n\n // MDC — Mapped Diagnostic Context — attaches key-value pairs to EVERY\n // log line produced on this thread, automatically. This means if you\n // grep your logs for a customerId, you find ALL related log lines,\n // not just the ones where you remembered to include the id manually.\n MDC.put(\"customerId\", customerId);\n MDC.put(\"productSku\", productSku);\n\n try {\n // INFO: a meaningful business event that always matters\n logger.info(\"Placing order for {} units of SKU {}\", quantity, productSku);\n\n if (quantity > 1000) {\n // WARN: something unusual, but we're still handling it\n logger.warn(\"Large order quantity {} for SKU {} — triggering manual review flag\",\n quantity, productSku);\n }\n\n // DEBUG: internal state useful during development/diagnosis,\n // not noise in normal production operation\n logger.debug(\"Checking inventory for SKU {} — requested qty: {}\", productSku, quantity);\n\n boolean inventoryAvailable = checkInventory(productSku, quantity);\n\n if (!inventoryAvailable) {\n // WARN with context — not an error (expected scenario), but needs attention\n logger.warn(\"Insufficient inventory for SKU {} — requested: {}, available: {}\",\n productSku, quantity, getAvailableStock(productSku));\n throw new InsufficientStockException(productSku, quantity);\n }\n\n Order createdOrder = persistOrder(customerId, productSku, quantity);\n\n // Confirm success with the generated orderId — makes log lines self-contained\n logger.info(\"Order {} successfully created for customer {}\",\n createdOrder.getOrderId(), customerId);\n\n return createdOrder;\n\n } catch (DatabaseException dbEx) {\n // ERROR: include the exception as the LAST parameter so Logback\n // prints the full stack trace automatically. Don't call\n // dbEx.getMessage() yourself — you'll lose the stack trace.\n logger.error(\"Database failure while persisting order for customer {} SKU {}\",\n customerId, productSku, dbEx);\n throw new OrderProcessingException(\"Order persistence failed\", dbEx);\n\n } finally {\n // CRITICAL: always clear MDC at the end of the request/thread boundary.\n // In thread pool environments, threads are reused. If you don't clear,\n // the next request on this thread inherits your customerId in its logs.\n MDC.clear();\n }\n }\n\n // ── Stub methods to make the example compile ──────────────────────────────\n\n private boolean checkInventory(String sku, int qty) {\n return true; // simplified for example\n }\n\n private int getAvailableStock(String sku) {\n return 500; // simplified for example\n }\n\n private Order persistOrder(String customerId, String sku, int qty) {\n return new Order(\"ORD-20240115-7829\", customerId, sku, qty);\n }\n}","output": "14:23:01.442 [main] INFO c.t.order.OrderService - Placing order for 3 units of SKU WIDGET-42\n14:23:01.445 [main] DEBUG c.t.order.OrderService - Checking inventory for SKU WIDGET-42 — requested qty: 3\n14:23:01.451 [main] INFO c.t.order.OrderService - Order ORD-20240115-7829 successfully created for customer CUST-881\n\nNote: The MDC values (customerId, productSku) appear in each line when your\nlogback.xml pattern includes %X{customerId} and %X{productSku} in the encoder pattern."
}
Environment-Specific Configs and Testing Your Log Output
Hard-coding log levels in logback.xml means you need different XML files per environment, which is fragile. Logback supports property substitution, and when combined with Spring Boot's application.properties or plain system properties, you get one XML file that behaves differently in dev, staging, and production without any file duplication.
For testing, the most common pain is log output polluting test console output, or worse — not being able to assert that a specific log message was produced during a test. Logback ships with logback-test.xml, which it prefers over logback.xml when running tests. Put it in src/test/resources with <root level="OFF"/> to silence all logging during tests unless you explicitly opt back in. To assert log output in unit tests, the logback-classic module includes ListAppender, an in-memory appender you can wire up programmatically and inspect after the fact.
This pattern is underused and incredibly valuable. If your PaymentService is supposed to log a WARN when a card is declined, write a test that proves it does. That log message is part of your contract — the on-call engineer depends on it. Testing it like any other behaviour keeps it honest.
OrderServiceLoggingTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package com.theforge.order;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import java.util.List;
importstatic org.assertj.core.api.Assertions.assertThat;
/**
* Proves that OrderService emits the correct log events.
* Log messages are part of your observable behaviour — test them.
*/
classOrderServiceLoggingTest {
privateListAppender<ILoggingEvent> logCapture;
privateLogger orderServiceLogger;
privateOrderService orderService;
@BeforeEachvoidattachLogCapture() {
// Cast to Logback's concrete Logger (not SLF4J's interface)// so we can manipulate it programmatically in the test.
orderServiceLogger = (Logger) LoggerFactory.getLogger(OrderService.class);
// ListAppender stores every log event in an in-memory list.// Perfect for assertions — no file I/O, no console noise.
logCapture = newListAppender<>();
logCapture.start();
// Attach our capture appender to the service's logger
orderServiceLogger.addAppender(logCapture);
// Make sure DEBUG events reach us during the test
orderServiceLogger.setLevel(Level.DEBUG);
orderService = newOrderService();
}
@AfterEachvoiddetachLogCapture() {
// Always clean up — leaving a stale appender affects other tests
orderServiceLogger.detachAppender(logCapture);
}
@TestvoidshouldLogInfoWhenOrderIsSuccessfullyPlaced() {
orderService.placeOrder("CUST-881", "WIDGET-42", 3);
List<ILoggingEvent> capturedEvents = logCapture.list;
// Assert that at least one INFO message confirms the order was createdassertThat(capturedEvents)
.filteredOn(event -> event.getLevel() == Level.INFO)
.extracting(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("successfully created"));
}
@TestvoidshouldLogWarnForLargeOrderQuantity() {
// A quantity over 1000 should trigger a WARN in OrderService
orderService.placeOrder("CUST-002", "BULK-SKU-9", 1500);
List<ILoggingEvent> capturedEvents = logCapture.list;
assertThat(capturedEvents)
.filteredOn(event -> event.getLevel() == Level.WARN)
.extracting(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("Large order quantity"));
}
@TestvoiddebugMessagesShouldIncludeSkuInformation() {
orderService.placeOrder("CUST-333", "GADGET-7", 2);
assertThat(logCapture.list)
.filteredOn(event -> event.getLevel() == Level.DEBUG)
.extracting(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("GADGET-7"));
}
}
No log output appears on the console — all events are captured in-memory by ListAppender.
Tests pass in ~85ms.
Interview Gold: Testing Log Behaviour
Most candidates can configure logback.xml. Very few can explain how to assert log output in a unit test. Knowing ListAppender and being able to write the test above puts you in the top 10% of candidates interviewing for senior Java roles. It also shows you understand that observable behaviour includes more than return values.
Production Insight
In a microservice incident, the on-call engineer reads log patterns — not code — to decide next steps.
If a 'WARN: payment declined' pattern changes silently due to a code refactor, debugging takes hours.
Rule: write a test that asserts every critical log message stays exactly as documented in your runbook.
Key Takeaway
Use logback-test.xml to suppress logs in tests — not production config.
ListAppender is your tool for asserting log output in JUnit.
If it's in the runbook, test it.
When to Test Log Output
IfLog message is documented in runbook or alerting rule
→
UseMust test — it's part of the operational contract.
IfLog message contains dynamic business data (orderId, amount)
→
UseTest that the data appears correctly in the log line.
IfLog level is used in monitoring (e.g., ERROR count > threshold)
→
UseTest that the correct level is used for that scenario.
Async Logging with Logback — Prevent I/O Blocking in High-Throughput Paths
Synchronous file logging blocks the application thread while the log event is written to disk. In high-throughput scenarios — 10,000 requests per second — the cumulative I/O wait can add 30-50ms per request, collapsing throughput and increasing tail latency. Logback's AsyncAppender solves this by offloading log writes to a background thread, returning control to the application immediately.
Under the hood, AsyncAppender wraps a synchronous appender (like RollingFileAppender) and uses a bounded blocking queue. The application thread only enqueues the log event; the async worker thread drains the queue and delegates to the wrapped appender. If the queue is full — either because writes can't keep up or because a disk is slow — the default behaviour discards log events of TRACE, DEBUG, and INFO level, preserving WARN and ERROR. You can control this with the discardingThreshold parameter.
One critical trap: if the JVM crashes, log events still in the queue are lost. For transactions that require audit-trail guarantees, never use async logging alone — use a synchronous log with a dedicated, transaction-safe output (like a database). Async logging is for performance, not durability.
logback.xml (async snippet)XML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- Wrap the rolling file appender in an async appender -->
<appender name="ASYNC"class="ch.qos.logback.classic.AsyncAppender">
<!-- Maximum number of log events in the queue before discarding -->
<queueSize>512</queueSize>
<!-- DiscardTRACE/DEBUG/INFO when the queue exceeds thispercentage (default80) -->
<discardingThreshold>0</discardingThreshold>
<!-- Never block the application thread -- always prefer dropping events -->
<neverBlock>true</neverBlock>
<!-- The real appender that does I/O (rolling file) -->
<appender-ref ref="ROLLING_FILE"/>
</appender>
<!-- Then reference ASYNC instead of ROLLING_FILE in your logger config -->
<logger name="com.theforge" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC"/>
</logger>
Output
No visible difference in log output — the same messages appear, but application threads now spend ~0ms waiting for disk I/O. The async worker thread handles writes.
AsyncAppender Can Drop Messages on Overflow
By default, when the async queue is 80% full, AsyncAppender drops TRACE, DEBUG, and INFO events. If you need those levels in high load scenarios, set discardingThreshold="0" to keep everything — but accept that application threads may block if the queue fills completely. Test your throughput to size the queue appropriately.
Production Insight
A trading platform we consulted had a 50ms avg latency that was actually I/O wait — 40ms of it was synchronous log writes.
Switching to AsyncAppender dropped P99 latency from 200ms to 110ms.
But they lost some debug logs in burst spikes — acceptable for them, but we had to prove it.
Key Takeaway
AsyncAppender moves I/O off the critical thread — good for throughput and latency.
But async means potential log loss on crash; never use it for audit-grade logs.
Always size the queue and test under production load patterns.
Should You Use Async Logging?
IfApplication throughput > 500 TPS and logs to disk
→
UseUse AsyncAppender around your file appender.
IfApplication has latency SLAs under 50ms
→
UseAsyncAppender removes I/O jitter from your critical path.
IfYou cannot tolerate any log loss (audit trail)
→
UseAvoid async logging; use synchronous appender with dedicated log shipping.
● Production incidentPOST-MORTEMseverity: high
MDC Poisoning Caused a False Customer Escalation
Symptom
Logs showed customerId 'CUST-123' for every request on thread pool threads, even when processing requests for other customers. The on-call engineer searched logs for 'CUST-123' and found all related logs — but they pertained to a different user.
Assumption
The team assumed MDC values were request-scoped and would reset automatically. They didn't know threads are reused in Servlet containers and Spring Boot.
Root cause
The controller called MDC.put() at the start of each request but never called MDC.clear() in a finally block. The thread pool reused the thread for the next request, carrying the stale customerId forward.
Fix
Added a Servlet Filter that clears MDC after every request using a finally block, and educated the team to always pair MDC.put() with MDC.clear() in try-finally.
Key lesson
MDC is thread-local — threads in a pool retain it across requests.
Always clear MDC in a finally block or use a framework filter that does it for you.
Treat MDC as a resource that must be released, like a database connection.
Production debug guideSymptom → Action guide for the most common logging failures5 entries
Symptom · 01
No log output appears in console or file
→
Fix
Check if logback.xml is on the classpath. Verify root logger level is not set too high. Run grep -r logback startup.log to see config loading messages.
Symptom · 02
Duplicate log lines — every message appears twice
→
Fix
Your package logger likely has an appender-ref and also propagates to root. Set additivity="false" on the package logger.
Symptom · 03
MDC fields are missing from log lines
→
Fix
Ensure the encoder pattern includes %X{key} for keys you put. Also verify MDC.clear() isn't called before the log line is written — check placement.
Symptom · 04
Logs stop after a few hours without error
→
Fix
Rolling policy is missing or misconfigured. The log file grew beyond disk quota. Check df -h and verify the rolling appender's maxFileSize and totalSizeCap.
Symptom · 05
Application slogs to death — high CPU/GC
→
Fix
Switch all log statements to parameterised format. Run a profiler to confirm no string concatenation in hot paths. Consider using AsyncAppender.
★ Quick Debug Cheat Sheet: Logging FailuresFive symptoms, immediate commands, and the one fix that resolves each
No log output at all−
Immediate action
Check classpath for logback.xml
Commands
java -jar myapp.jar --debug | grep -i logback
ls -la target/classes/logback*.xml
Fix now
Place logback.xml in src/main/resources and rebuild.
Logs only at WARN+ despite setting DEBUG+
Immediate action
Verify which logback.xml is being picked up
Commands
grep 'level=' $(find . -name 'logback*.xml')
Check for multiple logback configs: find . -name '*logback*'
Fix now
Ensure only one logback.xml exists, with <root level="DEBUG">.
MDC values leaking across requests+
Immediate action
Search for MDC.put() without matching MDC.clear()
Commands
grep -r 'MDC.put' src/ -A2 -B2
grep -r 'MDC.clear' src/ -A1 -B1
Fix now
Wrap the request handling in try { MDC.put(...) } finally { MDC.clear(); }.
Server disk filling up fast+
Immediate action
Check log file size and rolling policy
Commands
ls -lh logs/application*
du -sh logs/
Fix now
Enable a rolling file appender with totalSizeCap=1GB and maxHistory=30.
Replace string concatenation with parameterised logging: logger.info("msg {}", var).
SLF4J + Logback vs java.util.logging (JUL)
Feature / Aspect
SLF4J + Logback
java.util.logging (JUL)
Setup complexity
Two jars + one XML file
Zero setup — built into JDK
Configuration format
logback.xml (flexible, powerful)
logging.properties (limited)
Performance
Async appenders, parameterised msgs, very fast
Synchronous, slower in high-throughput scenarios
Rolling file support
Built-in: size + time + compression
Requires custom Handler implementation
MDC (contextual data)
Built-in MDC with thread-local storage
Not supported natively
Log level granularity
TRACE, DEBUG, INFO, WARN, ERROR
FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE
Library ecosystem adoption
Dominant — Spring, Hibernate, most OSS uses SLF4J
Rarely used outside legacy JDK internals
Testing support
ListAppender, programmatic config
Custom Handler required — boilerplate-heavy
Conditional processing
Janino-based conditional config in XML
Not supported
Best for
Any non-trivial Java application
Quick scripts or environments with zero external dependencies
Key takeaways
1
SLF4J is only an API
it never writes a log line by itself. Logback is the implementation. Your code depending on SLF4J means it stays decoupled from the actual logging engine underneath.
2
Always use parameterised logging (logger.info("Order {}", orderId))
never string concatenation. String assembly is skipped entirely when the log level is inactive, which matters enormously in high-throughput loops.
3
MDC is your most powerful prod-debugging tool. Attach a requestId or customerId at the request boundary and it flows through every log line on that thread
including lines from libraries you don't control. Just always clear it in a finally block.
4
Log messages are observable behaviour
test them with ListAppender. If your on-call runbook says 'look for WARN: card declined in the logs', that message is as important as your return value. Treat it like one.
5
Async logging reduces P99 latency by removing I/O from the critical path, but can drop events on crash. Never use it for audit-grade logs.
Common mistakes to avoid
5 patterns
×
Using string concatenation in log statements
Symptom
In hot loops, logger.debug("Processing order " + orderId) builds the string even when DEBUG is disabled, creating garbage objects and wasting CPU. Profilers show high allocation rates in logging code.
Fix
Always use parameterised messages: logger.debug("Processing order {}", orderId). SLF4J only assembles the string when the log level is active.
×
Forgetting to clear the MDC
Symptom
In a thread pool (any servlet container or Spring app), threads are reused across requests. If you call MDC.put("userId", userId) at the start of a request and never call MDC.clear() in a finally block, the next request served by that thread inherits a stale userId in all its log lines. This silently poisons your logs.
Fix
Always pair MDC.put() with MDC.clear() in a finally block, or use a Servlet Filter that automatically cleans up after every request.
×
Logging the exception message separately instead of passing the exception as the last argument
Symptom
logger.error("DB failed: " + e.getMessage()) discards the entire stack trace. When the on-call engineer views the log, they see only a string — no stack frames, no root cause.
Fix
SLF4J detects when the last argument is a Throwable and automatically appends the full stack trace. Correct form: logger.error("DB failed for order {}", orderId, exception) — the exception goes last, no explicit stack trace printing needed.
×
Using the default logback.xml fallback instead of a custom configuration
Symptom
No log output appears during development, or all logs are WARN+ only. Developers waste time thinking their code isn't running.
Fix
Place a custom logback.xml in src/main/resources with root level set to DEBUG for local development. Use property substitution to keep one config file for all environments.
×
Not configuring rolling policies — letting the log file grow indefinitely
Symptom
After weeks of uptime, the server disk fills up. Applications crash with 'No space left on device'. Rolling is never configured because 'it worked fine on dev'.
Fix
Always configure RollingFileAppender with a size-based trigger (10MB) and a totalSizeCap (1GB). Set maxHistory to retain logs for a reasonable retention window.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Why does SLF4J use a facade pattern instead of being a full logging fram...
Q02SENIOR
What is the MDC and why must you always clear it at the end of a request...
Q03SENIOR
If you have logback-classic and slf4j-log4j12 both on your classpath, wh...
Q04JUNIOR
Explain the difference between additivity="true" (default) and additivit...
Q05SENIOR
How would you configure Logback to log JSON-formatted output for consump...
Q06SENIOR
What are the trade-offs between synchronous and asynchronous logging wit...
Q01 of 06SENIOR
Why does SLF4J use a facade pattern instead of being a full logging framework itself? What problem does this solve for library authors specifically?
ANSWER
SLF4J as a facade decouples the API from the implementation. Library authors can depend only on slf4j-api, leaving the consuming application to choose Logback, Log4j2, or JUL at deployment time. This avoids classpath conflicts and forced migration when the logging world evolves. Without this facade, a library that depends on Log4j would force all consumers to include Log4j, even if they prefer another framework.
Q02 of 06SENIOR
What is the MDC and why must you always clear it at the end of a request in a servlet container? What's the exact failure mode if you forget?
ANSWER
MDC (Mapped Diagnostic Context) is a thread-local map that attaches key-value pairs to every log event produced on that thread. In a servlet container (or any thread pool), threads are reused across requests. If you don't clear MDC in a finally block, the next request served by that thread sees the previous request's MDC values in its logs. This poisons log searches: searching for a customerId returns logs from other customers, leading to incorrect debugging and false escalations.
Q03 of 06SENIOR
If you have logback-classic and slf4j-log4j12 both on your classpath, what happens? How would you diagnose it and fix it in a Maven project?
ANSWER
SLF4J will detect multiple bindings at startup and print a warning to stderr, noting which binding was chosen arbitrarily (usually the first one found on the classpath). The log output may not match your configuration. To diagnose, run mvn dependency:tree | grep slf4j to see all SLF4J dependencies. Fix by excluding one binding: for example, if logback-classic is desired, exclude slf4j-log4j12 from any transitive dependency that pulls it in.
Q04 of 06JUNIOR
Explain the difference between additivity="true" (default) and additivity="false" in logback.xml. When would you use each?
ANSWER
Additivity controls whether a log event passed to a logger also propagates to ancestor loggers. With additivity="true" (default), an event logged on com.theforge.order also goes to com.theforge and then to the root logger, potentially being written to multiple appenders. Use additivity="true" when you want the root logger's appender to catch all events without needing to add it to every package logger. Use additivity="false" when you define an appender on a package logger and you don't want duplicates — set it to false to prevent the event from propagating to ancestors.
Q05 of 06SENIOR
How would you configure Logback to log JSON-formatted output for consumption by ELK or similar log aggregators?
ANSWER
Use Logback's ch.qos.logback.classic.net.LoggingEventCompositeJsonEncoder (from logback-contrib) or a custom Layout that outputs JSON. The standard approach is to add a dependency on logback-contrib or use net.logstash.logback.encoder.LogstashEncoder. Then configure an appender with an encoder class instead of a pattern. Example: <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>. The encoder automatically includes standard fields (timestamp, level, logger, message, thread) and can be configured to include MDC fields.
Q06 of 06SENIOR
What are the trade-offs between synchronous and asynchronous logging with Logback's AsyncAppender? When would you choose one over the other?
ANSWER
Synchronous logging blocks the application thread until the log event is written — simple and guarantees no loss on normal operations. AsyncAppender offloads I/O to a background thread, reducing latency impact on the application, but introduces a queue. Trade-offs: (1) On JVM crash, queued log events are lost. (2) Under extreme load, the queue may overflow and drop lower-level log events (configurable). (3) Async adds complexity in tuning queue size and discarding thresholds. Choose synchronous for audit-critical logs, choose async for high-throughput scenarios where 100% log completeness is less important than latency and throughput.
01
Why does SLF4J use a facade pattern instead of being a full logging framework itself? What problem does this solve for library authors specifically?
SENIOR
02
What is the MDC and why must you always clear it at the end of a request in a servlet container? What's the exact failure mode if you forget?
SENIOR
03
If you have logback-classic and slf4j-log4j12 both on your classpath, what happens? How would you diagnose it and fix it in a Maven project?
SENIOR
04
Explain the difference between additivity="true" (default) and additivity="false" in logback.xml. When would you use each?
JUNIOR
05
How would you configure Logback to log JSON-formatted output for consumption by ELK or similar log aggregators?
SENIOR
06
What are the trade-offs between synchronous and asynchronous logging with Logback's AsyncAppender? When would you choose one over the other?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
Do I need to add both slf4j-api and logback-classic to my pom.xml?
Yes, both. The slf4j-api jar is what your code compiles against — it contains only interfaces and no logging logic. The logback-classic jar is the runtime implementation and also provides the SLF4J binding. Without slf4j-api your code won't compile; without logback-classic nothing gets logged at runtime.
Was this helpful?
02
What happens if I don't have a logback.xml in my project?
Logback falls back to a default BasicConfigurator that logs WARN level and above to the console only, using a minimal pattern. You won't see any DEBUG or INFO output. You'll also see a warning printed to stderr on startup: 'No appenders could be found for logger'. Add a logback.xml to src/main/resources to take control of your configuration.
Was this helpful?
03
What's the difference between logger.error("msg", exception) and logger.error("msg: " + exception.getMessage())?
They're very different in practice. Passing the exception as the last Throwable argument tells SLF4J to print the complete stack trace automatically. Concatenating exception.getMessage() logs only the message string and throws away the entire stack trace and cause chain — making production debugging exponentially harder. Always pass the exception object as the final argument.
Was this helpful?
04
How do I include MDC values in my log pattern?
Use %X{keyName} in the encoder pattern. For example: <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{customerId}] - %msg%n</pattern>. This will output the value of MDC key 'customerId' in every log line. If the key is not set, it outputs empty brackets.
Was this helpful?
05
What is the recommended queue size for AsyncAppender in a high-traffic service?
Start with 1024. Monitor log loss (check for 'Dropped' in async appender metrics). If you see frequent drops, increase to 2048 or 4096. But consider whether you need those DEBUG/INFO events under load — sometimes dropping them is acceptable. For audit-critical logs, don't use async.
Was this helpful?
06
Can I use Logback's conditional configuration to toggle levels per environment?
Yes. Logback supports Janino-based conditions: <if condition='property("env").equals("production")'> then set root level to WARN, else set to DEBUG. Add the logback-contrib` dependency and 'janino' library to your classpath to use conditional processing in logback.xml.