skip to content

A generic Repository<T, ID> interface with methods like findById, findAll(Specification spec), and save(T entity) is shared as a base type across a dozen unrelated entity types in a large system. What failure modes tend to show up as the system grows, and how do teams typically evolve away from it?

level: principalimportance: nice to knowfreq 30%

answer

  1. one-size-fits-all save() hides invariant checks
  2. generic Specification becomes lowest-common-denominator
  3. child entities shouldn't get their own generic Repository
  4. evolution narrows toward per-aggregate bespoke interfaces
  5. JpaRepository<T,ID> is the real-world version of this base

basics

~10 s

One generic Repository shared by every entity forces them all into the same shape of querying and saving, which breaks once some entities need different rules. Teams usually replace it with entity-specific, purpose-built interfaces.

solid answer

~40 s

A shared generic Repository<T, ID> base is convenient early on — one interface, minimal boilerplate, consistent CRUD across every entity — but as a system grows it tends to produce recurring failure modes: entities with genuinely different consistency needs get forced through the same save()/findAll() shape; a generic Specification/Criteria querying mechanism becomes a leaky, database-shaped API that's hard to optimize per entity; and 'save everything through one generic path' obscures aggregate boundaries, since a naive generic Repository has no natural place to enforce that only aggregate roots get their own repository. Teams typically evolve away from a single generic base by narrowing to purpose-built, per-aggregate repository interfaces with explicit, intention-revealing methods, keeping only truly universal concerns in a thin shared base, if any survives at all.

go deeper

for a junior

Not expected to have an opinion here; recognizing that 'one interface for everything' can eventually feel awkward is enough.

for a middle

Should recognize that generic Specification-style querying can become hard to use for entity-specific needs.

for a senior

Should be able to describe at least two concrete failure modes and the general direction of the fix (narrowing to bespoke interfaces).

for a principal

Should be able to distinguish this failure mode from DDD aggregate-repository semantics precisely, judge when a generic base is still appropriate versus premature, and describe how a real system evolves the boundary over time without over- or under-correcting.

## Why a generic base looks attractive A generic `Repository<T, ID>` interface — declaring `findById(id: ID): T?`, `findAll(spec: Specification<T>): List<T>`, `save(entity: T): T`, `delete(id: ID)` — is an attractive starting point in a growing codebase because it lets every entity type get CRUD support for free, with **zero repeated boilerplate**: `class CustomerRepository : Repository<Customer, Long>`, `class OrderRepository : Repository<Order, Long>`, and so on, each inheriting the same generic operations. This is essentially what frameworks like Spring Data JPA formalize and generate automatically via `JpaRepository<T, ID>`, and for a meaningful stretch of a system's growth it works well precisely because most entities really do share the same basic shape of persistence need early on. ## The failure modes The failure modes emerge specifically from **heterogeneity** that develops as the system matures and entities stop being uniform in their persistence needs. 1. **First, consistency and invalidation requirements diverge**: some entities are simple value-holders where 'save the whole object' is perfectly fine, while others are complex aggregates where saving 'the whole object' through a generic `save()` risks silently persisting a partially-invalid intermediate state, or bypassing invariants that should only be checked by an aggregate-specific save path (this overlaps with, but is distinct from, full DDD aggregate-repository semantics, which govern which objects even get their own repository in the first place). A generic `save(T)` signature has no way to express 'this type has extra invariants that must hold before persisting' — that logic either gets duplicated in every caller, or silently omitted somewhere, which is how production bugs slip through: an entity gets saved in an invalid intermediate state because the generic path offered no natural place to intercept and validate. 2. **Second, the generic querying mechanism** — typically a `Specification<T>` or `Criteria<T>` object parameterized over the shared base type — tends to become a lowest-common-denominator API that mirrors whatever the underlying query engine can express generically (equality, ranges, basic joins), rather than the richer, more specific query vocabulary any one entity's real use cases need. Teams often find themselves either fighting the generic Specification API to express an entity-specific query it wasn't really designed for, or falling back to a non-generic escape hatch (a raw `@Query` or hand-written method) anyway, at which point the generic abstraction has stopped pulling its weight for that entity while still imposing its shape everywhere else. 3. **Third, and often the most consequential in retrospect**: a single generic Repository base makes it easy to accidentally treat every entity as independently saveable and independently queryable, when in a well-modeled domain only some objects (aggregate roots) should really have their own Repository at all — child entities that only make sense in the context of a parent aggregate shouldn't be independently loadable and saveable through their own generic Repository, because that invites callers to load and mutate a child object without going through the aggregate root that's supposed to enforce invariants across the whole aggregate. A flat, generic `Repository<T, ID>` applied uniformly doesn't naturally surface this distinction; it takes deliberate modeling discipline (or a design review) to notice that `OrderLineItem` shouldn't have its own top-level Repository the way `Order` does. ## How teams evolve away from it As these failure modes accumulate, the typical evolution is a **narrowing rather than an abandonment**. - The generic base gets replaced, entity by entity, with a purpose-built Repository interface exposing intention-revealing, entity-specific methods (`findOrdersAwaitingFulfillment()`, `reserveInventoryFor(orderId)`-style operations that may not even look like traditional CRUD), while a genuinely universal concern — e.g., `findById` — may survive as a thin, optional shared building block rather than the entire interface's shape. - Some teams keep a generic base purely at the *implementation* level (a shared internal helper for boilerplate CRUD wiring) while making the *public* interface each aggregate exposes entirely bespoke, which captures the original convenience (less repeated plumbing code) without forcing every caller through a one-size-fits-all contract. ## A real-world instance A concrete, well-known real-world instance of this exact tension: Spring Data JPA's `JpaRepository<T, ID>` is, functionally, precisely this generic base, and a huge number of production Spring codebases start by extending it directly for every entity. Teams commonly report exactly the trajectory described above as their domain model matures — introducing entity-specific `@Query` methods and `Specification` composition as needs diverge, and, in domain-heavy modules, eventually wrapping or replacing the generic `JpaRepository` extension with a hand-written, framework-agnostic interface exposing only the operations that specific aggregate's business logic actually needs, precisely to stop child entities and simple value objects from acquiring their own independently-callable generic repository the way the true aggregate roots do.

  • How does this generic-Repository failure mode relate to, but differ from, the concern that DDD aggregate-scoped repositories address?
    They overlap but aren't the same concern: this failure mode is about a shared generic interface across many entity types losing the ability to express entity-specific invariants and queries, while DDD aggregate-repository semantics specifically govern which objects should even have their own repository at all, restricting repositories to aggregate roots so child entities are only ever reached and mutated through the root's consistency boundary. A system can fix the generic-interface problem (giving every entity its own bespoke interface) while still getting the aggregate-boundary question wrong, and vice versa.
  • Is it ever fine to keep a shared generic base indefinitely?
    Yes, for systems that stay genuinely uniform — many simple CRUD-heavy admin tools or internal services never develop the invariant or query heterogeneity described above, and forcing an early narrowing into bespoke per-entity interfaces in that context would itself be premature over-engineering; the decision to narrow should track actual observed divergence in the codebase, not be applied preemptively.

A single generic Repository<T, ID> for every entity is like giving every department in a company the exact same one-page expense form: it's efficient at first, but eventually the shipping department needs customs fields the marketing department doesn't, and forcing both through the identical form either breaks shipping's process or quietly lets marketing skip fields shipping actually needs enforced.

saying these in an interview costs you the question

  • Thinks a generic Repository<T, ID> is a universally correct default with no growth-related downside
  • Can't name a concrete failure mode beyond vague 'it's not flexible enough'
  • Conflates this generic-interface issue with full DDD aggregate-repository semantics as if they were the same concern
  • Proposes fixing every problem by adding more generic parameters/type bounds rather than narrowing to specific interfaces

context