This mid-course project asks you to build an E-Commerce Product Catalog, a console application that manages a catalog of products and answers rich queries about them, consolidating everything from the first four modules into one realistic system. You will model products as objects with proper encapsulation, organise them in collections, process and query them with the Streams API, handle invalid data with exceptions, and use lambdas, method references, Optional, and functional composition throughout.
Unlike the smaller exercises, this is an open-ended project: you are given the requirements, an architecture, and a phased build plan, but you write the bulk of the code yourself, making design decisions and combining the techniques as a real developer would. The catalog supports adding products, filtering and sorting them by various criteria, grouping by category, computing aggregate statistics, and looking up individual products safely, the kind of querying every e-commerce backend performs.
Learning Objectives
- Integrate OOP design (encapsulated classes, records, immutability) with collections, generics, streams, exceptions, and the functional style into one coherent application.
- Model a domain (products, categories, prices) as well-designed types with validating construction so the catalog can never hold invalid data.
- Use the Streams API with lambdas and method references to filter, map, sort, group, and aggregate catalog data declaratively.
- Apply Optional for safe lookups and exceptions for invalid input, so absence and error are handled explicitly rather than via null or crashes.
- Make independent design decisions, choosing collection types, structuring query methods, and composing predicates, as a developer does on a real task.
- Verify the system against realistic scenarios, confirming queries are correct and bad data is rejected gracefully.
Technical Requirements
- A Product type (a record is ideal) with at least name, category, price (use a long of minor units or BigDecimal, never double), and stock quantity, with validation on creation.
- A custom exception (e.g. InvalidProductException) thrown when product data is invalid, keeping the catalog's invariants intact.
- A Catalog class holding products in appropriate collections and exposing query methods built on the Streams API.
- Filtering (by category, price range, in-stock), sorting (by price, name, stock) via Comparator chaining with method references, and grouping (by category) via Collectors.groupingBy.
- Aggregate queries: total inventory value, average price per category, count per category, and the most/least expensive product, returned via Optional where a result may be absent.
- Safe single-product lookup by name returning Optional<Product>, and graceful handling of duplicate or missing entries.
- A small main method (or simple menu) that loads sample data, including some invalid records to exercise error handling, and runs each query.
Architecture & Design
The system separates three concerns cleanly: the data model (Product and the custom exception), the storage and query layer (Catalog), and the driver (main). The Product is an immutable record that validates itself on construction, so an invalid product simply cannot exist; the Catalog owns the collections and exposes only meaningful query methods, keeping its internal storage private; and the driver wires sample data in and exercises the queries.
This layering mirrors real applications, where a domain model, a repository/service that queries it, and an entry point are distinct responsibilities. The Catalog should hold products in a List for ordered iteration and may additionally maintain a Map from category to products if grouped queries are frequent, a design decision you make based on which queries dominate, exactly the collection-choice reasoning from Module 3. Every query method is built from streams over the stored products, returning new collections or aggregates rather than exposing or mutating the internal storage.
// Product.java + InvalidProductException.java -- the validated, immutable data model.
import java.util.Objects;
public class InvalidProductException extends Exception {
public InvalidProductException(String message) { super(message); }
}
// A record: immutable, with equals/hashCode/toString generated. Validate in a
// compact constructor so an invalid Product can NEVER be created.
public record Product(String name, String category, long priceCents, int stock) {
public Product { // compact canonical constructor
Objects.requireNonNull(name, "name");
Objects.requireNonNull(category, "category");
if (name.isBlank()) throw new IllegalArgumentException("name required");
if (priceCents < 0) throw new IllegalArgumentException("price cannot be negative");
if (stock < 0) throw new IllegalArgumentException("stock cannot be negative");
}
public boolean inStock() { return stock > 0; }
public double priceDollars() { return priceCents / 100.0; }
}
Phase 1 — Model and Storage
In Phase 1 you build the data model and the Catalog's storage with a validated ingestion path. The Product record validates itself, and the Catalog exposes an add method that accepts product data, attempts to construct a Product, and either stores it or surfaces an InvalidProductException so the caller knows a record was rejected. This establishes the invariant the whole project rests on: every product in the catalog is valid.
The design decision here is how to store products for the queries you will build. A List<Product> gives ordered iteration and is the natural primary store; whether to also maintain a Map<String, List<Product>> by category depends on how often grouped queries run, building it eagerly speeds those queries but adds maintenance on every add. Start with the List and add the map only if grouped queries dominate, the kind of pragmatic, query-driven structure choice real design demands.
// Catalog.java -- Phase 1: validated ingestion into private storage.
import java.util.*;
public class Catalog {
private final List<Product> products = new ArrayList<>(); // primary store
// Validated ingestion: construct the Product, surfacing failures to the caller.
public void add(String name, String category, long priceCents, int stock)
throws InvalidProductException {
try {
products.add(new Product(name, category, priceCents, stock));
} catch (RuntimeException e) { // record's validation throws unchecked
throw new InvalidProductException("Rejected '" + name + "': " + e.getMessage());
}
}
public int size() { return products.size(); }
// Expose a read-only VIEW, never the mutable internal list (encapsulation).
public List<Product> all() { return Collections.unmodifiableList(products); }
}
Phase 2 — Queries with Streams
Phase 2 is the heart of the project: building the query methods on top of the stored products using the Streams API, lambdas, method references, and Optional. You will implement filtering (by category, by price range, in-stock only), sorting (by price, by name, by stock) using Comparator chaining with key-extractor method references, and grouping by category with Collectors.groupingBy, plus a safe single-product lookup returning Optional<Product>.
Each query is a small stream pipeline that reads as a description of intent, and the design discipline is to keep every method focused and to return new collections or Optionals rather than mutating state. Compose predicates where it clarifies (in-stock and within a price range), use method references for extractors (Product::priceCents), and let groupingBy replace what would otherwise be manual map-building loops. This is where Modules 3 and 4 pay off together: the collections hold the data and the streams express the questions.
// Catalog.java (continued) -- Phase 2: stream-based queries, returning new results.
import java.util.*;
import java.util.stream.*;
public class CatalogQueries { // illustrative: these methods belong in Catalog
private final List<Product> products;
CatalogQueries(List<Product> products) { this.products = products; }
// FILTER: in-stock products within a price range (composed conditions).
List<Product> available(long minCents, long maxCents) {
return products.stream()
.filter(Product::inStock)
.filter(p -> p.priceCents() >= minCents && p.priceCents() <= maxCents)
.collect(Collectors.toList());
}
// SORT: by price descending, then name, via Comparator chaining + method refs.
List<Product> byPriceThenName() {
return products.stream()
.sorted(Comparator.comparingLong(Product::priceCents).reversed()
.thenComparing(Product::name))
.collect(Collectors.toList());
}
// GROUP: products by category (groupingBy replaces a manual map-build loop).
Map<String, List<Product>> byCategory() {
return products.stream().collect(Collectors.groupingBy(Product::category));
}
// SAFE LOOKUP: returns Optional, making 'not found' explicit.
Optional<Product> findByName(String name) {
return products.stream().filter(p -> p.name().equals(name)).findFirst();
}
}
Phase 3 — Aggregates and the Driver
Phase 3 adds aggregate analytics and the driver that ties everything together. The aggregates, total inventory value, average price per category, count per category, and the most and least expensive product, exercise reductions and downstream collectors: mapToLong(...).sum() for totals, groupingBy with averagingDouble or counting for per-category figures, and max/min with a Comparator returning Optional for the extremes, since the catalog might be empty.
The driver loads sample data, deliberately including a few invalid records to confirm they are rejected without aborting the load, then runs each query and prints the results. This final phase demonstrates the whole system working end to end: validated ingestion that tolerates bad input, declarative queries over well-chosen collections, and aggregates computed with streams, with Optional handling the empty cases, the complete shape of a real catalog backend in miniature.
// StoreApp.java -- Phase 3: aggregates + a driver tolerant of bad input.
import java.util.*;
import java.util.stream.*;
public class StoreApp {
// Aggregates (illustrative; belong in Catalog, operating on its product list):
static long totalValueCents(List<Product> ps) {
return ps.stream().mapToLong(p -> p.priceCents() * p.stock()).sum();
}
static Map<String, Double> avgPriceByCategory(List<Product> ps) {
return ps.stream().collect(Collectors.groupingBy(
Product::category, Collectors.averagingDouble(Product::priceDollars)));
}
static Optional<Product> mostExpensive(List<Product> ps) {
return ps.stream().max(Comparator.comparingLong(Product::priceCents));
}
public static void main(String[] args) {
Catalog catalog = new Catalog();
// Sample data INCLUDING invalid records to exercise error handling.
Object[][] rows = {
{"Bat", "Equipment", 8500L, 12}, {"Ball", "Equipment", 1200L, 50},
{"Jersey", "Apparel", 4000L, 0}, {"", "Apparel", 999L, 3}, // blank name -> reject
{"Gloves", "Equipment", -10L, 5}}; // negative price -> reject
for (Object[] r : rows) {
try {
catalog.add((String) r[0], (String) r[1], (long) r[2], (int) r[3]);
} catch (InvalidProductException e) {
System.out.println("Skipped -> " + e.getMessage()); // tolerated, not fatal
}
}
List<Product> ps = catalog.all();
System.out.printf("Inventory value: $%.2f%n", totalValueCents(ps) / 100.0);
System.out.println("Avg price/category: " + avgPriceByCategory(ps));
mostExpensive(ps).ifPresent(p -> System.out.println("Priciest: " + p.name()));
}
}
Evaluation Rubric
- Correctness: every query returns accurate results, and aggregates match hand-checked values on the sample data.
- Data integrity: invalid products are rejected on creation and during ingestion, the catalog never holds an invalid product, and bad records do not abort the load.
- Design quality: clean separation of model, query layer, and driver; private storage exposed only through query methods and read-only views; sensible collection choices justified by the queries.
- Functional style: queries built from focused stream pipelines using lambdas, method references, composed predicates, and Comparator chaining, not hand-written loops.
- Safe absence handling: single-product lookups and extremes return Optional and are consumed with orElse/ifPresent rather than risking null or empty-stream errors.
- Readability: methods are small and well-named, prices use integer minor units or BigDecimal (never double), and the code reads as a description of what each query does.
Extension Challenges: Add pagination to the listing queries (skip and limit on the stream) and a full-text search that matches a term against name and category. Introduce a discount system where a composed Function transforms prices for products in a promoted category, demonstrating function composition on real data. For a larger stretch, persist the catalog to a file and reload it using try-with-resources from lesson 13, and add an inventory-low report using groupingBy with a filtering downstream collector. Finally, write a few assertions or a small test harness that verifies each query against known sample data.