10 · Project — Inventory Management System¶
The Level 2 capstone pulls together everything from this level: OOP design,
the Collections Framework, generics-backed collections, streams, custom
exceptions, java.nio.file persistence, and JUnit 5 tests — combined into one
working command-line application, in the same spirit as
Level 1's contact book.
What you'll build¶
A multi-class CLI Inventory Management System that:
- Adds products (id, name, quantity, price)
- Lists all products
- Updates a product's quantity
- Removes a product
- Finds a product by id
- Persists everything to a CSV file between runs, via
java.nio.file - Ships with a JUnit 5 test class covering the core logic
Project layout¶
inventory-system/
pom.xml
src/
main/
java/
com/example/inventory/Product.java
com/example/inventory/InventoryStorage.java
com/example/inventory/InventoryManager.java
test/
java/
com/example/inventory/InventoryStorageTest.java
pom.xml¶
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>inventory-system</artifactId>
<version>1.0.0</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>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Product.java — the data model¶
A record is a natural fit here: a product is a plain, immutable bundle of
data (see Module 9). Quantity updates are
modeled by creating a new Product rather than mutating one.
// Product.java
package com.example.inventory;
public record Product(String id, String name, int quantity, double price) {
public Product {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Product id cannot be blank");
}
if (quantity < 0) {
throw new IllegalArgumentException("Quantity cannot be negative: " + quantity);
}
if (price < 0) {
throw new IllegalArgumentException("Price cannot be negative: " + price);
}
}
// Returns a copy of this product with a different quantity
public Product withQuantity(int newQuantity) {
return new Product(id, name, newQuantity, price);
}
// Serialize to a single CSV line: id,name,quantity,price
public String toCsvLine() {
return id + "," + name + "," + quantity + "," + price;
}
// Parse a stored line back into a Product
public static Product fromCsvLine(String line) {
String[] parts = line.split(",", -1);
if (parts.length != 4) {
throw new IllegalArgumentException("Malformed product line: " + line);
}
return new Product(parts[0], parts[1], Integer.parseInt(parts[2]), Double.parseDouble(parts[3]));
}
@Override
public String toString() {
return String.format("%-8s %-15s qty=%-5d $%.2f", id, name, quantity, price);
}
}
InventoryStorage.java — file persistence¶
// InventoryStorage.java
package com.example.inventory;
import java.io.IOException;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class InventoryStorage {
private final Path filePath;
public InventoryStorage(String fileName) {
this.filePath = Paths.get(fileName);
}
// LinkedHashMap keeps insertion order, keyed by product id for O(1) lookup
public Map<String, Product> load() {
Map<String, Product> products = new LinkedHashMap<>();
if (!Files.exists(filePath)) {
return products; // no file yet -- start empty
}
try {
List<String> lines = Files.readAllLines(filePath);
for (String line : lines) {
if (!line.isBlank()) {
Product p = Product.fromCsvLine(line);
products.put(p.id(), p);
}
}
} catch (IOException e) {
System.out.println("Warning: could not read inventory file: " + e.getMessage());
}
return products;
}
public void save(Map<String, Product> products) {
List<String> lines = new ArrayList<>();
for (Product p : products.values()) {
lines.add(p.toCsvLine());
}
try {
if (filePath.getParent() != null) {
Files.createDirectories(filePath.getParent());
}
Files.write(filePath, lines);
} catch (IOException e) {
System.out.println("Error: could not save inventory: " + e.getMessage());
}
}
}
InventoryManager.java — CLI logic¶
// InventoryManager.java
package com.example.inventory;
import java.util.LinkedHashMap;
import java.util.Map;
public class InventoryManager {
static Map<String, Product> products;
static InventoryStorage storage = new InventoryStorage("inventory.csv");
public static void main(String[] args) {
products = storage.load();
if (args.length == 0) {
printUsage();
return;
}
String command = args[0];
try {
switch (command) {
case "add" -> addProduct(args);
case "list" -> listProducts();
case "update-quantity" -> updateQuantity(args);
case "remove" -> removeProduct(args);
case "find" -> findProduct(args);
default -> printUsage();
}
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
static void printUsage() {
System.out.println("Usage: InventoryManager [add <id> <name> <qty> <price> | " +
"list | update-quantity <id> <qty> | remove <id> | find <id>]");
}
static void addProduct(String[] args) {
if (args.length != 5) {
throw new IllegalArgumentException("add requires: <id> <name> <qty> <price>");
}
Product p = new Product(args[1], args[2], Integer.parseInt(args[3]), Double.parseDouble(args[4]));
products.put(p.id(), p);
storage.save(products);
System.out.println("Added: " + p);
}
static void listProducts() {
if (products.isEmpty()) {
System.out.println("No products yet.");
return;
}
for (Product p : products.values()) {
System.out.println(p);
}
}
static void updateQuantity(String[] args) {
if (args.length != 3) {
throw new IllegalArgumentException("update-quantity requires: <id> <qty>");
}
String id = args[1];
int newQty = Integer.parseInt(args[2]);
Product existing = products.get(id);
if (existing == null) {
throw new IllegalArgumentException("No product with id " + id);
}
Product updated = existing.withQuantity(newQty);
products.put(id, updated);
storage.save(products);
System.out.println("Updated: " + updated);
}
static void removeProduct(String[] args) {
if (args.length != 2) {
throw new IllegalArgumentException("remove requires: <id>");
}
String id = args[1];
Product removed = products.remove(id);
storage.save(products);
System.out.println(removed != null ? "Removed " + id : "No product with id " + id);
}
static void findProduct(String[] args) {
if (args.length != 2) {
throw new IllegalArgumentException("find requires: <id>");
}
Product p = products.get(args[1]);
System.out.println(p != null ? p : "No product with id " + args[1]);
}
}
InventoryStorageTest.java — JUnit 5 tests¶
// InventoryStorageTest.java
package com.example.inventory;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class InventoryStorageTest {
@Test
void productRoundTripsThroughCsv() {
Product original = new Product("P1", "Widget", 10, 4.99);
Product parsed = Product.fromCsvLine(original.toCsvLine());
assertEquals(original.id(), parsed.id());
assertEquals(original.name(), parsed.name());
assertEquals(original.quantity(), parsed.quantity());
assertEquals(original.price(), parsed.price(), 0.0001);
}
@Test
void withQuantityReturnsNewProductWithoutMutatingOriginal() {
Product original = new Product("P2", "Gadget", 5, 9.99);
Product updated = original.withQuantity(20);
assertEquals(5, original.quantity()); // original untouched -- records are immutable
assertEquals(20, updated.quantity());
assertEquals(original.id(), updated.id());
}
@Test
void constructorRejectsNegativeQuantity() {
assertThrows(IllegalArgumentException.class,
() -> new Product("P3", "Broken", -1, 1.0));
}
@Test
void saveAndLoadRoundTripThroughATempFile(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) {
InventoryStorage storage = new InventoryStorage(tempDir.resolve("inventory.csv").toString());
Map<String, Product> original = new java.util.LinkedHashMap<>();
original.put("P1", new Product("P1", "Widget", 10, 4.99));
original.put("P2", new Product("P2", "Gadget", 5, 9.99));
storage.save(original);
Map<String, Product> loaded = storage.load();
assertEquals(2, loaded.size());
assertEquals("Widget", loaded.get("P1").name());
}
}
Running it¶
mvn compile
mvn exec:java -Dexec.mainClass="com.example.inventory.InventoryManager" -Dexec.args="add P1 Widget 10 4.99"
# Added: P1 Widget qty=10 $4.99
mvn exec:java -Dexec.mainClass="com.example.inventory.InventoryManager" -Dexec.args="add P2 Gadget 5 9.99"
mvn exec:java -Dexec.mainClass="com.example.inventory.InventoryManager" -Dexec.args="list"
# P1 Widget qty=10 $4.99
# P2 Gadget qty=5 $9.99
mvn exec:java -Dexec.mainClass="com.example.inventory.InventoryManager" -Dexec.args="update-quantity P2 20"
# Updated: P2 Gadget qty=20 $9.99
mvn test
# [INFO] Running com.example.inventory.InventoryStorageTest
# [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
# [INFO] BUILD SUCCESS
(Running via plain javac/java also works, the same way as
Level 1's contact book — just
compile everything under src/main/java with javac -d out ... and run with
java -cp out com.example.inventory.InventoryManager.)
Each invocation reloads inventory.csv from disk via
java.nio.file, so the inventory persists across
separate runs of the program, and the accompanying test class exercises the
core Product and InventoryStorage logic the way
Module 7 taught, independent of the CLI wiring.
How It Actually Works¶
The JUnit 5 tests run because the framework uses reflection at test-discovery time — it scans the compiled class for methods annotated @Test, invokes each one through Method.invoke(), and catches any thrown AssertionError to report a failure (a passing test is just a method that returns normally). InventoryStorage's file persistence round-trips through the same buffered-I/O mechanism as any file write in Java: the JVM's BufferedWriter batches your writes in a memory buffer and only issues the actual OS write() syscall when that buffer fills or you flush/close it.
Stretch goals¶
- Add a
restock <id> <amount>command that increases quantity relative to the current value instead of replacing it outright. - Add validation that rejects
adding a duplicate id, using a custom checked exception (DuplicateProductException, tying back to Module 5) instead of silently overwriting. - Add a
low-stock <threshold>command that streams the products (Module 4) to print only those withquantitybelow the given threshold. - Switch storage from CSV to a small in-memory
TreeMapsorted by product name for thelistcommand's display order, without changing the on-disk format (Module 2). - Expand
InventoryStorageTestwith a@ParameterizedTestcovering several malformed CSV lines and asserting each throwsIllegalArgumentException.
Completing this project means you're ready for Level 3 · Advanced.