Welcome to Chapter 5 of our Java project series! In this chapter, we’re going to build a practical Temperature Converter application. This project, while seemingly simple, introduces crucial concepts like robust user input handling, data validation, mathematical conversions, and providing a good command-line user experience.

Building this application will solidify your understanding of parsing user input, safely converting data types, implementing core business logic (the conversion formulas), and handling potential errors gracefully. These are fundamental skills applicable to almost any real-world application, regardless of its complexity or UI. We’ll leverage the latest stable Java features as of December 2025, focusing on clean code, testability, and production-readiness from the outset.

By the end of this chapter, you will have a fully functional, console-based temperature converter that can convert between Celsius, Fahrenheit, and Kelvin, complete with input validation, error handling, and a user-friendly interface.

Planning & Design

For our Temperature Converter, we’ll follow a modular design to keep our code clean and maintainable. We’ll separate concerns into distinct components:

  1. TemperatureConverterApp (Main Application Class): Handles the main program loop, user interaction (input/output), and orchestrates calls to the conversion logic.
  2. TemperatureConverter (Core Logic Class): Contains the static methods for performing the actual temperature conversions. This class will be purely functional, taking input temperatures and units and returning converted values.
  3. ConversionUnit (Enum): Defines the supported temperature units (Celsius, Fahrenheit, Kelvin) to ensure type safety and prevent invalid unit inputs.
  4. InvalidInputException (Custom Exception): A custom exception to signal specific input errors, making error handling more explicit.

File Structure:

We’ll maintain our standard Maven project structure. Inside src/main/java/com/example/temperatureconverter, we’ll have:

src/
└── main/
    └── java/
        └── com/
            └── example/
                └── temperatureconverter/
                    ├── TemperatureConverterApp.java
                    ├── TemperatureConverter.java
                    ├── ConversionUnit.java
                    └── InvalidInputException.java
└── test/
    └── java/
        └── com/
            └── example/
                └── temperatureconverter/
                    └── TemperatureConverterTest.java

Step-by-Step Implementation

We’ll build the application incrementally, testing each component as we go.

1. Setup/Configuration: Project Structure and Dependencies

First, ensure your Maven project is set up. If you’re continuing from previous chapters, you already have one. We’ll create the initial files.

a) Create ConversionUnit Enum

This enum will define our supported units and provide utility for parsing string inputs into valid units.

File: src/main/java/com/example/temperatureconverter/ConversionUnit.java

package com.example.temperatureconverter;

import java.util.Optional;
import java.util.logging.Logger;

/**
 * Represents the supported temperature units for conversion.
 * Provides utility methods for parsing string inputs into valid units.
 */
public enum ConversionUnit {
    CELSIUS("C", "Celsius"),
    FAHRENHEIT("F", "Fahrenheit"),
    KELVIN("K", "Kelvin");

    private static final Logger LOGGER = Logger.getLogger(ConversionUnit.class.getName());
    private final String symbol;
    private final String fullName;

    ConversionUnit(String symbol, String fullName) {
        this.symbol = symbol;
        this.fullName = fullName;
    }

    public String getSymbol() {
        return symbol;
    }

    public String getFullName() {
        return fullName;
    }

    /**
     * Parses a string input into a ConversionUnit.
     * The input can be the symbol (e.g., "C") or the full name (e.g., "Celsius"), case-insensitive.
     *
     * @param input The string to parse.
     * @return An Optional containing the ConversionUnit if found, otherwise empty.
     */
    public static Optional<ConversionUnit> fromString(String input) {
        if (input == null || input.trim().isEmpty()) {
            LOGGER.fine("Received null or empty input for unit parsing.");
            return Optional.empty();
        }
        String normalizedInput = input.trim().toUpperCase();
        for (ConversionUnit unit : values()) {
            if (unit.symbol.equals(normalizedInput) || unit.fullName.toUpperCase().equals(normalizedInput)) {
                return Optional.of(unit);
            }
        }
        LOGGER.warning("Could not parse '" + input + "' into a valid ConversionUnit.");
        return Optional.empty();
    }

    /**
     * Returns a string representation of all supported units for user display.
     * Example: "C (Celsius), F (Fahrenheit), K (Kelvin)"
     *
     * @return A comma-separated string of units.
     */
    public static String getSupportedUnitsString() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < values().length; i++) {
            ConversionUnit unit = values()[i];
            sb.append(unit.getSymbol()).append(" (").append(unit.getFullName()).append(")");
            if (i < values().length - 1) {
                sb.append(", ");
            }
        }
        return sb.toString();
    }
}

Explanation:

  • We define three enum constants: CELSIUS, FAHRENHEIT, KELVIN.
  • Each constant has a symbol (e.g., “C”) and a fullName (e.g., “Celsius”) for flexibility in user input.
  • The fromString method is crucial for safely parsing user input into a valid ConversionUnit. It returns an Optional to clearly indicate if a unit was found or not, preventing NullPointerExceptions. We also added basic logging for debugging.
  • getSupportedUnitsString provides a helpful string for user prompts.
  • java.util.logging.Logger is used for internal diagnostics, a standard practice for production applications.

b) Create InvalidInputException Custom Exception

A custom exception allows us to catch specific application-level errors distinct from general Java exceptions.

File: src/main/java/com/example/temperatureconverter/InvalidInputException.java

package com.example.temperatureconverter;

/**
 * Custom exception for invalid user input in the temperature converter application.
 * This helps to distinguish specific application-level input errors from general
 * programming errors or system issues.
 */
public class InvalidInputException extends Exception {

    /**
     * Constructs a new InvalidInputException with the specified detail message.
     *
     * @param message The detail message (which is saved for later retrieval by the getMessage() method).
     */
    public InvalidInputException(String message) {
        super(message);
    }

    /**
     * Constructs a new InvalidInputException with the specified detail message and cause.
     *
     * @param message The detail message.
     * @param cause The cause (which is saved for later retrieval by the getCause() method).
     */
    public InvalidInputException(String message, Throwable cause) {
        super(message, cause);
    }
}

Explanation:

  • This is a simple custom checked exception that extends java.lang.Exception.
  • It provides constructors to include a message and an optional cause, which is good practice for error propagation.

2. Core Implementation: Temperature Conversion Logic

Now, let’s implement the heart of our application: the conversion formulas.

a) Create TemperatureConverter Class

This class will contain static methods for all conversion types. We’ll start with Celsius to Fahrenheit and then expand.

File: src/main/java/com/example/temperatureconverter/TemperatureConverter.java

package com.example.temperatureconverter;

import java.util.logging.Logger;

/**
 * Provides static methods for converting temperatures between Celsius, Fahrenheit, and Kelvin.
 * All conversion methods include basic validation to ensure temperatures are within reasonable
 * absolute zero limits.
 */
public final class TemperatureConverter {

    private static final Logger LOGGER = Logger.getLogger(TemperatureConverter.class.getName());

    // Absolute zero constants in Celsius, Fahrenheit, and Kelvin
    public static final double ABSOLUTE_ZERO_CELSIUS = -273.15;
    public static final double ABSOLUTE_ZERO_FAHRENHEIT = -459.67;
    public static final double ABSOLUTE_ZERO_KELVIN = 0.0;

    // Private constructor to prevent instantiation, as this is a utility class
    private TemperatureConverter() {
        // SonarLint rule: Utility classes should not have public constructors.
        // This class is purely static and should not be instantiated.
    }

    /**
     * Converts Celsius to Fahrenheit.
     * Formula: F = C * 9/5 + 32
     *
     * @param celsius The temperature in Celsius.
     * @return The temperature in Fahrenheit.
     * @throws InvalidInputException If the input temperature is below absolute zero Celsius.
     */
    public static double convertCelsiusToFahrenheit(double celsius) throws InvalidInputException {
        if (celsius < ABSOLUTE_ZERO_CELSIUS) {
            LOGGER.warning("Attempted to convert Celsius below absolute zero: " + celsius);
            throw new InvalidInputException("Temperature cannot be below absolute zero (" + ABSOLUTE_ZERO_CELSIUS + "°C)");
        }
        double fahrenheit = celsius * 9 / 5 + 32;
        LOGGER.fine(String.format("%.2f°C converted to %.2f°F", celsius, fahrenheit));
        return fahrenheit;
    }

    /**
     * Converts Fahrenheit to Celsius.
     * Formula: C = (F - 32) * 5/9
     *
     * @param fahrenheit The temperature in Fahrenheit.
     * @return The temperature in Celsius.
     * @throws InvalidInputException If the input temperature is below absolute zero Fahrenheit.
     */
    public static double convertFahrenheitToCelsius(double fahrenheit) throws InvalidInputException {
        if (fahrenheit < ABSOLUTE_ZERO_FAHRENHEIT) {
            LOGGER.warning("Attempted to convert Fahrenheit below absolute zero: " + fahrenheit);
            throw new InvalidInputException("Temperature cannot be below absolute zero (" + ABSOLUTE_ZERO_FAHRENHEIT + "°F)");
        }
        double celsius = (fahrenheit - 32) * 5 / 9;
        LOGGER.fine(String.format("%.2f°F converted to %.2f°C", fahrenheit, celsius));
        return celsius;
    }

    /**
     * Converts Celsius to Kelvin.
     * Formula: K = C + 273.15
     *
     * @param celsius The temperature in Celsius.
     * @return The temperature in Kelvin.
     * @throws InvalidInputException If the input temperature is below absolute zero Celsius.
     */
    public static double convertCelsiusToKelvin(double celsius) throws InvalidInputException {
        if (celsius < ABSOLUTE_ZERO_CELSIUS) {
            LOGGER.warning("Attempted to convert Celsius below absolute zero: " + celsius);
            throw new InvalidInputException("Temperature cannot be below absolute zero (" + ABSOLUTE_ZERO_CELSIUS + "°C)");
        }
        double kelvin = celsius + 273.15;
        LOGGER.fine(String.format("%.2f°C converted to %.2fK", celsius, kelvin));
        return kelvin;
    }

    /**
     * Converts Kelvin to Celsius.
     * Formula: C = K - 273.15
     *
     * @param kelvin The temperature in Kelvin.
     * @return The temperature in Celsius.
     * @throws InvalidInputException If the input temperature is below absolute zero Kelvin.
     */
    public static double convertKelvinToCelsius(double kelvin) throws InvalidInputException {
        if (kelvin < ABSOLUTE_ZERO_KELVIN) {
            LOGGER.warning("Attempted to convert Kelvin below absolute zero: " + kelvin);
            throw new InvalidInputException("Temperature cannot be below absolute zero (" + ABSOLUTE_ZERO_KELVIN + "K)");
        }
        double celsius = kelvin - 273.15;
        LOGGER.fine(String.format("%.2fK converted to %.2f°C", kelvin, celsius));
        return celsius;
    }

    /**
     * Converts Fahrenheit to Kelvin.
     * Achieved by converting Fahrenheit to Celsius, then Celsius to Kelvin.
     *
     * @param fahrenheit The temperature in Fahrenheit.
     * @return The temperature in Kelvin.
     * @throws InvalidInputException If the input temperature is below absolute zero Fahrenheit.
     */
    public static double convertFahrenheitToKelvin(double fahrenheit) throws InvalidInputException {
        // Re-use existing conversion methods for better maintainability and less error-prone code
        double celsius = convertFahrenheitToCelsius(fahrenheit);
        double kelvin = convertCelsiusToKelvin(celsius);
        LOGGER.fine(String.format("%.2f°F converted to %.2fK", fahrenheit, kelvin));
        return kelvin;
    }

    /**
     * Converts Kelvin to Fahrenheit.
     * Achieved by converting Kelvin to Celsius, then Celsius to Fahrenheit.
     *
     * @param kelvin The temperature in Kelvin.
     * @return The temperature in Fahrenheit.
     * @throws InvalidInputException If the input temperature is below absolute zero Kelvin.
     */
    public static double convertKelvinToFahrenheit(double kelvin) throws InvalidInputException {
        // Re-use existing conversion methods
        double celsius = convertKelvinToCelsius(kelvin);
        double fahrenheit = convertCelsiusToFahrenheit(celsius);
        LOGGER.fine(String.format("%.2fK converted to %.2f°F", kelvin, fahrenheit));
        return fahrenheit;
    }
}

Explanation:

  • TemperatureConverter is a final class with a private constructor, making it a utility class that cannot be instantiated. This is a best practice for classes containing only static methods.
  • We define ABSOLUTE_ZERO constants for each unit, which are crucial for physical validation.
  • Each conversion method takes a double and returns a double.
  • Absolute Zero Validation: Before performing any calculation, each method checks if the input temperature is below its respective absolute zero. This is a critical piece of validation for physical quantities. If invalid, it throws our custom InvalidInputException.
  • Method Chaining: For conversions like Fahrenheit to Kelvin, we chain existing methods (FahrenheitToCelsius then CelsiusToKelvin). This promotes code reuse and reduces potential errors.
  • Logging: LOGGER.fine messages are added to trace the conversion process, useful for debugging in more complex scenarios. String.format is used for formatted logging.

3. Testing This Component: TemperatureConverterTest

It’s crucial to test our core conversion logic independently. We’ll use JUnit 5 for this.

a) Add JUnit 5 Dependency (if not already present)

Open your pom.xml and ensure you have JUnit 5 dependencies. If not, add them within the <dependencies> block:

File: 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</groupId>
    <artifactId>temperature-converter-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source> <!-- Targeting Java 21+ for modern features -->
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.jupiter.version>5.10.0</junit.jupiter.version> <!-- Latest stable JUnit 5 -->
        <log4j.version>2.23.0</log4j.version> <!-- Example for external logging, though we use JUL for simplicity -->
    </properties>

    <dependencies>
        <!-- JUnit 5 for testing -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-params</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- Optional: If you want to use Log4j2 instead of java.util.logging -->
        <!--
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>${log4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>${log4j.version}</version>
        </dependency>
        -->
    </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>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.1.2</version>
            </plugin>
        </plugins>
    </build>
</project>

Note on Java Version: The search results indicate Java 24.0.2 and dev.java mentions “Java 25 is here!” as of Dec 2025. For practical purposes and broad compatibility with modern features, setting maven.compiler.source and target to 21 is a good balance. Java 21 is an LTS (Long-Term Support) release and provides many modern features. If Java 25 is officially released as LTS by Dec 2025, you could update to 25. For now, we proceed with 21 as a robust and current baseline.

b) Create TemperatureConverterTest

File: src/test/java/com/example/temperatureconverter/TemperatureConverterTest.java

package com.example.temperatureconverter;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Unit tests for the TemperatureConverter class.
 * Ensures that all conversion logic is accurate and handles edge cases like absolute zero.
 */
class TemperatureConverterTest {

    private static final double DELTA = 0.01; // Tolerance for double comparisons

    @ParameterizedTest(name = "{0}°C to {1}°F")
    @CsvSource({
            "0.0, 32.0",      // Freezing point
            "100.0, 212.0",   // Boiling point
            "25.0, 77.0",
            "-40.0, -40.0"    // Point where C and F are equal
    })
    @DisplayName("Celsius to Fahrenheit Conversions")
    void testConvertCelsiusToFahrenheit(double celsius, double expectedFahrenheit) throws InvalidInputException {
        double actualFahrenheit = TemperatureConverter.convertCelsiusToFahrenheit(celsius);
        assertEquals(expectedFahrenheit, actualFahrenheit, DELTA,
                () -> String.format("Expected %.2f°F for %.2f°C, but got %.2f°F", expectedFahrenheit, celsius, actualFahrenheit));
    }

    @Test
    @DisplayName("Celsius to Fahrenheit - Absolute Zero Edge Case")
    void testConvertCelsiusToFahrenheit_AbsoluteZero() throws InvalidInputException {
        double absoluteZeroC = TemperatureConverter.ABSOLUTE_ZERO_CELSIUS;
        double expectedF = -459.67; // -273.15 * 9/5 + 32
        assertEquals(expectedF, TemperatureConverter.convertCelsiusToFahrenheit(absoluteZeroC), DELTA);
    }

    @Test
    @DisplayName("Celsius to Fahrenheit - Below Absolute Zero Throws Exception")
    void testConvertCelsiusToFahrenheit_BelowAbsoluteZero() {
        double belowAbsoluteZeroC = TemperatureConverter.ABSOLUTE_ZERO_CELSIUS - 0.01;
        InvalidInputException thrown = assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertCelsiusToFahrenheit(belowAbsoluteZeroC),
                "Should throw InvalidInputException for temperatures below absolute zero Celsius");
        assertTrue(thrown.getMessage().contains("absolute zero"));
    }

    @ParameterizedTest(name = "{0}°F to {1}°C")
    @CsvSource({
            "32.0, 0.0",
            "212.0, 100.0",
            "77.0, 25.0",
            "-40.0, -40.0"
    })
    @DisplayName("Fahrenheit to Celsius Conversions")
    void testConvertFahrenheitToCelsius(double fahrenheit, double expectedCelsius) throws InvalidInputException {
        double actualCelsius = TemperatureConverter.convertFahrenheitToCelsius(fahrenheit);
        assertEquals(expectedCelsius, actualCelsius, DELTA,
                () -> String.format("Expected %.2f°C for %.2f°F, but got %.2f°C", expectedCelsius, fahrenheit, actualCelsius));
    }

    @Test
    @DisplayName("Fahrenheit to Celsius - Absolute Zero Edge Case")
    void testConvertFahrenheitToCelsius_AbsoluteZero() throws InvalidInputException {
        double absoluteZeroF = TemperatureConverter.ABSOLUTE_ZERO_FAHRENHEIT;
        double expectedC = -273.15; // (-459.67 - 32) * 5/9
        assertEquals(expectedC, TemperatureConverter.convertFahrenheitToCelsius(absoluteZeroF), DELTA);
    }

    @Test
    @DisplayName("Fahrenheit to Celsius - Below Absolute Zero Throws Exception")
    void testConvertFahrenheitToCelsius_BelowAbsoluteZero() {
        double belowAbsoluteZeroF = TemperatureConverter.ABSOLUTE_ZERO_FAHRENHEIT - 0.01;
        InvalidInputException thrown = assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertFahrenheitToCelsius(belowAbsoluteZeroF),
                "Should throw InvalidInputException for temperatures below absolute zero Fahrenheit");
        assertTrue(thrown.getMessage().contains("absolute zero"));
    }

    @ParameterizedTest(name = "{0}°C to {1}K")
    @CsvSource({
            "0.0, 273.15",
            "100.0, 373.15",
            "-273.15, 0.0" // Absolute zero
    })
    @DisplayName("Celsius to Kelvin Conversions")
    void testConvertCelsiusToKelvin(double celsius, double expectedKelvin) throws InvalidInputException {
        double actualKelvin = TemperatureConverter.convertCelsiusToKelvin(celsius);
        assertEquals(expectedKelvin, actualKelvin, DELTA,
                () -> String.format("Expected %.2fK for %.2f°C, but got %.2fK", expectedKelvin, celsius, actualKelvin));
    }

    @Test
    @DisplayName("Celsius to Kelvin - Below Absolute Zero Throws Exception")
    void testConvertCelsiusToKelvin_BelowAbsoluteZero() {
        double belowAbsoluteZeroC = TemperatureConverter.ABSOLUTE_ZERO_CELSIUS - 0.01;
        assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertCelsiusToKelvin(belowAbsoluteZeroC));
    }

    @ParameterizedTest(name = "{0}K to {1}°C")
    @CsvSource({
            "273.15, 0.0",
            "373.15, 100.0",
            "0.0, -273.15" // Absolute zero
    })
    @DisplayName("Kelvin to Celsius Conversions")
    void testConvertKelvinToCelsius(double kelvin, double expectedCelsius) throws InvalidInputException {
        double actualCelsius = TemperatureConverter.convertKelvinToCelsius(kelvin);
        assertEquals(expectedCelsius, actualCelsius, DELTA,
                () -> String.format("Expected %.2f°C for %.2fK, but got %.2f°C", expectedCelsius, kelvin, actualCelsius));
    }

    @Test
    @DisplayName("Kelvin to Celsius - Below Absolute Zero Throws Exception")
    void testConvertKelvinToCelsius_BelowAbsoluteZero() {
        double belowAbsoluteZeroK = TemperatureConverter.ABSOLUTE_ZERO_KELVIN - 0.01;
        assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertKelvinToCelsius(belowAbsoluteZeroK));
    }

    @ParameterizedTest(name = "{0}°F to {1}K")
    @CsvSource({
            "32.0, 273.15",    // 0°C
            "212.0, 373.15",   // 100°C
            "-459.67, 0.0"     // Absolute zero
    })
    @DisplayName("Fahrenheit to Kelvin Conversions")
    void testConvertFahrenheitToKelvin(double fahrenheit, double expectedKelvin) throws InvalidInputException {
        double actualKelvin = TemperatureConverter.convertFahrenheitToKelvin(fahrenheit);
        assertEquals(expectedKelvin, actualKelvin, DELTA,
                () -> String.format("Expected %.2fK for %.2f°F, but got %.2fK", expectedKelvin, fahrenheit, actualKelvin));
    }

    @Test
    @DisplayName("Fahrenheit to Kelvin - Below Absolute Zero Throws Exception")
    void testConvertFahrenheitToKelvin_BelowAbsoluteZero() {
        double belowAbsoluteZeroF = TemperatureConverter.ABSOLUTE_ZERO_FAHRENHEIT - 0.01;
        assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertFahrenheitToKelvin(belowAbsoluteZeroF));
    }

    @ParameterizedTest(name = "{0}K to {1}°F")
    @CsvSource({
            "273.15, 32.0",    // 0°C
            "373.15, 212.0",   // 100°C
            "0.0, -459.67"     // Absolute zero
    })
    @DisplayName("Kelvin to Fahrenheit Conversions")
    void testConvertKelvinToFahrenheit(double kelvin, double expectedFahrenheit) throws InvalidInputException {
        double actualFahrenheit = TemperatureConverter.convertKelvinToFahrenheit(kelvin);
        assertEquals(expectedFahrenheit, actualFahrenheit, DELTA,
                () -> String.format("Expected %.2f°F for %.2fK, but got %.2f°F", expectedFahrenheit, kelvin, actualFahrenheit));
    }

    @Test
    @DisplayName("Kelvin to Fahrenheit - Below Absolute Zero Throws Exception")
    void testConvertKelvinToFahrenheit_BelowAbsoluteZero() {
        double belowAbsoluteZeroK = TemperatureConverter.ABSOLUTE_ZERO_KELVIN - 0.01;
        assertThrows(InvalidInputException.class, () ->
                TemperatureConverter.convertKelvinToFahrenheit(belowAbsoluteZeroK));
    }
}

Explanation:

  • We use JUnit 5’s @Test for individual tests and @ParameterizedTest with @CsvSource for data-driven tests, which is excellent for testing multiple inputs with expected outputs for formulas.
  • DELTA is used for assertEquals with double values to account for potential floating-point precision issues.
  • We test common conversion points (freezing, boiling, equal points).
  • Crucially, we test the absolute zero edge cases and verify that InvalidInputException is thrown when temperatures fall below absolute zero using assertThrows. This demonstrates robust error handling.
  • DisplayName provides clear names for tests in reports.
  • Lambda expressions in assertEquals messages are used for lazy evaluation, improving performance by only building the message string if the assertion fails.

To Run Tests: Navigate to your project root in the terminal and run: mvn test

You should see all tests pass. If any fail, carefully review your TemperatureConverter.java code against the formulas and absolute zero checks.

4. Core Implementation: Main Application Logic (TemperatureConverterApp)

Now we tie everything together with the main application class, handling user interaction.

File: src/main/java/com/example/temperatureconverter/TemperatureConverterApp.java

package com.example.temperatureconverter;

import java.util.InputMismatchException;
import java.util.Optional;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Main application class for the Temperature Converter.
 * Handles user input, orchestrates conversions, and provides output.
 * Uses `java.util.logging` for internal diagnostics and `Scanner` for console input.
 */
public class TemperatureConverterApp {

    private static final Logger LOGGER = Logger.getLogger(TemperatureConverterApp.class.getName());
    private final Scanner scanner;

    /**
     * Constructor for the TemperatureConverterApp.
     * Initializes the Scanner for user input.
     */
    public TemperatureConverterApp() {
        this.scanner = new Scanner(System.in);
        // Configure logging for better visibility in development
        // For production, this would typically be configured via logging.properties or a framework
        LOGGER.setLevel(Level.INFO); // Set to INFO for user-facing logs, FINE for detailed dev logs
    }

    /**
     * Main entry point for the application.
     *
     * @param args Command line arguments (not used in this application).
     */
    public static void main(String[] args) {
        LOGGER.info("Starting Temperature Converter Application...");
        TemperatureConverterApp app = new TemperatureConverterApp();
        app.run();
        LOGGER.info("Temperature Converter Application finished.");
    }

    /**
     * Runs the main loop of the temperature converter application.
     * Prompts the user for input, performs conversion, and displays results.
     */
    public void run() {
        System.out.println("----------------------------------------");
        System.out.println("  Welcome to the Temperature Converter! ");
        System.out.println("----------------------------------------");
        System.out.println("Supported units: " + ConversionUnit.getSupportedUnitsString());
        System.out.println("Type 'exit' to quit at any time.");

        while (true) {
            try {
                // 1. Get source unit
                ConversionUnit fromUnit = promptForUnit("Enter source unit (e.g., C, F, K): ");
                if (fromUnit == null) { // User typed 'exit'
                    break;
                }

                // 2. Get target unit
                ConversionUnit toUnit = promptForUnit("Enter target unit (e.g., C, F, K): ");
                if (toUnit == null) { // User typed 'exit'
                    break;
                }

                // 3. Get temperature value
                double temperature = promptForTemperature("Enter temperature value: ");
                // No need to check for null here, as promptForTemperature handles 'exit' internally
                // and throws an exception if parsing fails.

                // 4. Perform conversion
                double convertedTemperature = convertTemperature(temperature, fromUnit, toUnit);

                // 5. Display result
                System.out.printf("Result: %.2f %s is %.2f %s%n",
                        temperature, fromUnit.getSymbol(), convertedTemperature, toUnit.getSymbol());

            } catch (InvalidInputException e) {
                LOGGER.log(Level.WARNING, "Invalid input from user: {0}", e.getMessage());
                System.err.println("Error: " + e.getMessage());
            } catch (InputMismatchException e) { // Catches non-double input for temperature if not handled by nextLine()
                LOGGER.log(Level.WARNING, "Unexpected input type error: {0}", e.getMessage());
                System.err.println("Error: Invalid number format. Please enter a valid number.");
                scanner.nextLine(); // Consume the invalid input to prevent infinite loop
            } catch (Exception e) { // Catch any other unexpected errors
                LOGGER.log(Level.SEVERE, "An unexpected error occurred: {0}", e.getMessage(), e);
                System.err.println("An unexpected error occurred. Please try again.");
            }
            System.out.println("\n----------------------------------------\n"); // Separator for next conversion
        }
        scanner.close(); // Close scanner to release system resources
    }

    /**
     * Prompts the user for a temperature unit and parses it.
     *
     * @param prompt The message to display to the user.
     * @return The parsed ConversionUnit, or null if the user typed 'exit'.
     * @throws InvalidInputException If the unit input is invalid.
     */
    private ConversionUnit promptForUnit(String prompt) throws InvalidInputException {
        while (true) {
            System.out.print(prompt);
            String input = scanner.nextLine().trim();
            if (input.equalsIgnoreCase("exit")) {
                LOGGER.info("User requested to exit during unit prompt.");
                return null;
            }

            Optional<ConversionUnit> unit = ConversionUnit.fromString(input);
            if (unit.isPresent()) {
                return unit.get();
            } else {
                LOGGER.warning("Invalid unit input received: '" + input + "'");
                System.err.println("Invalid unit. Please enter one of: " + ConversionUnit.getSupportedUnitsString());
            }
        }
    }

    /**
     * Prompts the user for a temperature value and parses it.
     *
     * @param prompt The message to display to the user.
     * @return The parsed temperature value.
     * @throws InvalidInputException If the temperature input is not a valid number or user requests exit.
     */
    private double promptForTemperature(String prompt) throws InvalidInputException {
        while (true) {
            System.out.print(prompt);
            String input = scanner.nextLine().trim();
            if (input.equalsIgnoreCase("exit")) {
                LOGGER.info("User requested to exit during temperature prompt.");
                // We're treating 'exit' during temperature input as an InvalidInputException
                // to break out of the current conversion flow. The run() method will catch this.
                throw new InvalidInputException("User exited.");
            }
            try {
                return Double.parseDouble(input);
            } catch (NumberFormatException e) {
                LOGGER.log(Level.WARNING, "Invalid temperature number format: '{0}'", input);
                System.err.println("Invalid temperature value. Please enter a valid number.");
            }
        }
    }

    /**
     * Orchestrates the temperature conversion based on source and target units.
     *
     * @param temperature The temperature value to convert.
     * @param fromUnit The source unit.
     * @param toUnit The target unit.
     * @return The converted temperature.
     * @throws InvalidInputException If an unsupported conversion path is requested or
     *                                 if the temperature is below absolute zero.
     */
    private double convertTemperature(double temperature, ConversionUnit fromUnit, ConversionUnit toUnit)
            throws InvalidInputException {
        if (fromUnit == toUnit) {
            LOGGER.info(String.format("Source and target units are the same (%s), returning original temperature.", fromUnit.getSymbol()));
            return temperature; // No conversion needed
        }

        LOGGER.info(String.format("Attempting to convert %.2f %s to %s", temperature, fromUnit.getSymbol(), toUnit.getSymbol()));

        // This switch statement leverages Java 17+ enhanced switch expressions for conciseness
        // and pattern matching for switch (preview in Java 17, standard in Java 21) if complex.
        // For simplicity, we'll use a traditional switch here, but mention the capability.
        return switch (fromUnit) {
            case CELSIUS -> switch (toUnit) {
                case FAHRENHEIT -> TemperatureConverter.convertCelsiusToFahrenheit(temperature);
                case KELVIN -> TemperatureConverter.convertCelsiusToKelvin(temperature);
                default -> throw new InvalidInputException("Unsupported target unit for Celsius conversion: " + toUnit.getSymbol());
            };
            case FAHRENHEIT -> switch (toUnit) {
                case CELSIUS -> TemperatureConverter.convertFahrenheitToCelsius(temperature);
                case KELVIN -> TemperatureConverter.convertFahrenheitToKelvin(temperature);
                default -> throw new InvalidInputException("Unsupported target unit for Fahrenheit conversion: " + toUnit.getSymbol());
            };
            case KELVIN -> switch (toUnit) {
                case CELSIUS -> TemperatureConverter.convertKelvinToCelsius(temperature);
                case FAHRENHEIT -> TemperatureConverter.convertKelvinToFahrenheit(temperature);
                default -> throw new InvalidInputException("Unsupported target unit for Kelvin conversion: " + toUnit.getSymbol());
            };
            // The outer switch should cover all ConversionUnit enums, so default here implies an unhandled state,
            // which should ideally not happen if enum is fully covered.
            // However, a defensive approach is good.
            default -> {
                LOGGER.severe("Unhandled source unit in conversion logic: " + fromUnit.getSymbol());
                throw new InvalidInputException("Unsupported source unit: " + fromUnit.getSymbol());
            }
        };
    }
}

Explanation:

  • main method: The entry point. It creates an instance of TemperatureConverterApp and calls run().
  • run method: This is the main application loop.
    • It welcomes the user and explains supported units and how to exit.
    • It continuously prompts for source unit, target unit, and temperature value.
    • It uses a try-catch block to gracefully handle InvalidInputException (from our custom checks and NumberFormatException) and general Exceptions, providing user-friendly error messages.
    • Resource Management: scanner.close() is called when the application exits to release system resources, preventing resource leaks.
  • promptForUnit:
    • Loops until valid unit input is received or the user types “exit”.
    • Uses ConversionUnit.fromString() to parse input safely.
    • Provides clear error messages for invalid unit input.
  • promptForTemperature:
    • Loops until valid numeric input is received or the user types “exit”.
    • Uses Double.parseDouble() to convert String to double.
    • Catches NumberFormatException for non-numeric input.
    • Treats “exit” as an InvalidInputException to simplify flow control in run().
  • convertTemperature:
    • This method acts as a router, delegating to the appropriate static method in TemperatureConverter based on the fromUnit and toUnit.
    • It includes a check for fromUnit == toUnit to avoid unnecessary conversion.
    • The nested switch statements (using Java 17+ syntax for clarity, though a traditional if-else if chain would also work) ensure all valid conversion paths are covered. A default case is included for defensive programming, though with a finite enum, all cases should ideally be handled explicitly.
  • Logging: LOGGER.info, LOGGER.warning, and LOGGER.severe are used throughout to provide insight into application flow, user actions, and errors. This is invaluable for debugging and monitoring in production.

5. Production Considerations

  • Error Handling: We’ve implemented robust error handling using try-catch blocks for NumberFormatException and our custom InvalidInputException. This prevents the application from crashing due to bad user input and provides informative feedback. The top-level catch (Exception e) in run() is a safety net for unexpected issues.
  • Performance Optimization: For this simple console application, performance is not a major concern. However, in larger applications, avoiding repeated expensive operations (e.g., parsing configuration files inside a loop) and choosing efficient data structures would be key. Our conversion logic is purely mathematical and highly efficient.
  • Security Considerations:
    • Input Validation: Crucial for security. We validate numeric inputs and unit types. For a console app, direct security threats are minimal, but in web applications, input validation prevents injection attacks (SQL, XSS). Our absolute zero checks are a form of domain-specific validation.
    • Resource Management: Closing the Scanner (scanner.close()) prevents resource leaks, which can be a denial-of-service vector in server applications if too many resources are held open.
  • Logging and Monitoring: We’ve integrated java.util.logging for internal diagnostics. In a production environment, you would typically configure logging.properties or use a more advanced logging framework like Log4j2 or SLF4J with Logback. This allows for:
    • Centralized Logging: Sending logs to a file, a log management system (e.g., ELK stack, Splunk, Datadog).
    • Log Levels: Adjusting verbosity (e.g., INFO in production, FINE/DEBUG in development).
    • Structured Logging: Outputting logs in JSON for easier parsing by monitoring tools.
  • Code Organization: The separation of concerns into TemperatureConverterApp, TemperatureConverter, ConversionUnit, and InvalidInputException makes the codebase organized, readable, and easy to maintain or extend.

6. Code Review Checkpoint

At this point, you have:

  • Files created/modified:
    • src/main/java/com/example/temperatureconverter/ConversionUnit.java
    • src/main/java/com/example/temperatureconverter/InvalidInputException.java
    • src/main/java/com/example/temperatureconverter/TemperatureConverter.java
    • src/main/java/com/example/temperatureconverter/TemperatureConverterApp.java
    • src/test/java/com/example/temperatureconverter/TemperatureConverterTest.java
    • pom.xml (updated for JUnit 5)
  • Core functionality:
    • Defined temperature units using an enum.
    • Implemented a custom exception for invalid inputs.
    • Developed robust temperature conversion logic for Celsius, Fahrenheit, and Kelvin, including absolute zero validation.
    • Created a console application that prompts for input, performs conversions, and displays results.
    • Integrated comprehensive error handling for invalid user input and unexpected issues.
    • Added logging for internal application flow and errors.
  • Integration: All components work together to provide a complete temperature conversion experience. The TemperatureConverterApp orchestrates calls to ConversionUnit for parsing and TemperatureConverter for the actual math.

7. Common Issues & Solutions

  1. Issue: InputMismatchException or NoSuchElementException when using Scanner.

    • Problem: If you mix scanner.next() or scanner.nextInt() with scanner.nextLine(), the nextLine() call might consume the leftover newline character from the previous next() call, leading to unexpected empty inputs or skipping prompts. NoSuchElementException occurs if the scanner is closed prematurely or no input is available.
    • Solution: Always use scanner.nextLine() to read the entire line of input, then parse it (e.g., Double.parseDouble(), Integer.parseInt()). This avoids the newline character issue. For NumberFormatException, catch it and re-prompt. Ensure scanner.close() is called only when the application is truly exiting. In our TemperatureConverterApp, we consistently use scanner.nextLine().
    • Prevention: Consistency in Scanner usage is key. Our code explicitly handles NumberFormatException when parsing numeric values from nextLine().
  2. Issue: Floating-point precision errors in conversions.

    • Problem: When comparing double values directly using ==, you might encounter issues due to the nature of floating-point representation. For example, 0.1 + 0.2 might not exactly equal 0.3.
    • Solution: When comparing double or float values, use a small tolerance (epsilon or delta). In our unit tests, we used assertEquals(expected, actual, DELTA). For display, use String.format("%.2f", value) to round to a specific number of decimal places.
    • Prevention: Be aware of floating-point arithmetic limitations and use appropriate comparison techniques and formatting for display.
  3. Issue: Application crashes on invalid unit input (e.g., “XYZ”).

    • Problem: Without proper validation, attempting to use an unparsed unit string can lead to NullPointerException or other runtime errors.
    • Solution: Our ConversionUnit.fromString() method returns an Optional<ConversionUnit>. This forces you to explicitly check if a unit was found (unit.isPresent()) before using it. If not present, the promptForUnit method reprompts the user, preventing the invalid unit from reaching the conversion logic.
    • Prevention: Use Optional for methods that might not return a value. Implement robust input validation loops that continue prompting until valid input is provided.

8. Testing & Verification

To test and verify the entire chapter’s work:

  1. Compile the project:

    mvn clean install
    

    This command cleans the previous build, compiles all source code, runs the unit tests, and packages the application. All unit tests should pass.

  2. Run the application:

    java -jar target/temperature-converter-app-1.0-SNAPSHOT.jar
    

    (Note: If you haven’t configured the maven-jar-plugin to create an executable JAR with dependencies, you might need to run it directly from the main class, ensuring the classpath is correctly set. For simplicity in these early chapters, running from IDE or mvn exec:java might be easier.)

    Alternative (simpler for console apps without full JAR configuration):

    mvn exec:java -Dexec.mainClass="com.example.temperatureconverter.TemperatureConverterApp"
    
  3. Interact with the application:

    • Valid Conversions:
      • Convert 0 C to F: Expected 32.00 F
      • Convert 100 C to F: Expected 212.00 F
      • Convert 32 F to C: Expected 0.00 C
      • Convert 212 F to C: Expected 100.00 C
      • Convert 0 K to C: Expected -273.15 C
      • Convert -40 C to F: Expected -40.00 F
      • Convert 25 C to K: Expected 298.15 K
      • Convert 298.15 K to F: Expected 77.00 F
    • Invalid Temperature Input:
      • Enter “abc” for temperature value. It should show an error and re-prompt.
      • Enter a value below absolute zero (e.g., -300 C). It should catch the InvalidInputException and show an error.
    • Invalid Unit Input:
      • Enter “X” or “unknown” for a unit. It should show an error and re-prompt.
    • Exit Functionality:
      • Type “exit” at any prompt. The application should gracefully shut down.

By performing these checks, you verify that the application handles both valid and invalid user inputs correctly, performs accurate conversions, and provides a good user experience.

Summary & Next Steps

Congratulations! In this chapter, you’ve successfully built a robust Temperature Converter application. You started by designing the component architecture, then implemented:

  • Type-safe unit definitions using an enum.
  • A custom exception for specific application errors.
  • The core conversion logic with absolute zero validation.
  • Comprehensive unit tests to ensure the accuracy and reliability of your conversion formulas.
  • A user-friendly console interface that safely handles user input, parses data, performs conversions, and displays results.
  • Production-ready practices including error handling, logging, and proper resource management.

This project was a significant step in understanding how to build interactive command-line tools with robust data handling. You’ve seen how to protect your application from bad input and provide clear feedback to the user.

In the next chapter, we’ll continue to build on these fundamentals as we tackle the Basic To-Do List Application. This will introduce concepts like storing and managing data (initially in memory, later persisting it), handling multiple commands, and further refining the command-line user experience. Get ready to manage tasks!