Demo: building a generic in-memory repository
On This Lesson 11 topics
Lesson Overview
This is a hands-on lesson. You now know the pieces: a type parameter on a class, generic methods, bounds, and wildcards. We use them to build one small, realistic class, an in-memory repository. A repository is an object that stores items and lets you save, find, list, and delete them. "In-memory" means it keeps them in a map in RAM, so everything is lost when the program stops (you saw why in the first module: RAM is volatile). A database-backed repository comes much later; the shape of the class stays the same.
The point is that one class, written once, serves two unrelated types: a Contact stored under an integer id, and an Event stored under a text id. The compiler guards both. By the end you will have the core of the module lab, "Generic repository", where you do the same on your own types.
Everything below was compiled and run with JDK 21. Use any editor or IDE you like. Menus differ by product, so the steps show plain files and the javac and java commands, which you can run in a terminal from the folder that holds the files. If your IDE compiles for you, the code is the same.
This lesson builds on all earlier lessons of this module, on Map from the collections module, and on records. Records were previewed in the equality module and are used here only as quick data holders.
Learning Objectives
By the end of this lesson you will be able to:
- Build a generic class with two type parameters,
InMemoryRepository<T, ID>. - Back it with a
Map<ID, T>and implementsave,findById,findAll, anddeletewith no casts. - Use the same class for two different item types and two different id types.
- Show that saving a wrong item type or a wrong id type is rejected at compile time.
- Reproduce and explain the two planned mistakes:
new T()and the raw type. - Extend the design with a bound so
savecan take the id from the item.
Written Explanation
Step 1: the shape
A repository needs two types that vary. T is the type of the item stored (Contact, Event), and ID is the type of its key (Integer, String). Those are the two parameters. The storage is a Map<ID, T>, the structure you chose in the collections module for lookup by key.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class InMemoryRepository<T, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();LinkedHashMap is a Map that remembers insertion order, which makes the demo output predictable. A plain HashMap works equally well; its order is not guaranteed.
Step 2: the four operations
public void save(ID id, T item) {
store.put(id, item);
}
public T findById(ID id) {
return store.get(id);
}
public List<T> findAll() {
return new ArrayList<>(store.values());
}
public boolean delete(ID id) {
return store.remove(id) != null;
}
}Notice what is missing: casts. store.get(id) already returns T, and findAll returns List<T>. The type parameters flow from the declaration at the top through every method.
Two design choices worth naming:
findByIdreturnsnullwhen the id is unknown. That is simple but easy to forget to check. Later in the course you will meet a safer return type for "maybe nothing"; for now, always test fornull.findAllreturns a copy (new ArrayList<>(store.values())). A caller who changes the returned list cannot damage the repository's internal map. This is the encapsulation idea from the object-oriented modules.
Step 3: two item types
public record Contact(String name, String email) { }
public record Event(String title, int attendees) { }Now use the one repository class for both:
InMemoryRepository<Contact, Integer> contacts = new InMemoryRepository<>();
contacts.save(1, new Contact("Ada", "ada@example.com"));
contacts.save(2, new Contact("Grace", "grace@example.com"));
InMemoryRepository<Event, String> events = new InMemoryRepository<>();
events.save("demo-day", new Event("Demo Day", 40));
Contact c = contacts.findById(2); // no cast
System.out.println(c.name());
System.out.println(contacts.findAll());
System.out.println(events.findById("demo-day"));
System.out.println(contacts.findById(99));
System.out.println(contacts.delete(1) + " " + contacts.delete(1));
System.out.println(contacts.findAll().size());Output:
Grace
[Contact[name=Ada, email=ada@example.com], Contact[name=Grace, email=grace@example.com]]
Event[title=Demo Day, attendees=40]
null
true false
1The second delete(1) returns false, because the contact was already removed. findById(99) returns null.
Step 4: the compiler guards both types
Now try to mix them up:
contacts.save(1, new Event("Demo Day", 40));
contacts.save("one", new Contact("Ada", "a@x.com"));Bad.java:4: error: incompatible types: Event cannot be converted to Contact
contacts.save(1, new Event("Demo Day", 40));
Bad.java:5: error: incompatible types: String cannot be converted to Integer
contacts.save("one", new Contact("Ada", "a@x.com"));
2 errorsThis is the acceptance test for the lab: one line that fails to compile when the wrong type is saved. Both the wrong item and the wrong id are caught at compile time. With a pre-generics repository holding Object, both lines would compile.
Step 5: the planned mistakes
Mistake A: new T(). Suppose you want a repository method that creates an empty item:
T create() { return new T(); }error: unexpected type
required: class
found: type parameter TThe type is not known when the code runs, so Java cannot build one. Fix it by having the caller pass the item in, which is exactly what save(ID id, T item) does.
Mistake B: the raw type. Leave the type arguments off:
InMemoryRepository raw = new InMemoryRepository();
raw.save(1, "anything");
Contact c = (Contact) raw.findById(1);It compiles. The compiler prints only a note about unchecked operations; with the option -Xlint:unchecked, it prints warnings such as unchecked call to save(ID,T) as a member of the raw type InMemoryRepository. Running it gives:
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class Contact ...
at Raw.main(Raw.java:5)You are back in lesson 1: a String stored where a Contact was expected, found only at runtime. Fix: always write the type arguments, InMemoryRepository<Contact, Integer>.
Step 6: a bound to remove the repeated id
Passing the id separately lets a caller save a Contact under the wrong id. A common refinement is for the item to know its own id, through a small interface, and for the repository to require it with a bound:
public interface HasId<ID> {
ID getId();
}
public class Repo<T extends HasId<ID>, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();
public void save(T item) { store.put(item.getId(), item); }
// findById, findAll, delete as before
}Now save takes one argument. The bound T extends HasId<ID> is what makes item.getId() legal, the same mechanism as compareTo in lesson 4. A Book implements HasId<String> works as Repo<Book, String>. Using String as T is rejected: type argument String is not within bounds of type-variable T.
Examples
Example 1: a repository of contacts (simple)
InMemoryRepository<Contact, Integer> contacts = new InMemoryRepository<>();
contacts.save(1, new Contact("Ada", "ada@example.com"));
Contact c = contacts.findById(1);
System.out.println(c.email());Output: ada@example.com. The variable c is a Contact directly from findById.
Example 2: a second, unrelated repository (practical)
InMemoryRepository<Event, String> events = new InMemoryRepository<>();
events.save("demo-day", new Event("Demo Day", 40));
System.out.println(events.findAll().size());Output: 1. The same class, a different T and a different ID, no code copied.
Example 3: the bound in action (realistic)
public class Book implements HasId<String> {
private final String isbn;
private final String title;
public Book(String isbn, String title) { this.isbn = isbn; this.title = title; }
public String getId() { return isbn; }
public String toString() { return title; }
}
Repo<Book, String> books = new Repo<>();
books.save(new Book("111", "Java"));
System.out.println(books.findById("111") + " " + books.findAll().size());Output: Java 1. The repository found the id by itself through the bound.
Backend Insight
This is the pattern behind the data-access layer of most backend applications. Spring Data's JpaRepository<T, ID>, which appears in the Junior level, has exactly these two type parameters: the entity type and the id type. Its methods have the same names and shapes (save, findById, findAll, delete). When you meet it, you will recognize that you have already built a small version of it.
Even at this scale, the structure has value. Tests often use an in-memory repository instead of a real database, because it is fast and needs no setup. The service code talks only to the repository's methods, so swapping the in-memory version for a database-backed version changes no service code. That freedom comes from designing to an interface and from the generic type parameters, so one contract fits every entity.
Common Mistakes
Mistake 1: Using new T()
You cannot create an instance of a type parameter. Take the instance from the caller.
Mistake 2: Leaving the raw type in
InMemoryRepository repo = new InMemoryRepository(); compiles but loses all checking. Always give both type arguments.
Mistake 3: Returning the internal map or list
return store.values(); hands callers a live view of your internals. Return a copy, as findAll does.
Mistake 4: Forgetting that findById may return null
Calling a method on the result without a check throws NullPointerException for an unknown id. Test for null, or document the behavior clearly.
Mistake 5: Mixing up the order of the type arguments
InMemoryRepository<Integer, Contact> compiles but means "items are integers, ids are contacts". Keep the order <T, ID> and name them in variable names or comments when unsure.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. Add int count() to InMemoryRepository<T, ID>. What does it return?
Show solution A1
A1. It returns the number of stored items: return store.size();.
A2. In InMemoryRepository<Event, String>, what are T and ID?
Show solution A2
A2. T is Event (the stored items) and ID is String (the keys).
A3. Is the line contacts.save(1, "text") accepted for InMemoryRepository<Contact, Integer>? Why?
Show solution A3
A3. No. The second parameter must be a Contact, so a String is rejected with incompatible types: String cannot be converted to Contact.
Level B: Application
B1. Add boolean existsById(ID id) to the repository.
Show solution B1
B1.
public boolean existsById(ID id) {
return store.containsKey(id);
}B2. Create a third type, Book(String isbn, String title) as a record, and a InMemoryRepository<Book, String>. Save two books and read one back with no cast.
Show solution B2
B2.
record Book(String isbn, String title) { }
InMemoryRepository<Book, String> books = new InMemoryRepository<>();
books.save("111", new Book("111", "Java"));
books.save("222", new Book("222", "SQL"));
Book b = books.findById("222");
System.out.println(b.title());The last line prints SQL.
B3. Why does findAll return a copy of the values instead of store.values()?
Show solution B3
B3. A copy protects the repository: a caller who removes or adds elements to the returned list cannot change the internal map. Returning the live view would let outside code damage the stored data, which breaks encapsulation.
Level C: Challenge
C1. Write HasId<ID> and a version of the repository with T extends HasId<ID> whose save takes only the item. Show that Repo<String, Integer> is rejected.
Show solution C1
C1.
public interface HasId<ID> { ID getId(); }
public class Repo<T extends HasId<ID>, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();
public void save(T item) { store.put(item.getId(), item); }
public T findById(ID id) { return store.get(id); }
public List<T> findAll() { return new ArrayList<>(store.values()); }
public boolean delete(ID id) { return store.remove(id) != null; }
}Repo<String, Integer> r = new Repo<>(); fails with type argument String is not within bounds of type-variable T, because String does not implement HasId.
C2. A teammate declares InMemoryRepository<Contact, int>. What does the compiler say, and what should they write?
Show solution C2
C2. The compiler says unexpected type; required: reference; found: int. Type arguments must be reference types, so write InMemoryRepository<Contact, Integer>.
Mini Challenge
Complete the module lab, "Generic repository", on your own types:
- Write
InMemoryRepository<T, ID>withsave,findById,findAll, anddelete. - Use it for two types of your choice, such as
ContactandEvent, with different id types. - Add one line, clearly commented, that fails to compile when the wrong type is saved, and paste the compiler message in a comment beside it. Then comment the line out so the program still builds.
- Check that your project has no casts and no raw types.
Check your work: searching your code for (Contact) or (Event) finds nothing, and every use of InMemoryRepository has angle brackets.
Summary & Next Lesson
Key takeaways
- A repository with
<T, ID>stores any item type under any id type, using aMap<ID, T>inside. - With type parameters there are no casts:
findByIdreturnsTandfindAllreturnsList<T>. - The compiler rejects the wrong item type or id type. That compile error is the proof of type safety.
new T()and raw types are the two classic mistakes; pass objects in and always write type arguments.- A bound such as
T extends HasId<ID>lets the repository use the item's own id. - This is the shape of Spring Data's
JpaRepository<T, ID>.
Next lesson
You have finished the generics module. The next module opens with "Natural ordering with Comparable", which builds directly on the Comparable you used as a bound here and shows how to make your own classes comparable.
Self-check
- Which two types vary in
InMemoryRepository<T, ID>? - Why is no cast needed in
Contact c = contacts.findById(2);? - What does the compiler do with
contacts.save(1, new Event(...))? - Why is
new T()not possible? - What does the bound
T extends HasId<ID>allow insidesave?
Show answers
- The stored item type
Tand the key typeID. findByIdis declared to returnT, and forInMemoryRepository<Contact, Integer>the compiler treatsTasContact.- It rejects it with
incompatible types: Event cannot be converted to Contact. - The actual type is not known when the code runs, so an instance cannot be created.
- Calling
item.getId(), because everyTis guaranteed to implementHasId<ID>.
References
- Oracle, The Java Tutorials: Generic Types. Supports multiple type parameters and the diamond used in the repository.
- Oracle, The Java Tutorials: Restrictions on Generics. Supports that
new T()is not allowed and that type arguments cannot be primitive. - Oracle, The Java Tutorials: Raw Types. Supports the unchecked-warning behavior and the runtime
ClassCastExceptionrisk of raw types. - Oracle, The Java Tutorials: Bounded Type Parameters. Supports using a bound to call the bound's methods on
T. - The repository code and every message above were compiled and run with JDK 21. The claims about Spring Data's
JpaRepository<T, ID>and about the module order (that the next module starts with natural ordering withComparable) come from this course's own module specs, not from an external page fetched for this lesson.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.