Module 10 · Capstone: Professional Java Project ⏱ 19 min

Milestone 3: Interfaces for Extensibility

By the end of this lesson you will be able to:
  • Design a repository interface that hides the storage implementation
  • Apply the Strategy pattern to make search behaviour interchangeable
  • Predict which implementation runs when a variable is declared as the interface type

In a real project, storage technology changes. Today you keep books in a HashMap because it is fast and requires no setup. Next quarter the team decides to move the catalog to PostgreSQL so multiple servers can share it. If every class that needs a book says new InMemoryBookRepository(), you will edit dozens of files when the database arrives. Even a simple rename of the class forces a global search-and-replace that is easy to get wrong. Worse, you cannot write unit tests that use a fake repository, because the concrete type is baked into every caller.

An interface is a contract that hides the how behind a what. A BookRepository interface declares methods like findByIsbn and save; an InMemoryBookRepository implements those methods with a Map, and a future DatabaseBookRepository will implement them with SQL. Callers only know the interface, so swapping the implementation changes exactly one line: the new call. That is how professional code stays flexible as requirements grow.

flowchart TD
  S["LibraryService"] -->|"uses"| R["BookRepository (interface)"]
  R --> I1["InMemoryBookRepository"]
  R --> I2["DatabaseBookRepository\n(future)"]
  style R fill:#c2410c,color:#fff
  style I1 fill:#15803d,color:#fff
  style I2 fill:#3776ab,color:#fff
The Repository pattern: callers depend on the interface, not the concrete storage.

The repository contract

A repository interface lists the operations your domain needs without committing to a storage technology. findByIsbn returns the book or null; save stores it; findAll returns every book. These method signatures are the contract every implementation must honour. The interface contains no fields, no constructors, and no logic — only promises.

The power is in the variable type. When you write BookRepository repo = new InMemoryBookRepository(), the compiler treats repo as a BookRepository. You can only call methods the interface declares, even though the object behind the variable has extra methods. That restriction is the feature: it stops you from accidentally depending on HashMap-specific behaviour that a database version could not provide. The interface name becomes a real type you can use for parameters, return values, and fields, just like a class.

Reading the contract

Look at the BookRepository interface below. It has no body, no braces full of logic, only method headers. That minimalism is deliberate: it says what a repository must do without prescribing how. The InMemoryBookRepository class that follows is one valid answer; a database version would look identical from the outside and behave differently on the inside. When you read the code, focus on the boundary between the interface and the class — that boundary is where flexibility lives.

Main.java — a repository interface and an in-memory implementation. Real, compiling Java.
import java.util.HashMap;
import java.util.Map;

public class Main {
    static class Book {
        String isbn, title, author;
        Book(String i, String t, String a) {
            isbn = i; title = t; author = a;
        }
    }

    interface BookRepository {
        Book findByIsbn(String isbn);
    }

    static class InMemoryBookRepository implements BookRepository {
        private Map<String, Book> books = new HashMap<>();
        InMemoryBookRepository() {
            books.put("001", new Book("001", "Clean Code", "Robert Martin"));
        }
        public Book findByIsbn(String isbn) {
            return books.get(isbn);
        }
    }

    public static void main(String[] args) {
        BookRepository repo = new InMemoryBookRepository();
        Book b = repo.findByIsbn("001");
        System.out.println(b.title);
    }
}

Why this matters for testing

Interfaces do not just prepare you for databases; they let you test in isolation. A unit test for LibraryService should not need a real database or even a real HashMap. You can write a FakeBookRepository that implements BookRepository with a simple List, pre-load it with known data, and hand it to the service. Because the service accepts BookRepository, it does not care which implementation it receives.

Without the interface, the service would construct its own InMemoryBookRepository internally, and your test would have no way to replace it. The result is slower tests, more setup, and hidden dependencies. A test that needs no database is a test that runs in milliseconds and fails only when your logic is wrong, not when the network is slow. Interface-based design is how you keep business logic testable without a database running on the build machine.

Main.java — a fake repository for testing, passed where the interface is expected. Real, compiling Java.
import java.util.Arrays;
import java.util.List;

public class Main {
    static class Book {
        String title;
        Book(String t) { title = t; }
    }

    interface BookRepository {
        List<Book> findAll();
    }

    static class FakeRepository implements BookRepository {
        public List<Book> findAll() {
            return Arrays.asList(new Book("A"), new Book("B"));
        }
    }

    public static void main(String[] args) {
        BookRepository repo = new FakeRepository();
        System.out.println(repo.findAll().size());
    }
}

Strategy: interchangeable search behaviour

A library search box can hunt by title, author, or ISBN. One way to build this is three separate methods inside the service, each looping the collection differently. The problem is that every time you add a new search type, you edit the service class again, and the class grows longer and harder to read. The alternative is a long if chain inside one method: if (type.equals("title")) ... else if .... That centralises complexity and guarantees merge conflicts when two developers add search types at the same time.

The Strategy pattern extracts each algorithm into its own class that shares a common interface. SearchStrategy declares matches(Book); TitleSearch checks the title, AuthorSearch checks the author. The service accepts any SearchStrategy and does not care which one it received. Adding a GenreSearch later means writing one new class, not modifying existing code that already works.

flowchart LR
  C["findBooks(strategy)"] -->|"delegates"| S["SearchStrategy"]
  S --> TS["TitleSearch"]
  S --> AS["AuthorSearch"]
  TS -->|"title check"| M1["match"]
  AS -->|"author check"| M2["match"]
  style S fill:#c2410c,color:#fff
  style TS fill:#15803d,color:#fff
  style AS fill:#3776ab,color:#fff
The Strategy pattern: different search rules share one interface and can be swapped at runtime.

Wiring a strategy

The SearchStrategy interface below is even smaller than the repository: one method, one decision. Each implementation encapsulates a single rule, and the countMatches method accepts any rule without knowing its name. That is polymorphism doing real work in a design pattern. Notice how the loop does not change when you add a new search type; only the object you pass in changes.

Main.java — a SearchStrategy interface with two implementations and a method that accepts either. Real, compiling Java.
public class Main {
    static class Book {
        String title, author;
        Book(String t, String a) { title = t; author = a; }
    }

    interface SearchStrategy {
        boolean matches(Book book);
    }

    static class TitleSearch implements SearchStrategy {
        String keyword;
        TitleSearch(String k) { keyword = k; }
        public boolean matches(Book b) {
            return b.title.contains(keyword);
        }
    }

    static class AuthorSearch implements SearchStrategy {
        String keyword;
        AuthorSearch(String k) { keyword = k; }
        public boolean matches(Book b) {
            return b.author.contains(keyword);
        }
    }

    static int countMatches(Book[] books, SearchStrategy strategy) {
        int count = 0;
        for (Book b : books) {
            if (strategy.matches(b)) count++;
        }
        return count;
    }

    public static void main(String[] args) {
        Book[] books = {
            new Book("Java Basics", "Alice Smith"),
            new Book("Advanced Java", "Bob Jones")
        };
        System.out.println(countMatches(books, new TitleSearch("Java")));
        System.out.println(countMatches(books, new AuthorSearch("Smith")));
    }
}

Combining the patterns

Real code uses both patterns together. A LibraryService holds a BookRepository to fetch books, and a search method accepts a SearchStrategy to decide which ones to keep. The service never knows whether the repository is in-memory or on disk, and it never knows whether the search checks titles or authors. Both details are hidden behind interfaces.

This layering is how large Java applications stay maintainable. The domain layer defines contracts; the infrastructure layer implements them; and the application layer wires them together. When a requirement changes, you usually edit one implementation or add one new class, rather than opening files across the whole codebase. This style of wiring objects together is called dependency injection, and it is the standard pattern in professional Java frameworks like Spring.

flowchart TD
  GOOD["BookRepository repo = new InMemoryBookRepository()"] -->|"swap"| FUTURE["new DatabaseBookRepository()"]
  BAD["InMemoryBookRepository repo = new InMemoryBookRepository()"] -->|"stuck"| SAME["same type forever"]
  style GOOD fill:#15803d,color:#fff
  style BAD fill:#b91c1c,color:#fff
Declaring the variable as the interface keeps the code open to new implementations.
Exercise

What does this program print? A repository is used through its interface. Read carefully and type the exact output (two lines).

public class Main {
    static class Book {
        String title;
        Book(String t) { title = t; }
    }

    interface BookRepository {
        Book findByIsbn(String isbn);
    }

    static class InMemoryBookRepository implements BookRepository {
        public Book findByIsbn(String isbn) {
            if ("001".equals(isbn)) return new Book("Clean Code");
            return null;
        }
    }

    public static void main(String[] args) {
        BookRepository repo = new InMemoryBookRepository();
        Book b = repo.findByIsbn("001");
        System.out.println(b.title);
        System.out.println(repo.findByIsbn("002") == null);
    }
}
Exercise

Why should service code declare variables as BookRepository instead of InMemoryBookRepository?

Exercise

What does this program print? A strategy finds the first matching book. Predict the exact output (one line).

public class Main {
    static class Book {
        String title, author;
        Book(String t, String a) { title = t; author = a; }
    }

    interface SearchStrategy {
        boolean matches(Book b);
    }

    static class TitleSearch implements SearchStrategy {
        String keyword;
        TitleSearch(String k) { keyword = k; }
        public boolean matches(Book b) {
            return b.title.contains(keyword);
        }
    }

    static void printFirstMatch(Book[] books, SearchStrategy s) {
        for (Book b : books) {
            if (s.matches(b)) {
                System.out.println(b.title);
                break;
            }
        }
    }

    public static void main(String[] args) {
        Book[] books = {
            new Book("Python Guide", "Alice"),
            new Book("Java Guide", "Bob")
        };
        printFirstMatch(books, new TitleSearch("Java"));
    }
}
Exercise

You add a new IsbnSearch class that implements SearchStrategy. What must you change in Library.findBooks to use it?

Exercise

Transfer task. A repository and a strategy are used together. Predict the exact output (one line).

import java.util.Arrays;
import java.util.List;

public class Main {
    static class Book {
        String title, author;
        Book(String t, String a) { title = t; author = a; }
    }

    interface BookRepository {
        List<Book> findAll();
    }

    interface SearchStrategy {
        boolean matches(Book b);
    }

    static class MyRepo implements BookRepository {
        public List<Book> findAll() {
            return Arrays.asList(
                new Book("Java Core", "Alice"),
                new Book("Java Streams", "Bob"),
                new Book("Python Core", "Charlie")
            );
        }
    }

    static class AuthorSearch implements SearchStrategy {
        String keyword;
        AuthorSearch(String k) { keyword = k; }
        public boolean matches(Book b) {
            return b.author.contains(keyword);
        }
    }

    static int countMatches(BookRepository repo, SearchStrategy s) {
        int count = 0;
        for (Book b : repo.findAll()) {
            if (s.matches(b)) count++;
        }
        return count;
    }

    public static void main(String[] args) {
        System.out.println(countMatches(new MyRepo(), new AuthorSearch("Bob")));
    }
}

Recap

  • An interface hides implementation details so callers depend on behaviour, not construction.
  • A repository interface decouples business logic from storage technology and makes testing with fakes possible.
  • The Strategy pattern makes algorithms interchangeable by wrapping them in interface implementations.
  • Declare variables as the interface type so you can swap implementations without editing service code.
  • Adding a new implementation requires writing one class, not rewriting every caller.

Checkpoint quiz

What is the main benefit of declaring a variable as the interface type rather than the concrete class?

In the Strategy pattern, what does the context method (like countMatches) know about the strategy it receives?

Go deeper — technical resources