Generics

Project: A Generic Repository

15 min Lesson 10 of 13

Project: A Generic Repository

Throughout this tutorial you have studied generics piece by piece: generic classes, bounded type parameters, wildcards, type erasure, and the rules around inheritance. In this final lesson you apply everything at once by building a Generic Repository — a reusable, type-safe in-memory store that can manage any entity you throw at it without duplicating a single line of persistence logic.

This pattern mirrors what real frameworks (Spring Data, Hibernate) do under the hood. Understanding it by hand makes those frameworks much less magical.

The Problem Without Generics

Imagine you need an in-memory store for a User and a separate one for a Product. Without generics you write two nearly identical classes, or you fall back to raw Object lists and lose all compile-time safety. Generics let you write the logic once and have the compiler enforce the correct type at every call site.

Step 1 — Define a Minimal Entity Contract

Every entity the repository manages must have a unique identifier. We express that contract with a bounded interface:

public interface Identifiable<ID extends Comparable<ID>> { ID getId(); }

The bound ID extends Comparable<ID> means the repository can later sort or look up entities by their IDs using natural ordering — without knowing whether IDs are Long, String, or anything else.

Step 2 — Concrete Entity Classes

public record User(Long id, String name, String email) implements Identifiable<Long> { @Override public Long getId() { return id; } } public record Product(String sku, String title, double price) implements Identifiable<String> { @Override public String getId() { return sku; } }

Java 17 records are ideal here: compact, immutable, and free of boilerplate. Notice that User uses a Long ID while Product uses a String SKU — the repository will handle both.

Step 3 — The Generic Repository Interface

import java.util.List; import java.util.Optional; public interface Repository<T extends Identifiable<ID>, ID extends Comparable<ID>> { void save(T entity); Optional<T> findById(ID id); List<T> findAll(); void delete(ID id); int count(); }

Two type parameters: T — the entity type, bounded to Identifiable<ID> — and ID — the key type, bounded to Comparable<ID>. Every method is fully typed; no casting, no Object.

Why two type parameters? If you only had T extends Identifiable (raw), you would lose the ID type and be forced to cast when you call getId(). The second parameter ID threads the key type through every method signature, keeping everything safe.

Step 4 — The In-Memory Implementation

import java.util.*; public class InMemoryRepository<T extends Identifiable<ID>, ID extends Comparable<ID>> implements Repository<T, ID> { private final Map<ID, T> store = new LinkedHashMap<>(); @Override public void save(T entity) { store.put(entity.getId(), entity); } @Override public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); } @Override public List<T> findAll() { return Collections.unmodifiableList(new ArrayList<>(store.values())); } @Override public void delete(ID id) { store.remove(id); } @Override public int count() { return store.size(); } }

The implementation delegates to a LinkedHashMap (preserving insertion order). Returning Collections.unmodifiableList is a defensive copy pattern: callers get a snapshot, not a backdoor into the internal store.

Use Optional instead of returning null. findById returns Optional<T> so callers are forced to handle the "not found" case explicitly. This eliminates an entire class of NullPointerException bugs.

Step 5 — Extending the Repository With a Generic Method

A real repository often needs query capabilities. You can add a generic method that accepts a filter predicate, so the same class supports arbitrary searches without knowing what the entity looks like:

import java.util.function.Predicate; import java.util.stream.Collectors; public List<T> findWhere(Predicate<? super T> predicate) { return store.values().stream() .filter(predicate) .collect(Collectors.toList()); }

The wildcard Predicate<? super T> follows the PECS rule (Producer Extends, Consumer Super): a predicate consumes an entity to produce a boolean, so super is correct. This lets callers pass a Predicate<Object> or a Predicate<User> — both work.

Step 6 — Wiring It All Together

public class Main { public static void main(String[] args) { // Typed specifically for User at declaration, no casting anywhere InMemoryRepository<User, Long> userRepo = new InMemoryRepository<>(); userRepo.save(new User(1L, "Alice", "alice@example.com")); userRepo.save(new User(2L, "Bob", "bob@example.com")); userRepo.save(new User(3L, "Carol", "carol@example.com")); userRepo.findById(2L).ifPresent(u -> System.out.println("Found: " + u.name())); List<User> gmailUsers = userRepo.findWhere(u -> u.email().endsWith("@example.com")); System.out.println("Count: " + gmailUsers.size()); // 3 userRepo.delete(1L); System.out.println("After delete: " + userRepo.count()); // 2 // Product repo — completely separate type, zero code duplication InMemoryRepository<Product, String> productRepo = new InMemoryRepository<>(); productRepo.save(new Product("SKU-001", "Laptop", 999.99)); productRepo.findById("SKU-001").ifPresent(p -> System.out.println("Product: " + p.title())); } }
Type erasure in action: at runtime both userRepo and productRepo are the same InMemoryRepository class — the generic parameters are erased. This is why you cannot do if (userRepo instanceof InMemoryRepository<User, Long>); the JVM only sees InMemoryRepository. The type safety exists at compile time only.

Design Takeaways

  • One implementation, unlimited entity types. InMemoryRepository will work for any class that implements Identifiable, with no changes.
  • Compile-time safety everywhere. The compiler rejects userRepo.save(new Product(...)) — no runtime surprises.
  • Readable API via Optional and streams. Callers write idiomatic Java without casting or null checks.
  • Open for extension. Subclasses can add domain-specific finders (e.g., UserRepository extends InMemoryRepository<User, Long> with a findByEmail method) without touching the base class.

Summary

You built a fully functional, type-safe generic repository from scratch: a bounded interface contract, two concrete entity records, a two-parameter generic interface, an InMemoryRepository implementation, and a generic filter method using PECS wildcards. Every concept from this tutorial — generic classes, bounded parameters, wildcards, and type erasure — contributed to the final design. This is the real power of generics: write logic once, let the compiler verify correctness at every use site, and never write a cast again.