skip to content

When an application needs complex queries - filtering, sorting, pagination, cross-aggregate reporting - how can a repository support that without leaking infrastructure query mechanics (SQL fragments, ORM criteria objects) into the domain model?

level: seniorimportance: should knowfreq 68%

answer

  1. named finder methods, not generic query()
  2. Specification pattern for composable criteria
  3. reporting-shaped needs -> separate read model
  4. don't let repo interface balloon with near-duplicate finders
  5. raw SQL escape hatch is a slippery slope

basics

~20 s

Instead of letting callers build raw database queries through the repository, you give the repository a small number of named methods that describe what business question is being asked (like 'find overdue orders'), or you move heavy reporting needs to a completely separate read-only layer that doesn't pretend to be the domain repository.

solid answer

~40 s

Two complementary techniques keep queries from leaking infrastructure into the domain: intention-revealing finder methods, and a specification-style object that encapsulates a selection criterion in domain vocabulary (e.g., OverdueSpecification) which the repository implementation translates into whatever query language it needs internally, without that translation ever being visible to callers. Neither approach tries to make the repository a general-purpose query engine - once query needs become genuinely reporting-shaped (joins across aggregates, aggregation, arbitrary ad-hoc filters for a UI), the pragmatic answer is usually to stop using the aggregate repository for that need entirely and serve it from a separate read model or query service that's explicitly allowed to know about the storage schema, because forcing that need through the write-side repository abstraction usually does more harm than good.

go deeper

for a junior

Should recognize that a repository shouldn't just accept a raw SQL string, without needing to know the alternatives in depth.

for a middle

Should be able to write a few named finder methods for realistic business questions and explain why they're better than a generic query method.

for a senior

Should be able to decide, for a given query need, whether it belongs on the aggregate repository (named finder/specification) or a separate read model, and justify the split.

for a principal

Should be able to set org-level policy for where reporting/query needs live relative to aggregate repositories, and recognize and reverse architectural erosion (interface bloat, SQL escape hatches) across teams.

## Why querying strains the repository contract The tension repositories face on querying is that their core contract — collection-like, aggregate-in/aggregate-out access via `add/remove/findById` — was never designed to answer arbitrary questions like 'give me page 3 of orders placed by customers in Germany last quarter, sorted by total, where status is not cancelled.' That kind of question is inherently about the storage's query capabilities (filtering, sorting, joining, paging), and the naive way to answer it — accepting a raw SQL fragment, an ORM criteria object, or a lambda expression that gets translated into a query — means the repository interface now speaks the storage technology's language instead of the domain's, which is precisely what the pattern exists to prevent. The mechanism for avoiding that leak has **two complementary layers**. ## Layer one — intention-revealing finder methods The first, and usually sufficient for genuine domain needs, is **intention-revealing finder methods**: instead of `find(Criteria)`, the repository exposes named methods like `findOverdueOrders(asOf: Instant): List<Order>` or `findByCustomerAndStatus(customerId: CustomerId, status: OrderStatus): List<Order>`. - Each method's name states a real business question. - Its parameters are domain types (not SQL fragments or column names). - Its implementation — inside the infrastructure layer — is free to translate that into whatever the store needs: a `WHERE` clause, an index lookup, a query against a document store. Callers never see or construct the query itself; they ask a domain question and get domain objects back. This works well as long as the set of questions stays small and stable enough to enumerate as methods — which, for most aggregates' actual domain-driven query needs (as opposed to arbitrary UI filtering), is usually true. ## Layer two — the Specification pattern The second technique, useful when the shape of a query needs to be composed rather than fixed, is the **Specification pattern** applied to querying: a small object, expressed in domain vocabulary (e.g., `class OverdueSpecification(asOf: Instant)`), that represents a selection criterion and can be passed to a single repository method like `findMatching(spec: Specification<Order>): List<Order>`. The specification itself contains no SQL or ORM code in its public shape; the repository implementation is the only place that knows how to translate a given specification into an actual query, often via a visitor or an internal method per specification type. This buys some composability (specifications can sometimes be combined with and/or) without handing callers a query language, but it's worth being honest that specifications add real design and maintenance cost, and many teams find named finder methods cover their actual needs without ever building a specification layer. ## The shared trade-off Both techniques share the same trade-off: they keep the repository domain-pure at the cost of not being a general-purpose query tool. That's fine for domain-driven questions the aggregate's own business logic cares about, but it breaks down fast for genuinely reporting-shaped needs — a dashboard wanting arbitrary filter combinations, pagination, cross-aggregate joins (orders joined with customers joined with products), or aggregation (sum of revenue by region). Trying to force those needs through the aggregate repository, even via named methods or specifications, tends to produce **one of two bad outcomes**: 1. either the repository interface balloons with dozens of oddly-specific finder methods that don't correspond to any real domain concept, 2. or someone eventually adds a generic query escape hatch anyway because named methods can't keep up, undoing the abstraction. The pragmatic, and increasingly standard, answer for this class of need is to stop treating it as a repository problem at all: serve reporting and complex-query needs from a **separate read model** — a dedicated query service, denormalized read tables, or a CQRS-style read side — that is explicitly allowed to know about the storage schema and query technology, because it isn't mediating access to a consistency-protecting aggregate; it's just answering questions. ## Failure modes Failure modes when this boundary isn't respected: - a repository interface that accumulates dozens of near-duplicate finder methods (`findByStatusAndCustomer`, `findByStatusAndDateRange`, `findByCustomerAndDateRangeAndStatus`...) as a UI's filter requirements evolve, each one a thin wrapper that exists only because someone refused to introduce a separate read path; - or a repository method that accepts a raw SQL `WHERE-clause` string 'just this once' for an urgent reporting need, which becomes the template every future urgent need copies, until the interface is a SQL-fragment executor wearing a domain-sounding name. ## A healthy example A concrete healthy example: an `OrderRepository` keeps three methods — `findById`, `add`, and `findOverdueOrders(asOf)` — all genuinely used by domain/application logic that needs real `Order` aggregates to invoke behavior on. A separate `OrderReportingQueries` class, living in infrastructure and openly using raw SQL against a read replica, answers the operations dashboard's arbitrary filter/sort/paginate needs by returning plain DTOs, never `Order` aggregates — and nobody expects `OrderReportingQueries` to respect the collection illusion, because it was never meant to.

  • Is the Specification pattern always worth introducing once a repository needs more than one or two finder methods?
    No - specifications add real indirection (a class per criterion, translation logic in the repository implementation) that only pays off when criteria genuinely need to be composed together dynamically. For a small, stable set of business questions, plain named finder methods are simpler to read, simpler to test, and don't need the extra abstraction layer.
  • How do you decide whether a new query need belongs on the aggregate's repository versus a separate read model?
    The deciding question is whether the caller needs a real aggregate to invoke domain behavior on, or just data to display/report. If it's the former (e.g., loading an Order to call applyDiscount), it belongs on the repository as a named finder; if it's the latter (a dashboard, an export, a search results page), it belongs on a separate read model that's allowed to bypass the aggregate abstraction entirely.
  • Does using a separate read model for reporting mean you're doing full CQRS?
    Not necessarily - you can have a single database and still maintain a clear separation between the write-side aggregate repositories and read-only query classes/services that use raw SQL or views against the same schema. Full CQRS (separate read/write data stores, eventual consistency, event-driven synchronization) is a much bigger architectural commitment that this pattern doesn't require.

It's like a reference librarian who'll happily fetch 'books by this author published after 2010' because that's a question they know how to translate into the catalog system, but won't hand you the catalog's raw database terminal just because you asked a slightly unusual question - for genuinely open-ended research, they point you to a separate research desk built for exactly that.

saying these in an interview costs you the question

  • Adds a generic query()/executeSql() method to a repository interface as a shortcut
  • Repository interface has a dozen near-duplicate finder methods that map to UI filter checkboxes rather than domain concepts
  • Treats every reporting/dashboard need as something the aggregate repository must satisfy
  • Uses Specification objects that themselves contain SQL or ORM-specific code in their public shape
  • Can't articulate the difference between 'need a real aggregate to invoke behavior' versus 'need data to display'

context