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.

Syntaxinterface 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> from findById. 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.get takes Object, so map.get(2) on a Map<Long, User> compiles and silently finds nothing. findById(ID id) with ID = Long rejects the int at compile time.
  • Return copies. findAll should 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 findByEmail go in interface 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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]]
// true

Adding 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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.