Repository Pattern · Conventions
Three Names for Every Repository,
and Why the Rule Still Drifted
Every Repository operation fits three method names: find<Noun>s, save<Noun>, delete<Noun>. No separate single-record lookup, no update method. A rule this small seems impossible to break, yet with nothing but prose holding it in place it drifted in four of five implementations of the same design. Once a check enforced it, the first run found three more violations nobody had noticed.
The Repository pattern itself is a settled idea: one Aggregate Root, one Repository interface in the Domain layer, one implementation in Infrastructure. What's less obvious is how much drift is possible in the method names on that interface, and how much that drift costs once several people (or several independently written implementations) are writing against the same convention.
I watched that drift happen in my example project, which implements the same backend design in five languages side by side. The naming rule below was written down from the start. It was still broken in four of the five implementations, each in a different way.
The Rule
Three method-name patterns cover every Repository operation. List lookup is always find<Noun>s, as in findOrders or findUsers. Save or upsert is save<Noun>. Delete is delete<Noun>. That's the whole vocabulary.
The single-record case doesn't get its own method. A Service calls the list lookup with take: 1 and pulls the record out with .then(r => r.<noun>s.pop()):
const order = await this.orderRepository
.findOrders({ orderId, take: 1, page: 0 })
.then((r) => r.orders.pop())
if (!order) throw new Error(OrderErrorMessage['Order not found.'])Keeping findOne and findMany as separate methods duplicates the dynamic filter-condition logic between them for no benefit. Unifying the lookup into one path keeps the Repository implementation simpler, and there's exactly one place to add a new optional filter later. And a Repository never has an update method at all: look the Aggregate up, change it through its own domain method, and save it via save<Noun>. An updateOrder(patch) method would let a caller mutate fields directly, bypassing the invariant checks the Aggregate exists to enforce.
What the Drift Looked Like
A cross-language audit of the five implementations, specifically hunting for "design deviations not justified by real language differences," found the root doc's naming rule violated in four of the five languages, each in a different way. Two used a bare Save/save with no noun at all. That compiles fine and reads fine in isolation, but breaks the moment you're scanning a Repository interface for what it does across a codebase with more than one Aggregate. A second BC in four of the five languages (the older of the two domains) had a "dedicated findOne plus a separate findAll" pair, the exact anti-pattern the unified lookup is meant to prevent, sitting right next to a newer domain in the same codebase that had already gotten it right. Only one language was compliant everywhere.
One language's naming-convention doc even declared the cleanup "done." It was, for the Command-side Repository. The four parallel Query-side interfaces still had the old pattern. The doc covered half the ground and called it the whole thing.
A cleanup that touches one interface and forgets its Query-side twin is invisible in a diff review that only looks at the file you expect to have changed. Trust the naming pattern only once you've grepped every interface with a Repository-shaped role, not just the one the changelog entry mentions.
Why a Rule This Simple Kept Slipping Through
No automated check existed for this specific convention. The architecture checks that did exist (scripts that statically verify code against the documented rules) looked at structural placement (is the Repository interface in domain/, is the implementation in infrastructure/, does the Interface layer avoid touching Infrastructure directly), but none of that says anything about whether a method is spelled save or saveOrder. A naming convention that only lives in prose gets followed exactly as consistently as everyone remembers to reread the prose, which in practice means until the second person touches the file, or the fourth language port is written by someone who read a different paragraph first.
Turning the Rule Into a Regression Guard
The fix that stuck wasn't another manual pass. It was writing one check per language that mechanically flags the violating shapes: a blocklist of findBy*, bare findAll, bare save, bare delete, and anything without the expected noun suffix. Every language already had its own architecture checker (a TypeScript AST walk, a Go program, bash-plus-grep, a Python AST walk; the mechanism differs, the rule doesn't), so this was additive, not a new tool.
Run against the newly fixed code, it passed everywhere, as expected. Run against the authentication domain in three of the five languages, which no earlier audit had covered because all of them were scoped to the two business domains, it found three more violations. The manual audits had been finding symptoms one at a time. A rule this cheap to write was the fix.
The Same Three Names, in Five Different Type Systems
Once fixed, the interface reads almost identically across every language. Only the surrounding syntax changes, never the three method-name patterns themselves:
// Go
type Repository interface {
FindAccounts(ctx context.Context, q FindQuery) ([]*Account, int, error)
SaveAccount(ctx context.Context, account *Account) error
}
// Java
public interface AccountRepository {
AccountsWithCount findAccounts(AccountFindQuery query);
void saveAccount(Account account);
void deleteAccount(String accountId);
}
// Kotlin
interface AccountRepository {
fun findAccounts(query: AccountFindQuery): Pair<List<Account>, Long>
fun saveAccount(account: Account)
fun deleteAccount(accountId: String)
}
// Python (FastAPI)
class AccountRepository(AccountQuery, ABC):
@abstractmethod
async def save_account(self, account: Account) -> None: ...Go returns a slice plus a count plus an error, Kotlin reaches for a Pair, Python leans on ABC and async. Every one of those differences is a language idiom, not an architecture decision. What a check for this convention has to ask is language-agnostic almost by definition: strip the syntax away, and it's asking whether the method is spelled find<Noun>s/save<Noun>/delete<Noun>, full stop. That's why the same blocklist logic (flag findBy*, bare findAll, bare save) ported cleanly into five completely different static-analysis mechanisms, a TypeScript AST walk, a Go program, bash-plus-grep, a Kotlin/Java AST walk, a Python AST walk, without any of them needing a fundamentally different rule.
What the Rule Deliberately Doesn't Check
It doesn't check whether the noun is spelled correctly for the domain, whether the return shape is right, or whether the query logic inside the method is correct. That's business logic, and keeping a naming check out of business logic is the same discipline the rest of my architecture checks keep to. It checks one narrow, mechanically-verifiable thing: does the method name on a Repository-shaped interface match one of three patterns. That narrowness is what makes it cheap enough to run on every commit instead of every few months.
docs/architecture/repository-pattern.md (the full naming rules, soft delete, and dynamic filter pattern, in my example project that implements the same backend design in five languages) · docs/harness.md (what an architecture check may and may not assume)