Repository Pattern · Conventions
The Naming Rule
That Caught Real Bugs
A method-naming convention feels like the most boring possible thing to standardize. Then you write a tool that actually checks it, run it once, and it finds three real violations nobody had noticed across three different codebases.
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 actually costs once several people (or several independently-written language ports) are writing against the same convention.
The Rule
Three method-name patterns cover every Repository operation. List lookup is always find<Noun>s — findOrders, 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 real 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.
Where This Actually Broke
A cross-language audit of this repo's five ports, 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 — compiles fine, reads fine in isolation, but breaks the moment you're scanning a Repository interface for what it actually does across a codebase with more than one Aggregate. A second BC in four of the five languages — the older of two domains in this repo — 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.
The most instructive single case: one language's own naming-convention doc proudly declared the Repository-naming cleanup "done." It was — for the Command-side Repository. The parallel Query-side interfaces, four of them, still had the old pattern untouched. The doc wasn't lying so much as covering half the ground and calling 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. Harnesses in this repo check 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: well, 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 actually stuck wasn't a fifth manual pass — it was writing one harness rule 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 harness (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 a different domain nobody had thought to re-check — the authentication domain, in three of the five languages — it immediately found three more real violations that had never been part of any prior audit's scope, because prior audits had all been scoped to the two business domains everyone kept thinking about. A rule this cheap to write turned out to be the actual fix; the manual audits before it were finding symptoms one at a time.
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 harness rule for this convention actually has to check 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 exactly 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 a naming-convention harness rule staying out of business logic is exactly the discipline this repo's harness design keeps to everywhere. 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 actually run on every commit instead of every few months.
docs/architecture/repository-pattern.md — the full naming rules, soft delete, and dynamic filter pattern · docs/harness.md — what a harness rule may and may not assume