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 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.
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.
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
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.
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
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);
}
}
findByIsbn("001")returns a Book whose title is "Clean Code".findByIsbn("002")returns null, andnull == nullis true.
Why should service code declare variables as BookRepository instead of InMemoryBookRepository?
Declaring variables as the interface type decouples the caller from the implementation. You can replace InMemoryBookRepository with DatabaseBookRepository later, and every line that only uses BookRepository needs no edit.
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"));
}
}
The loop checks each book in order.
Python Guidedoes not contain "Java", so the loop continues.Java Guidematches, prints, and thenbreakstops the loop.
You add a new IsbnSearch class that implements SearchStrategy. What must you change in Library.findBooks to use it?
Because findBooks accepts the interface type, any new implementation can be passed in without editing the method. That is the core benefit of polymorphism: existing code handles types it has never seen.
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")));
}
}
Only one book in the repository has an author containing "Bob".
Java Streamsis by Bob, so the count is 1.
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.