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:

  1. Default Properties File: We’ll use a standard application.properties file located in src/main/resources. This file will hold all default configuration values for our application.
  2. Configuration Manager: A dedicated ConfigurationManager class will be responsible for loading these properties. It will provide a centralized point to access configuration values throughout the application.
  3. Environment Variable Overrides: We will design our ConfigurationManager to prioritize environment variables over values found in the application.properties file. 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.

  1. 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.
  2. Logback Implementation: Logback will handle the actual logging. It’s known for its speed and advanced features.
  3. logback.xml Configuration: Logback’s behavior will be controlled by an XML configuration file (logback.xml) in src/main/resources.
  4. 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.
  5. Structured JSON Output: Both appenders will be configured to output logs in JSON format using the logstash-logback-encoder library. This makes logs machine-readable and easily ingestible by centralized logging systems like ELK Stack, Splunk, or Grafana Loki.
  6. 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, and maxAttempts for our Number Guessing Game.
  • The game.numberguessing prefix 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.source and maven.compiler.target to 21. 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-plugin is explicitly included for clarity, though its default behavior often covers src/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: ConfigurationManager uses 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.properties from the classpath using getClass().getClassLoader().getResourceAsStream(). This is a standard way to access resources bundled with your JAR.
  • Error Handling: Basic System.err.println is 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.maxAttempts becomes GAME_NUMBERGUESSING_MAXATTEMPTS). If an environment variable is present, its value is returned, overriding any value in application.properties. Otherwise, it falls back to the value from the loaded properties object.
  • 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. Optional is 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 NumberGuessingGame constructor now retrieves minNumber, maxNumber, and maxAttempts from ConfigurationManager.getInstance().
  • Sensible default values (e.g., 1, 100, 7) are provided to config.getInt() in case a property is missing or malformed in application.properties or environment variables.
  • Basic validation is added to ensure that minNumber is less than maxNumber and maxAttempts is positive.
c) Testing This Component

Let’s test our configuration management.

  1. 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.jar
    

    Verify that the game starts with the range 1-100 and 7 attempts, as specified in application.properties.

  2. Modify application.properties: Change the maxAttempts in src/main/resources/application.properties to 5.

    # src/main/resources/application.properties
    game.numberguessing.minNumber=1
    game.numberguessing.maxNumber=100
    game.numberguessing.maxAttempts=5
    

    Recompile and run the game:

    mvn clean install
    java -jar target/simple-java-projects-1.0-SNAPSHOT.jar
    

    The game should now indicate you have 5 attempts.

  3. Override with Environment Variable: Now, let’s test the environment variable override. Set the GAME_NUMBERGUESSING_MAXATTEMPTS environment 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.properties value. This confirms our environment variable override is working.

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 includes logback-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 our ConfigurationManager.
  • CONSOLE Appender: 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., level instead of level_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.
  • FILE Appender: Outputs logs to logs/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 LogstashEncoder for consistent JSON output.
  • <root level="INFO">: This is the default logger. Any logger not explicitly configured will inherit from this. We set it to INFO for production, meaning DEBUG logs won’t be shown by default.
  • <logger name="com.example.app" level="DEBUG" additivity="false">: This logger specifically targets our application’s packages. By setting level="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.println calls are replaced with logger.warn(), logger.error(), and logger.debug() calls. This allows us to control the verbosity and format of these messages via logback.xml.
  • An RuntimeException is thrown if configuration loading fails, as this is a critical startup error for most applications.
  • logger.debug statements 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 final ensures it’s created once and reused.
  • Replacing System.out.println: All informational and warning messages are now sent through the logger instance using logger.info(), logger.warn(), logger.debug(), and logger.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 InputMismatchException for non-integer input is now explicitly caught and logged as a WARN. The main method includes a general try-catch block to log any unhandled exceptions at the ERROR level 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.out is 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.

  1. Run the game: Compile and run the game:

    mvn clean install
    java -jar target/simple-java-projects-1.0-SNAPSHOT.jar
    

    Play the game for a bit, making some correct and incorrect guesses, and maybe entering some invalid input.

  2. 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 fields application and service.

    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 DEBUG logs, ensure the logger for com.example.app in logback.xml is set to DEBUG.

  3. Check Log File: A new directory logs/ should have been created in your project root, containing application.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.

  4. Experiment with Log Levels: Edit src/main/resources/logback.xml. Change the level for the com.example.app logger from DEBUG to INFO.

    <!-- 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 the DEBUG messages from ConfigurationManager or NumberGuessingGame. 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.properties or 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’s maxHistory and totalSizeCap but needs to be managed at the infrastructure level for centralized systems.
  • Performance Impact: While Logback is highly optimized, excessive logging (especially at DEBUG or TRACE levels 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 (ERROR level 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 ConfigurationManager class that loads settings from application.properties and 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.xml to output machine-readable JSON logs to both the console and a rolling file.
  • Application Logging: Replaced System.out.println statements with appropriate logger.info, logger.warn, logger.debug, and logger.error calls throughout the ConfigurationManager and NumberGuessingGame.

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 use ConfigurationManager and 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:

  1. 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 (or log4j-slf4j-impl if using Log4j2) as a dependency in your pom.xml. Double-check the versions and that Maven successfully downloaded them. Run mvn dependency:tree to inspect your project’s dependencies.
  2. application.properties or logback.xml not 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 install or mvn package completes without errors. Check the contents of your target directory (specifically the JAR file if you’re building one, or target/classes if running directly from IDE) to confirm application.properties and logback.xml are 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.
  3. 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.xml presence: Ensure logback.xml is in src/main/resources.
      • Log Level: Check the level attribute in your logback.xml for the root logger and specific loggers (e.g., com.example.app). If root level="INFO", DEBUG messages won’t be shown.
      • Appender Configuration: Verify that your CONSOLE and FILE appenders are correctly defined and referenced in the root or specific logger elements.
      • Encoder: Ensure the encoder class is net.logstash.logback.encoder.LogstashEncoder for JSON output. If you’re missing the logstash-logback-encoder dependency, it won’t work.

Testing & Verification

To ensure everything is working as expected:

  1. Clean and Rebuild:

    mvn clean install
    

    This ensures all dependencies are downloaded and the project is built correctly.

  2. Run the application:

    java -jar target/simple-java-projects-1.0-SNAPSHOT.jar
    
  3. Verify Configuration:

    • The game introduction should state the minNumber, maxNumber, and maxAttempts that match your application.properties file.
    • Try overriding maxAttempts with an environment variable (export GAME_NUMBERGUESSING_MAXATTEMPTS=5 on Linux/macOS, set GAME_NUMBERGUESSING_MAXATTEMPTS=5 on Windows CMD), run the game again, and verify the new attempt count.
  4. 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.log file in your project root. Open it and confirm it contains JSON-formatted log entries.
    • Log Levels:
      • With com.example.app logger set to DEBUG in logback.xml, you should see detailed messages about configuration loading.
      • Change com.example.app to INFO, restart (or wait for scan period), and confirm DEBUG messages are no longer visible.
    • Error Handling: Enter non-numeric input when prompted for a guess. You should see a WARN level 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 ConfigurationManager to 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.println with 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.