Java

Repository Pattern in Plain Java

Executive Summary

The repository pattern starts with one move: define the repository as an interface in the domain’s vocabulary. It offers save, findById, findAll, delete, and the business-named queries the service needs. Records and Optional appear in the signatures, while SQL, JDBC, and the EntityManager stay invisible. Then write at least two implementations. One is in-memory, over a ConcurrentHashMap, for tests and demos. The other is the JDBC one from the CRUD article or a JPA-backed one from the mapping article, each hiding its machinery entirely.

Services take the interface through the constructor, a preview of the dependency injection article’s pattern. As a result, the test tiers split naturally. Fast unit tests use the in-memory implementation or a Mockito mock of the interface. Meanwhile, integration tests run the real JDBC implementation over H2. A few boundaries keep the pattern honest. Methods speak the domain’s language, not the database’s, and implementation types never cross the interface. Also, each aggregate gets one repository rather than a generic framework of repositories. Finally, transactions are orchestrated where the business operation lives.

The in-memory implementation must match semantics, such as sorting and not-found, or the fast tests lie. After all, the pattern’s whole value is that both implementations satisfy one contract. Spring Data JPA automates exactly this seam, and deep coverage of that belongs in the Spring Boot course on this site.

The Idea: Services Depend on the Contract

The pattern in one diagram, and every arrow is the point:

                      CatalogService            business rules, no storage words
                           |
                    BookRepository            the CONTRACT: domain types only
                     /       |       \
        JdbcBookRepository  InMemory   JpaBookRepository
          (CRUD article)    (a map)      (EntityManager)
                 SQL         none          JPQL

  the service compiles against the interface.
  every implementation is swappable, mockable, and testable in isolation.

The repository’s formal role is the mediator between the domain and the mapping layer. Its practical consequences explain why it appears in essentially every serious Java codebase. First, persistence decisions stop being business decisions. Second, the test pyramid gets a fast tier that behaves like the real one. Third, the storage technology can change. That sounds theoretical until the day a project moves from H2 to PostgreSQL and the service files do not open.

The Interface: Domain Types, Domain Words

Extracted from the CRUD article’s class with one keyword added, the contract is short. It speaks only the domain’s language:

import java.util.List;
import java.util.Optional;

public interface BookRepository {

    void save(Book book);                                  // create or update

    Optional<Book> findById(String isbn);                   // read one: not-found is a value

    List<Book> findAll();                                  // read many

    List<Book> findByAuthor(String author);                // a query the service needs

    boolean delete(String isbn);                           // count as verdict
}

Read what is absent, because absence is the design. There is no SQLException, no Connection, no DataSource, no entity class, and no executeQuery. The signatures carry the CRUD article’s discipline: records in, Optional for not-found, and booleans for verdicts. So the interface is the one place a reader learns what the catalog can do without learning how it is stored. SQLException is the interesting case. The interface deliberately does not declare it, so implementations translate storage failures into the domain’s exception types. That keeps JDBC’s checked-exception machinery an implementation detail.

Two Implementations, One Contract

The in-memory implementation is the pattern’s quiet superpower. It is a real repository with the semantics of the database and none of the setup:

import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public class InMemoryBookRepository implements BookRepository {
    private final ConcurrentHashMap<String, Book> books = new ConcurrentHashMap<>();

    @Override public void save(Book book) {
        books.put(book.isbn(), book);                    // create or update, same as MERGE
    }

    @Override public Optional<Book> findById(String isbn) {
        return Optional.ofNullable(books.get(isbn));      // absent = not found, same as SQL
    }

    @Override public List<Book> findAll() {
        return books.values().stream()
                .sorted(Comparator.comparing(Book::title))
                .toList();                                // ORDER BY title, same semantics
    }

    @Override public List<Book> findByAuthor(String author) {
        return books.values().stream()
                .filter(b -> b.author().equalsIgnoreCase(author))
                .sorted(Comparator.comparing(Book::title))
                .toList();
    }

    @Override public boolean delete(String isbn) {
        return books.remove(isbn) != null;                // the count's verdict, as a boolean
    }
}

The JDBC implementation is the CRUD article’s class plus two words: implements BookRepository. Similarly, the JPA implementation is the mapping article’s EntityManager work behind the same five methods. Both hide their machinery behind the interface. The DataSource stays inside the JDBC class, and the EntityManagerFactory stays inside the JPA class. Therefore, no caller can tell which is serving.

Services Depend on the Interface

The service takes the contract through its constructor, so the dependency injection article’s pattern arrives two articles early. The business logic then becomes storage-blind:

public class CatalogService {
    private final BookRepository books;          // the CONTRACT, not a class

    public CatalogService(BookRepository books) {
        this.books = books;
    }

    public boolean withdrawDamaged(String isbn) {           // business rules live HERE
        return books.findById(isbn)
                   .filter(b -> b.available() > 0)
                   .map(b -> books.delete(isbn))
                   .orElse(false);
    }
}
// production wiring:
var service = new CatalogService(new JdbcBookRepository(pool));

// fast unit test: no database, real semantics:
var repo = new InMemoryBookRepository();
repo.save(new Book("978-0134685991", "Effective Java", "Bloch", 2018, 1));
var service = new CatalogService(repo);

// Mockito unit test: verify the interaction, not the storage:
@Mock BookRepository repo;
when(repo.findById("978-0134685991")).thenReturn(Optional.of(book));
var service = new CatalogService(repo);

Notice what the tiers can now do. The unit test of withdrawDamaged runs in microseconds against the in-memory implementation. Next, the Mockito test verifies the not-found contract. Finally, the CRUD article’s H2 integration tests cover the SQL seam. In short, the good tests article’s pyramid assembles out of one interface. No test of the service ever mentions SQL, and no change to the storage ever touches the service’s tests.

The Boundaries: Keeping the Pattern Honest

Four rules keep a repository a repository instead of a leaky database wrapper:

  1. Methods speak the domain’s language. Use findByAuthor and withdrawDamaged’s supporting queries, not raw query plumbing. When an interface grows methods named after SQL clauses, the boundary is dissolving.
  2. Implementation types never cross. No Connection, EntityManager, or ResultSet appears in the signatures. The JPA implementation translates entities to records at the boundary, so callers never hold managed objects they did not ask for.
  3. One repository per aggregate. Write BookRepository and MemberRepository, not a generic Repository<T> framework. Generic repositories collapse into lowest-common-denominator save and find. Then the domain language, the pattern’s whole point, evaporates.
  4. Transactions belong to the business operation. The service orchestrates multi-repository operations. So the pooling article’s try-rollback boundary stays at the operation, not inside individual repository methods.

How Real Systems Do This

The repository pattern is the default seam of production Java. Teams hand-roll it exactly as this article does, or else frameworks automate it. At heart, those framework repositories are this interface with the implementations generated. Teams with the discipline get two compounding benefits. First, a fast test tier never needs a database. Second, they can change storage per aggregate, such as a new cache in front of one repository, without touching the rest.

The story that sold me on the in-memory tier is a suite-speed number.

A service’s unit tests had drifted, test by test, onto the real database, and the suite took nine minutes. That meant the CI article’s gate was nine minutes every push, so developers batched their runs. That is the exact slow-suite death spiral the good tests article warns about. Extracting the repository interfaces took three weeks of unglamorous work, one aggregate per week. Then we pointed the unit tier at in-memory implementations. Afterward, the unit tier ran in under twenty seconds, and the H2 integration band stayed at two minutes. The pull request cycle shrank by an hour a day across the team. Nothing about the business logic changed, and that is the point. The pattern’s wins are architectural, because they arrive as test speed and merge velocity. They cost one interface and the discipline to keep it clean.

Frameworks automate this seam: Spring Data JPA, per the project reference, generates repository implementations from interface method names. Deep coverage of Spring Data JPA and its abstractions belongs in the Spring Boot course on this site.

Decision Framework

  1. Does the service need storage? It depends on a repository interface, never a JDBC class, an EntityManager, or a pool.
  2. Does more than one implementation exist or is one plausibly coming? The interface pays for itself immediately: in-memory for tests, real storage for production.
  3. Does a method name sound like SQL? Rename it into the domain’s language, because the interface is the catalog’s vocabulary, not the database’s.
  4. Does an implementation type leak across the boundary? Translate at the seam: entities become records, SQLException becomes the domain’s exception.
  5. Is a multi-repository operation needed? The service owns the transaction boundary, and implementations participate through a deliberate seam, not hidden autocommit.
  6. Is the in-memory implementation matching semantics? Sorting, not-found, and verdicts must behave like the database’s, or the fast tier lies.
  7. Is a framework generating the implementations? The contract still matters, and the plain-Java pattern is the vocabulary for reviewing what the framework writes.

When NOT to Use This

  • Do not wrap reporting or bulk operations that need full SQL power in a repository interface. A findByEverything method is a query in disguise. Instead, a dedicated reader, a thin, explicit query class, is the honest shape.
  • Do not build a generic repository framework. One interface per aggregate keeps the domain language, whereas the abstraction framework collapses into plumbing.
  • Do not place business rules inside repositories. Checkout logic lives in the service, while the repository answers questions and stores facts. The boundary works because each side has one job.
  • Do not add the pattern to a script or a tool with one consumer of one query. The interface is ceremony until a second implementation or a test tier exists.
  • Do not mock the repository in tests that should test the repository. The H2 tier exists for the SQL, whereas the mock tier exists for the service’s logic.

Common Mistakes

  • The interface mirroring the database. It has method-per-table and method-per-column shapes, so when every field grows a finder, the pattern inverts into a schema dump.
  • Implementation types in signatures. An Optional of a JPA entity crossing the boundary hands callers managed objects. Then the mapping article’s detach surprises arrive on your side of the seam.
  • An in-memory implementation with different semantics. An unordered findAll or a throwing not-found makes the fast tier diverge from reality, so green unit tests lie.
  • One giant repository. A CatalogRepository owning books, members, and loans recreates the God object the OOP articles retired. The fix is one aggregate per interface.
  • Transactions hidden inside individual repository methods. Multi-repository operations then compose wrongly, because each method commits alone. It is the pooling article’s partial-write story in new clothes.
  • Testing the service against the SQL implementation only. The unit tier’s speed is the pattern’s dividend, so skipping it leaves the dividend uncollected.
  • Skipping the interface because a framework generates implementations. The contract is still the review surface, and generated code deserves named expectations too.

Key Takeaways

  • The repository pattern is the seam you already built. It is an interface in the domain’s language, with implementations hiding JDBC or JPA entirely.
  • Signatures carry domain types only: records, Optional, booleans, and no SQLException, Connection, or EntityManager crosses the boundary.
  • Two implementations earn the pattern: in-memory for a microsecond test tier and real storage for production. Both satisfy one contract with matching semantics.
  • Services depend on the contract, so the test tiers split naturally: Mockito and in-memory for logic, H2 for the SQL seam.
  • One repository per aggregate, methods in the domain’s words, and transactions at the business operation keep the boundary honest.
  • The pattern’s dividend is architectural: test speed, storage freedom, and business logic that outlives its persistence.
  • Spring Data JPA automates this exact seam, and the plain-Java version is the vocabulary for reviewing what the framework generates.

FAQ

What is the repository pattern in Java?

An interface between the domain and persistence. Its methods, like save, findById, and delete, use the domain’s language. Implementations, whether JDBC, JPA, or in-memory, hide behind it. Services depend on the contract, so storage becomes swappable and the fast test tier needs no database.

Should a repository interface return entities?

No: return domain types, records for data and Optional for not-found. JPA entities are managed and mutable, and leaking them across the boundary hands callers tracking machinery they never asked for, so the JPA implementation translates entities to records at the seam.

How do I test code that uses a repository?

Split the tiers. Unit tests of services run against the in-memory implementation or a Mockito mock of the interface. Then integration tests run the JDBC implementation over in-memory H2, and end-to-end tests use the real database. Each tier tests its own seam.

What is the difference between a repository and a DAO?

A DAO exposes the database’s operations, one table’s verbs with little abstraction. A repository speaks the domain’s language, presenting a collection-like contract, save and find in business terms, with the storage fully hidden. The difference is who the caller has to know about.

Do I need the repository pattern if Spring Data JPA generates them?

You need the contract either way: generated implementations still deserve named methods in the domain’s language and tests at the right tiers. The plain-Java version is also the vocabulary for reviewing what the framework generates, and deep coverage of Spring Data JPA belongs in the Spring Boot course on this site.

Conclusion

Part 8 is complete, and your data layer is now a set of decisions rather than a stack of APIs. Use JDBC’s mechanics when you want the SQL explicit, and JPA and Hibernate when the domain earns the mapping. Pools and transactions sit under everything. Finally, the repository pattern keeps all of it behind one contract in the domain’s language. The CRUD service you can now build is recognizable in every production Java codebase on earth.

The next part turns outward. It starts with HTTP, REST, and JSON in plain Java, the request-response model your services will speak. Then comes dependency injection as a design principle. Finally, a guided preview of Spring Boot shows where the ecosystem took these same patterns.

One interface, two implementations, and a fast test tier: that is the whole pattern, and it pays every sprint.

Last updated on 24 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *