Welcome to Chapter 13 of our journey to build production-ready Java applications! In this chapter, we’ll address two critical aspects of any robust software system: configuration management and structured logging. As applications grow in complexity and move through different environments (development, testing, production), hardcoding settings becomes a nightmare. Similarly, traditional unstructured logs are difficult to parse, analyze, and use for effective monitoring and debugging.
This chapter will guide you through externalizing application settings into a dedicated configuration file and demonstrating how to override them with environment variables, a common practice in production deployments. We’ll also upgrade our logging infrastructure from simple System.out.println statements to a powerful, industry-standard solution: SLF4J with Logback, configured to output machine-readable, structured (JSON) logs. By the end of this chapter, our application will be more flexible, easier to manage across environments, and provide invaluable insights through rich, structured logs.
As a prerequisite, you should have a working version of the “Number Guessing Game” or any other simple application from previous chapters that we can use as a base for applying these new concepts. We’ll specifically be enhancing the Number Guessing Game to demonstrate configuration and logging.
Planning & Design
Before we dive into the code, let’s outline our design choices for configuration and logging.
Configuration Management Design
For configuration, we’ll adopt a tiered approach:
- Default Properties File: We’ll use a standard
application.propertiesfile located insrc/main/resources. This file will hold all default configuration values for our application. - Configuration Manager: A dedicated
ConfigurationManagerclass will be responsible for loading these properties. It will provide a centralized point to access configuration values throughout the application. - Environment Variable Overrides: We will design our
ConfigurationManagerto prioritize environment variables over values found in theapplication.propertiesfile. This is crucial for production deployments where sensitive information (like database credentials) or environment-specific settings (like API endpoints) should not be hardcoded or committed to version control.
Structured Logging Design
For logging, we’ll leverage the power of the SLF4J (Simple Logging Facade for Java) API coupled with Logback, its highly performant and flexible implementation.
- SLF4J API: We’ll use SLF4J as the abstraction layer, allowing us to switch logging implementations (e.g., from Logback to Log4j2) easily in the future if needed, without changing our application code.
- Logback Implementation: Logback will handle the actual logging. It’s known for its speed and advanced features.
logback.xmlConfiguration: Logback’s behavior will be controlled by an XML configuration file (logback.xml) insrc/main/resources.- Appenders: We’ll configure two appenders:
- Console Appender: For real-time feedback during development.
- File Appender: To persist logs to a file, essential for production monitoring and post-mortem analysis.
- Structured JSON Output: Both appenders will be configured to output logs in JSON format using the
logstash-logback-encoderlibrary. This makes logs machine-readable and easily ingestible by centralized logging systems like ELK Stack, Splunk, or Grafana Loki. - Log Levels: We’ll utilize standard log levels (DEBUG, INFO, WARN, ERROR) to categorize messages and control verbosity.
Step-by-Step Implementation
We will apply these concepts to our NumberGuessingGame.
Feature 1: Configuration Management
First, let’s set up our configuration file and a manager to handle it.
a) Setup/Configuration
We’ll start by creating our application.properties file and adding the necessary Maven dependency for resource handling.
1. Create application.properties:
Create a new file at src/main/resources/application.properties. This file will contain our default game settings.
# src/main/resources/application.properties
# Default configuration for the Number Guessing Game
game.numberguessing.minNumber=1
game.numberguessing.maxNumber=100
game.numberguessing.maxAttempts=7
Explanation:
- We’ve defined three properties:
minNumber,maxNumber, andmaxAttemptsfor our Number Guessing Game. - The
game.numberguessingprefix helps to scope these properties to a specific part of our application, preventing naming conflicts as the project grows.
2. Update pom.xml for Resource Management:
Ensure your pom.xml properly includes src/main/resources so that Maven copies application.properties to the classpath when building the JAR. This is usually handled by default, but it’s good practice to explicitly define the maven-resources-plugin if you have custom resource filtering needs. For this simple case, the default setup is usually sufficient, but we’ll ensure the build section is correctly structured.
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.app</groupId>
<artifactId>simple-java-projects</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source> <!-- Targeting Java 25, but 21 is LTS and widely used for modern projects, compatible with 25 -->
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- No new dependencies for properties loading yet -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<!-- Ensure resources are copied -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>com.example.app.numberguessing.NumberGuessingGame</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Explanation:
- We’ve set
maven.compiler.sourceandmaven.compiler.targetto21. While Java 25 is the latest, for stability and widespread adoption in a tutorial, using the current LTS version (Java 21) is a common and practical choice, as it’s fully compatible with newer JDKs. You can easily switch to 25 if your environment supports it, but 21 ensures broader compatibility. - The
maven-resources-pluginis explicitly included for clarity, though its default behavior often coverssrc/main/resources.
b) Core Implementation
Now, let’s create our ConfigurationManager class to load these properties.
1. Create ConfigurationManager.java:
// src/main/java/com/example/app/config/ConfigurationManager.java
package com.example.app.config;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import java.util.Optional;
/**
* Manages application configuration by loading properties from a file
* and allowing overrides via environment variables.
* This class uses the Singleton pattern to ensure a single, globally accessible
* instance of the configuration.
*/
public class ConfigurationManager {
private static final String CONFIG_FILE_NAME = "application.properties";
private static Properties properties;
private static ConfigurationManager instance; // Singleton instance
// Private constructor to prevent direct instantiation
private ConfigurationManager() {
properties = new Properties();
try (InputStream input = getClass().getClassLoader().getResourceAsStream(CONFIG_FILE_NAME)) {
if (input == null) {
System.err.println("Sorry, unable to find " + CONFIG_FILE_NAME + ". Using default/empty properties.");
} else {
properties.load(input);
}
} catch (IOException ex) {
System.err.println("Error loading properties from " + CONFIG_FILE_NAME + ": " + ex.getMessage());
// In a production app, you might want to throw a RuntimeException here
// or use a proper logging framework (which we'll add shortly!)
}
}
/**
* Returns the singleton instance of ConfigurationManager.
* Initializes the manager if it hasn't been initialized yet.
*
* @return The ConfigurationManager instance.
*/
public static synchronized ConfigurationManager getInstance() {
if (instance == null) {
instance = new ConfigurationManager();
}
return instance;
}
/**
* Retrieves a string property value.
* Prioritizes environment variables over properties file values.
*
* @param key The key of the property.
* @return The property value as a String, or null if not found.
*/
public String getString(String key) {
// Check environment variable first (converted to uppercase with underscores)
String envKey = key.toUpperCase().replace('.', '_');
String envValue = System.getenv(envKey);
if (envValue != null && !envValue.isEmpty()) {
return envValue;
}
// Fallback to properties file
return properties.getProperty(key);
}
/**
* Retrieves an integer property value.
*
* @param key The key of the property.
* @param defaultValue The default value to return if the property is not found or invalid.
* @return The property value as an int, or the defaultValue if not found or invalid.
*/
public int getInt(String key, int defaultValue) {
return Optional.ofNullable(getString(key))
.map(value -> {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
System.err.println("Warning: Invalid integer format for property '" + key + "': " + value + ". Using default: " + defaultValue);
return defaultValue;
}
})
.orElse(defaultValue);
}
/**
* Retrieves a boolean property value.
*
* @param key The key of the property.
* @param defaultValue The default value to return if the property is not found or invalid.
* @return The property value as a boolean, or the defaultValue if not found or invalid.
*/
public boolean getBoolean(String key, boolean defaultValue) {
return Optional.ofNullable(getString(key))
.map(Boolean::parseBoolean)
.orElse(defaultValue);
}
}
Explanation:
- Singleton Pattern:
ConfigurationManageruses a singleton pattern (getInstance()) to ensure that the properties file is loaded only once and that all parts of the application access the same configuration. - Constructor: The private constructor loads
application.propertiesfrom the classpath usinggetClass().getClassLoader().getResourceAsStream(). This is a standard way to access resources bundled with your JAR. - Error Handling: Basic
System.err.printlnis used for errors during property loading. We’ll replace this with proper logging soon. getString(String key): This method is the core of our configuration logic. It first checks for an environment variable with a transformed key (e.g.,game.numberguessing.maxAttemptsbecomesGAME_NUMBERGUESSING_MAXATTEMPTS). If an environment variable is present, its value is returned, overriding any value inapplication.properties. Otherwise, it falls back to the value from the loadedpropertiesobject.getInt(String key, int defaultValue)/getBoolean(String key, boolean defaultValue): Helper methods to retrieve properties as specific types, providing default values and basic error handling for parsing.Optionalis used for cleaner null handling.
2. Modify NumberGuessingGame.java to use ConfigurationManager:
Now, let’s update our game to fetch its settings from the ConfigurationManager.
// src/main/java/com/example/app/numberguessing/NumberGuessingGame.java
package com.example.app.numberguessing;
import com.example.app.config.ConfigurationManager; // Import our new config manager
import java.util.Random;
import java.util.Scanner;
public class NumberGuessingGame {
private final int minNumber;
private final int maxNumber;
private final int maxAttempts;
public NumberGuessingGame() {
// Get the singleton instance of ConfigurationManager
ConfigurationManager config = ConfigurationManager.getInstance();
// Load configuration values, providing sensible defaults if not found
this.minNumber = config.getInt("game.numberguessing.minNumber", 1);
this.maxNumber = config.getInt("game.numberguessing.maxNumber", 100);
this.maxAttempts = config.getInt("game.numberguessing.maxAttempts", 7);
// Basic validation for configuration
if (this.minNumber >= this.maxNumber) {
System.err.println("Configuration Error: minNumber must be less than maxNumber. Using defaults.");
// Fallback to hardcoded defaults or throw an exception
// For now, we'll just log and proceed, but in a real app,
// this might be a fatal error
this.minNumber = 1;
this.maxNumber = 100;
}
if (this.maxAttempts <= 0) {
System.err.println("Configuration Error: maxAttempts must be greater than 0. Using default.");
this.maxAttempts = 7;
}
}
public void startGame() {
Scanner scanner = new Scanner(System.in);
Random random = new Random();
int targetNumber = random.nextInt(maxNumber - minNumber + 1) + minNumber;
int attempts = 0;
boolean hasGuessedCorrectly = false;
System.out.println("Welcome to the Number Guessing Game!");
System.out.printf("I'm thinking of a number between %d and %d. You have %d attempts.%n", minNumber, maxNumber, maxAttempts);
while (attempts < maxAttempts) {
System.out.printf("Attempt %d/%d: Enter your guess: ", attempts + 1, maxAttempts);
if (!scanner.hasNextInt()) {
System.out.println("Invalid input. Please enter a number.");
scanner.next(); // Consume the invalid input
continue;
}
int guess = scanner.nextInt();
attempts++;
if (guess < minNumber || guess > maxNumber) {
System.out.printf("Your guess %d is out of the valid range (%d-%d). Try again.%n", guess, minNumber, maxNumber);
// We don't increment attempts for out-of-range guesses in this simplified version,
// but a more strict game might. For now, we count it as an attempt.
} else if (guess < targetNumber) {
System.out.println("Too low! Try again.");
} else if (guess > targetNumber) {
System.out.println("Too high! Try again.");
} else {
System.out.printf("Congratulations! You guessed the number %d in %d attempts!%n", targetNumber, attempts);
hasGuessedCorrectly = true;
break;
}
}
if (!hasGuessedCorrectly) {
System.out.printf("Sorry, you've used all %d attempts. The number was %d.%n", maxAttempts, targetNumber);
}
scanner.close(); // Close the scanner to prevent resource leaks
}
public static void main(String[] args) {
NumberGuessingGame game = new NumberGuessingGame();
game.startGame();
}
}
Explanation:
- The
NumberGuessingGameconstructor now retrievesminNumber,maxNumber, andmaxAttemptsfromConfigurationManager.getInstance(). - Sensible default values (e.g.,
1,100,7) are provided toconfig.getInt()in case a property is missing or malformed inapplication.propertiesor environment variables. - Basic validation is added to ensure that
minNumberis less thanmaxNumberandmaxAttemptsis positive.
c) Testing This Component
Let’s test our configuration management.
Run the game with default settings: Compile and run the game from your project root:
mvn clean install java -jar target/simple-java-projects-1.0-SNAPSHOT.jarVerify that the game starts with the range 1-100 and 7 attempts, as specified in
application.properties.Modify
application.properties: Change themaxAttemptsinsrc/main/resources/application.propertiesto5.# src/main/resources/application.properties game.numberguessing.minNumber=1 game.numberguessing.maxNumber=100 game.numberguessing.maxAttempts=5Recompile and run the game:
mvn clean install java -jar target/simple-java-projects-1.0-SNAPSHOT.jarThe game should now indicate you have 5 attempts.
Override with Environment Variable: Now, let’s test the environment variable override. Set the
GAME_NUMBERGUESSING_MAXATTEMPTSenvironment variable before running the game.- Linux/macOS:
export GAME_NUMBERGUESSING_MAXATTEMPTS=3 java -jar target/simple-java-projects-1.0-SNAPSHOT.jar unset GAME_NUMBERGUESSING_MAXATTEMPTS # Clean up - Windows (Command Prompt):
set GAME_NUMBERGUESSING_MAXATTEMPTS=3 java -jar target\simple-java-projects-1.0-SNAPSHOT.jar set GAME_NUMBERGUESSING_MAXATTEMPTS= - Windows (PowerShell):
$env:GAME_NUMBERGUESSING_MAXATTEMPTS="3" java -jar target\simple-java-projects-1.0-SNAPSHOT.jar Remove-Item Env:GAME_NUMBERGUESSING_MAXATTEMPTS
The game should now indicate you have 3 attempts, overriding both the default and the
application.propertiesvalue. This confirms our environment variable override is working.- Linux/macOS:
Feature 2: Structured Logging with SLF4J & Logback
Now that our configuration is robust, let’s enhance our application’s observability with structured logging.
a) Setup/Configuration
We’ll add the necessary logging dependencies to our pom.xml and create the logback.xml configuration file.
1. Update pom.xml with Logging Dependencies:
Add the following dependencies to the <dependencies> section of your pom.xml.
<!-- pom.xml -->
<dependencies>
<!-- SLF4J API -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.11</version> <!-- Latest stable as of Dec 2025 (or recent stable) -->
</dependency>
<!-- Logback Classic (includes Logback Core) -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.14</version> <!-- Latest stable as of Dec 2025 (or recent stable) -->
</dependency>
<!-- Logstash Logback Encoder for JSON output -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version> <!-- Latest stable as of Dec 2025 (or recent stable) -->
</dependency>
</dependencies>
Explanation:
slf4j-api: This is the logging facade. Our application code will only interact with this API, not directly with Logback.logback-classic: This is the Logback implementation. It automatically includeslogback-core.logstash-logback-encoder: This library provides encoders to format logs into JSON, which is ideal for structured logging and integration with tools like Logstash.
2. Create logback.xml:
Create a new file at src/main/resources/logback.xml. This file configures Logback.
<!-- src/main/resources/logback.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- ConfigurationManager will use this logger to report loading issues -->
<logger name="com.example.app.config.ConfigurationManager" level="INFO" />
<!-- Console Appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<fieldNames>
<timestamp>timestamp</timestamp>
<level>level</level>
<thread>thread</thread>
<logger>logger</logger>
<message>message</message>
<stackTrace>stacktrace</stackTrace>
</fieldNames>
<!-- Add custom fields for application context -->
<customFields>{"application":"simple-java-projects", "service":"number-guessing-game"}</customFields>
</encoder>
</appender>
<!-- Rolling File Appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- Daily rollover -->
<fileNamePattern>logs/application-%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- Keep 30 days of history -->
<maxHistory>30</maxHistory>
<!-- Max file size 1GB -->
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<fieldNames>
<timestamp>timestamp</timestamp>
<level>level</level>
<thread>thread</thread>
<logger>logger</logger>
<message>message</message>
<stackTrace>stacktrace</stackTrace>
</fieldNames>
<!-- Add custom fields for application context -->
<customFields>{"application":"simple-java-projects", "service":"number-guessing-game"}</customFields>
</encoder>
</appender>
<!-- Root Logger Configuration -->
<root level="INFO"> <!-- Default logging level for all loggers -->
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<!-- Specific logger for development/debugging.
Can be set to DEBUG locally without affecting production INFO level. -->
<logger name="com.example.app" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>
</configuration>
Explanation:
<configuration scan="true" scanPeriod="30 seconds">: Logback will scan this file every 30 seconds for changes and reconfigure itself without needing an application restart. Useful for dynamic log level adjustments.<logger name="com.example.app.config.ConfigurationManager" level="INFO" />: Sets a specific log level for ourConfigurationManager.CONSOLEAppender: Outputs logs to the standard console.LogstashEncoder: Formats logs into JSON.fieldNames: Maps standard Logback fields to custom JSON field names for consistency (e.g.,levelinstead oflevel_string).customFields: Allows adding static fields to every log entry (e.g., application name, service name). This is incredibly useful for filtering and identifying logs in a centralized system.
FILEAppender: Outputs logs tologs/application.log.RollingFileAppender: Automatically rolls over log files based on time (TimeBasedRollingPolicy) and/or size.fileNamePattern: Defines how rolled-over files are named (daily in this case).maxHistory: Keeps logs for the last 30 days.totalSizeCap: Limits the total size of all archived log files.- Uses the same
LogstashEncoderfor consistent JSON output.
<root level="INFO">: This is the default logger. Any logger not explicitly configured will inherit from this. We set it toINFOfor production, meaningDEBUGlogs won’t be shown by default.<logger name="com.example.app" level="DEBUG" additivity="false">: This logger specifically targets our application’s packages. By settinglevel="DEBUG", we can see more verbose logs during development.additivity="false"prevents logs from being sent to the root logger’s appenders again, avoiding duplicate entries.
b) Core Implementation
Now, let’s integrate SLF4J into our ConfigurationManager and NumberGuessingGame.
1. Update ConfigurationManager.java to use SLF4J:
Replace System.err.println with proper logging.
// src/main/java/com/example/app/config/ConfigurationManager.java
package com.example.app.config;
import org.slf4j.Logger; // Import SLF4J Logger
import org.slf4j.LoggerFactory; // Import SLF4J LoggerFactory
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import java.util.Optional;
/**
* Manages application configuration by loading properties from a file
* and allowing overrides via environment variables.
* This class uses the Singleton pattern to ensure a single, globally accessible
* instance of the configuration.
*/
public class ConfigurationManager {
private static final Logger logger = LoggerFactory.getLogger(ConfigurationManager.class); // Initialize logger
private static final String CONFIG_FILE_NAME = "application.properties";
private static Properties properties;
private static ConfigurationManager instance; // Singleton instance
// Private constructor to prevent direct instantiation
private ConfigurationManager() {
properties = new Properties();
try (InputStream input = getClass().getClassLoader().getResourceAsStream(CONFIG_FILE_NAME)) {
if (input == null) {
logger.warn("Configuration file '{}' not found. Using default/empty properties.", CONFIG_FILE_NAME);
} else {
properties.load(input);
logger.info("Configuration loaded from '{}'.", CONFIG_FILE_NAME);
}
} catch (IOException ex) {
logger.error("Error loading properties from '{}': {}", CONFIG_FILE_NAME, ex.getMessage(), ex);
// In a production app, you might want to throw a RuntimeException here
// to prevent the application from starting with incomplete configuration.
throw new RuntimeException("Failed to load application configuration.", ex);
}
}
/**
* Returns the singleton instance of ConfigurationManager.
* Initializes the manager if it hasn't been initialized yet.
*
* @return The ConfigurationManager instance.
*/
public static synchronized ConfigurationManager getInstance() {
if (instance == null) {
instance = new ConfigurationManager();
}
return instance;
}
/**
* Retrieves a string property value.
* Prioritizes environment variables over properties file values.
*
* @param key The key of the property.
* @return The property value as a String, or null if not found.
*/
public String getString(String key) {
// Check environment variable first (converted to uppercase with underscores)
String envKey = key.toUpperCase().replace('.', '_');
String envValue = System.getenv(envKey);
if (envValue != null && !envValue.isEmpty()) {
logger.debug("Config: Key '{}' resolved from environment variable '{}' with value '{}'.", key, envKey, envValue);
return envValue;
}
// Fallback to properties file
String propValue = properties.getProperty(key);
if (propValue != null) {
logger.debug("Config: Key '{}' resolved from properties file with value '{}'.", key, propValue);
} else {
logger.debug("Config: Key '{}' not found in environment or properties file.", key);
}
return propValue;
}
/**
* Retrieves an integer property value.
*
* @param key The key of the property.
* @param defaultValue The default value to return if the property is not found or invalid.
* @return The property value as an int, or the defaultValue if not found or invalid.
*/
public int getInt(String key, int defaultValue) {
return Optional.ofNullable(getString(key))
.map(value -> {
try {
int parsedValue = Integer.parseInt(value);
logger.debug("Config: Parsed integer for '{}' as {}.", key, parsedValue);
return parsedValue;
} catch (NumberFormatException e) {
logger.warn("Invalid integer format for property '{}': '{}'. Using default: {}.", key, value, defaultValue);
return defaultValue;
}
})
.orElseGet(() -> {
logger.debug("Config: Key '{}' not found, using default integer value: {}.", key, defaultValue);
return defaultValue;
});
}
/**
* Retrieves a boolean property value.
*
* @param key The key of the property.
* @param defaultValue The default value to return if the property is not found or invalid.
* @return The property value as a boolean, or the defaultValue if not found or invalid.
*/
public boolean getBoolean(String key, boolean defaultValue) {
return Optional.ofNullable(getString(key))
.map(value -> {
boolean parsedValue = Boolean.parseBoolean(value);
logger.debug("Config: Parsed boolean for '{}' as {}.", key, parsedValue);
return parsedValue;
})
.orElseGet(() -> {
logger.debug("Config: Key '{}' not found, using default boolean value: {}.", key, defaultValue);
return defaultValue;
});
}
}
Explanation:
- We now use
LoggerFactory.getLogger(ConfigurationManager.class)to get a logger instance. System.err.printlncalls are replaced withlogger.warn(),logger.error(), andlogger.debug()calls. This allows us to control the verbosity and format of these messages vialogback.xml.- An
RuntimeExceptionis thrown if configuration loading fails, as this is a critical startup error for most applications. logger.debugstatements are added to trace how configuration values are resolved, which is invaluable during development and debugging.
2. Modify NumberGuessingGame.java to use SLF4J:
Now, let’s replace all System.out.println statements in our game with proper logger calls.
// src/main/java/com/example/app/numberguessing/NumberGuessingGame.java
package com.example.app.numberguessing;
import com.example.app.config.ConfigurationManager;
import org.slf4j.Logger; // Import SLF4J Logger
import org.slf4j.LoggerFactory; // Import SLF4J LoggerFactory
import java.util.InputMismatchException; // Specific exception for scanner
import java.util.Random;
import java.util.Scanner;
public class NumberGuessingGame {
private static final Logger logger = LoggerFactory.getLogger(NumberGuessingGame.class); // Initialize logger
private final int minNumber;
private final int maxNumber;
private final int maxAttempts;
public NumberGuessingGame() {
logger.info("Initializing NumberGuessingGame...");
ConfigurationManager config = ConfigurationManager.getInstance();
this.minNumber = config.getInt("game.numberguessing.minNumber", 1);
this.maxNumber = config.getInt("game.numberguessing.maxNumber", 100);
this.maxAttempts = config.getInt("game.numberguessing.maxAttempts", 7);
// Basic validation for configuration
if (this.minNumber >= this.maxNumber) {
logger.error("Configuration Error: minNumber ({}) must be less than maxNumber ({}). Using hardcoded defaults.", this.minNumber, this.maxNumber);
this.minNumber = 1;
this.maxNumber = 100;
}
if (this.maxAttempts <= 0) {
logger.error("Configuration Error: maxAttempts ({}) must be greater than 0. Using hardcoded default.", this.maxAttempts);
this.maxAttempts = 7;
}
logger.info("Game initialized with settings: minNumber={}, maxNumber={}, maxAttempts={}",
minNumber, maxNumber, maxAttempts);
}
public void startGame() {
Scanner scanner = new Scanner(System.in);
Random random = new Random();
int targetNumber = random.nextInt(maxNumber - minNumber + 1) + minNumber;
int attempts = 0;
boolean hasGuessedCorrectly = false;
logger.info("Game started. Target number generated between {} and {}.", minNumber, maxNumber);
logger.info("Welcome to the Number Guessing Game!");
logger.info("I'm thinking of a number between {} and {}. You have {} attempts.", minNumber, maxNumber, maxAttempts);
while (attempts < maxAttempts) {
logger.debug("Current attempt: {}/{}", attempts + 1, maxAttempts);
System.out.printf("Attempt %d/%d: Enter your guess: ", attempts + 1, maxAttempts); // User prompt still uses System.out
int guess;
try {
guess = scanner.nextInt();
} catch (InputMismatchException e) {
logger.warn("Invalid input received. User entered non-integer value.");
System.out.println("Invalid input. Please enter a number.");
scanner.next(); // Consume the invalid input
continue;
}
attempts++;
if (guess < minNumber || guess > maxNumber) {
logger.warn("User guess {} is out of range ({}-{}).", guess, minNumber, maxNumber);
System.out.printf("Your guess %d is out of the valid range (%d-%d). Try again.%n", guess, minNumber, maxNumber);
} else if (guess < targetNumber) {
logger.info("User guess {} was too low. Target: {}.", guess, targetNumber);
System.out.println("Too low! Try again.");
} else if (guess > targetNumber) {
logger.info("User guess {} was too high. Target: {}.", guess, targetNumber);
System.out.println("Too high! Try again.");
} else {
logger.info("User guessed the number correctly! Target: {}, Guess: {}, Attempts: {}", targetNumber, guess, attempts);
System.out.printf("Congratulations! You guessed the number %d in %d attempts!%n", targetNumber, attempts);
hasGuessedCorrectly = true;
break;
}
}
if (!hasGuessedCorrectly) {
logger.info("User failed to guess the number. Target: {}, Attempts used: {}", targetNumber, attempts);
System.out.printf("Sorry, you've used all %d attempts. The number was %d.%n", maxAttempts, targetNumber);
}
scanner.close();
logger.info("Game finished.");
}
public static void main(String[] args) {
try {
NumberGuessingGame game = new NumberGuessingGame();
game.startGame();
} catch (Exception e) {
logger.error("An unexpected error occurred during game execution: {}", e.getMessage(), e);
// This top-level catch is for unexpected runtime issues.
// For user-facing errors, use System.err.println or more specific error handling.
System.err.println("An unexpected application error occurred. Please check the logs for details.");
System.exit(1); // Indicate abnormal termination
}
}
}
Explanation:
- Logger Initialization:
private static final Logger logger = LoggerFactory.getLogger(NumberGuessingGame.class);initializes a logger instance for the class.static finalensures it’s created once and reused. - Replacing
System.out.println: All informational and warning messages are now sent through theloggerinstance usinglogger.info(),logger.warn(),logger.debug(), andlogger.error(). - Parameterized Logging: SLF4J supports parameterized messages (e.g.,
logger.info("Message with param {}", param);). This is efficient as the string formatting only happens if the log level is enabled. - Error Handling: The
InputMismatchExceptionfor non-integer input is now explicitly caught and logged as aWARN. Themainmethod includes a generaltry-catchblock to log any unhandled exceptions at theERRORlevel before terminating the application. - User Prompts: Notice that user prompts like “Enter your guess:” still use
System.out.printf. This is a common pattern:System.outis for direct user interaction, while logging frameworks are for internal application events and diagnostics.
c) Testing This Component
Let’s test our new structured logging.
Run the game: Compile and run the game:
mvn clean install java -jar target/simple-java-projects-1.0-SNAPSHOT.jarPlay the game for a bit, making some correct and incorrect guesses, and maybe entering some invalid input.
Observe Console Output: You should see JSON-formatted log entries in your console. Each line will be a complete JSON object, containing fields like
timestamp,level,message,logger,thread, and our custom fieldsapplicationandservice.Example console output snippet (formatted for readability, actual output will be on a single line):
{"@timestamp":"2025-12-04T10:30:00.123+00:00","@version":"1","message":"Initializing NumberGuessingGame...","logger_name":"com.example.app.numberguessing.NumberGuessingGame","thread_name":"main","level":"INFO","application":"simple-java-projects","service":"number-guessing-game"} {"@timestamp":"2025-12-04T10:30:00.125+00:00","@version":"1","message":"Configuration loaded from 'application.properties'.","logger_name":"com.example.app.config.ConfigurationManager","thread_name":"main","level":"INFO","application":"simple-java-projects","service":"number-guessing-game"} {"@timestamp":"2025-12-04T10:30:00.127+00:00","@version":"1","message":"Config: Key 'game.numberguessing.minNumber' resolved from properties file with value '1'.","logger_name":"com.example.app.config.ConfigurationManager","thread_name":"main","level":"DEBUG","application":"simple-java-projects","service":"number-guessing-game"} ... {"@timestamp":"2025-12-04T10:30:05.456+00:00","@version":"1","message":"User guess 50 was too low. Target: 75.","logger_name":"com.example.app.numberguessing.NumberGuessingGame","thread_name":"main","level":"INFO","application":"simple-java-projects","service":"number-guessing-game"}Note: If you don’t see
DEBUGlogs, ensure theloggerforcom.example.appinlogback.xmlis set toDEBUG.Check Log File: A new directory
logs/should have been created in your project root, containingapplication.log. Open this file. It should contain the same JSON-formatted logs as the console output. You’ll notice that each log entry is on a single line, making it easy for log parsers.Experiment with Log Levels: Edit
src/main/resources/logback.xml. Change thelevelfor thecom.example.applogger fromDEBUGtoINFO.<!-- src/main/resources/logback.xml --> <logger name="com.example.app" level="INFO" additivity="false"> <appender-ref ref="CONSOLE" /> <appender-ref ref="FILE" /> </logger>Save the file. Because
scan="true"is enabled, Logback should automatically reconfigure within 30 seconds (or you can restart the application to be sure). Now, when you run the game, you should no longer see theDEBUGmessages fromConfigurationManagerorNumberGuessingGame. This demonstrates how log levels can be dynamically controlled.
Production Considerations
Implementing configuration management and structured logging is a significant step towards production readiness. Here’s what to consider further:
Configuration Management
- Secrets Management: Never commit sensitive information (API keys, database passwords) directly into
application.propertiesor environment variables in plain text within your CI/CD pipelines. Use dedicated secrets management solutions like HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager, or Kubernetes Secrets. Your application would then retrieve these secrets at runtime. - Configuration Hierarchies: For more complex applications, consider libraries like Typesafe Config or Spring Boot’s externalized configuration, which offer more sophisticated ways to merge configurations from multiple sources (files, environment variables, command-line arguments, remote config servers) with well-defined precedence rules.
- Dynamic Configuration: For microservices architectures, consider tools like Spring Cloud Config Server or Consul that allow changing configuration at runtime without restarting the application.
Logging and Monitoring
- Centralized Logging: In a production environment, logs from multiple instances of your application (and other services) should be aggregated into a centralized logging system (e.g., ELK Stack - Elasticsearch, Logstash, Kibana; Splunk; Grafana Loki). Structured JSON logs are ideal for this, as they can be easily parsed, indexed, searched, and visualized.
- Log Retention Policies: Implement strict policies for how long logs are stored and when they are archived or deleted to manage storage costs and comply with regulations (e.g., GDPR, HIPAA). This is handled by
RollingFileAppender’smaxHistoryandtotalSizeCapbut needs to be managed at the infrastructure level for centralized systems. - Performance Impact: While Logback is highly optimized, excessive logging (especially at
DEBUGorTRACElevels in production) can impact application performance and disk I/O. Use log levels judiciously. Asynchronous logging can mitigate some performance overhead by offloading log writing to a separate thread. - Security & PII: Never log sensitive user data (Personally Identifiable Information - PII), passwords, credit card numbers, or other confidential information. Implement careful sanitization or redaction of data before it reaches the logging system.
- Alerting: Integrate your logging system with an alerting system (e.g., PagerDuty, Opsgenie) to notify operations teams immediately when critical errors (
ERRORlevel logs) or unusual patterns are detected.
Code Review Checkpoint
At this point, you have successfully implemented robust configuration management and structured logging.
Summary of what was built:
- Configuration Manager: A
ConfigurationManagerclass that loads settings fromapplication.propertiesand allows overriding via environment variables. - Number Guessing Game Integration: The game now retrieves its operational parameters (min/max number, max attempts) from the
ConfigurationManager. - SLF4J + Logback: Integrated the SLF4J logging facade with the Logback implementation.
- Structured JSON Logging: Configured Logback using
logback.xmlto output machine-readable JSON logs to both the console and a rolling file. - Application Logging: Replaced
System.out.printlnstatements with appropriatelogger.info,logger.warn,logger.debug, andlogger.errorcalls throughout theConfigurationManagerandNumberGuessingGame.
Files created/modified:
pom.xml: Added SLF4J, Logback, and Logstash encoder dependencies; updated compiler source/target.src/main/resources/application.properties: New file for application configuration.src/main/resources/logback.xml: New file for Logback configuration.src/main/java/com/example/app/config/ConfigurationManager.java: New class for managing configuration.src/main/java/com/example/app/numberguessing/NumberGuessingGame.java: Modified to useConfigurationManagerand SLF4J for logging.
How it integrates with existing code:
The NumberGuessingGame now depends on the ConfigurationManager for its settings and uses the Logger for all internal messages, making it more configurable and observable without changing its core game logic.
Common Issues & Solutions
Here are a few common issues you might encounter and how to resolve them:
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder"- Issue: This error means SLF4J couldn’t find a concrete logging implementation (like Logback or Log4j2) on the classpath.
- Solution: Ensure you have
logback-classic(orlog4j-slf4j-implif using Log4j2) as a dependency in yourpom.xml. Double-check the versions and that Maven successfully downloaded them. Runmvn dependency:treeto inspect your project’s dependencies.
application.propertiesorlogback.xmlnot loading- Issue: The application starts but doesn’t seem to pick up settings from your properties file or logging isn’t configured as expected.
- Solution:
- File Path: Verify the files are correctly placed in
src/main/resources. - Maven Build: Ensure
mvn clean installormvn packagecompletes without errors. Check the contents of yourtargetdirectory (specifically the JAR file if you’re building one, ortarget/classesif running directly from IDE) to confirmapplication.propertiesandlogback.xmlare present at the root of the classpath. - Typos: Double-check for typos in filenames (
application.properties,logback.xml) or property keys. - IDE Cache: If running from an IDE, sometimes IDEs don’t refresh resources immediately. Try cleaning and rebuilding the project, or restarting the IDE.
- File Path: Verify the files are correctly placed in
Logs not appearing, or appearing in plain text instead of JSON
- Issue: You’re using
logger.info()but nothing is printed, or it’s not in the expected JSON format. - Solution:
logback.xmlpresence: Ensurelogback.xmlis insrc/main/resources.- Log Level: Check the
levelattribute in yourlogback.xmlfor the root logger and specific loggers (e.g.,com.example.app). Ifroot level="INFO",DEBUGmessages won’t be shown. - Appender Configuration: Verify that your
CONSOLEandFILEappenders are correctly defined and referenced in therootor specificloggerelements. - Encoder: Ensure the
encoderclass isnet.logstash.logback.encoder.LogstashEncoderfor JSON output. If you’re missing thelogstash-logback-encoderdependency, it won’t work.
- Issue: You’re using
Testing & Verification
To ensure everything is working as expected:
Clean and Rebuild:
mvn clean installThis ensures all dependencies are downloaded and the project is built correctly.
Run the application:
java -jar target/simple-java-projects-1.0-SNAPSHOT.jarVerify Configuration:
- The game introduction should state the
minNumber,maxNumber, andmaxAttemptsthat match yourapplication.propertiesfile. - Try overriding
maxAttemptswith an environment variable (export GAME_NUMBERGUESSING_MAXATTEMPTS=5on Linux/macOS,set GAME_NUMBERGUESSING_MAXATTEMPTS=5on Windows CMD), run the game again, and verify the new attempt count.
- The game introduction should state the
Verify Structured Logging:
- Console: Observe the console output. All application messages (excluding user input prompts) should be in JSON format.
- Log File: Check for the
logs/application.logfile in your project root. Open it and confirm it contains JSON-formatted log entries. - Log Levels:
- With
com.example.applogger set toDEBUGinlogback.xml, you should see detailed messages about configuration loading. - Change
com.example.apptoINFO, restart (or wait for scan period), and confirmDEBUGmessages are no longer visible.
- With
- Error Handling: Enter non-numeric input when prompted for a guess. You should see a
WARNlevel log entry in your console and log file indicating “Invalid input received.”
Your application is now configured to handle external properties and produce rich, structured logs, making it far more manageable and observable in any environment!
Summary & Next Steps
Congratulations! In this chapter, you’ve taken a significant leap towards building production-ready applications by implementing robust configuration management and structured logging. You learned:
- How to externalize application settings using
application.properties. - To create a
ConfigurationManagerto centralize property access and prioritize environment variables for overrides. - To integrate the industry-standard SLF4J logging facade with Logback as its implementation.
- To configure Logback to produce machine-readable JSON logs for enhanced observability.
- To replace
System.out.printlnwith proper logging calls, using different log levels appropriately.
These practices are fundamental for any application destined for production. Externalizing configuration makes your application adaptable to different environments without code changes, and structured logging provides the critical visibility needed for monitoring, debugging, and incident response.
In the next chapter, Chapter 14: Robust Error Handling and Exception Management, we will build upon our logging foundation to design and implement a comprehensive strategy for handling errors and exceptions gracefully, ensuring our application remains stable and provides meaningful feedback even when things go wrong.