1. Where legacy comes from in repositories
If you have ever googled “Spring Data JPA repository example”, you know how this usually goes: you open an article, see familiar annotations, copy a piece of code — and 15 minutes later you have a working method. The problem is that some of those articles were written for Spring Data JPA from five years ago (or even ten). The code may compile, but the style and semantics no longer match the modern API, especially if our baseline is Spring Boot 4 + Spring Data JPA 4.x.
Here it is important to catch one idea: legacy is not an insult, but a timestamp. Like an old photo with a button phone: at the time of the shot it was normal, but today it feels strange to plan an application architecture “as in 2012”. In Spring Data this is especially noticeable because method names changed, new return types were added, the semantics of reference objects became clearer, and more careful ways of describing data reads appeared.
By this point, derived queries, JPQL, projections, and native queries are already familiar on their own. The pain usually starts not because there are too few tools, but because there are too many of them, and the repository quickly turns into a dump if you mix them without logic. So first of all, we do not need another syntax, but a proper radar for smells.
This is where the first major reason for legacy repositories comes from: “code from the internet” is often written for a different version and a different level of team maturity. A tutorial example might intentionally simplify or “mix everything in one place” to show 5 features in 10 minutes. But when such an example becomes your main repository in a project, it starts living its own life and spreading.
The second reason is simpler and more depressing: a repository is the place where it is convenient to “drop one more method”. It is almost like the Downloads folder in an operating system: at first it holds one file, then twenty, and then you are already afraid to open it. A repository becomes a dump of random methods not because the developer is lazy, but because repositories rarely have an “API owner” who watches the shape and discipline.
That is exactly what we need now: to learn how to recognise repositories that formally work, but already drag a project into legacy, and to separate them from APIs where the intention is visible through the interface.
2. Legacy-style repository as an API
A repository in Spring Data JPA is not just “an interface that can talk to the database”. It is the public contract of your data layer. The service layer (and sometimes the query layer) will call repository methods the same way you call methods from the standard library: expecting them to be predictable, readable, and not to produce surprises.
legacy-style repository is a repository that formally works, but looks as if it was written “on the fly” and “as needed”. Usually it lacks a single logic in two things: how to choose a query mechanism and how to choose return types. As a result, the same interface may contain derived queries, JPQL, native SQL, pagination, projections, and even odd “just in case” methods, while the names and signatures make it hard to tell which part is the “main road” and which is a one-off experiment.
Such a repository has an unpleasant effect: it does not break immediately. It breaks at the moment when you start evolving it. The team opens the interface, sees “you can do everything here already”, and adds a couple more methods in the style they are used to. A week later the repository becomes a mix of three different coffee flavours (all instant), and a month later nobody remembers why one list is returned as List<Product>, the second as Page<Product>, the third as List<ProductCatalogRow>, and the fourth as List<Object[]> from some ancient article. And the most frustrating part is that most problems here are not about SQL, but about API design.
To “feel” the problem, imagine that a repository is a cafe menu. A modern menu is easy to read: sections, clear dish names, predictable portions. A legacy menu is an A4 sheet where, between borscht and desserts, an instruction about how to change car wipers has suddenly been inserted because “well, if we are already writing something here”. It may be useful, but it feels unsettling.
Next, we will look at the main symptoms of a legacy repository. They often come as a set, like a combo meal.
3. Symptom #1: giant repository
At first, it even looks convenient. You have a ProductRepository, and there you also added a method to “find by status”, “find expensive items”, “read everything with SQL”, “search by name with pagination”. Everything in one place: nice. But if the interface has no logic for why the methods are exactly like this, it starts to resemble a kitchen drawer where spoons, a screwdriver, batteries, two bolts, and someone’s mysterious key are all mixed together. The moment you urgently need something, you pull everything out. The same thing happens with repositories.
Let us look at a typical “looks fine, but already worrying” example:
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query: reads simply, but it is only one of the "languages" in this interface
List<Product> findByStatus(ProductStatus status);
// JPQL: already a different query mechanism (and a different reading style)
@Query("""
select p
from Product p
where p.price > :min
""")
List<Product> findExpensive(@Param("min") BigDecimal min);
// Native SQL: a third mechanism in the same repository
// Important: the example is intentionally "bad" in style (select *) to show the symptom
@Query(value = """
select *
from product
""", nativeQuery = true)
List<Product> readEverythingRaw();
// Derived query + pagination: another variation of the contract (Page + Pageable)
Page<Product> findByNameContainingIgnoreCase(String text, Pageable pageable);
}
Formally, everything is fine. But this is already an example of an interface where four methods use three different reading "languages": derived query, JPQL, and native SQL. The issue is not that this is forbidden. The issue is that in a month you will open this interface and realise: “So which style is the main one here?”. If the answer is “any one that is faster to write”, you are already on the path to a giant repository.
What does a healthier fragment usually look like? It does not have to be perfect, and it definitely does not claim to cover the entire interface. Its goal is simpler: for the signatures to show intent and for the interface not to encourage accidental growth. The minimal healthy step is to keep methods in a readable form and in the return types that directly express the meaning.
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// One business key -> one result (and it may be absent)
Optional<Product> findBySku(String sku);
// A separate contract for counting, without extra entity overhead
long countByStatus(ProductStatus status);
// List + explicit sorting (the contract for "how to order" is not hidden)
List<Product> findByStatus(ProductStatus status, Sort sort);
}
There is no magic here. But there is discipline: one method looks up exactly one product by business key (and honestly says that it may not exist), another counts, and the third returns a list with explicit sorting. Such an interface raises fewer questions and is better protected from accidental expansion.
To connect this with our shop-data-jpa project, it is useful to remember the architectural decision: package-by-feature. This is already an antidote to giant repositories at the structural level. We have catalog, inventory, ordering, and these are not just pretty folders. They are a hint: if you suddenly want to put a method for “find orders by email” into ProductRepository, you have most likely just missed the package.
Sometimes a giant repository appears even within a single feature, when the repository tries to be a CRUD repository, a reporting centre, an admin search tool, and a place for native SQL experiments all at once. At that stage, a simple rule helps: if a method reads like a separate subsystem, it is better to model it as a separate read model (projection/record) and give it a proper name instead of hiding the meaning in chaos.
4. Symptom #2: entities are returned everywhere
A very common internet habit looks like this: “the repository is about entities, so it must return entities.” And then everything, even a catalog for the admin UI, even a list for a dropdown, even an order summary — everything returns full entities. It sounds logical... until the use case asks for just 2-3 fields.
At that point, returning entities “by default” stops being universality and becomes a lazy default. The repository no longer distinguishes between the write model and lightweight reads, and the calling code gets a full persistence model where a short read model is needed.
Not because the entity is bad, but because it is a convenient way not to think about the shape of the result at all.
Let us imagine two scenarios in catalog:
1) We need a product for price changes (that is, we are really working with the write model). Then an entity is fine.
2) We need a list of products in the catalog: name + price. Loading everything else (status, relations, details, etc.) is often pointless. Here, a projection makes more sense.
Here is the simplest way to show a “modern turn” at the level of a single method:
import java.math.BigDecimal;
// Projection: a contract of "we read only the required fields", not "we pull the whole entity"
public interface ProductCatalogRow {
String getName();
BigDecimal getPrice();
}
And now a repository method that returns not an entity, but a read model:
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Important: return Product not an entity, but a read model for the catalog (partial read)
List<ProductCatalogRow> findByStatus(ProductStatus status);
}
Notice that the method is still derived — and that is normal. But the signature now makes it clear that this is specifically “catalog reading”, not loading the whole entity for a future edit. This is one of the signs of a modern style: you express intent not only by name, but also by the shape of the result.
If you want a quick self-check, ask yourself almost like in an interview, but to yourself: “If I return an entity now, do I understand why I need all its fields and relations?”. If the answer is “well, let it be”, that is the moment when the repository starts turning into a legacy layer.
5. Symptom #3: method-name monster
Derived queries are a great tool. But they have a sneaky side: they create the feeling that you can “just add a bit more” to the method name. And then a bit more. And then a bit more. And now you have a method that does not fit on the screen, and reading it feels like deciphering an ancient spell.
Here is a typical monster that often appears when there are many conditions:
import java.math.BigDecimal;
import java.util.List;
public interface ProductRepository {
// The method name starts encoding the whole query, and the signature stops reading like an API
List<Product> findByStatusAndCategoryIdAndPriceBetweenAndNameContainingIgnoreCaseOrderById(
ProductStatus status, // filter by status
Long categoryId, // filter by category
BigDecimal min, // lower price bound
BigDecimal max, // upper price bound
String text // search by name
);
}
Technically, Spring Data will parse this. But as an API it is already a bad contract. It violates the main rule of readability: the signature stops being a “quick description of intent” and turns into an “embedded query language directly in Java names”. Such code is hard to discuss in a team and hard to change. Any new condition turns the name into an even longer construction, and the method starts looking like a chatbot that cannot stop.
One of the signs of a modern style is knowing when to stop. If the name no longer reads well, it is better to move honestly to an explicit query. Even if you type a little more, you gain clarity.
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository {
// An explicit query instead of a "novel" in the method name:
// the name expresses intent, and filter details live in JPQL
@Query("""
select p
from Product p
where p.status = :status
and p.category.id = :categoryId
and p.price between :min and :max
and lower(p.name) like lower(concat('%', :text, '%'))
order by p.id
""")
List<Product> findCatalogChunk(@Param("status") ProductStatus status,
@Param("categoryId") Long categoryId,
@Param("min") BigDecimal min,
@Param("max") BigDecimal max,
@Param("text") String text);
}
The point is not that JPQL is “better”. The point is that the API is human again. The name findCatalogChunk is clear: “read a chunk of the catalog”. And the filter details live where they belong — in the query text.
And notice: we are still not choosing a “universal rule for selecting a tool” — that will be the next part of the day. Right now we are just recording the symptom: if a derived name has become a monster, this is very often a sign of a legacy style that grows without control.
6. Symptom #4: ghosts of the old API
One of the simplest ways to tell modern code from internet legacy is to look at method names. Spring Data JPA has methods that existed in older versions, then became deprecated, and then were replaced with clearer ones.
You may encounter in articles (and, worse, in other people’s projects) calls like these:
// In old examples this was often done (and it is a signal of "code from another era")
Product p = productRepository.getOne(productId); // legacy style (old versions)
Why is this a worrying signal? Because it usually means not only “the method is obsolete”, but also “the example author lives in a different mental model of the repository”. Often, with such calls come other habits as well: returning entities everywhere, native queries “because that is easier”, and a 200-method repository.
Modern style tries to name things so that the intent is visible from the name. That is why, instead of the historical getOne, the current API has a more honest getReferenceById (we will discuss the semantics in detail later in the course plan). What matters to us now is only one thing: do not drag old names into new code without understanding.
It is useful to keep in mind a simple table of “what you may see in old videos and how it is usually written now” (not as a dogma, but as a reference):
| In old examples | In modern style | Why it matters |
|---|---|---|
| getOne(id) | getReferenceById(id) | It is clearer that this is specifically a “reference” |
| getById(id) | getReferenceById(id) | We remove the ambiguity of “was it loaded or just referenced?” |
| methods with get… without context | find…, , |
The name should express the contract |
Once again: this is not a “religion of names”. It is a way to make the repository predictable and protected from copy-paste.
7. Symptom #5: native query by default
Native query is a useful tool. Earlier in the course we deliberately gave it a place: reporting queries, vendor-specific features, and sometimes complex selections that are awkward to express in JPQL. But precisely for that reason, native query should be like a sharp knife in the kitchen: you can and should use it, but not try to spread butter on bread with it.
The legacy pattern looks like this: “I do not know / do not like JPQL, so I do everything in SQL.” And then methods like these appear in the repository:
import java.util.List;
import org.springframework.data.jpa.repository.Query;
public interface ProductRepository {
// Native SQL "by default": an example of a symptom, not a recommendation
@Query(value = """
select * from product
""", nativeQuery = true)
List<Product> readEverythingRaw(); // "well, it works"
}
This thing is dangerous not only because select * is a bad habit. It is dangerous because you suddenly tie the repository to the physical schema: table names, column names, and the dialect. That may be justified... but then you need to understand why you are doing it, and keep the scope narrow. Otherwise, you turn Spring Data JPA into a “storage of SQL strings”, and the JPA layer becomes just decoration.
The modern style in our course is pragmatic: native query is an exception, not the default. If a query can be expressed as a derived query, JPQL, or a projection, we choose the higher level. If the query is truly reporting-oriented or specific, then ok, native. But then we write it with discipline: a clear method name, an explicit result (often a projection), careful SQL, not “read everything”.
8. Signs of a modern repository style
After all these symptoms, it is easy to swing to the other extreme: “now everything must be written with only one correct tool”. That is the same trap. A healthy repository is based not on one favourite API, but on three simple questions.
First, what exactly does the method do. The name should express intent, not force the reader to guess why this method exists at all.
Second, what shape of result does the use case expect. One object, a list, a counter, a lightweight read model — these are different contracts. If the return type is chosen “just in case”, the interface quickly becomes unpredictable.
Third, which is the simplest mechanism that honestly expresses this query. Derived query is good as long as the name reads well. JPQL is good when the condition is already easier to write explicitly. Projection is good when the whole object is not needed. Native query is good where this is truly an SQL-first scenario, not a habit. If these three answers are not readable in the interface, a giant repository is almost guaranteed.
As soon as the smells become visible, you can choose a tool by use case rather than by habit or by memory of some random article from the internet.
9. Typical mistakes when working with repositories
Mistake #1: copying code from an old article without checking the version and API relevance.
This happens almost automatically: you see getOne(), getById(), some strange methods, and think that “if the author wrote it, then it must be right”. In a modern baseline (Spring Boot 4 / Spring Data JPA 4), such examples either do not compile or pull you into obsolete semantics. The right habit is to at least glance at the current API and remember that the internet is full of examples from “another era”.
Mistake #2: putting everything into one interface “because it is faster this way”.
At first, it speeds up development, then it turns into debt: you open the repository and do not understand what its base contract is and what are just random wishes from different tasks. The repository starts growing like a snowball, and any change requires “five minutes to find where everything lives”. Modern style does not forbid different query mechanisms, but it does require meaning and structure.
Mistake #3: returning entity from every read by habit.
An entity is a great thing when you are really working with the domain model inside the data layer. But as soon as the use case asks for a short read model (catalog, summary, report), entity-return everywhere starts to look like a lazy default. Plus it pushes services to depend on extra fields and relations. Projections exist precisely so that the repository can be accurate in the result shape.
Mistake #4: writing derived names “because you can”, even when they are no longer readable.
Derived queries are good as long as the method name is a readable phrase. When the name becomes a novel, the API stops being an API and becomes “query text encoded in CamelCase”. At that point it is better to move honestly to JPQL (@Query) or to a more suitable tool than to keep growing a monster.
Mistake #5: making native query the first choice instead of a targeted exception.
Native SQL is sometimes necessary. But when the repository turns into a set of select * from ... strings, you lose the benefits of the JPA layer, reduce readability, and increase coupling to the schema. Modern style is not a ban on native, but discipline: use it where it is truly justified, and do not replace everything else with it.
GO TO FULL VERSION