100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Java Mastery
60 minintermediate

Mid-Course Project: E-Commerce Product Catalog

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.

Analogy🏏Cricket
🏏 Think of it like cricket: this project is your first full international match after a season of nets and practice games. In the nets you drilled each skill, batting, bowling, fielding, in isolation; in this match every skill must come together under real conditions, with you making the in-game decisions rather than following a coach's step-by-step script. Just as a real match tests whether your separately-honed skills cohere into a complete performance, this project tests whether your separately-learned techniques, OOP, collections, streams, exceptions, functional style, cohere into a working application. Just as a captain shapes the game with their own judgement rather than a fixed plan, you shape this catalog with your own design choices. The insight is that genuine capability shows only when the full toolkit is assembled, under your own direction, into something complete.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: a well-run team organisation has clear, separate departments, the players themselves (the data, each a properly-prepared individual), the team management that selects, arranges, and reports on them (the query layer), and the match-day operations that put it all into action (the driver). Just as players are validated and registered before they can be part of the squad, products are validated before entering the catalog. Just as management never lets outsiders rummage through the squad directly but answers questions about it through proper channels, the Catalog keeps its storage private and answers via query methods. Just as match-day operations simply coordinate the departments rather than doing their jobs, the driver wires the pieces together. The insight is that separating the data, the logic that queries it, and the coordination that runs it keeps a system as orderly as a well-managed team.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: before a tournament, the team first ensures every player is properly registered and medically cleared, no one unfit or unregistered makes the squad, and then decides how to organise the squad list for the questions selectors will ask. Just as registration guarantees every squad member is valid, the validating add guarantees every catalog product is valid. Just as the team keeps a master squad list and only sets up extra groupings (by role, by form) when those will be queried often, the Catalog keeps a master List and adds a category Map only if grouped queries are frequent. The insight is that establishing a clean, validated store first, then organising it for the questions you will actually ask, is the right order to build any data-backed system.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: with the squad registered, the selectors now answer the coach's questions by running the squad through declarative filters and sorts, 'show me the in-form middle-order batters, ranked by strike rate', 'group everyone by specialism', 'who has the highest average?'. Just as the selectors describe each query as a clear chain of criteria rather than manually sifting the list, your query methods describe each question as a stream pipeline. Just as 'group by specialism' is one organised operation rather than a tedious manual sort into piles, groupingBy is one operation rather than a hand-built map. Just as a question that may have no answer, 'is there an uncapped leg-spinner?', is handled with a clear 'none found' rather than confusion, a lookup returns Optional. The insight is that well-posed questions over organised data become clean, declarative pipelines.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: the season's end brings the summary analytics, total runs scored, each squad's batting average, the leading and trailing run-scorers, computed across all the validated player records, and then the whole operation is put through a full dress rehearsal to confirm it runs cleanly, including handling a few junk entries that should be discarded rather than derailing the report. Just as the season summary aggregates the verified records into headline figures, the aggregate queries reduce the catalog into totals and averages. Just as the leading-scorer figure must cope with the possibility of no qualifying players, the most-expensive query returns Optional for an empty catalog. Just as the dress rehearsal proves the whole operation holds together under realistic, messy conditions, the driver proves the catalog works end to end. The insight is that a system is only proven when it produces correct summaries and survives messy real input from start to finish.
java
// 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.

Submit your capstone project

Checking submission status…
Lesson 20 of 35
0% complete