A Generic Repository Interface
Design a Repository<T, ID> interface with one in-memory implementation that serves every entity type, the shape Spring Data uses.
interface Repository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
boolean deleteById(ID id);
}Every entity in a service needs the same basic operations: save it, find it by id, list them all, delete one. Writing UserRepository, ProductRepository and OrderRepository by hand repeats the same code with different types. Generics let you write the contract once.
interface Repository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
boolean deleteById(ID id);
}T is the entity type and ID is its key type: Long for a database sequence, String for a SKU, UUID for public ids. Spring Data's CrudRepository<T, ID> has the same two parameters and uses the same method names, so this shape carries straight over to the Spring Boot course. The return types differ in detail: Spring's deleteById returns void, and findAll returns a List only on ListCrudRepository.
One implementation for every entity
An in-memory version needs to read the id from an entity. A small Entity<ID> interface with an id() method, used as a bound, gives it that:
class InMemoryRepository<T extends Entity<ID>, ID> implements Repository<T, ID>Records implement it for free when they have an id component: record User(Long id, String email) implements Entity<Long> {}. Keep the bound on the implementation, not on the interface, so other implementations (JDBC, a remote API) are free to work differently.
Design choices that matter
- Return
Optional<T>fromfindById. A missing row is normal, and the type makes the caller decide: map it to a 404, a default or an exception. - Type the id.
Map.gettakesObject, somap.get(2)on aMap<Long, User>compiles and silently finds nothing.findById(ID id)withID = Longrejects theintat compile time. - Return copies.
findAllshould return an unmodifiable list, not the live map values, so callers cannot change the store behind its back. - Extend, do not bloat. Entity-specific queries such as
findByEmailgo ininterface UserRepository extends Repository<User, Long>, not in the generic interface.
Where it falls short
A generic repository covers the boring 80 per cent. Reports, joins and bulk updates rarely fit T and ID, and forcing them through the generic interface leads to methods like findByCriteria(Map<String, Object>) that throw away the type safety you started with. Write those as specific methods with specific return types.
The in-memory implementation here uses a LinkedHashMap and is not thread-safe. For a real server either switch to ConcurrentHashMap or put it behind a database. The interface stays the same either way, which is the point: handlers depend on Repository<User, Long>, and tests can hand them the in-memory version.
Example
When to use it
- Back every entity in a small service with one InMemoryRepository<T, ID> during development and in handler tests.
- Define UserRepository extends Repository<User, Long> with a findByEmail query used by the login handler.
- Swap the in-memory implementation for a JDBC one without changing the handlers that depend on Repository<User, Long>.
More examples
Typed ids catch the wrong key
Map.get and containsKey take Object, so the boxed Integer 2 compiles and never equals the Long key. The repository's ID parameter makes the same mistake a compile error.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
record User(Long id, String email) {}
interface Repository<T, ID> {
Optional<T> findById(ID id);
}
void main() {
Map<Long, User> byId = new HashMap<>();
byId.put(2L, new User(2L, "[email protected]"));
IO.println(byId.get(2)); // Integer key: compiles, finds nothing
IO.println(byId.containsKey(2));
IO.println(byId.get(2L));
Repository<User, Long> users = id -> Optional.ofNullable(byId.get(id));
// users.findById(2); // does not compile: int cannot be converted to Long
IO.println(users.findById(2L).isPresent());
}
// Output:
// null
// false
// User[id=2, [email protected]]
// trueAdding entity-specific queries
UserRepository inherits the generic methods with T fixed to User and ID to Long, and adds one query of its own. The in-memory class reuses the generic implementation and writes only findByEmail.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
interface Entity<ID> {
ID id();
}
interface Repository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
}
class InMemoryRepository<T extends Entity<ID>, ID> implements Repository<T, ID> {
private final Map<ID, T> rows = new LinkedHashMap<>();
public T save(T entity) { rows.put(entity.id(), entity); return entity; }
public Optional<T> findById(ID id) { return Optional.ofNullable(rows.get(id)); }
public List<T> findAll() { return List.copyOf(rows.values()); }
}
record User(Long id, String email) implements Entity<Long> {}
interface UserRepository extends Repository<User, Long> {
Optional<User> findByEmail(String email);
}
class InMemoryUserRepository extends InMemoryRepository<User, Long> implements UserRepository {
public Optional<User> findByEmail(String email) {
return findAll().stream()
.filter(u -> u.email().equalsIgnoreCase(email))
.findFirst();
}
}
void main() {
UserRepository users = new InMemoryUserRepository();
users.save(new User(1L, "[email protected]"));
users.save(new User(2L, "[email protected]"));
IO.println(users.findByEmail("[email protected]").map(User::id).orElse(-1L));
IO.println(users.findById(1L).orElseThrow().email());
}
// Output:
// 2
// [email protected]A generic lookup that maps to 404
One helper turns an empty Optional from any repository into an exception that the HTTP layer maps to 404. T and ID are inferred from the repository, so the result is an Order with no cast.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
interface Repository<T, ID> {
Optional<T> findById(ID id);
}
class NotFoundException extends RuntimeException {
NotFoundException(String message) { super(message); }
}
record Order(String id, long totalCents) {}
<T, ID> T getOrThrow(Repository<T, ID> repo, String kind, ID id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException(kind + " " + id + " not found"));
}
void main() {
Map<String, Order> rows = new LinkedHashMap<>();
rows.put("o-1", new Order("o-1", 2599));
Repository<Order, String> orders = id -> Optional.ofNullable(rows.get(id));
Order order = getOrThrow(orders, "order", "o-1");
IO.println("200 " + order);
try {
getOrThrow(orders, "order", "o-404");
} catch (NotFoundException e) {
IO.println("404 " + e.getMessage());
}
}
// Output:
// 200 Order[id=o-1, totalCents=2599]
// 404 order o-404 not found
Discussion