The question addresses the exact reason as to why I don't like repositories as the sole interface of your persistence layer. It leads to a lot of categorization which sometimes ends up as a completely subjective judgment.
This is a problem of your own making. You decided to use repositories which categorizes all your needed data operations and queries, and are now struggling to properly categorize some of your data operations and queries.
The solution is simply to come up with a better categorization, one that can actually house all the data operations and queries you need it to, in a logical and consistent manner.
The rest of this answer is based on an earlier answer I've written which touches on the same topic.
Repositories, at least the basic implementation thereof, tend to take a "one entity type per repository" approach. If you want to get a list of all countries with all their provinces and all of the province's cities, you'll have to separately talk to the
In short, repositories limit you to only being able to execute single-entity-type queries. For the same example, you would have to launch 3 separate database queries in order to get all countries and their provinces and their cities.
And don't get me wrong, I like repositories. I like having the neat little boxes so you can separate your storage of different domain objects, which e.g. would allow you to get the countries from your database but the provinces from a remote API and the cities for a second remote API.
But this separation of entity types into their own private boxes very much clashes with the benefit of having relational databases, where part of the benefit is that you can launch a single query that can take related entities into account (for filtering, sorting or returning).
You might rightly respond that "a repository can still return more than one entity type". And you would be correct. But if you have a query which returns both
Bar entities, where do you place it? In the
FooRepository? In the
BarRepository? There may be examples where the choice is easy, but there are also examples where the choice is hard and multiple developers may have different categorization methods and thus the codebase becomes inconsistent and the true purpose of the "one entity type per repository" approach will be thrown out the window.
Query objects are the only real way to get around the "one entity type per repository" approach. The shortest way I can describe what a query object is, is that you should think of it as a "one method repository".
This changes the responsiblity of the class, i.e. serving a particular query instead of serving all CRUD actions of a given entity, but it doesn't change the technical implementation (a query object's query method is effectively the same as a repository query method).
Repositories suffer from having to deal with multiple types of entities, and the more methods a repository has, the more distinct entity types it's likely going to be handling. By separating each repository method into a query object of its own, you've simply remove the contradictory suggestion that "this repository only handles one entity type", and instead are suggesting that "this query object runs this particular query, regardless of which entity types it needs to use".
Note: it's called a command object instead of a query object if it's about a data operation instead of a query, but the explanation and implementation is the same for both.
This tends to lead to a CQRS pattern, but you're not forced to migrate your entire codebase to such an approach. Simply splitting your repositories into smaller command/query objects can be sufficient for your use case.
You can still use query objects and repositories at the same time, and you are then able to enforce that repositories will never handle more than their designated entity type.
If a query makes use of more than one entity type (e.g.
Province), then it belongs in its own private query object (e.g.
If a query only focuses on one entity type (e.g.
Country), then it belongs to that entity type's repository (e.g.
Generally speaking, simple CRUD actions still belong to a repository, but complex (i.e. multi-entity) queries (and data operations) need their own query (or command) object.