Logging is crucial for debugging, monitoring, and maintaining Java applications. This guide covers all major Java logging frameworks, their configuration, and best practices.
1. Logging Framework Landscape
Major Logging Frameworks:
- java.util.logging (JUL): Built-in JDK logging
- Log4j 2: High-performance, modern logging
- Logback: Successor to Log4j 1.x, designed by same author
- SLF4J: Logging facade that abstracts underlying implementation
Framework Comparison:
| Framework | Performance | Configuration | Features | Adoption |
|---|---|---|---|---|
| Log4j 2 | ⭐⭐⭐⭐⭐ | XML/JSON/YAML | Rich | High |
| Logback | ⭐⭐⭐⭐ | XML/Groovy | Good | High |
| JUL | ⭐⭐ | Properties | Basic | Built-in |
| SLF4J | N/A | N/A | Facade | Universal |
2. SLF4J - The Logging Facade
SLF4J provides abstraction over various logging frameworks.
Maven Dependencies
<dependencies> <!-- SLF4J API --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.7</version> </dependency> <!-- Binding implementations (choose one) --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.7</version> </dependency> </dependencies>
Basic SLF4J Usage
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class Sfl4jExample {
// Recommended way: use class as logger name
private static final Logger logger = LoggerFactory.getLogger(Sfl4jExample.class);
public void processOrder(Order order) {
// Add contextual information
MDC.put("userId", order.getUserId());
MDC.put("orderId", order.getId());
try {
logger.info("Processing order: {}", order.getId());
if (logger.isDebugEnabled()) {
logger.debug("Order details: {}", order.toString());
}
// Business logic
validateOrder(order);
processPayment(order);
logger.info("Order processed successfully");
} catch (ValidationException e) {
logger.warn("Order validation failed: {}", e.getMessage());
} catch (PaymentException e) {
logger.error("Payment processing failed for order: {}", order.getId(), e);
} catch (Exception e) {
logger.error("Unexpected error processing order: {}", order.getId(), e);
} finally {
// Clear MDC
MDC.clear();
}
}
// Parameterized logging (avoid string concatenation)
public void parameterizedLogging() {
String user = "john";
int items = 5;
double total = 99.99;
// ✅ Good - efficient, no string building if level disabled
logger.debug("User {} purchased {} items for ${}", user, items, total);
// ❌ Bad - inefficient, string built even if DEBUG disabled
logger.debug("User " + user + " purchased " + items + " items for $" + total);
}
// Conditional logging
public void conditionalLogging() {
// Check log level before expensive operation
if (logger.isTraceEnabled()) {
String expensiveData = generateExpensiveDebugData();
logger.trace("Expensive data: {}", expensiveData);
}
}
}
class Order {
private String id;
private String userId;
// Constructors, getters, setters
public Order(String id, String userId) {
this.id = id;
this.userId = userId;
}
public String getId() { return id; }
public String getUserId() { return userId; }
@Override
public String toString() {
return String.format("Order{id='%s', userId='%s'}", id, userId);
}
}
class ValidationException extends Exception {
public ValidationException(String message) { super(message); }
}
class PaymentException extends Exception {
public PaymentException(String message) { super(message); }
}
3. Log4j 2 - High Performance Logging
Maven Dependencies
<dependencies> <!-- Log4j 2 API --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.20.0</version> </dependency> <!-- Log4j 2 Core --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.20.0</version> </dependency> <!-- SLF4J to Log4j 2 Bridge --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-slf4j2-impl</artifactId> <version>2.20.0</version> </dependency> </dependencies>
Log4j 2 Configuration (log4j2.xml)
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" monitorInterval="30">
<Properties>
<Property name="LOG_PATTERN">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
<Property name="APP_LOG_ROOT">./logs</Property>
</Properties>
<Appenders>
<!-- Console Appender -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${LOG_PATTERN}"/>
</Console>
<!-- File Appender -->
<File name="FileAppender" fileName="${APP_LOG_ROOT}/application.log">
<PatternLayout pattern="${LOG_PATTERN}"/>
</File>
<!-- Rolling File Appender -->
<RollingFile name="RollingFile"
fileName="${APP_LOG_ROOT}/application.log"
filePattern="${APP_LOG_ROOT}/application-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="${LOG_PATTERN}"/>
<Policies>
<TimeBasedTriggeringPolicy />
<SizeBasedTriggeringPolicy size="100 MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
<!-- Async Appender -->
<Async name="AsyncAppender" bufferSize="1000">
<AppenderRef ref="RollingFile"/>
</Async>
<!-- JSON Layout for Structured Logging -->
<File name="JsonFileAppender" fileName="${APP_LOG_ROOT}/application.json">
<JsonLayout compact="true" eventEol="true">
<KeyValuePair key="application" value="MyApp"/>
</JsonLayout>
</File>
</Appenders>
<Loggers>
<!-- Application-specific logger -->
<Logger name="com.myapp.service" level="DEBUG" additivity="false">
<AppenderRef ref="AsyncAppender"/>
<AppenderRef ref="Console"/>
</Logger>
<!-- Third-party library loggers -->
<Logger name="org.hibernate" level="WARN"/>
<Logger name="org.springframework" level="INFO"/>
<!-- Root logger -->
<Root level="INFO">
<AppenderRef ref="Console"/>
<AppenderRef ref="AsyncAppender"/>
</Root>
</Loggers>
</Configuration>
Log4j 2 Programmatic Configuration
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.ThreadContext;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.Configurator;
public class Log4j2Example {
private static final Logger logger = LogManager.getLogger(Log4j2Example.class);
public void demonstrateFeatures() {
// Basic logging
logger.info("Application started");
// Parameterized logging
String user = "alice";
int count = 42;
logger.debug("User {} has {} items", user, count);
// Structured logging with ThreadContext (MDC)
ThreadContext.put("userId", user);
ThreadContext.put("sessionId", "sess-12345");
logger.info("User action performed");
// Lazy logging for expensive operations
logger.trace("Expensive data: {}", () -> generateExpensiveData());
// Logging with throwable
try {
riskyOperation();
} catch (Exception e) {
logger.error("Operation failed", e);
}
// Clear ThreadContext
ThreadContext.clearAll();
}
private String generateExpensiveData() {
// Simulate expensive data generation
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "Expensive debug data";
}
private void riskyOperation() {
throw new RuntimeException("Simulated failure");
}
// Programmatic configuration
public static void setupLogging() {
LoggerContext context = (LoggerContext) LogManager.getContext(false);
Configuration config = context.getConfiguration();
// Dynamic log level changes
Configurator.setLevel("com.myapp.service", org.apache.logging.log4j.Level.DEBUG);
Configurator.setRootLevel(org.apache.logging.log4j.Level.INFO);
}
}
4. Logback - Robust Logging Implementation
Maven Dependencies
<dependencies> <!-- Logback Classic (includes SLF4J binding) --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.7</version> </dependency> <!-- Logback Core --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-core</artifactId> <version>1.4.7</version> </dependency> </dependencies>
Logback Configuration (logback.xml)
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<property name="LOG_HOME" value="./logs" />
<property name="APP_NAME" value="my-application" />
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" />
<!-- Console Appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%clr(%d{HH:mm:ss.SSS}){faint} %clr(%-5level) %clr(${APP_NAME}){magenta} %clr([%thread]){faint} %clr(%logger{36}){cyan} - %msg%n</pattern>
</encoder>
</appender>
<!-- File Appender -->
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_HOME}/${APP_NAME}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- Rolling File Appender -->
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/archived/${APP_NAME}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- Async Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1000</queueSize>
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="ROLLING_FILE" />
</appender>
<!-- Loggers -->
<logger name="com.myapp" level="DEBUG" />
<logger name="org.springframework" level="INFO" />
<logger name="org.hibernate" level="WARN" />
<!-- Root Logger -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="ASYNC" />
</root>
<!-- Separate logger for audit events -->
<logger name="AUDIT_LOGGER" level="INFO" additivity="false">
<appender name="AUDIT_FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_HOME}/audit.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} | %msg%n</pattern>
</encoder>
</appender>
</logger>
</configuration>
Logback Usage with SLF4J
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class LogbackExample {
private static final Logger logger = LoggerFactory.getLogger(LogbackExample.class);
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT_LOGGER");
public void processTransaction(Transaction transaction) {
// Set contextual information
MDC.put("transactionId", transaction.getId());
MDC.put("userId", transaction.getUserId());
MDC.put("amount", String.valueOf(transaction.getAmount()));
logger.info("Processing transaction: {}", transaction.getId());
try {
// Business logic
validateTransaction(transaction);
executeTransaction(transaction);
// Audit logging
auditLogger.info("TRANSACTION_SUCCESS|{}|{}|{}",
transaction.getId(), transaction.getUserId(), transaction.getAmount());
logger.info("Transaction completed successfully");
} catch (Exception e) {
logger.error("Transaction failed: {}", transaction.getId(), e);
// Audit logging for failure
auditLogger.error("TRANSACTION_FAILED|{}|{}|{}|{}",
transaction.getId(), transaction.getUserId(),
transaction.getAmount(), e.getMessage());
} finally {
MDC.clear();
}
}
// Custom log levels and markers
public void advancedLogging() {
// Conditional logging
if (logger.isDebugEnabled()) {
logger.debug("Detailed debug information: {}", getDetailedState());
}
// Performance logging
long startTime = System.currentTimeMillis();
expensiveOperation();
long duration = System.currentTimeMillis() - startTime;
if (duration > 1000) {
logger.warn("Slow operation took {} ms", duration);
}
}
private void expensiveOperation() {
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private String getDetailedState() {
return "Detailed application state";
}
private void validateTransaction(Transaction transaction) {
if (transaction.getAmount() <= 0) {
throw new IllegalArgumentException("Invalid transaction amount");
}
}
private void executeTransaction(Transaction transaction) {
// Transaction execution logic
}
}
class Transaction {
private String id;
private String userId;
private double amount;
public Transaction(String id, String userId, double amount) {
this.id = id;
this.userId = userId;
this.amount = amount;
}
// Getters
public String getId() { return id; }
public String getUserId() { return userId; }
public double getAmount() { return amount; }
}
5. java.util.logging (JUL) - Built-in Logging
JUL Configuration (logging.properties)
# Global logging level .level=INFO # Handlers handlers=java.util.logging.ConsoleHandler, java.util.logging.FileHandler # Console Handler java.util.logging.ConsoleHandler.level=INFO java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter # File Handler java.util.logging.FileHandler.level=INFO java.util.logging.FileHandler.pattern=./logs/java%u.log java.util.logging.FileHandler.limit=50000 java.util.logging.FileHandler.count=10 java.util.logging.FileHandler.formatter=java.util.logging.SimpleFormatter # Custom formatter for SimpleFormatter java.util.logging.SimpleFormatter.format=%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS %4$s %2$s - %5$s%6$s%n # Package-specific levels com.myapp.level=FINE org.springframework.level=WARNING
JUL Usage
import java.util.logging.Logger;
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.FileHandler;
import java.util.logging.SimpleFormatter;
public class JulExample {
private static final Logger logger = Logger.getLogger(JulExample.class.getName());
static {
setupLogging();
}
private static void setupLogging() {
try {
// Programmatic configuration
FileHandler fileHandler = new FileHandler("./logs/application.log", 50000, 10, true);
fileHandler.setFormatter(new SimpleFormatter());
logger.addHandler(fileHandler);
// Set custom level
logger.setLevel(Level.FINE);
} catch (Exception e) {
logger.log(Level.SEVERE, "Failed to setup logging", e);
}
}
public void demonstrateJul() {
// Different log levels
logger.severe("This is a severe message");
logger.warning("This is a warning message");
logger.info("This is an info message");
logger.config("This is a config message");
logger.fine("This is a fine message");
logger.finer("This is a finer message");
logger.finest("This is a finest message");
// Parameterized logging (Java 8+)
String user = "bob";
int count = 15;
logger.log(Level.INFO, "User {0} has {1} items", new Object[]{user, count});
// Logging with exception
try {
riskyOperation();
} catch (Exception e) {
logger.log(Level.SEVERE, "Operation failed", e);
}
// Conditional logging
if (logger.isLoggable(Level.FINE)) {
logger.fine("Expensive debug: " + expensiveDebugInfo());
}
}
private String expensiveDebugInfo() {
return "Generated debug information";
}
private void riskyOperation() {
throw new RuntimeException("Simulated error");
}
}
6. Structured Logging and Best Practices
Structured Logging with JSON
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.HashMap;
import java.util.Map;
public class StructuredLoggingExample {
private static final Logger logger = LoggerFactory.getLogger(StructuredLoggingExample.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
public void logStructuredEvent(Order order, String action, String status) {
Map<String, Object> logEvent = new HashMap<>();
logEvent.put("timestamp", System.currentTimeMillis());
logEvent.put("application", "order-service");
logEvent.put("action", action);
logEvent.put("status", status);
logEvent.put("orderId", order.getId());
logEvent.put("userId", order.getUserId());
logEvent.put("amount", order.getAmount());
try {
String jsonLog = objectMapper.writeValueAsString(logEvent);
logger.info("STRUCTURED_LOG: {}", jsonLog);
} catch (Exception e) {
logger.error("Failed to create structured log", e);
}
}
// Using MDC for structured logging
public void processWithMdc(Order order) {
MDC.put("orderId", order.getId());
MDC.put("userId", order.getUserId());
MDC.put("correlationId", generateCorrelationId());
try {
logger.info("Order processing started");
// Business logic
logger.info("Order processing completed");
} finally {
MDC.clear();
}
}
private String generateCorrelationId() {
return "corr-" + System.currentTimeMillis();
}
}
// Extended Order class
class Order {
private String id;
private String userId;
private double amount;
public Order(String id, String userId, double amount) {
this.id = id;
this.userId = userId;
this.amount = amount;
}
// Getters
public String getId() { return id; }
public String getUserId() { return userId; }
public double getAmount() { return amount; }
}
Logging Best Practices
public class LoggingBestPractices {
private static final Logger logger = LoggerFactory.getLogger(LoggingBestPractices.class);
// 1. Use appropriate log levels
public void properLogLevels() {
logger.error("System errors, require immediate attention");
logger.warn("Unexpected situations, but application continues");
logger.info("Normal business operations");
logger.debug("Detailed information for debugging");
logger.trace("Very detailed tracing information");
}
// 2. Include context in log messages
public void contextualLogging(String userId, String action) {
// ✅ Good - includes context
logger.info("User {} performed action {}", userId, action);
// ❌ Bad - no context
logger.info("Action performed");
}
// 3. Avoid expensive operations in log statements
public void efficientLogging(Order order) {
// ✅ Good - lazy evaluation
logger.debug("Order details: {}", () -> order.toDetailedString());
// ❌ Bad - expensive operation always executed
logger.debug("Order details: " + order.toDetailedString());
}
// 4. Use MDC for request-scoped information
public void processRequest(Request request) {
MDC.put("requestId", request.getId());
MDC.put("clientIp", request.getClientIp());
try {
logger.info("Request processing started");
// Process request
logger.info("Request processing completed");
} finally {
MDC.clear();
}
}
// 5. Log exceptions properly
public void exceptionHandling() {
try {
riskyOperation();
} catch (BusinessException e) {
// ✅ Good - log exception with context
logger.warn("Business rule violation for user {}: {}", getUserId(), e.getMessage());
} catch (TechnicalException e) {
// ✅ Good - log full stack trace for technical errors
logger.error("Technical error processing request {}", getRequestId(), e);
} catch (Exception e) {
// ✅ Good - unexpected errors with full context
logger.error("Unexpected error processing request {} for user {}",
getRequestId(), getUserId(), e);
}
}
// 6. Create separate loggers for different concerns
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT_LOGGER");
private static final Logger performanceLogger = LoggerFactory.getLogger("PERF_LOGGER");
public void specializedLogging() {
// Business audit trail
auditLogger.info("USER_LOGIN|{}|{}|success", getUserId(), getTimestamp());
// Performance metrics
long startTime = System.currentTimeMillis();
processData();
long duration = System.currentTimeMillis() - startTime;
performanceLogger.info("PROCESS_DURATION|{}|{}ms", getOperationName(), duration);
}
// Helper methods
private String getUserId() { return "user123"; }
private String getRequestId() { return "req456"; }
private String getTimestamp() { return String.valueOf(System.currentTimeMillis()); }
private String getOperationName() { return "dataProcessing"; }
private void processData() { /* implementation */ }
private void riskyOperation() { /* implementation */ }
}
class Request {
private String id;
private String clientIp;
public Request(String id, String clientIp) {
this.id = id;
this.clientIp = clientIp;
}
public String getId() { return id; }
public String getClientIp() { return clientIp; }
}
class BusinessException extends Exception {
public BusinessException(String message) { super(message); }
}
class TechnicalException extends Exception {
public TechnicalException(String message) { super(message); }
}
7. Performance Considerations
Async Logging Configuration
<!-- Log4j 2 Async Loggers -->
<Configuration>
<Appenders>
<File name="FileAppender" fileName="app.log">
<PatternLayout pattern="${LOG_PATTERN}"/>
</File>
</Appenders>
<Loggers>
<!-- Make specific loggers async -->
<AsyncLogger name="com.myapp.service" level="DEBUG" includeLocation="true">
<AppenderRef ref="FileAppender"/>
</AsyncLogger>
<!-- Or make all loggers async -->
<AsyncRoot level="INFO">
<AppenderRef ref="FileAppender"/>
</AsyncRoot>
</Loggers>
</Configuration>
Performance Tips
public class LoggingPerformance {
private static final Logger logger = LoggerFactory.getLogger(LoggingPerformance.class);
// 1. Use parameterized logging
public void efficientParameterizedLogging(String user, int count) {
// ✅ Good
logger.debug("User {} has {} items", user, count);
// ❌ Bad - string concatenation
logger.debug("User " + user + " has " + count + " items");
}
// 2. Use lazy evaluation for expensive operations
public void lazyEvaluation(Order order) {
// ✅ Good - only called if DEBUG enabled
logger.debug("Order details: {}", () -> order.toExpensiveString());
}
// 3. Check log level before expensive operations
public void levelChecking(Order order) {
if (logger.isDebugEnabled()) {
String expensiveData = order.generateExpensiveDebugData();
logger.debug("Debug data: {}", expensiveData);
}
}
// 4. Use async appenders in production
public void setupAsyncLogging() {
// Configure via configuration file, not code
}
}
8. Common Pitfalls and Solutions
public class LoggingPitfalls {
private static final Logger logger = LoggerFactory.getLogger(LoggingPitfalls.class);
// 1. Pitfall: Inconsistent log levels
public void inconsistentLevels() {
// ❌ Inconsistent
logger.info("Starting process");
logger.debug("Processing item"); // Should this be INFO or DEBUG?
// ✅ Consistent
logger.info("Starting order processing");
logger.debug("Processing order item {}", itemId);
}
// 2. Pitfall: Vague error messages
public void vagueErrors() {
try {
processData();
} catch (Exception e) {
// ❌ Vague
logger.error("Error occurred", e);
// ✅ Specific
logger.error("Failed to process data file {} for user {}",
fileName, userId, e);
}
}
// 3. Pitfall: Logging sensitive information
public void sensitiveData(User user) {
// ❌ Exposes sensitive data
logger.info("User logged in: {}", user.getPassword());
// ✅ Safe logging
logger.info("User {} logged in successfully", user.getUsername());
}
// 4. Pitfall: Not using MDC for correlation
public void withoutCorrelation(Request request) {
// ❌ No correlation
logger.info("Processing request");
// ✅ With correlation
MDC.put("requestId", request.getId());
logger.info("Processing request");
MDC.clear();
}
private void processData() { /* implementation */ }
}
class User {
private String username;
private String password;
public String getUsername() { return username; }
public String getPassword() { return password; }
}
Conclusion
Choosing the Right Framework:
- New Projects: Use Log4j 2 with SLF4J facade for best performance
- Spring Boot Projects: Use default Logback (good balance of features)
- Simple Applications: java.util.logging may suffice
- Library Development: Use SLF4J only to avoid forcing dependencies
Key Recommendations:
- Always use SLF4J as your logging API
- Configure async logging in production for better performance
- Use structured logging for better analysis and monitoring
- Implement MDC/ThreadContext for request correlation
- Follow consistent logging conventions across your application
- Monitor your logging performance and volume
By following these practices and choosing the appropriate framework for your needs, you can create effective, performant logging that greatly improves your application's maintainability and debuggability.