Welcome to Chapter 3 of our journey! In this chapter, we will embark on building our very first interactive application: a Simple Calculator. This project, while seemingly basic, is fundamental for grasping core programming concepts such as user input handling, conditional logic, method creation, and basic arithmetic operations. It lays a crucial foundation for more complex applications by demonstrating how to interact with users and process data.

This step is vital because it introduces the practical application of the Java environment we set up in previous chapters. You’ll move beyond “Hello, World!” to create a program that takes user input, performs calculations, and provides output. We’ll focus on building robust code, incorporating error handling from the outset, and ensuring our application is stable and predictable.

By the end of this chapter, you will have a fully functional command-line calculator that can perform addition, subtraction, multiplication, and division, complete with input validation and graceful error handling. You’ll also learn how to write unit tests for your logic, a critical practice for production-ready software.

Planning & Design

For our Simple Calculator, the architecture will be straightforward, adhering to the Single Responsibility Principle. We’ll separate the core calculation logic from the user interaction logic.

Component Architecture

  1. Calculator Class: This class will encapsulate all the arithmetic operations (add, subtract, multiply, divide). It will be a utility class with static methods, making it stateless and reusable. This separation ensures that our calculation logic can be tested independently of user input.
  2. Main Class: This will be our entry point. It will handle user input, parse operations, call the Calculator methods, and display results. It will also manage the main application loop.

File Structure

We will maintain a standard Maven project structure, which you should have initialized in Chapter 2.

.
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── com
    │           └── example
    │               └── calculator
    │                   ├── Calculator.java    <-- New file for arithmetic logic
    │                   └── Main.java          <-- Main application entry point
    └── test
        └── java
            └── com
                └── example
                    └── calculator
                        └── CalculatorTest.java <-- New file for unit tests

Input Handling Strategy

We’ll use Java’s java.util.Scanner class to read user input from the console. This class is suitable for simple command-line applications. We’ll implement a loop to allow multiple calculations and an exit condition.

Step-by-Step Implementation

Let’s begin by setting up our project files and then incrementally building the calculator’s functionality.

a) Setup/Configuration

Assuming you have a Maven project from Chapter 2, let’s add the necessary files.

First, ensure your pom.xml includes JUnit 5 for testing. If you don’t have it, add the following to your <dependencies> section:

<!-- pom.xml -->
<dependencies>
    <!-- Existing dependencies (if any) -->

    <!-- JUnit 5 for testing -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.0</version> <!-- Use the latest stable version -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.0</version> <!-- Use the latest stable version -->
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version> <!-- Ensure this supports Java 25 -->
            <configuration>
                <release>25</release> <!-- Target Java 25 -->
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.3</version> <!-- Use latest stable for JUnit 5 support -->
        </plugin>
    </plugins>
</build>

Why these dependencies?

  • junit-jupiter-api: Provides the core annotations and interfaces for writing JUnit 5 tests.
  • junit-jupiter-engine: This is the test engine that discovers and runs tests written with the JUnit Jupiter API.
  • <scope>test</scope>: This tells Maven that these dependencies are only needed for compiling and running tests, not for the main application runtime.
  • maven-compiler-plugin: Ensures our project compiles with the specified Java version (Java 25). The release tag is preferred over source and target for modern Java versions.
  • maven-surefire-plugin: This plugin is responsible for running the unit tests during the build lifecycle.

Now, create the following empty files:

  • src/main/java/com/example/calculator/Calculator.java
  • src/main/java/com/example/calculator/Main.java
  • src/test/java/com/example/calculator/CalculatorTest.java

b) Core Implementation

Step 1: Implement Basic Arithmetic Logic in Calculator.java

First, let’s create the Calculator class with static methods for our four basic operations. We’ll also add basic logging using System.out.println for now, which we will enhance later.

src/main/java/com/example/calculator/Calculator.java

package com.example.calculator;

/**
 * Utility class for performing basic arithmetic operations.
 * Provides static methods for addition, subtraction, multiplication, and division.
 * Includes basic error handling for division by zero.
 */
public class Calculator {

    /**
     * Performs addition of two numbers.
     * @param num1 The first number.
     * @param num2 The second number.
     * @return The sum of num1 and num2.
     */
    public static double add(double num1, double num2) {
        System.out.println("LOG: Performing addition: " + num1 + " + " + num2);
        return num1 + num2;
    }

    /**
     * Performs subtraction of two numbers.
     * @param num1 The first number (minuend).
     * @param num2 The second number (subtrahend).
     * @return The difference of num1 and num2.
     */
    public static double subtract(double num1, double num2) {
        System.out.println("LOG: Performing subtraction: " + num1 + " - " + num2);
        return num1 - num2;
    }

    /**
     * Performs multiplication of two numbers.
     * @param num1 The first number.
     * @param num2 The second number.
     * @return The product of num1 and num2.
     */
    public static double multiply(double num1, double num2) {
        System.out.println("LOG: Performing multiplication: " + num1 + " * " + num2);
        return num1 * num2;
    }

    /**
     * Performs division of two numbers.
     * @param num1 The first number (dividend).
     * @param num2 The second number (divisor).
     * @return The quotient of num1 and num2.
     * @throws ArithmeticException if num2 is zero.
     */
    public static double divide(double num1, double num2) {
        System.out.println("LOG: Attempting division: " + num1 + " / " + num2);
        if (num2 == 0) {
            System.err.println("ERROR: Division by zero attempted."); // Log error to standard error
            throw new ArithmeticException("Cannot divide by zero.");
        }
        return num1 / num2;
    }
}

Explanation:

  • package com.example.calculator;: Defines the package for our class, following standard Java naming conventions.
  • public class Calculator { ... }: Declares a public class named Calculator.
  • public static double add(double num1, double num2): Defines a public static method named add that takes two double arguments and returns their sum as a double. Using double allows for floating-point calculations, making the calculator more versatile.
  • System.out.println("LOG: ...");: Simple logging statement. In a real production application, we’d use a dedicated logging framework like SLF4J with Logback or Log4j2. We’ll explore this in a later section.
  • divide(double num1, double num2): This method includes crucial error handling.
    • if (num2 == 0): Checks if the divisor is zero.
    • System.err.println("ERROR: Division by zero attempted.");: Logs an error message to the standard error stream (stderr), which is typically used for error output, distinguishing it from regular program output.
    • throw new ArithmeticException("Cannot divide by zero.");: Throws a checked ArithmeticException. This forces any calling code to either handle this exception or declare that it throws it, ensuring that division by zero is explicitly addressed.

Step 2: Implement the Main Application Logic in Main.java

Now, let’s create the Main class to handle user interaction, input parsing, and calling our Calculator methods.

src/main/java/com/example/calculator/Main.java

package com.example.calculator;

import java.util.InputMismatchException;
import java.util.Scanner;

/**
 * Main application class for the Simple Calculator.
 * Handles user input, parses operations, and displays results.
 */
public class Main {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("Welcome to the Simple Calculator!");
        System.out.println("Enter 'exit' at any time to quit.");

        while (true) {
            System.out.print("\nEnter first number: ");
            String input1 = scanner.nextLine();
            if (input1.equalsIgnoreCase("exit")) {
                System.out.println("LOG: User requested exit.");
                break;
            }
            
            double num1;
            try {
                num1 = Double.parseDouble(input1);
            } catch (NumberFormatException e) {
                System.err.println("ERROR: Invalid input for first number. Please enter a numeric value.");
                System.out.println("LOG: Input parsing failed for: " + input1);
                continue; // Skip to next iteration of the loop
            }

            System.out.print("Enter operator (+, -, *, /): ");
            String operator = scanner.nextLine();
            if (operator.equalsIgnoreCase("exit")) {
                System.out.println("LOG: User requested exit.");
                break;
            }

            // Basic operator validation
            if (!operator.matches("[+\\-*/]")) {
                System.err.println("ERROR: Invalid operator. Please use +, -, *, or /.");
                System.out.println("LOG: Invalid operator entered: " + operator);
                continue;
            }

            System.out.print("Enter second number: ");
            String input2 = scanner.nextLine();
            if (input2.equalsIgnoreCase("exit")) {
                System.out.println("LOG: User requested exit.");
                break;
            }

            double num2;
            try {
                num2 = Double.parseDouble(input2);
            } catch (NumberFormatException e) {
                System.err.println("ERROR: Invalid input for second number. Please enter a numeric value.");
                System.out.println("LOG: Input parsing failed for: " + input2);
                continue; // Skip to next iteration of the loop
            }

            double result = 0;
            boolean calculationSuccessful = true;

            try {
                switch (operator) {
                    case "+":
                        result = Calculator.add(num1, num2);
                        break;
                    case "-":
                        result = Calculator.subtract(num1, num2);
                        break;
                    case "*":
                        result = Calculator.multiply(num1, num2);
                        break;
                    case "/":
                        result = Calculator.divide(num1, num2);
                        break;
                    default:
                        // This case should ideally not be reached due to earlier validation,
                        // but included for defensive programming.
                        System.err.println("ERROR: Unexpected operator encountered: " + operator);
                        calculationSuccessful = false;
                        break;
                }
            } catch (ArithmeticException e) {
                System.err.println("ERROR: Calculation failed: " + e.getMessage());
                System.out.println("LOG: ArithmeticException caught during calculation: " + e.getMessage());
                calculationSuccessful = false;
            }

            if (calculationSuccessful) {
                System.out.println("Result: " + num1 + " " + operator + " " + num2 + " = " + result);
                System.out.println("LOG: Calculation successful: " + num1 + operator + num2 + "=" + result);
            }
        }
        
        scanner.close(); // Close the scanner to release resources
        System.out.println("Thank you for using the Simple Calculator. Goodbye!");
        System.out.println("LOG: Application terminated.");
    }
}

Explanation:

  • import java.util.InputMismatchException; and import java.util.Scanner;: Imports necessary classes for input handling. InputMismatchException is technically not directly used here because we parse String input to double, handling NumberFormatException instead. Scanner is used to read input.
  • Scanner scanner = new Scanner(System.in);: Creates a Scanner object to read input from the standard input stream (console).
  • while (true) { ... }: An infinite loop that keeps the calculator running until the user explicitly types “exit”.
  • scanner.nextLine();: Reads an entire line of input from the user.
  • input1.equalsIgnoreCase("exit"): Checks if the user wants to quit, case-insensitively.
  • try { num1 = Double.parseDouble(input1); } catch (NumberFormatException e) { ... }: This is critical for input validation.
    • Double.parseDouble() attempts to convert the input string to a double.
    • If the input is not a valid number (e.g., “abc”), a NumberFormatException is thrown.
    • The catch block catches this exception, prints an error message to stderr, logs the failure, and continues the loop to ask for input again, preventing the program from crashing.
  • if (!operator.matches("[+\\-*/]")) { ... }: Basic validation for the operator using a regular expression. [+\\-*/] matches any single character that is +, -, *, or /. The backslash \ escapes the - because inside [] it has special meaning (range).
  • switch (operator) { ... }: Uses a switch statement to perform the correct operation based on the user’s input. Each case calls the corresponding static method from our Calculator class.
  • try { ... } catch (ArithmeticException e) { ... }: This try-catch block handles the ArithmeticException that our Calculator.divide() method throws if division by zero occurs. It prints a user-friendly error and logs the details.
  • scanner.close();: It’s crucial to close the Scanner when it’s no longer needed to release system resources. This is done after the while loop exits.

c) Testing This Component

We’ll implement unit tests for the Calculator class to ensure its arithmetic logic is correct and robust. This is a best practice for any production-ready code.

src/test/java/com/example/calculator/CalculatorTest.java

package com.example.calculator;

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

/**
 * Unit tests for the Calculator class.
 * Ensures that all arithmetic operations function correctly,
 * including edge cases like division by zero.
 */
public class CalculatorTest {

    @Test
    @DisplayName("Test addition of two positive numbers")
    void testAddPositiveNumbers() {
        // Given
        double num1 = 5.0;
        double num2 = 3.0;
        // When
        double result = Calculator.add(num1, num2);
        // Then
        assertEquals(8.0, result, "5.0 + 3.0 should be 8.0");
    }

    @Test
    @DisplayName("Test addition with negative numbers")
    void testAddNegativeNumbers() {
        assertEquals(-2.0, Calculator.add(-5.0, 3.0), "-5.0 + 3.0 should be -2.0");
        assertEquals(-8.0, Calculator.add(-5.0, -3.0), "-5.0 + -3.0 should be -8.0");
    }

    @Test
    @DisplayName("Test subtraction of two positive numbers")
    void testSubtractPositiveNumbers() {
        assertEquals(2.0, Calculator.subtract(5.0, 3.0), "5.0 - 3.0 should be 2.0");
    }

    @Test
    @DisplayName("Test subtraction with negative numbers")
    void testSubtractNegativeNumbers() {
        assertEquals(-8.0, Calculator.subtract(-5.0, 3.0), "-5.0 - 3.0 should be -8.0");
        assertEquals(-2.0, Calculator.subtract(-5.0, -3.0), "-5.0 - -3.0 should be -2.0");
    }

    @Test
    @DisplayName("Test multiplication of two positive numbers")
    void testMultiplyPositiveNumbers() {
        assertEquals(15.0, Calculator.multiply(5.0, 3.0), "5.0 * 3.0 should be 15.0");
    }

    @Test
    @DisplayName("Test multiplication with zero")
    void testMultiplyByZero() {
        assertEquals(0.0, Calculator.multiply(5.0, 0.0), "5.0 * 0.0 should be 0.0");
        assertEquals(0.0, Calculator.multiply(0.0, 3.0), "0.0 * 3.0 should be 0.0");
    }

    @Test
    @DisplayName("Test division of two positive numbers")
    void testDividePositiveNumbers() {
        assertEquals(2.0, Calculator.divide(6.0, 3.0), "6.0 / 3.0 should be 2.0");
    }

    @Test
    @DisplayName("Test division by one")
    void testDivideByOne() {
        assertEquals(7.0, Calculator.divide(7.0, 1.0), "7.0 / 1.0 should be 7.0");
    }

    @Test
    @DisplayName("Test division resulting in decimal")
    void testDivideDecimalResult() {
        assertEquals(2.5, Calculator.divide(5.0, 2.0), "5.0 / 2.0 should be 2.5");
    }

    @Test
    @DisplayName("Test division by zero should throw ArithmeticException")
    void testDivideByZeroThrowsException() {
        // Assert that an ArithmeticException is thrown when dividing by zero
        ArithmeticException exception = assertThrows(ArithmeticException.class, () -> {
            Calculator.divide(10.0, 0.0);
        }, "Division by zero should throw ArithmeticException");

        assertEquals("Cannot divide by zero.", exception.getMessage());
    }
}

Explanation:

  • import org.junit.jupiter.api.Test; and import static org.junit.jupiter.api.Assertions.*;: Imports JUnit 5 annotations and static assertion methods.
  • @Test: Marks a method as a test method.
  • @DisplayName("..."): Provides a more readable name for the test in test reports.
  • assertEquals(expected, actual, message): An assertion method that checks if two values are equal. If not, the test fails, and the message is displayed.
  • assertThrows(expectedType, executable, message): This is crucial for testing exception handling. It asserts that executing the provided lambda expression (() -> { Calculator.divide(10.0, 0.0); }) throws an exception of the expectedType (ArithmeticException in this case). It also returns the caught exception, allowing us to assert its message.

How to test what was just built:

  1. Compile and Run Unit Tests: Open your terminal in the project’s root directory (where pom.xml is located) and run:

    mvn clean install
    

    This command will compile your code, run the unit tests, and package your application. You should see output indicating that all tests passed.

  2. Run the Main Application: After mvn clean install completes, you can run the application:

    mvn exec:java -Dexec.mainClass="com.example.calculator.Main"
    

    Alternatively, if you’re using an IDE like IntelliJ IDEA or Eclipse, you can simply right-click src/main/java/com/example/calculator/Main.java and select “Run ‘Main.main()’”.

Expected Behavior:

  • When running mvn clean install, all 11 unit tests in CalculatorTest.java should pass successfully.
  • When running Main.java, the program should prompt you for numbers and an operator.
  • It should correctly perform addition, subtraction, multiplication, and division.
  • If you enter non-numeric input for numbers, it should print an error and ask again.
  • If you enter an invalid operator, it should print an error and ask again.
  • If you attempt to divide by zero, it should print an ERROR: Calculation failed: Cannot divide by zero. message and not crash.
  • Typing exit at any prompt should gracefully terminate the application.

Debugging Tips:

  • Check Console Output: Pay close attention to System.out.println (for regular output and our temporary logs) and System.err.println (for error messages).
  • Stack Traces: If the application crashes, carefully read the stack trace in the console. It tells you exactly which line of code caused the error.
  • IDE Debugger: Use your IDE’s debugger. Set breakpoints at the start of main method, inside the while loop, and within the try-catch blocks. Step through the code line by line to observe variable values and execution flow.
  • Unit Tests for Isolation: If a specific calculation is failing, write a dedicated unit test for that scenario to isolate the problem in the Calculator class, separate from input handling.

Production Considerations

While our calculator is simple, it’s never too early to consider production readiness.

Error Handling

  • Robust Input Validation: We’ve handled NumberFormatException and basic operator validation. For more complex inputs, consider custom parsers or dedicated libraries.
  • Specific Exceptions: Throwing ArithmeticException for division by zero is good. For other domain-specific errors, consider creating custom exception types (e.g., InvalidOperationException).
  • User Feedback: Ensure error messages are clear, concise, and guide the user on how to correct their input. Avoid exposing internal technical details.

Performance Optimization

For a simple command-line calculator, performance is rarely an issue. However, for larger Java applications:

  • JIT Compiler: Java’s Just-In-Time (JIT) compiler optimizes frequently executed code paths at runtime. For our simple arithmetic, this happens automatically.
  • Primitive Types: Using double for numbers is efficient. Avoid unnecessary object creation if primitives suffice.
  • Resource Management: Always close resources like Scanner to prevent resource leaks. We’ve done this with scanner.close().

Security Considerations

For this basic application, security concerns are minimal. However, in a real-world context:

  • Input Sanitization: While not strictly necessary for numeric input, for string inputs, always sanitize user input to prevent injection attacks (e.g., SQL injection, XSS).
  • Least Privilege: The application should run with the minimum necessary permissions. A console application typically inherits the user’s permissions.
  • Code Signing: For distributed applications (e.g., applets, rich internet applications), sign your code with a trusted certificate to assure users of its authenticity and integrity. (As per Oracle’s deployment best practices).

Logging and Monitoring

Our current System.out.println and System.err.println are sufficient for a basic console app, but inadequate for production.

Enhancement: Introduce SLF4J and Logback

Let’s upgrade our logging to use SLF4J (Simple Logging Facade for Java) as an abstraction layer, with Logback as the concrete implementation. This is a standard and highly recommended practice.

  1. Add Dependencies to pom.xml:

    <!-- pom.xml -->
    <dependencies>
        <!-- Existing dependencies (JUnit, etc.) -->
    
        <!-- SLF4J API -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.12</version> <!-- Use the latest stable version for SLF4J 2.x -->
        </dependency>
        <!-- Logback Classic (implementation for SLF4J) -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.4.14</version> <!-- Use the latest stable version -->
        </dependency>
    </dependencies>
    

    Why SLF4J/Logback?

    • Abstraction: SLF4J allows you to switch logging implementations (Logback, Log4j2, java.util.logging) without changing your code.
    • Performance: Logback is known for its speed and efficiency.
    • Configuration: Highly configurable (output formats, appenders, log levels) via XML or Groovy files, making it easy to manage logs in different environments (console, file, network).
    • Structured Logging: Supports structured logging, which is excellent for machine parsing and integration with log analysis tools.
  2. Update Calculator.java to use SLF4J:

    src/main/java/com/example/calculator/Calculator.java

    package com.example.calculator;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    /**
     * Utility class for performing basic arithmetic operations.
     * Provides static methods for addition, subtraction, multiplication, and division.
     * Includes basic error handling for division by zero.
     */
    public class Calculator {
    
        private static final Logger logger = LoggerFactory.getLogger(Calculator.class);
    
        /**
         * Performs addition of two numbers.
         * @param num1 The first number.
         * @param num2 The second number.
         * @return The sum of num1 and num2.
         */
        public static double add(double num1, double num2) {
            logger.debug("Performing addition: {} + {}", num1, num2); // Use parameterized logging
            return num1 + num2;
        }
    
        /**
         * Performs subtraction of two numbers.
         * @param num1 The first number (minuend).
         * @param num2 The second number (subtrahend).
         * @return The difference of num1 and num2.
         */
        public static double subtract(double num1, double num2) {
            logger.debug("Performing subtraction: {} - {}", num1, num2);
            return num1 - num2;
        }
    
        /**
         * Performs multiplication of two numbers.
         * @param num1 The first number.
         * @param num2 The second number.
         * @return The product of num1 and num2.
         */
        public static double multiply(double num1, double num2) {
            logger.debug("Performing multiplication: {} * {}", num1, num2);
            return num1 * num2;
        }
    
        /**
         * Performs division of two numbers.
         * @param num1 The first number (dividend).
         * @param num2 The second number (divisor).
         * @return The quotient of num1 and num2.
         * @throws ArithmeticException if num2 is zero.
         */
        public static double divide(double num1, double num2) {
            logger.debug("Attempting division: {} / {}", num1, num2);
            if (num2 == 0) {
                logger.error("Division by zero attempted for {} / {}.", num1, num2); // Log error using logger
                throw new ArithmeticException("Cannot divide by zero.");
            }
            return num1 / num2;
        }
    }
    
  3. Update Main.java to use SLF4J:

    src/main/java/com/example/calculator/Main.java

    package com.example.calculator;
    
    import java.util.InputMismatchException; // Keep for clarity, though not directly used now
    import java.util.Scanner;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    /**
     * Main application class for the Simple Calculator.
     * Handles user input, parses operations, and displays results.
     */
    public class Main {
    
        private static final Logger logger = LoggerFactory.getLogger(Main.class);
    
        public static void main(String[] args) {
            Scanner scanner = new Scanner(System.in);
            logger.info("Application started. Welcome message displayed.");
            System.out.println("Welcome to the Simple Calculator!");
            System.out.println("Enter 'exit' at any time to quit.");
    
            while (true) {
                System.out.print("\nEnter first number: ");
                String input1 = scanner.nextLine();
                if (input1.equalsIgnoreCase("exit")) {
                    logger.info("User requested exit.");
                    break;
                }
    
                double num1;
                try {
                    num1 = Double.parseDouble(input1);
                    logger.debug("Parsed first number: {}", num1);
                } catch (NumberFormatException e) {
                    System.err.println("ERROR: Invalid input for first number. Please enter a numeric value.");
                    logger.warn("Input parsing failed for first number: '{}'. Error: {}", input1, e.getMessage());
                    continue;
                }
    
                System.out.print("Enter operator (+, -, *, /): ");
                String operator = scanner.nextLine();
                if (operator.equalsIgnoreCase("exit")) {
                    logger.info("User requested exit.");
                    break;
                }
    
                if (!operator.matches("[+\\-*/]")) {
                    System.err.println("ERROR: Invalid operator. Please use +, -, *, or /.");
                    logger.warn("Invalid operator entered: '{}'", operator);
                    continue;
                }
                logger.debug("Parsed operator: {}", operator);
    
                System.out.print("Enter second number: ");
                String input2 = scanner.nextLine();
                if (input2.equalsIgnoreCase("exit")) {
                    logger.info("User requested exit.");
                    break;
                }
    
                double num2;
                try {
                    num2 = Double.parseDouble(input2);
                    logger.debug("Parsed second number: {}", num2);
                } catch (NumberFormatException e) {
                    System.err.println("ERROR: Invalid input for second number. Please enter a numeric value.");
                    logger.warn("Input parsing failed for second number: '{}'. Error: {}", input2, e.getMessage());
                    continue;
                }
    
                double result = 0;
                boolean calculationSuccessful = true;
    
                try {
                    switch (operator) {
                        case "+":
                            result = Calculator.add(num1, num2);
                            break;
                        case "-":
                            result = Calculator.subtract(num1, num2);
                            break;
                        case "*":
                            result = Calculator.multiply(num1, num2);
                            break;
                        case "/":
                            result = Calculator.divide(num1, num2);
                            break;
                        default:
                            System.err.println("ERROR: Unexpected operator encountered: " + operator);
                            logger.error("Unexpected operator in switch statement: '{}'", operator);
                            calculationSuccessful = false;
                            break;
                    }
                } catch (ArithmeticException e) {
                    System.err.println("ERROR: Calculation failed: " + e.getMessage());
                    logger.error("ArithmeticException caught during calculation ({} {} {}): {}", num1, operator, num2, e.getMessage(), e); // Log exception with stack trace
                    calculationSuccessful = false;
                }
    
                if (calculationSuccessful) {
                    System.out.println("Result: " + num1 + " " + operator + " " + num2 + " = " + result);
                    logger.info("Calculation successful: {} {} {} = {}", num1, operator, num2, result);
                }
            }
    
            scanner.close();
            logger.info("Scanner closed. Application terminating.");
            System.out.println("Thank you for using the Simple Calculator. Goodbye!");
        }
    }
    

    Notice the logger.error("...", e) in the catch block. Passing the exception object e to the logger will automatically include its stack trace in the log output, which is invaluable for debugging in production.

  4. Create logback.xml for configuration: Create a new file src/main/resources/logback.xml. This file configures how Logback behaves.

    src/main/resources/logback.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration>
    
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            </encoder>
        </appender>
    
        <!-- Root logger configuration -->
        <root level="INFO">
            <appender-ref ref="STDOUT" />
        </root>
    
        <!-- Specific logger for our calculator package to show DEBUG level -->
        <logger name="com.example.calculator" level="DEBUG" additivity="false">
            <appender-ref ref="STDOUT" />
        </logger>
    
    </configuration>
    

    Explanation of logback.xml:

    • <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">: Defines an appender named STDOUT that writes logs to the console.
    • <encoder>: Configures the format of the log messages.
      • %d{yyyy-MM-dd HH:mm:ss.SSS}: Timestamp.
      • [%thread]: Name of the current thread.
      • %-5level: Log level (e.g., INFO, DEBUG, ERROR), left-aligned.
      • %logger{36}: Name of the logger (typically the class name), truncated to 36 characters.
      • %msg%n: The log message and a new line.
    • <root level="INFO">: Sets the default logging level for all loggers to INFO. This means messages with DEBUG level from external libraries might not be shown unless explicitly configured.
    • <logger name="com.example.calculator" level="DEBUG" additivity="false">: This is a specific logger for our application’s package.
      • level="DEBUG": Overrides the root level to DEBUG for classes within com.example.calculator, allowing us to see our debug logs.
      • additivity="false": Prevents logs from this logger from being passed up to the root logger, avoiding duplicate console output.

Now, when you run your application, you’ll see structured log messages alongside your application’s output, providing much better insight into its behavior.

Code Review Checkpoint

At this point, you have a solid foundation for your Simple Calculator:

  • Calculator.java: Contains the core arithmetic logic, with methods for addition, subtraction, multiplication, and robust division handling (including division by zero check). It now uses SLF4J for logging.
  • Main.java: Serves as the application’s entry point, handling user input, parsing operations, calling Calculator methods, and displaying results. It incorporates comprehensive input validation and try-catch blocks for gracefully handling NumberFormatException and ArithmeticException. It also uses SLF4J for logging.
  • CalculatorTest.java: A suite of JUnit 5 unit tests that thoroughly verifies the correctness of the Calculator class’s arithmetic operations, including edge cases.
  • pom.xml: Updated with JUnit 5, SLF4J, and Logback dependencies, and configured for Java 25.
  • logback.xml: Configures Logback for structured console logging, enabling DEBUG level logs for our application’s package.

This setup ensures that our calculator is not just functional but also follows best practices for code organization, testing, and production-ready logging.

Common Issues & Solutions

  1. java.lang.NumberFormatException: For input string: "abc"

    • Issue: Occurs when Double.parseDouble() tries to convert a non-numeric string (like “abc”) into a number.
    • Debugging: The stack trace will point to the Double.parseDouble() call. Our try-catch block in Main.java specifically handles this by printing an error and prompting the user again, preventing a crash.
    • Prevention: Always use try-catch blocks around parsing operations when dealing with user input, or use utility methods like StringUtils.isNumeric() (from Apache Commons Lang) for pre-validation if you prefer.
  2. java.lang.ArithmeticException: Cannot divide by zero.

    • Issue: Happens when you attempt to divide a number by zero.
    • Debugging: The stack trace will lead to the Calculator.divide() method. Our Calculator explicitly throws this, and Main catches it, displaying a user-friendly message.
    • Prevention: Always check if the divisor is zero before performing division, especially with user-provided input.
  3. Incorrect Operator Handling / Unexpected Results

    • Issue: The calculator either doesn’t recognize an operator or performs the wrong operation.
    • Debugging:
      • Check the operator.matches("[+\\-*/]") line in Main.java to ensure the regex is correct and covers all expected operators.
      • Use the debugger to step through the switch statement in Main.java to see what value operator holds and which case it enters (or if it hits default).
      • Verify your unit tests for Calculator are thorough; if the Calculator methods themselves have bugs, unit tests will reveal them.
    • Prevention: Comprehensive input validation for operators and robust unit tests for the core logic are key.

Testing & Verification

Let’s do a final verification of our complete Chapter 3 work.

  1. Rebuild the project:

    mvn clean install
    
    • Verification: Ensure all unit tests pass, and the build is successful. You should see output similar to:
      [INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
      [INFO] ------------------------------------------------------------------------
      [INFO] BUILD SUCCESS
      [INFO] ------------------------------------------------------------------------
      
  2. Run the application:

    mvn exec:java -Dexec.mainClass="com.example.calculator.Main"
    
    • Verification:
      • Basic Operations:
        • 5 + 3 should be 8.0
        • 10 - 4 should be 6.0
        • 6 * 7 should be 42.0
        • 9 / 3 should be 3.0
        • 5 / 2 should be 2.5
      • Division by Zero:
        • 10 / 0 should display ERROR: Calculation failed: Cannot divide by zero.
      • Invalid Numeric Input:
        • Enter hello for a number. It should display ERROR: Invalid input for first number. Please enter a numeric value.
      • Invalid Operator Input:
        • Enter ^ for an operator. It should display ERROR: Invalid operator. Please use +, -, *, or /..
      • Exit Condition:
        • Type exit at any prompt. The application should gracefully terminate with “Thank you for using the Simple Calculator. Goodbye!”.
      • Logging: Observe the console output. You should now see structured log messages (e.g., 2025-12-04 10:30:00.123 [main] INFO com.example.calculator.Main - Application started...) alongside the calculator’s interactive prompts and results.

Everything should work as described, demonstrating a robust, user-friendly, and well-tested command-line calculator.

Summary & Next Steps

Congratulations! In this chapter, you successfully built your first interactive Java application: a Simple Calculator. You’ve learned to:

  • Design a simple application with separate concerns for logic and user interaction.
  • Handle user input from the console using java.util.Scanner.
  • Implement basic arithmetic operations.
  • Incorporate robust error handling for invalid input and critical operations like division by zero.
  • Write comprehensive unit tests using JUnit 5 to ensure code correctness.
  • Integrate a professional logging framework (SLF4J with Logback) for better observability in development and production environments.
  • Consider production aspects like performance, security, and structured logging.

This project, while small, covers many foundational concepts essential for any Java developer. The practices introduced here – modular design, error handling, testing, and logging – are crucial for building high-quality, maintainable, and production-ready applications.

In Chapter 4: Number Guessing Game: Random Numbers & Loop Control, we’ll build on these concepts. You’ll learn how to generate random numbers, implement more complex game logic, and utilize different types of loops and conditional statements to create an interactive guessing game. Get ready to add more dynamic behavior to your Java toolkit!