Many Java beginners rely on System.out.println() for quick debugging. While simple and intuitive, this approach causes severe issues in production environments:
- No Log-Level Control: Outputs everything at once without the ability to filter by severity (
ERROR,WARN,INFO,DEBUG). - No Persistence: Logs exist purely in the console. Once the application restarts, all output is lost.
- Poor Performance:
System.out.println()is a synchronous, blocking operation. High output volumes can significantly slow down your application thread. - Unformatted & Unarchived: Lacks unified formatting and offers no built-in log rotation (e.g., splitting logs daily or by size).
Note: A proper logging setup isn’t just a debugging tool — it is the lifeblood of production troubleshooting. Operating a live application without real logs is like navigating in complete darkness.
The Spring Boot Logging Architecture: SLF4J + Logback
Spring Boot includes SLF4J (the facade) and Logback (the implementation) out of the box, requiring zero additional dependencies.
What is SLF4J?
Think of SLF4J like JDBC: it provides a unified abstraction layer (API) that delegates the actual work to underlying frameworks like Logback or Log4j. By coding against SLF4J interfaces, you can swap logging implementations simply by updating your dependencies — without altering a single line of application code.
Mandatory Rule (Alibaba Java Development Manual): Applications must never directly invoke underlying logging framework APIs (e.g., Log4j, Logback). Always code against the SLF4J facade.
Correctly Using Logger in Code
1. Basic Usage Example
import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/user")public class UserController {
// 1. Declare logger as private static final using the class name private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@GetMapping("/{id}") public UserVO getUser(@PathVariable Long id) { // 2. Log using appropriate severity levels logger.debug("Debugging: Fetching user details, id={}", id); logger.info("Informational: Received query request, id={}", id); logger.warn("Warning: Missing non-critical data for user id={}", id); logger.error("Error: Failed to query user, id={}", id);
// 3. BEST PRACTICE: Use parameterized placeholders ({}), NOT string concatenation String name = "John"; String action = "Login"; logger.info("User {} performed action: {}", name, action);
// 4. ANTI-PATTERN: String concatenation (evaluates even if the log level is disabled) // logger.info("User " + name + " performed action: " + action);
return userService.getUser(id); }}2. Core Best Practices
- Declaration: Mark loggers as
private static finalto prevent redundant instances per class. - Parameterized Logging: Use
{}placeholders to leverage SLF4J’s lazy evaluation. This avoids unnecessary string construction overhead when log levels are disabled.
3. Log Level Selection Guide
| Level | Ideal Use Cases | Production Status |
|---|---|---|
| ERROR | System failures or exceptions requiring immediate human intervention. | Enabled |
| WARN | Potential runtime issues, fallback executions, or configuration anomalies. | Enabled |
| INFO | Key business milestones (e.g., user login, checkout, payment processing). | Enabled |
| DEBUG | Detailed operational context for development or troubleshooting. | Disabled |
| TRACE | Fine-grained step-by-step execution details. | Disabled |
Security Alert: Never enable DEBUG or TRACE globally in production. These levels can dump raw SQL arguments, request bodies, passwords, bearer tokens, or PII into log files.
Hands-On Logging Configuration
1. Basic application.yml Setup
logging: # Path to a custom Logback configuration (optional) config: classpath:logback-spring.xml # Configure severity per package level: com.example.demo.dao: DEBUG # Show SQL during local development org.springframework.web: INFO com.example.demo: INFO # Basic file output (for simple applications) file: name: logs/application.log max-size: 100MB max-history: 302. Enterprise logback-spring.xml Setup
Using logback-spring.xml over logback.xml allows you to leverage Spring’s profile-specific configuration and property interpolation. Place this file inside src/main/resources:
<?xml version="1.0" encoding="UTF-8"?><configuration scan="true" scanPeriod="60 seconds">
<!-- 1. Console Appender --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender>
<!-- 2. Rolling File Appender (Time & Size Based) --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/application.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxHistory>30</maxHistory> <totalSizeCap>3GB</totalSizeCap> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender>
<!-- 3. Dedicated Error File Appender --> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/error.log</file> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/error.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender>
<!-- 4. Profile-based Configuration --> <springProfile name="dev"> <root level="DEBUG"> <appender-ref ref="CONSOLE" /> </root> </springProfile>
<springProfile name="prod"> <root level="INFO"> <appender-ref ref="FILE" /> <appender-ref ref="ERROR_FILE" /> </root> <!-- Explicitly suppress verbose framework SQL logs in production --> <logger name="org.hibernate.SQL" level="INFO" /> <logger name="org.hibernate.type.descriptor.sql" level="INFO" /> </springProfile>
<!-- 5. Default Fallback --> <springProfile name="default"> <root level="INFO"> <appender-ref ref="CONSOLE" /> <appender-ref ref="FILE" /> </root> </springProfile>
</configuration>Modern Structured Logging (Spring Boot 3.4+)
Plain-text logs are easy for humans to read, but difficult for log aggregators (e.g., ELK, Datadog) to parse efficiently. Modern distributed systems rely on JSON-formatted structured logs.
Spring Boot 3.4+ introduces native structured logging configuration:
logging: structured: format: console: ecs # Elastic Common Schema format # console: logstash # Or Logstash format # console: gelf # Or GELF format file: name: logs/app.jsonOnce enabled, logs render as clean JSON events:
{ "@timestamp": "2026-08-18T10:30:00.123Z", "log.level": "INFO", "service.name": "my-service", "message": "User John logged in successfully", "userId": "12345"}Attaching Business Context
Method A: Using MDC (Mapped Diagnostic Context)
MDC binds context parameters to the current execution thread, ensuring every log statement emitted during that request inherits the context.
import org.slf4j.MDC;
@RestControllerpublic class OrderController {
@GetMapping("/order/{orderId}") public OrderVO getOrder(@PathVariable String orderId) { // Attach contextual keys to the thread MDC.put("orderId", orderId); MDC.put("userId", getCurrentUserId()); try { logger.info("Processing order fetch request"); return orderService.getOrder(orderId); } finally { // ALWAYS clear MDC in a finally block to prevent thread pool contamination MDC.clear(); } }}Add %X{orderId} to your Logback output pattern to automatically inject thread context:
%X{userId} - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%nMethod B: Fluent Logging API (Spring Boot 3.4+)
Add direct key-value pairs per log statement without populating global MDC state:
logger.atInfo() .setMessage("Order created successfully") .addKeyValue("orderId", order.getId()) .addKeyValue("amount", order.getAmount()) .log();Log Hygiene: Protecting Sensitive Data
Log files are prime targets during security audits. Strictly enforce these rules across your team:
| Data Category | Handling Strategy |
|---|---|
| Passwords, API Keys, Tokens | Never log. Mask entirely (e.g., ****). |
| PII (Phone, Email, Government IDs) | Anonymize or redact (e.g., 138****1234). |
| Raw Request/Response Objects | Log specific safe fields instead of dumping the whole instance. |
| Database Credentials / Secrets | Strictly forbidden in log outputs. |
Danger:
logger.info("Login payload: {}", request); // Danger: dumps raw password fieldSafe:
logger.info("Login attempt for user '{}' result: {}", username, isSuccess ? "SUCCESS" : "FAILED");Pro Tip: Configure custom Logback rewrite filters or masking layout policies to redact keywords (password, token, secret) globally at the appender pipeline level.
Enterprise Pattern: Centralizing Logs with AOP
Manually writing enter/exit log statements in every controller introduces boilerplate. Use Spring AOP to handle HTTP boundary logging cleanly across your services:
import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;
@Aspect@Componentpublic class WebLogAspect {
private static final Logger logger = LoggerFactory.getLogger(WebLogAspect.class);
// Intercept all controller endpoints @Around("execution(* com.example.demo.controller.*.*(..))") public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { long startTime = System.currentTimeMillis(); String methodName = joinPoint.getSignature().toShortString();
logger.info("[Request Start] Method: {}, Args: {}", methodName, joinPoint.getArgs()); try { Object result = joinPoint.proceed(); long executionTime = System.currentTimeMillis() - startTime; logger.info("[Request End] Method: {}, Elapsed: {}ms", methodName, executionTime); return result; } catch (Exception e) { long executionTime = System.currentTimeMillis() - startTime; logger.error("[Request Error] Method: {}, Elapsed: {}ms, Exception: {}", methodName, executionTime, e.getMessage(), e); throw e; } }}Production Readiness Checklist
Verify your logging implementation against these criteria before deploying to production:
- No
System.out.printlnore.printStackTrace(): Zero direct console prints in codebase. - Facade Usage: All statements go through
org.slf4j.Logger. - Static Logger Declaration: Loggers are declared as
private static final. - Parameterized Formatting: Use
{}placeholders instead of string concatenation (+). - Appropriate Severity Levels: Log levels accurately reflect event severity (
ERROR,WARN,INFO,DEBUG). - Configured Retention:
logback-spring.xmlenforces log file rolling and size limits. - Production Suppression:
DEBUGlevels are explicitly disabled in production profiles. - Data Redaction: Sensitive details (passwords, tokens, PII) are properly masked.
- (Optional) AOP Integration: Request boundaries are automatically logged without boilerplate.
- (Optional) Structured Logs: Spring Boot 3.4+ JSON logging enabled for automated parsing.
Final Thoughts
Moving from System.out.println to a structured logging system is more than just a code improvement — it marks the transition from classroom projects to production-grade engineering. Treat your log design with care: when things go wrong in live environments, your logs are often your only source of truth.