Build a Concurrent Inventory Manager CLI

Build a thread-safe inventory CLI using ConcurrentHashMap and virtual threads.

Introduction

30 Second Summary

Think about a busy warehouse receiving shipments from multiple suppliers at the same time. If two people update the same stock count without coordination, inventory numbers go wrong fast.

In this project, you will build a command-line inventory manager in Java that handles concurrent stock updates from multiple simulated suppliers without data corruption.

What You'll Build

A multi-class CLI application that uses thread-safe collections, locking strategies, virtual threads, and file persistence to manage warehouse inventory in real-time.

By the end of this project, you'll have:

  • A thread-safe inventory store backed by ConcurrentHashMap and ReentrantLock for compound operations.
  • A JSON persistence layer using Java NIO file I/O with proper resource management.
  • A concurrent supplier simulator that spawns virtual threads to restock products in parallel.
  • Secret Mission: Add a low-stock alert monitor that watches inventory levels across threads and triggers warnings.

Are there any prerequisites?

This project assumes you have prior Java experience and a working Maven setup. You should be comfortable writing Java classes and running commands in a terminal.

Before We Start

Before We Start...

Set Up the Maven Project

Every Java project needs a consistent structure so your IDE, build tool, and teammates can find things in predictable places. Maven handles this for you by generating a standard project layout from a template called an archetype.

In this step, you will scaffold a Maven project, configure it for Java 21 with the Gson JSON library, and lay out the class files you will build on throughout the project.

In this step, get ready to:
  • Scaffold a Maven project using the quickstart archetype.
  • Configure the build for Java 21 and add the Gson dependency.
  • Create the class skeleton and confirm the app runs.
Create the Maven project structure

Maven's archetype system generates a ready-made folder layout with a pom.xml, source directories, and a sample class. You will use the quickstart archetype to get started quickly.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Maven project by running this command:
mvn archetype:generate -DgroupId=com.inventory -DartifactId=concurrent-inventory -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.5 -DinteractiveMode=false

What does this command do?

  • -DgroupId=com.inventory sets the Java package name for your source code.
  • -DartifactId=concurrent-inventory names the project folder and the built JAR file.
  • -DarchetypeArtifactId=maven-archetype-quickstart tells Maven to use a minimal Java project template.
  • -DinteractiveMode=false skips all prompts so Maven runs the command without asking questions.
  • You should see output ending with BUILD SUCCESS in your terminal.
  • Move into the new project folder by running:
cd concurrent-inventory

Seeing BUILD FAILURE?

Confirm you have an internet connection. Maven downloads the archetype template the first time you run this command.

Check that mvn --version shows Maven is installed and points to JDK 21.

Help me troubleshoot my Maven archetype command

Now open the concurrent-inventory folder in your IDE.

  • In your IDE, click File in the top menu bar.
  • Click Open (or Open Folder depending on your IDE).
  • Navigate to Desktop and select the concurrent-inventory folder, then click Open.

The archetype generated a basic pom.xml that needs updating. You will replace its entire contents with a version configured for Java 21, Gson, and the exec plugin.

  • Open pom.xml in the root of the project.
  • Select all the existing content and delete it.
  • Paste the following configuration:
<?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.inventory</groupId>
    <artifactId>concurrent-inventory</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Gson for JSON serialization and deserialization -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.14.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Allows running the app with mvn exec:java -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <mainClass>com.inventory.InventoryApp</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What does this configuration do?

  • The <properties> block sets the compiler to Java 21 so you can use records and virtual threads.
  • The gson dependency gives you a simple JSON library for reading and writing inventory data to a file.
  • The exec-maven-plugin lets you run the app with a single terminal command instead of manually specifying the main class every time.
  • Save pom.xml.
  • Verify the project compiles by running this command in your terminal:
mvn compile
  • You should see BUILD SUCCESS in the output. This confirms Maven downloaded Gson and compiled the project with Java 21.

Build failing on compile?

If you see an error about invalid target release, confirm your JAVA_HOME points to JDK 21. Run java -version to check.

If Gson fails to download, check your internet connection and try again.

Help me fix my Maven compile error

✔️ Awesome, I've got everything!

Great. Make sure you have saved pom.xml before moving on.

ⓧ I'd like to double check the full code

<?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.inventory</groupId>
    <artifactId>concurrent-inventory</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.14.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <mainClass>com.inventory.InventoryApp</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
Create the package and class skeleton

The archetype generated a sample App.java file you do not need. You will replace it with the five classes that make up the inventory manager.

  • In your IDE's file explorer, navigate to src/main/java/com/inventory/.
  • Delete App.java (the archetype-generated file).
  • Also delete the src/test/ directory entirely. You will not need the generated test file for this project.

Now create five new Java files inside src/main/java/com/inventory/.

  • Create Product.java with this content:
package com.inventory;

public record Product() {}
  • Create InventoryStore.java with this content:
package com.inventory;

public class InventoryStore {}
  • Create SupplierSimulator.java with this content:
package com.inventory;

public class SupplierSimulator {}
  • Create JsonPersistence.java with this content:
package com.inventory;

public class JsonPersistence {}
  • Create InventoryApp.java with this content:
package com.inventory;

public class InventoryApp {}

Why a record for Product?

A Java record is an immutable data carrier that automatically generates a constructor, getters, equals(), hashCode(), and toString().

This is the modern way to define simple data objects in Java 21. You will add fields to it in the next step.

  • Save all five files.
  • Confirm the project still compiles by running:
mvn compile
  • You should see BUILD SUCCESS again. This confirms all five files have valid package declarations and no syntax errors.

Compile error after creating the classes?

Make sure each file starts with package com.inventory; on the first line.

Verify the files are saved inside src/main/java/com/inventory/ and not in the project root.

Help me fix my package structure

Verify the project runs

An empty class compiles, but it cannot run without a main method. You will add one to InventoryApp.java so Maven can launch the application.

  • Open InventoryApp.java and replace its contents with:
package com.inventory;

public class InventoryApp {
    public static void main(String[] args) {
        System.out.println("Inventory Manager starting...");
    }
}

What does this code do?

The main method is the entry point Java looks for when launching an application. It prints a startup message to confirm everything is wired up correctly.

The exec-maven-plugin you configured in pom.xml points directly at com.inventory.InventoryApp, so Maven knows which class to run.

  • Save InventoryApp.java.
  • Run the application by executing:
mvn exec:java
  • You should see Inventory Manager starting... printed in the terminal output (among Maven's own log lines).

Not seeing the startup message?

Make sure you saved InventoryApp.java before running the command.

Scroll up in the terminal output. Maven prints its own logs around the program output, so the message may appear between [INFO] lines.

Help me run my InventoryApp

✔️ Awesome, I've got everything!

Great. Confirm you have saved all files before continuing.

ⓧ I'd like to double check the full code

package com.inventory;

public class InventoryApp {
    public static void main(String[] args) {
        System.out.println("Inventory Manager starting...");
    }
}

Your project skeleton is ready with all five classes in place and a working build. Next up, you will fill in the Product record and build a thread-safe InventoryStore backed by ConcurrentHashMap.

Build the Product Model and Inventory Store

Your Maven project is scaffolded and compiling. Now it is time to build the core data layer that everything else depends on.

Every inventory system needs two things: a model that describes a product, and a store that holds many products safely. In a warehouse with multiple suppliers updating stock at the same time, "safely" means thread-safe. You will use Java's ConcurrentHashMap for individual operations and a ReentrantLock for compound operations that span multiple reads and writes.

In this step, get ready to:
  • Define the Product data model as a Java record.
  • Implement a thread-safe inventory store with ConcurrentHashMap and ReentrantLock.
  • Test storage and retrieval with sample products.
Create the Product record and InventoryStore with ConcurrentHashMap

A Java record gives you an immutable data carrier with automatic equals, hashCode, and toString methods. This makes it perfect for representing a warehouse product.

  • Open Product.java in your IDE (located at src/main/java/com/inventory/Product.java).
  • Replace the entire contents with this code:
package com.inventory;

// Immutable product data carrier using Java record syntax
public record Product(String id, String name, String category, double price, int quantity) {

    // Create a copy with an updated quantity (records are immutable)
    public Product withQuantity(int newQuantity) {
        return new Product(id, name, category, price, newQuantity);
    }
}

What does this code do?

The record keyword defines Product with five fields. Java automatically generates a constructor, getter methods (id(), name(), etc.), and toString.

Since records are immutable, the withQuantity method creates a brand new Product with the updated value instead of modifying the existing one. This is essential for thread safety.

  • Save Product.java.

Next, you will build the store that holds these products. The InventoryStore class uses a ConcurrentHashMap for safe individual read/write operations across threads.

  • Open InventoryStore.java and replace its contents with this code:
package com.inventory;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

public class InventoryStore {

    // Thread-safe map for individual get/put operations
    private final ConcurrentHashMap<String, Product> inventory = new ConcurrentHashMap<>();
    // Lock for compound operations that span multiple map accesses
    private final ReentrantLock lock = new ReentrantLock();
    // Path where inventory data will be persisted later
    private final Path dataFile;

    public InventoryStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    // Add a product to the store (single put is already thread-safe)
    public void addProduct(Product product) {
        inventory.put(product.id(), product);
    }

    // Retrieve a single product by ID
    public Product getProduct(String id) {
        return inventory.get(id);
    }

    // Return an immutable snapshot of all products
    public List<Product> getAllProducts() {
        return List.copyOf(inventory.values());
    }

    // Filter by category using Streams, sorted alphabetically by name
    public List<Product> getProductsByCategory(String category) {
        return inventory.values().stream()
                .filter(p -> p.category().equalsIgnoreCase(category))
                .sorted((a, b) -> a.name().compareToIgnoreCase(b.name()))
                .collect(Collectors.toList());
    }

    // Return an immutable copy of the underlying map
    public Map<String, Product> getInventoryMap() {
        return Map.copyOf(inventory);
    }
}

What does this code do?

ConcurrentHashMap is a thread-safe map that allows multiple threads to read and write simultaneously without locking the entire structure. Individual operations like put and get are already safe on their own.

The getProductsByCategory method uses Java's Streams API to filter, sort, and collect results in a single pipeline. The ReentrantLock is declared here but will be used in the next substep for compound operations.

  • Save InventoryStore.java.
  • Confirm your project still compiles by running:
mvn compile

Seeing a compilation error?

Make sure you saved both Product.java and InventoryStore.java. InventoryStore depends on Product, so both files must be saved before compiling.

Check that the package com.inventory; statement is at the top of both files. The package name must match the folder structure src/main/java/com/inventory/.

Help me debug a compilation error in my InventoryStore or Product class.

Add compound operations with ReentrantLock

Individual map operations are thread-safe with ConcurrentHashMap. But what about operations that read a value, modify it, and write it back? Between the read and write, another thread could change the value. This is where the ReentrantLock comes in.

  • In InventoryStore.java, find the closing brace of the getInventoryMap method.
  • Add the following updateStock method below it:
    // Lock ensures read-modify-write is atomic across threads
    public void updateStock(String productId, int quantityChange) {
        lock.lock();
        try {
            Product product = inventory.get(productId);
            if (product != null) {
                int newQuantity = Math.max(0, product.quantity() + quantityChange);
                inventory.put(productId, product.withQuantity(newQuantity));
            }
        } finally {
            lock.unlock();
        }
    }

Why lock for a single map update?

The operation reads the current quantity, adds or subtracts, then writes back. Without a lock, two threads could both read quantity as 10, each add 5, and both write 15. The correct answer should be 20. The lock prevents this read-modify-write race condition.

The try/finally pattern guarantees the lock is released even if an exception occurs. This is critical for preventing deadlocks.

  • Add the transferStock method directly below updateStock:
    // Transfer requires reading two entries and writing two entries atomically
    public boolean transferStock(String fromId, String toId, int quantity) {
        lock.lock();
        try {
            Product from = inventory.get(fromId);
            Product to = inventory.get(toId);
            if (from == null || to == null || from.quantity() < quantity) {
                return false;
            }
            inventory.put(fromId, from.withQuantity(from.quantity() - quantity));
            inventory.put(toId, to.withQuantity(to.quantity() + quantity));
        } finally {
            lock.unlock();
        }
        return true;
    }

What does transferStock do?

This moves units from one product to another. It reads both products, validates there is enough stock, then updates both entries. All four operations (two reads, two writes) must happen atomically. If another thread modified either product between steps, you could end up with negative stock or duplicated units.

This is the classic interview scenario for explaining why ConcurrentHashMap alone is not enough. It handles individual operations safely, but compound operations that span multiple entries need explicit synchronization.

Why ReentrantLock instead of synchronized?

Virtual threads (which you will use in a later step) can unmount from their carrier thread when blocked inside a ReentrantLock. Inside a synchronized block, a virtual thread pins to its carrier, blocking other virtual threads from running on it.

This distinction is a key interview topic for Java 21 concurrency. Using ReentrantLock from the start keeps the code virtual-thread-friendly.

  • Save InventoryStore.java.
  • Confirm the project compiles by running:
mvn compile

You should see BUILD SUCCESS in the output.

Seeing unreported exception or cannot find symbol?

Make sure the updateStock and transferStock methods are inside the InventoryStore class body (before the final closing brace), not outside it.

Verify that withQuantity exists in your Product.java record. The lock methods call product.withQuantity(), which must be defined.

Help me fix a compilation error in my InventoryStore lock methods.

✔️ Awesome, I've got everything!

Great. Double check you have saved InventoryStore.java before moving on.

ⓧ I'd like to double check the full code

package com.inventory;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

public class InventoryStore {

    private final ConcurrentHashMap<String, Product> inventory = new ConcurrentHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();
    private final Path dataFile;

    public InventoryStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    public void addProduct(Product product) {
        inventory.put(product.id(), product);
    }

    public Product getProduct(String id) {
        return inventory.get(id);
    }

    public List<Product> getAllProducts() {
        return List.copyOf(inventory.values());
    }

    public List<Product> getProductsByCategory(String category) {
        return inventory.values().stream()
                .filter(p -> p.category().equalsIgnoreCase(category))
                .sorted((a, b) -> a.name().compareToIgnoreCase(b.name()))
                .collect(Collectors.toList());
    }

    public void updateStock(String productId, int quantityChange) {
        lock.lock();
        try {
            Product product = inventory.get(productId);
            if (product != null) {
                int newQuantity = Math.max(0, product.quantity() + quantityChange);
                inventory.put(productId, product.withQuantity(newQuantity));
            }
        } finally {
            lock.unlock();
        }
    }

    public boolean transferStock(String fromId, String toId, int quantity) {
        lock.lock();
        try {
            Product from = inventory.get(fromId);
            Product to = inventory.get(toId);
            if (from == null || to == null || from.quantity() < quantity) {
                return false;
            }
            inventory.put(fromId, from.withQuantity(from.quantity() - quantity));
            inventory.put(toId, to.withQuantity(to.quantity() + quantity));
        } finally {
            lock.unlock();
        }
        return true;
    }

    public Map<String, Product> getInventoryMap() {
        return Map.copyOf(inventory);
    }
}
Test the store manually

With the data model and store in place, you will update the main method to add sample products and verify that queries work correctly.

  • Open InventoryApp.java and replace its contents with this code:
package com.inventory;

import java.nio.file.Path;

public class InventoryApp {
    public static void main(String[] args) {
        // Create the store with a path for future persistence
        InventoryStore store = new InventoryStore(Path.of("inventory.json"));

        // Add sample products to verify storage
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));

        // Query all products and filter by category
        System.out.println(store.getAllProducts());
        System.out.println(store.getProductsByCategory("Electronics"));
    }
}

What does this code do?

This creates an InventoryStore, adds two electronics products, then prints all products and a category-filtered list. Since Product is a record, System.out.println calls the auto-generated toString method, which formats each product with all its field values.

  • Save InventoryApp.java.
  • Run the application:
mvn exec:java -q

You should see two products printed in record format, showing fields like Product[id=ELC001, name=USB-C Cable, ...]. Both getAllProducts and getProductsByCategory return the same two items since both are in the Electronics category.

Seeing no output or an error?

If you see a ClassNotFoundException, make sure the mainClass in your pom.xml is set to com.inventory.InventoryApp.

If you see an empty list [], check that your addProduct method calls inventory.put(product.id(), product) and not just inventory.get.

Help me debug why my inventory app is not printing products correctly.

✔️ Awesome, I've got everything!

Great. Make sure you see both products listed in the output before moving on.

ⓧ I'd like to double check the full code

package com.inventory;

import java.nio.file.Path;

public class InventoryApp {
    public static void main(String[] args) {
        InventoryStore store = new InventoryStore(Path.of("inventory.json"));
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));
        System.out.println(store.getAllProducts());
        System.out.println(store.getProductsByCategory("Electronics"));
    }
}

Your data model and thread-safe store are working. Next up, you will add file persistence so inventory data survives between application restarts.

Implement JSON File Persistence with NIO

Your inventory store holds products in memory, but everything disappears the moment the application stops. A real warehouse system needs its data to survive restarts.

In this step, you will build a JSON-based persistence layer using Gson and Java NIO file I/O. Every time a product changes, the store writes to disk. Every time the app starts, it reads from disk.

In this step, get ready to:
  • Write the JsonPersistence class with save and load methods using NIO.
  • Wire persistence into InventoryStore so data saves automatically.
  • Verify that data survives an application restart.
Write the JsonPersistence class with save functionality

Gson converts Java objects to JSON strings and back. Combined with Java NIO's Files.newBufferedWriter, you can write the entire inventory map to a file in one go.

  • Open JsonPersistence.java and replace the empty class with the following code:
package com.inventory;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class JsonPersistence {

    // Pretty-printed Gson instance for human-readable JSON output
    private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
    // TypeToken captures the generic type Map<String, Product> for deserialization
    private static final Type INVENTORY_TYPE = new TypeToken<Map<String, Product>>() {}.getType();

    public static void saveInventory(Map<String, Product> inventory, Path filePath) {
        // try-with-resources ensures the writer is closed even if an exception occurs
        try (BufferedWriter writer = Files.newBufferedWriter(filePath)) {
            GSON.toJson(inventory, writer);
        } catch (IOException e) {
            System.err.println("Error saving inventory: " + e.getMessage());
        }
    }
}

What does this code do?

GsonBuilder().setPrettyPrinting().create() builds a Gson instance that formats JSON with indentation. This makes the output file easy to read when you inspect it manually.

TypeToken<Map<String, Product>> captures the full generic type at compile time. Without it, Gson would not know what type of objects to create when deserializing.

Files.newBufferedWriter(filePath) is the modern NIO way to open a file for writing. Wrapping it in try-with-resources guarantees the stream closes properly, even if serialization throws an exception.

Why try-with-resources instead of manual close?

The try-with-resources statement automatically calls close() on any resource declared in its header. This eliminates an entire category of bugs where file handles leak because a developer forgot to close them in a finally block.

Add the load method to JsonPersistence

The load method reads JSON back from disk into a ConcurrentHashMap. It also handles the first-run scenario where no file exists yet.

  • In JsonPersistence.java, add the loadInventory method below the saveInventory method:
    public static Map<String, Product> loadInventory(Path filePath) {
        // If no file exists yet, return an empty map (first run)
        if (!Files.exists(filePath)) {
            return new ConcurrentHashMap<>();
        }
        // try-with-resources ensures the reader is closed after deserialization
        try (BufferedReader reader = Files.newBufferedReader(filePath)) {
            Map<String, Product> loaded = GSON.fromJson(reader, INVENTORY_TYPE);
            return loaded != null ? new ConcurrentHashMap<>(loaded) : new ConcurrentHashMap<>();
        } catch (IOException e) {
            System.err.println("Error loading inventory: " + e.getMessage());
            return new ConcurrentHashMap<>();
        }
    }

What does this code do?

Files.exists(filePath) checks whether the JSON file has been created yet. On the very first run, there is nothing to load, so returning an empty map lets the app start cleanly.

GSON.fromJson(reader, INVENTORY_TYPE) deserializes the file contents back into a Map<String, Product>. The null check handles the edge case where the file exists but is empty.

  • Save JsonPersistence.java.

Seeing a compile error?

  • Make sure all the imports at the top of the file are present. TypeToken comes from com.google.gson.reflect.TypeToken and is easy to miss.
  • Verify the Gson dependency in pom.xml uses version 2.14.0 and that you have run mvn compile at least once to download it.

Help me fix a compile error in my JsonPersistence class

✔️ Awesome, I've got everything!

Great. Make sure you have saved JsonPersistence.java before moving on.

ⓧ I'd like to double check the full code

package com.inventory;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class JsonPersistence {

    private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
    private static final Type INVENTORY_TYPE = new TypeToken<Map<String, Product>>() {}.getType();

    public static void saveInventory(Map<String, Product> inventory, Path filePath) {
        try (BufferedWriter writer = Files.newBufferedWriter(filePath)) {
            GSON.toJson(inventory, writer);
        } catch (IOException e) {
            System.err.println("Error saving inventory: " + e.getMessage());
        }
    }

    public static Map<String, Product> loadInventory(Path filePath) {
        if (!Files.exists(filePath)) {
            return new ConcurrentHashMap<>();
        }
        try (BufferedReader reader = Files.newBufferedReader(filePath)) {
            Map<String, Product> loaded = GSON.fromJson(reader, INVENTORY_TYPE);
            return loaded != null ? new ConcurrentHashMap<>(loaded) : new ConcurrentHashMap<>();
        } catch (IOException e) {
            System.err.println("Error loading inventory: " + e.getMessage());
            return new ConcurrentHashMap<>();
        }
    }
}
Wire persistence into InventoryStore

The persistence class exists, but nothing calls it yet. You need the store to load data on startup and save after every mutation.

  • Open InventoryStore.java and find the constructor:
    public InventoryStore(Path dataFile) {
        this.dataFile = dataFile;
    }
  • Replace the constructor with this updated version that loads existing data on startup:
    public InventoryStore(Path dataFile) {
        this.dataFile = dataFile;
        // Load any previously saved inventory from the JSON file
        Map<String, Product> loaded = JsonPersistence.loadInventory(dataFile);
        inventory.putAll(loaded);
    }

What does this change do?

When the store is created, it immediately calls JsonPersistence.loadInventory(dataFile) to read any saved data from the JSON file. If the file does not exist yet, the method returns an empty map and the store starts fresh.

  • Next, add a private save() helper method at the bottom of the InventoryStore class, just before the closing brace:
    // Delegates persistence to JsonPersistence so mutation methods stay clean
    private void save() {
        JsonPersistence.saveInventory(inventory, dataFile);
    }
  • Now add a save(); call at the end of the addProduct method, so it looks like this:
    public void addProduct(Product product) {
        inventory.put(product.id(), product);
        save();
    }
  • Add save(); after the finally block in the updateStock method, so the end of the method looks like this:
        } finally {
            lock.unlock();
        }
        save();
    }
  • Add save(); in the transferStock method after the finally block, before return true; so the end looks like this:
        } finally {
            lock.unlock();
        }
        save();
        return true;
    }

Why save after the lock is released?

Placing save() outside the lock means the file write does not block other threads from updating the in-memory map. The map itself is the source of truth for reads. The file is just a durable backup for restarts.

  • Save InventoryStore.java.
  • Confirm the project still compiles by running:
mvn compile

You should see BUILD SUCCESS with no errors.

Getting a compile error in InventoryStore?

  • Check that JsonPersistence.java is saved and in the same package (com.inventory). The store references JsonPersistence.loadInventory and JsonPersistence.saveInventory as static methods.
  • Make sure the save() method is inside the class body, not outside the closing brace of InventoryStore.

Help me fix a compile error in InventoryStore after adding persistence

✔️ Awesome, I've got everything!

Great. Make sure you have saved InventoryStore.java before moving on.

ⓧ I'd like to double check the full code

package com.inventory;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

public class InventoryStore {

    private final ConcurrentHashMap<String, Product> inventory = new ConcurrentHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();
    private final Path dataFile;

    public InventoryStore(Path dataFile) {
        this.dataFile = dataFile;
        Map<String, Product> loaded = JsonPersistence.loadInventory(dataFile);
        inventory.putAll(loaded);
    }

    public void addProduct(Product product) {
        inventory.put(product.id(), product);
        save();
    }

    public Product getProduct(String id) {
        return inventory.get(id);
    }

    public List<Product> getAllProducts() {
        return List.copyOf(inventory.values());
    }

    public List<Product> getProductsByCategory(String category) {
        return inventory.values().stream()
                .filter(p -> p.category().equalsIgnoreCase(category))
                .sorted((a, b) -> a.name().compareToIgnoreCase(b.name()))
                .collect(Collectors.toList());
    }

    public void updateStock(String productId, int quantityChange) {
        lock.lock();
        try {
            Product product = inventory.get(productId);
            if (product != null) {
                int newQuantity = Math.max(0, product.quantity() + quantityChange);
                inventory.put(productId, product.withQuantity(newQuantity));
            }
        } finally {
            lock.unlock();
        }
        save();
    }

    public boolean transferStock(String fromId, String toId, int quantity) {
        lock.lock();
        try {
            Product from = inventory.get(fromId);
            Product to = inventory.get(toId);
            if (from == null || to == null || from.quantity() < quantity) {
                return false;
            }
            inventory.put(fromId, from.withQuantity(from.quantity() - quantity));
            inventory.put(toId, to.withQuantity(to.quantity() + quantity));
        } finally {
            lock.unlock();
        }
        save();
        return true;
    }

    public Map<String, Product> getInventoryMap() {
        return Map.copyOf(inventory);
    }

    private void save() {
        JsonPersistence.saveInventory(inventory, dataFile);
    }
}
Verify the persistence round-trip

The real test of persistence is whether your data survives a full application restart. If you can stop the app, start it again, and see the same products, the file I/O is working correctly.

  • Run the application by executing:
mvn exec:java

You should see the two products printed to the console, just like before.

  • After the program finishes, confirm the JSON file was created by running:
ls inventory.json

You should see inventory.json listed in the output.

  • Inspect the file contents by running:
cat inventory.json

You should see pretty-printed JSON with product entries for ELC001 and ELC002, each containing the id, name, category, price, and quantity fields.

  • Run the application a second time:
mvn exec:java

You should see the same products printed again. The data was loaded from inventory.json on startup, proving the round-trip works.

Why does the second run show duplicates in the list?

The current InventoryApp.java always calls addProduct with the same IDs. Since ConcurrentHashMap uses the product ID as the key, it simply overwrites the existing entry with the same data. The list output stays the same.

No inventory.json file created?

  • Make sure you added save(); inside the addProduct method. Without it, the file is never written.
  • Check that you are running ls from the project root directory (concurrent-inventory). The JSON file is written relative to where Maven runs.
  • Look for an error message in the console output. If Files.newBufferedWriter fails, the catch block prints it to System.err.

Help me figure out why inventory.json is not being created

Your inventory data now lives on disk. Next up, you will simulate multiple suppliers restocking products concurrently using virtual threads.

Add Concurrent Supplier Simulation with Virtual Threads

Your inventory store persists data safely to disk. But a real warehouse doesn't get updated by one person at a time.

In this step, you'll simulate multiple suppliers restocking products at the same time using virtual threads. This is the exact concurrency pattern interviewers ask about for mid-to-senior Java roles.

In this step, get ready to:
  • Build a SupplierSimulator that spawns virtual threads to restock products concurrently.
  • Run concurrent updates and confirm thread safety with ConcurrentHashMap and ReentrantLock.
  • Demonstrate what happens when you remove the lock, then fix it.
Create the SupplierSimulator class

Virtual threads are lightweight threads managed by the JVM instead of the operating system. They let you create thousands of concurrent tasks without the memory overhead of platform threads.

Why virtual threads instead of a traditional thread pool?

A traditional thread pool (like Executors.newFixedThreadPool()) allocates a fixed number of platform threads, each consuming about 1MB of stack memory. Virtual threads consume only a few kilobytes each.

The supplier simulation pattern (many short-lived I/O-like tasks with sleep delays) is exactly the use case virtual threads were designed for. Each supplier task sleeps to simulate network latency, and virtual threads unmount from their carrier thread during that sleep, freeing it for other tasks.

  • Open SupplierSimulator.java and replace its contents with the following code:
package com.inventory;

import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SupplierSimulator {

    // Supplier names to label each simulated restocking task
    private static final List<String> SUPPLIER_NAMES = List.of(
            "Acme Corp", "GlobalParts", "QuickShip", "MegaSupply", "FastTrack"
    );

    private final Random random = new Random();

    public void simulateRestock(InventoryStore store, int taskCount) {
        // Snapshot current products so tasks can pick random targets
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products in inventory to restock.");
            return;
        }

        System.out.println("Starting restock simulation with " + taskCount + " supplier tasks...");

        // Virtual thread executor: each submitted task gets its own virtual thread
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                String supplier = SUPPLIER_NAMES.get(random.nextInt(SUPPLIER_NAMES.size()));
                executor.submit(() -> {
                    try {
                        // Simulate network/processing delay
                        Thread.sleep(random.nextInt(100, 500));
                        Product target = products.get(random.nextInt(products.size()));
                        int qty = random.nextInt(1, 51);
                        store.updateStock(target.id(), qty);
                        System.out.printf("  [%s] %s restocked %s with +%d units%n",
                                Thread.currentThread().getName(), supplier, target.name(), qty);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }

        System.out.println("Restock simulation complete.");
    }
}

What does this code do?

Executors.newVirtualThreadPerTaskExecutor() creates an ExecutorService that starts a new virtual thread for each submitted task. The number of threads is unbounded.

Wrapping it in try-with-resources means the close() method is called implicitly at the end of the block. That waits for all spawned virtual threads to terminate before continuing.

  • Thread.sleep(random.nextInt(100, 500)) simulates real-world network latency. Virtual threads unmount from their carrier thread during sleep, freeing it for other tasks.
  • Thread.currentThread().getName() prints the virtual thread name, which differs from platform thread names (you'll see names like "" instead of "pool-1-thread-1").
  • store.updateStock(target.id(), qty) calls the locked method you built in Step 2, so multiple virtual threads can safely update the same product.
  • Save SupplierSimulator.java.

Seeing a compile error?

If your IDE shows an error on Executors.newVirtualThreadPerTaskExecutor(), confirm your project is targeting Java 21. Check that pom.xml has <maven.compiler.source>21</maven.compiler.source>.

Help me fix a compile error in SupplierSimulator.java related to virtual threads

Run concurrent updates and observe thread safety

Now you'll wire the simulator into your main method and watch multiple virtual threads restock products at the same time.

  • Open InventoryApp.java and replace its contents with the following code:
package com.inventory;

import java.nio.file.Path;

public class InventoryApp {
    public static void main(String[] args) {
        InventoryStore store = new InventoryStore(Path.of("inventory.json"));
        SupplierSimulator simulator = new SupplierSimulator();
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));
        // Launch concurrent restocking with 10 virtual threads
        simulator.simulateRestock(store, 10);
        System.out.println(store.getAllProducts());
    }
}

What changed in the main method?

  • You instantiate a SupplierSimulator and call simulateRestock(store, 10) to spawn 10 concurrent supplier tasks.
  • After the simulation completes (all virtual threads finish), getAllProducts() prints the final inventory state with updated quantities.
  • Save InventoryApp.java.
  • Run the application by executing this command in your terminal:
mvn exec:java -q

You should see output showing multiple virtual threads restocking products simultaneously. Each line shows a thread name, supplier name, product, and quantity added.

Not seeing any restock output?

Make sure you saved both SupplierSimulator.java and InventoryApp.java before running. If you see "No products in inventory to restock", delete your inventory.json file and run again so the two sample products get re-added fresh.

Help me debug why my SupplierSimulator isn't producing any output

Demonstrate the race condition problem and solution

Your updateStock method works safely because of the ReentrantLock. To understand why the lock matters, you'll temporarily remove it and watch things break.

Why ReentrantLock instead of synchronized?

Virtual threads can unmount from their carrier thread during blocking I/O inside a ReentrantLock. But they PIN to the carrier inside a synchronized block, which defeats the purpose of being lightweight. This is a key interview distinction.

  • In InventoryStore.java, find the transferStock method.
  • Find these lines:
    public boolean transferStock(String fromId, String toId, int quantity) {
        lock.lock();
        try {
            Product from = inventory.get(fromId);
            Product to = inventory.get(toId);
            if (from == null || to == null || from.quantity() < quantity) {
                return false;
            }
            inventory.put(fromId, from.withQuantity(from.quantity() - quantity));
            inventory.put(toId, to.withQuantity(to.quantity() + quantity));
        } finally {
            lock.unlock();
        }
        save();
        return true;
    }
  • Temporarily comment out lock.lock(), the try/finally block structure, and lock.unlock() so the reads and writes happen without synchronization.
  • Update your main method to call transferStock concurrently from multiple threads (e.g., transferring between ELC001 and ELC002 in a loop) while the simulator also runs.
  • Run the application and observe the output. Without the lock, you may see inconsistent totals where quantity appears or disappears between the two products.

What's happening without the lock?

The transferStock method reads from one product, then writes to two products. Without the lock, another thread can read stale values between those operations. The total inventory quantity changes because reads and writes interleave unpredictably.

ConcurrentHashMap protects individual put and get calls from corruption. But compound operations (read-then-write across multiple keys) need external synchronization. That's exactly what ReentrantLock provides.

  • Re-enable the lock.lock(), try/finally, and lock.unlock() in transferStock by uncommenting those lines.
  • Revert your main method back to the version from the previous substep (instantiate store, simulator, add two products, call simulateRestock(store, 10), print all products).
  • Run the application again and confirm stock quantities are consistent. The total quantity across all products should only increase (since the simulator only adds stock).

Didn't see inconsistent totals without the lock?

Race conditions are timing-dependent. You might need to increase the number of concurrent tasks or reduce the sleep delay to trigger the issue consistently. The key takeaway is that without the lock, the code is UNSAFE even if it happens to produce correct results on one run.

Help me understand why I'm not seeing a race condition in my transferStock method

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files with the lock re-enabled.

ⓧ I'd like to double check the full code

Here is the full SupplierSimulator.java:

package com.inventory;

import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SupplierSimulator {

    private static final List<String> SUPPLIER_NAMES = List.of(
            "Acme Corp", "GlobalParts", "QuickShip", "MegaSupply", "FastTrack"
    );

    private final Random random = new Random();

    public void simulateRestock(InventoryStore store, int taskCount) {
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products in inventory to restock.");
            return;
        }

        System.out.println("Starting restock simulation with " + taskCount + " supplier tasks...");

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                String supplier = SUPPLIER_NAMES.get(random.nextInt(SUPPLIER_NAMES.size()));
                executor.submit(() -> {
                    try {
                        Thread.sleep(random.nextInt(100, 500));
                        Product target = products.get(random.nextInt(products.size()));
                        int qty = random.nextInt(1, 51);
                        store.updateStock(target.id(), qty);
                        System.out.printf("  [%s] %s restocked %s with +%d units%n",
                                Thread.currentThread().getName(), supplier, target.name(), qty);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }

        System.out.println("Restock simulation complete.");
    }
}

And here is the full InventoryApp.java:

package com.inventory;

import java.nio.file.Path;

public class InventoryApp {
    public static void main(String[] args) {
        InventoryStore store = new InventoryStore(Path.of("inventory.json"));
        SupplierSimulator simulator = new SupplierSimulator();
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));
        simulator.simulateRestock(store, 10);
        System.out.println(store.getAllProducts());
    }
}

Your inventory system now handles concurrent access from multiple virtual threads safely. Next up, you'll build an interactive CLI so you can query, add, and report on inventory in real-time while simulations run.

Build the Interactive CLI Loop

You have a thread-safe inventory store, file persistence, and a supplier simulator. But right now there is no way for a user to interact with any of it.

In this step, you will build the command-line interface that ties everything together. The CLI accepts typed commands, dispatches them to the appropriate method, and keeps running until the user types quit.

In this step, get ready to:
  • Implement a Scanner-based command parser with a switch-expression dispatch loop.
  • Add a formatted inventory report powered by the Streams API.
  • Run an integration test proving concurrency and persistence work end to end.
Implement the command parser

The CLI loop reads user input, splits it into a command and an optional argument, then dispatches to the matching method. A switch expression handles the routing cleanly.

  • Open InventoryApp.java and replace its entire contents with the following code:
package com.inventory;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.stream.Collectors;

public class InventoryApp {

    // Path to the JSON file where inventory persists between runs
    private static final Path DATA_FILE = Path.of("inventory.json");

    public static void main(String[] args) {
        InventoryStore store = new InventoryStore(DATA_FILE);
        SupplierSimulator simulator = new SupplierSimulator();
        Scanner scanner = new Scanner(System.in);

        System.out.println("=== Concurrent Inventory Manager ===");
        System.out.println("Commands: list, add, restock, search <category>, simulate, report, quit");

        // Seed sample data on first run when the JSON file is empty
        if (store.getAllProducts().isEmpty()) {
            seedSampleData(store);
            System.out.println("Loaded sample inventory data.");
        }

        boolean running = true;
        while (running) {
            System.out.print("\ninventory> ");
            String input = scanner.nextLine().trim();
            String[] parts = input.split("\\s+", 2);
            String command = parts[0].toLowerCase();

            switch (command) {
                case "list" -> listProducts(store);
                case "add" -> addProduct(store, scanner);
                case "restock" -> restockProduct(store, scanner);
                case "search" -> {
                    if (parts.length < 2) {
                        System.out.println("Usage: search <category>");
                    } else {
                        searchByCategory(store, parts[1]);
                    }
                }
                case "simulate" -> simulator.simulateRestock(store, 15);
                case "report" -> printReport(store);
                case "quit" -> {
                    running = false;
                    System.out.println("Inventory saved. Goodbye!");
                }
                default -> System.out.println("Unknown command. Try: list, add, restock, search, simulate, report, quit");
            }
        }

        scanner.close();
    }

    // Populates the store with sample products across three categories
    private static void seedSampleData(InventoryStore store) {
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));
        store.addProduct(new Product("ELC003", "HDMI Adapter", "Electronics", 18.50, 8));
        store.addProduct(new Product("OFF001", "Notebook A5", "Office", 4.99, 150));
        store.addProduct(new Product("OFF002", "Ballpoint Pen Pack", "Office", 7.49, 80));
        store.addProduct(new Product("TOL001", "Screwdriver Set", "Tools", 34.99, 15));
        store.addProduct(new Product("TOL002", "Measuring Tape", "Tools", 9.99, 30));
        store.addProduct(new Product("TOL003", "Drill Bits", "Tools", 24.99, 5));
    }
}

What does this code do?

DATA_FILE is a constant so every part of the app references the same file path.

The while (running) loop reads a line of input, splits it into a command word and an optional argument with split("\\s+", 2), then dispatches to the right handler. The enhanced switch expression uses Java 21 arrow-case syntax for clean, fallthrough-free routing.

seedSampleData only runs when the store is empty (first launch), populating 8 products across Electronics, Office, and Tools categories.

Start fresh with sample data

Your existing inventory.json file still has data from the previous step. Delete it so seedSampleData triggers on the next run and loads all 8 products.

  • Delete the existing inventory.json file from your project root by running:
rm inventory.json

The file won't compile yet because the helper methods (listProducts, addProduct, etc.) don't exist. You will add them next.

  • Add the following two read-only display methods below seedSampleData, inside the InventoryApp class (before the closing }):
    // Displays all products as a formatted table sorted alphabetically
    private static void listProducts(InventoryStore store) {
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products in inventory.");
            return;
        }
        System.out.printf("%-8s %-20s %-12s %8s %8s%n", "ID", "Name", "Category", "Price", "Qty");
        System.out.println("-".repeat(60));
        products.stream()
                .sorted((a, b) -> a.name().compareToIgnoreCase(b.name()))
                .forEach(p -> System.out.printf("%-8s %-20s %-12s %8.2f %8d%n",
                        p.id(), p.name(), p.category(), p.price(), p.quantity()));
    }

    // Filters products by category using the store's stream-based query
    private static void searchByCategory(InventoryStore store, String category) {
        List<Product> results = store.getProductsByCategory(category);
        if (results.isEmpty()) {
            System.out.println("No products found in category: " + category);
            return;
        }
        System.out.printf("Products in '%s':%n", category);
        results.forEach(p -> System.out.printf("  %s - %s (qty: %d)%n", p.id(), p.name(), p.quantity()));
    }

What does this code do?

  • listProducts sorts alphabetically using a Stream and prints a table with printf alignment specifiers like %-20s (left-aligned, 20 chars wide).
  • searchByCategory delegates filtering to InventoryStore.getProductsByCategory(), which already uses Streams with filter() and sorted() under the hood.
  • Add the following two interactive mutation methods below searchByCategory, still inside the class:
    // Prompts the user for product details and adds to the store
    private static void addProduct(InventoryStore store, Scanner scanner) {
        System.out.print("Product ID: ");
        String id = scanner.nextLine().trim();
        System.out.print("Name: ");
        String name = scanner.nextLine().trim();
        System.out.print("Category: ");
        String category = scanner.nextLine().trim();
        System.out.print("Price: ");
        double price = Double.parseDouble(scanner.nextLine().trim());
        System.out.print("Quantity: ");
        int qty = Integer.parseInt(scanner.nextLine().trim());

        store.addProduct(new Product(id, name, category, price, qty));
        System.out.println("Product added: " + name);
    }

    // Looks up a product by ID and adds the specified quantity
    private static void restockProduct(InventoryStore store, Scanner scanner) {
        System.out.print("Product ID to restock: ");
        String id = scanner.nextLine().trim();
        System.out.print("Quantity to add: ");
        int qty = Integer.parseInt(scanner.nextLine().trim());

        Product product = store.getProduct(id);
        if (product == null) {
            System.out.println("Product not found: " + id);
            return;
        }
        store.updateStock(id, qty);
        System.out.printf("Restocked %s: +%d units (now %d)%n",
                product.name(), qty, store.getProduct(id).quantity());
    }

What does this code do?

  • addProduct prompts for each field one at a time, constructs a new Product record, and persists it through the store.
  • restockProduct calls updateStock, which uses the ReentrantLock you built earlier. This makes it safe to call even while virtual threads are running the simulator concurrently.
  • Save InventoryApp.java.

The file will not compile yet because the printReport method is still missing. You will add it in the next substep.

Seeing a compile error about method order?

Make sure all helper methods are inside the InventoryApp class (between the opening and closing braces of the class). Java does not care about method order within a class, but all methods must be enclosed by the class body.

help me fix compile errors in my InventoryApp.java file

Add a formatted inventory report

The report command showcases the Streams API for real analytics. It computes total stock value, groups products by category, and surfaces the lowest-stock items.

  • Add the printReport method below searchByCategory in InventoryApp.java:
    // Computes inventory analytics using Streams collectors
    private static void printReport(InventoryStore store) {
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products to report on.");
            return;
        }

        // Total value = sum of (price * quantity) across all products
        double totalValue = products.stream()
                .mapToDouble(p -> p.price() * p.quantity())
                .sum();

        // Total stock = sum of all quantities
        int totalStock = products.stream()
                .mapToInt(Product::quantity)
                .sum();

        // Group products by category and count items per group
        Map<String, Long> categoryCount = products.stream()
                .collect(Collectors.groupingBy(Product::category, Collectors.counting()));

        // Bottom 5 products sorted by quantity ascending
        List<Product> lowStock = products.stream()
                .sorted((a, b) -> Integer.compare(a.quantity(), b.quantity()))
                .limit(5)
                .collect(Collectors.toList());

        System.out.println("\n=== INVENTORY REPORT ===");
        System.out.printf("Total products: %d%n", products.size());
        System.out.printf("Total stock units: %d%n", totalStock);
        System.out.printf("Total inventory value: $%.2f%n", totalValue);

        System.out.println("\nItems by category:");
        categoryCount.forEach((cat, count) ->
                System.out.printf("  %-12s: %d items%n", cat, count));

        System.out.println("\nLowest stock items:");
        lowStock.forEach(p ->
                System.out.printf("  %-20s: %d units%n", p.name(), p.quantity()));
    }

What does this code do?

  • mapToDouble(...).sum() converts each product to its total value (price times quantity) and sums the results into a single double.
  • Collectors.groupingBy(Product::category, Collectors.counting()) groups all products by their category and counts how many are in each group. The result is a Map<String, Long>.
  • The lowStock pipeline sorts ascending by quantity and takes the first 5. These are your most at-risk items.

Why are these Streams patterns important for interviews?

Interviewers test whether you can chain groupingBy, counting, sorted, and limit together without intermediate variables. This report method demonstrates exactly that skill with real data.

  • Save InventoryApp.java.
  • Compile the project by running:
mvn compile
  • You should see BUILD SUCCESS in the terminal output. If you see errors, double-check that all methods are inside the class and that all imports are present at the top of the file.

Seeing a symbol not found error?

Check that your imports include java.util.List, java.util.Map, java.util.Scanner, and java.util.stream.Collectors. Missing any one of these causes the compiler to fail on the methods that use them.

help me fix symbol not found errors in my Java inventory CLI

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete InventoryApp.java as it should look now:

package com.inventory;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.stream.Collectors;

public class InventoryApp {

    private static final Path DATA_FILE = Path.of("inventory.json");

    public static void main(String[] args) {
        InventoryStore store = new InventoryStore(DATA_FILE);
        SupplierSimulator simulator = new SupplierSimulator();
        Scanner scanner = new Scanner(System.in);

        System.out.println("=== Concurrent Inventory Manager ===");
        System.out.println("Commands: list, add, restock, search <category>, simulate, report, quit");

        if (store.getAllProducts().isEmpty()) {
            seedSampleData(store);
            System.out.println("Loaded sample inventory data.");
        }

        boolean running = true;
        while (running) {
            System.out.print("\ninventory> ");
            String input = scanner.nextLine().trim();
            String[] parts = input.split("\\s+", 2);
            String command = parts[0].toLowerCase();

            switch (command) {
                case "list" -> listProducts(store);
                case "add" -> addProduct(store, scanner);
                case "restock" -> restockProduct(store, scanner);
                case "search" -> {
                    if (parts.length < 2) {
                        System.out.println("Usage: search <category>");
                    } else {
                        searchByCategory(store, parts[1]);
                    }
                }
                case "simulate" -> simulator.simulateRestock(store, 15);
                case "report" -> printReport(store);
                case "quit" -> {
                    running = false;
                    System.out.println("Inventory saved. Goodbye!");
                }
                default -> System.out.println("Unknown command. Try: list, add, restock, search, simulate, report, quit");
            }
        }

        scanner.close();
    }

    private static void listProducts(InventoryStore store) {
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products in inventory.");
            return;
        }
        System.out.printf("%-8s %-20s %-12s %8s %8s%n", "ID", "Name", "Category", "Price", "Qty");
        System.out.println("-".repeat(60));
        products.stream()
                .sorted((a, b) -> a.name().compareToIgnoreCase(b.name()))
                .forEach(p -> System.out.printf("%-8s %-20s %-12s %8.2f %8d%n",
                        p.id(), p.name(), p.category(), p.price(), p.quantity()));
    }

    private static void addProduct(InventoryStore store, Scanner scanner) {
        System.out.print("Product ID: ");
        String id = scanner.nextLine().trim();
        System.out.print("Name: ");
        String name = scanner.nextLine().trim();
        System.out.print("Category: ");
        String category = scanner.nextLine().trim();
        System.out.print("Price: ");
        double price = Double.parseDouble(scanner.nextLine().trim());
        System.out.print("Quantity: ");
        int qty = Integer.parseInt(scanner.nextLine().trim());

        store.addProduct(new Product(id, name, category, price, qty));
        System.out.println("Product added: " + name);
    }

    private static void restockProduct(InventoryStore store, Scanner scanner) {
        System.out.print("Product ID to restock: ");
        String id = scanner.nextLine().trim();
        System.out.print("Quantity to add: ");
        int qty = Integer.parseInt(scanner.nextLine().trim());

        Product product = store.getProduct(id);
        if (product == null) {
            System.out.println("Product not found: " + id);
            return;
        }
        store.updateStock(id, qty);
        System.out.printf("Restocked %s: +%d units (now %d)%n",
                product.name(), qty, store.getProduct(id).quantity());
    }

    private static void searchByCategory(InventoryStore store, String category) {
        List<Product> results = store.getProductsByCategory(category);
        if (results.isEmpty()) {
            System.out.println("No products found in category: " + category);
            return;
        }
        System.out.printf("Products in '%s':%n", category);
        results.forEach(p -> System.out.printf("  %s - %s (qty: %d)%n", p.id(), p.name(), p.quantity()));
    }

    private static void printReport(InventoryStore store) {
        List<Product> products = store.getAllProducts();
        if (products.isEmpty()) {
            System.out.println("No products to report on.");
            return;
        }

        double totalValue = products.stream()
                .mapToDouble(p -> p.price() * p.quantity())
                .sum();

        int totalStock = products.stream()
                .mapToInt(Product::quantity)
                .sum();

        Map<String, Long> categoryCount = products.stream()
                .collect(Collectors.groupingBy(Product::category, Collectors.counting()));

        List<Product> lowStock = products.stream()
                .sorted((a, b) -> Integer.compare(a.quantity(), b.quantity()))
                .limit(5)
                .collect(Collectors.toList());

        System.out.println("\n=== INVENTORY REPORT ===");
        System.out.printf("Total products: %d%n", products.size());
        System.out.printf("Total stock units: %d%n", totalStock);
        System.out.printf("Total inventory value: $%.2f%n", totalValue);

        System.out.println("\nItems by category:");
        categoryCount.forEach((cat, count) ->
                System.out.printf("  %-12s: %d items%n", cat, count));

        System.out.println("\nLowest stock items:");
        lowStock.forEach(p ->
                System.out.printf("  %-20s: %d units%n", p.name(), p.quantity()));
    }

    private static void seedSampleData(InventoryStore store) {
        store.addProduct(new Product("ELC001", "USB-C Cable", "Electronics", 12.99, 45));
        store.addProduct(new Product("ELC002", "Wireless Mouse", "Electronics", 29.99, 22));
        store.addProduct(new Product("ELC003", "HDMI Adapter", "Electronics", 18.50, 8));
        store.addProduct(new Product("OFF001", "Notebook A5", "Office", 4.99, 150));
        store.addProduct(new Product("OFF002", "Ballpoint Pen Pack", "Office", 7.49, 80));
        store.addProduct(new Product("TOL001", "Screwdriver Set", "Tools", 34.99, 15));
        store.addProduct(new Product("TOL002", "Measuring Tape", "Tools", 9.99, 30));
        store.addProduct(new Product("TOL003", "Drill Bits", "Tools", 24.99, 5));
    }
}
Run the final integration test

Time to prove everything works together. You will launch the CLI, trigger concurrent restocking, and confirm that data persists across restarts.

  • Start the application by running:
mvn exec:java
  • You should see the banner === Concurrent Inventory Manager === followed by the command list and a Loaded sample inventory data. message.
  • Type list and press Enter. You should see a formatted table with 8 products across Electronics, Office, and Tools categories.
  • Type simulate and press Enter. Watch as 15 virtual threads restock products concurrently. Each line shows the thread name, supplier, product, and quantity added.
  • Type report and press Enter. You should see updated totals reflecting the restock, category counts, and the 5 lowest-stock items.
  • Type quit and press Enter to exit.
  • Confirm persistence by opening inventory.json in your editor. You should see all products with their updated quantities saved as pretty-printed JSON.
  • Run mvn exec:java again. This time, the app should NOT print "Loaded sample inventory data" because the store is not empty. Your data survived the restart.

What should I see during simulate?

Each restock line shows a virtual thread name (like VirtualThread[#21]), the supplier that triggered it, the product restocked, and how many units were added. The lines may appear in any order because the threads run concurrently.

The CLI prompt reappears after all 15 tasks finish. This is because the try-with-resources block in SupplierSimulator waits for all virtual threads to complete before returning control.

App hangs or doesn't show the prompt?

If the app appears stuck, the virtual threads may be sleeping (each has a random delay up to 500ms). Wait a few seconds for them to complete.

If you see no output at all, confirm you deleted the old inventory.json. An empty store means the simulate command prints "No products in inventory to restock." and does nothing.

help me debug my concurrent inventory CLI that hangs or shows no output

Your CLI is fully operational. You can list, add, restock, search, simulate concurrent supplier activity, and generate Streams-powered reports. Next up, you will tackle the Secret Mission and build a low-stock alert monitor that watches your inventory in the background.

Secret mission

Add a Low-Stock Alert Monitor

Your inventory system handles concurrent updates safely, but what happens when stock drops dangerously low? In this bonus challenge, you'll build a background monitoring thread that watches inventory levels and fires alerts using a producer-consumer pattern with LinkedBlockingQueue.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files on disk or delete them entirely.

Resources you used:

  • The concurrent-inventory project folder on your Desktop (source code, compiled classes, Maven build files).
  • The inventory.json data file inside the project folder.
  • Maven local repository cache (~/.m2/repository) containing the downloaded Gson dependency.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the inventory manager or want to revisit the concurrency patterns later.

  • Your project folder stays on your Desktop.
  • The inventory.json file retains your inventory data for next time you run the app.
  • Maven's local .m2 cache stays on disk. It is shared across all your Maven projects.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files stay on disk until you decide to delete them.

  • If the application is still running in your terminal, type quit to exit gracefully and save the inventory.
  • Your project will be ready to run again with mvn exec:java whenever you come back.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes the source code, compiled output, and the persisted inventory data.

  • If the application is running, type quit to exit first.
  • Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf concurrent-inventory
  • On Windows, run this instead:
cd ~\Desktop
rmdir /s /q concurrent-inventory
  • Optionally, remove the cached Gson dependency from Maven's local repository:
rm -rf ~/.m2/repository/com/google/code/gson

JDK 21 and Maven remain installed on your system. They are general-purpose tools you will use in other projects.

Nice Work!

Nice Work!

You just built a fully concurrent inventory management CLI in Java. Your application handles simultaneous stock updates from multiple supplier threads without data corruption, persists data to disk, and reports on inventory health in real time.

You've learned how to:

  • Build a thread-safe inventory store using ConcurrentHashMap for individual operations and ReentrantLock for compound operations that require atomicity across multiple map reads and writes.
  • Spawn virtual threads with Executors.newVirtualThreadPerTaskExecutor() to simulate concurrent supplier restocking and observe thread-safe behavior under load.
  • Implement persistent JSON storage using Gson and Java NIO with proper try-with-resources resource management and a Streams-powered reporting engine using groupingBy, counting, and sorted collectors.
  • Secret Mission: Implemented a producer-consumer monitoring pattern with LinkedBlockingQueue and a daemon virtual thread to surface low-stock alerts between CLI prompts.

Ready to quiz yourself?