Repository Operations for Business Logic Entry Points
Goal
Repository interfaces must expose a standard set of operations with clear naming, explicit intent, predictable return types, and a strict error model.
Each operation must make its purpose unambiguous. A caller reading the repository interface must know immediately whether it is creating a new entity, updating an existing one, retrieving zero-or-one by identity or unique key, retrieving zero-or-many by criteria, performing a dynamic listing, counting matches, asking whether something exists, or deleting by identity.
The operation set is organized in two families:
- Entity fetch family (
findBy*, findManyBy*, search) — returns domain entities.
- Aggregation family (
countBy*, existsBy*, existManyBy*) — returns scalars (number, boolean) computed over the same predicates.
Two cross-cutting rules apply to every operation:
- Domain outcomes are valid return values, never errors. Not found, empty list, zero count, false existence — all are valid results. Errors are reserved for infrastructure failures.
save is forbidden. Always distinguish between create and update explicitly so the caller's intent is never ambiguous.
What Counts as In Scope
Apply this skill to code that does one or more of these things:
- defines a repository interface or repository class
- defines repository method signatures for domain-entity persistence operations
- introduces a
save method that handles both creation and update
- introduces a method that throws or returns a domain error when an entity is not found
- introduces a
findBy* method whose return type is a collection
- introduces an
existsBy* / existManyBy* method whose implementation does not delegate to the corresponding countBy*
- introduces a
countBy* whose predicate visibly diverges from the corresponding findManyBy*
- exposes a domain-shaped error type (
*NotFoundError, *AlreadyTakenError, etc.) in a repository signature
- defines find, search, count, exists, create, update, or delete operations on a repository
The Operations
1. findBy<Field> (single-row)
Retrieves zero or one domain entity by a single field or by a conjunction of fields.
- Naming:
findBy<Field> for one field; findBy<Field1>And<Field2> for two fields conjoined; and so on. findById is the most common case (Field = Id). Do not use findBy<Field1>Or<Field2> for single-row queries — OR over fields breaks the uniqueness contract and must be expressed as a multi-row query instead.
- Parameter: the value(s) for the field(s).
- Returns: the domain entity, or an absence value idiomatic to the stack —
T | null, T | undefined, Optional<T>, Maybe<T>, T?. Never throws or produces a domain error when the entity does not exist; absence is a valid return value.
2. findManyBy<Field> (multi-row)
Retrieves zero or more domain entities by one or more fields, optionally combining them with AND, OR, or both.
- Naming:
findManyBy<Field> for one field; findManyBy<Field1>And<Field2>, findManyBy<Field1>Or<Field2>, and combinations of AND and OR for multiple fields.
- Parameter: the value(s) for the field(s).
- Returns: a collection of domain entities — array, list, sequence, idiomatic to the stack. An empty collection when nothing matches is a valid return value, not an error.
- A multi-row method must never be named
findBy*. The Many segment is part of the contract and signals expected cardinality at every call site.
3. search
Retrieves a collection of domain entities matching a dynamic combination of filter clauses, with pagination and sorting.
- Name:
search or the project's idiomatic equivalent.
- Parameters: a list of filter clauses, pagination parameters (page number and page size), and a list of sort clauses.
- Returns: a paginated result containing the collection of domain entities and the total count of matching entities.
- Use
search only when the filter or sort fields are not known a priori — for example, dynamic UI listings, admin tables. When the filter fields are fixed and known at definition time, use findManyBy<Field> with named parameters instead.
- One
search method per repository. Express filter criteria as filter clauses that reference an attribute, a comparison operator, and a value. This avoids creating a separate method for each filter combination.
- When no entities match, return a paginated result with an empty collection and a total count of zero, never an error.
The search operation combines filtering, pagination, and sorting in a single method:
- Filtering: a list of filter clauses, each referencing an attribute of the domain entity, a comparison operator (e.g.,
eq, neq, gt, gte, lt, lte, contains), and a value.
- Pagination: a page number and a page size. The return type must include both the collection of domain entities and the total count of matching entities so the caller can compute the total number of pages.
- Sorting: a list of sort clauses, each specifying an attribute name and a direction (ascending or descending).
4. countBy<Field> (numeric aggregation)
Returns the number of domain entities matching one or more fields combined with AND, OR, or both.
- Naming:
countBy<Field> for one field; countBy<Field1>And<Field2>, countBy<Field1>Or<Field2>, and combinations of AND and OR for multiple fields. There is no countManyBy* form: count is intrinsically about cardinality, so the single-vs-multi distinction does not apply. AND/OR combinators are allowed for any field count.
- Parameter: the value(s) for the field(s).
- Returns: a non-negative integer idiomatic to the stack (
number, int, Int).
- Contract:
countBy<Field>(args) must return the same number that findManyBy<Field>(args).length would return for the same arguments — same predicate, same semantics. Where a findBy<Field> exists for a unique field, countBy<Field> must return 0 or 1 according to whether the entity is present.
- Implementation: free, and encouraged to use the engine's native aggregation (
COUNT(*) in SQL, prisma.model.count, countDocuments in MongoDB, etc.). Forced delegation to findManyBy* is not required because the cost would be O(n) — loading every matching row in memory just to count it defeats the purpose of having a separate aggregation operation.
- Verification when not delegating: tests must verify that
countBy<Field>(args) equals findManyBy<Field>(args).length (or findBy<Field>(args) !== null ? 1 : 0 for unique fields) on a representative set of inputs. The contract is enforced by tests, not by implementation form.
- An empty result is
0, never an error.
- Forbidden: a
countBy<Field> whose predicate visibly diverges from the corresponding findManyBy<Field> (for example, a hidden status filter). That is no longer a "count derived from find" — it is a different operation and must be renamed accordingly, or the dynamic filter must be moved to search.
5. existsBy<Field> (single-row exists) / existManyBy<Field> (multi-row exists)
Returns whether at least one domain entity exists matching a criterion.
- Naming:
existsBy<Field> (verb in third-person singular: "does it exist") for the single-row form; existManyBy<Field> (verb in plural, without the trailing s: "do they exist") for the multi-row form. The presence/absence of the s is semantic, not stylistic.
- Combinator rules: same as
findBy* / findManyBy* — single-row admits only AND between fields; multi-row admits AND, OR, and combinations.
- Parameter: identical to the corresponding
countBy* method.
- Returns: a boolean idiomatic to the stack (
boolean, bool).
- Implementation requirement: every
existsBy* / existManyBy* method must delegate to the corresponding countBy* of the same repository:
existsBy<Field>(args) returns countBy<Field>(args) > 0.
existManyBy<Field>(args) returns countBy<Field>(args) > 0.
- Running an independent query is forbidden. The reason: the boolean contract is bound to the count contract — same filters, same domain semantics. Delegating prevents the two from diverging over time, and
count already encapsulates the engine-native aggregation, so there is no performance reason to bypass it.
- A negative result is never an error.
false is a valid return value.
6. create
Persists a new domain entity and returns the created entity.
- Name:
create or the project's idiomatic equivalent.
- Parameter: the domain entity to persist.
- Returns: the created domain entity. The returned entity must reflect the state after persistence, including any identity or field assigned during creation.
7. update
Persists changes to an existing domain entity and returns the updated entity.
- Name:
update or the project's idiomatic equivalent.
- Parameter: the domain entity with updated state.
- Returns: the updated domain entity. The returned entity must reflect the state after persistence.
8. deleteById
Removes a domain entity by its identity.
- Name:
deleteById or the project's idiomatic equivalent (e.g., delete_by_id).
- Parameter: the entity's identity value.
- Returns: nothing (void, unit,
None, or the project's empty equivalent).
- Idempotent: deleting a non-existent id is a valid outcome, not an error.
Forbidden Operations
The following operations or implementations must not appear on a repository. Each has a single canonical replacement.
save — ambiguous. Replace with explicit create and update.
getBy* / getById that throws when the entity does not exist — pushes absence handling onto exceptions. Replace with findBy* returning an absence-permitting type. The caller decides whether absence is an error in its use case.
findBy<Field> whose return type is a collection — multi-row query with single-row naming. Rename to findManyBy<Field>.
existsBy* / existManyBy* that does not delegate to the corresponding countBy* — must call countBy<Field> and return > 0.
countBy<Field> whose predicate diverges from the corresponding findManyBy<Field> — that is a different operation; rename it explicitly or move the dynamic filter to search.
existsWith*, hasBy*, countMany*, and other non-canonical names — rename to follow the existsBy* / existManyBy* / countBy* conventions, including the singular/plural s rule for exists / exist.
Error Model
Repository operations are allowed to produce errors only for infrastructure failures. Any other outcome is a valid return value.
Infrastructure failures (allowed):
- The database is unreachable, the connection is lost, or the operation times out.
- The persistence engine reports an integrity violation: foreign-key broken, unique-constraint duplicated, NOT NULL constraint violated, deadlock detected.
- Serialization or deserialization fails when mapping between domain types and persistence representations.
- Transport-level failures: driver error, connection pool exhausted, transaction aborted by the engine.
- Any uncaught exception originating in the underlying infrastructure layer.
Domain outcomes (never errors):
findBy* does not find a row → returns an absence value.
findManyBy* / search does not find any rows → returns an empty collection / a page with total 0.
countBy* finds nothing → returns 0.
existsBy* / existManyBy* finds nothing → returns false.
deleteById is called with an id that does not exist → succeeds (idempotent).
The error type a repository signature exposes (RepositoryError or equivalent) must describe only infrastructure categories. Domain-shaped error names — UserNotFoundError, OrderInvalidError, EmailAlreadyTakenError, OrderCannotBeCancelledError — must not appear in repository signatures. They belong in the entry point or business-logic layer, which translates the repository's return value into a domain error when the use case requires it.
Detection Workflow
Find repository interfaces and classes used by business-logic entry points.
Check for save, persist, upsert, or any method that conflates creation and update — flag for split into create / update.
Check single-row queries.
- Verify
findBy* returns an absence-permitting type and never throws on not-found.
- Verify single-row queries combine fields with AND only.
Check multi-row queries.
- Verify multi-row queries are named
findManyBy*, never findBy* with a collection return type.
- Verify they return an empty collection (not an error, not null) when nothing matches.
Check search.
- Verify it accepts filter clauses, pagination, and sort clauses.
- Verify it returns a paginated result with collection and total count.
- Verify it returns an empty result (not an error) when nothing matches.
Check countBy*.
- Verify it returns a non-negative integer.
- Verify the predicate matches the corresponding
findManyBy* (same fields, same combinators).
- When the implementation does not delegate to
findManyBy*, verify a test exists that asserts countBy*(args) equals findManyBy*(args).length for representative inputs.
- Flag
countBy* whose predicate diverges silently from findManyBy*.
Check existence operations.
- Verify they are named
existsBy* (singular form) or existManyBy* (plural form, no trailing s on the verb).
- Verify the implementation delegates to the corresponding
countBy* and returns > 0. Flag any independent query.
- Flag any
existsWith*, hasBy*, or other non-canonical names.
Check write operations.
- Verify
create accepts a domain entity and returns the created one.
- Verify
update accepts a domain entity and returns the updated one.
- Verify
deleteById accepts an identity, returns nothing, and is idempotent.
Check the error type.
- Verify the repository's error type lists only infrastructure categories.
- Flag any domain-shaped error name in a repository signature (
*NotFoundError, *AlreadyTakenError, *InvalidError, etc.).
Writing or Changing Repository Interfaces
Define the operations the entry point needs.
- Start from the business-logic entry point's requirements.
- Add only the operations that are needed. Not every repository needs every operation.
Name each operation by the canonical pattern.
findBy<Field> for single-row, findManyBy<Field> for multi-row.
search for dynamic listings.
countBy<Field> for numeric aggregation (AND/OR allowed, no countManyBy* form).
existsBy<Field> / existManyBy<Field> for booleans (with the singular/plural s rule), implemented as countBy<Field> > 0.
create, update, deleteById for writes.
Choose return types that respect the error model.
- Single-row find: absence-permitting type (
T | null, Optional<T>, etc.).
- Multi-row find / search: collection / paginated result (empty when nothing matches).
- Count: non-negative integer.
- Existence: boolean.
- Errors only for infrastructure — never for domain outcomes.
Follow the project's naming convention.
- Adapt to the project's casing style:
findById, find_by_id, FindById.
- Adapt to the project's collection types, error handling, and result conventions.
Add operations incrementally.
- Add a repository operation only when a business-logic entry point needs it.
- Do not pre-populate repositories with operations that no entry point uses yet.
Delegation to Execution Context
When the business-logic-entry-point-execution-context skill is active in the project, repository methods do not receive the transaction as a parameter. The repository implementation retrieves the transaction from the execution context internally. When the transaction from the execution context is undefined or null, the repository must create a new standalone transaction for that operation. All other rules from this skill still apply: the same operations, naming conventions, return types, error model, and the no-save rule.
Examples
TypeScript with execution context:
type SortDirection = 'asc' | 'desc'
type SortClause<T> = {
attribute: keyof T
direction: SortDirection
}
type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'
type FilterClause<T> = {
attribute: keyof T
operator: FilterOperator
value: unknown
}
type PaginatedResult<T> = {
items: T[]
totalCount: number
}
interface OrderRepository {
findById(orderId: OrderId): ResultAsync<Order | null, RepositoryError>
findManyByCustomerId(customerId: CustomerId): ResultAsync<Order[], RepositoryError>
search(
filters: FilterClause<Order>[],
pageNumber: number,
pageSize: number,
sortBy: SortClause<Order>[],
): ResultAsync<PaginatedResult<Order>, RepositoryError>
countById(orderId: OrderId): ResultAsync<number, RepositoryError>
countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError>
existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError>
existManyByCustomerId(customerId: CustomerId): ResultAsync<boolean, RepositoryError>
create(order: Order): ResultAsync<Order, RepositoryError>
update(order: Order): ResultAsync<Order, RepositoryError>
deleteById(orderId: OrderId): ResultAsync<void, RepositoryError>
}
A correct implementation uses the engine's native count aggregation and derives existence from count:
class PrismaOrderRepository implements OrderRepository {
// ... findById, findManyByCustomerId, etc.
countById(orderId: OrderId): ResultAsync<number, RepositoryError> {
// Native aggregation — no entities materialized.
return ResultAsync.fromPromise(
this.prisma.order.count({ where: { id: orderId } }),
(error) => new RepositoryError(error),
)
}
countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError> {
return ResultAsync.fromPromise(
this.prisma.order.count({ where: { customerId } }),
(error) => new RepositoryError(error),
)
}
existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError> {
return this.countById(orderId).map((n) => n > 0)
}
existManyByCustomerId(customerId: CustomerId): ResultAsync<boolean, RepositoryError> {
return this.countByCustomerId(customerId).map((n) => n > 0)
}
}
Not this:
interface OrderRepository {
// Bad: throws on not-found instead of returning an absence value
findById(orderId: OrderId): ResultAsync<Order, OrderNotFoundError>
// Bad: multi-row with single-row naming
findByCustomerId(customerId: CustomerId): ResultAsync<Order[], RepositoryError>
// Bad: ambiguous `save`
save(order: Order): ResultAsync<Order, RepositoryError>
// Bad: domain-shaped error in a repository signature
update(order: Order): ResultAsync<Order, OrderInvalidError>
}
class BadOrderRepository implements OrderRepository {
// Bad: existsBy* runs its own query instead of delegating to countBy*
existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError> {
return ResultAsync.fromPromise(
this.prisma.order.findUnique({ where: { id: orderId } }),
(error) => new RepositoryError(error),
).map((r) => r !== null)
}
// Bad: countBy* with a hidden domain filter ("active") that does not match findManyByCustomerId
countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError> {
return ResultAsync.fromPromise(
this.prisma.order.count({
where: { customerId, status: { not: 'cancelled' } },
}),
(error) => new RepositoryError(error),
)
}
}
TypeScript (explicit passing, for languages without execution context):
interface OrderRepository {
findById(transaction: Transaction, orderId: OrderId): ResultAsync<Order | null, RepositoryError>
findManyByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<Order[], RepositoryError>
search(
transaction: Transaction,
filters: FilterClause<Order>[],
pageNumber: number,
pageSize: number,
sortBy: SortClause<Order>[],
): ResultAsync<PaginatedResult<Order>, RepositoryError>
countById(transaction: Transaction, orderId: OrderId): ResultAsync<number, RepositoryError>
countByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<number, RepositoryError>
existsById(transaction: Transaction, orderId: OrderId): ResultAsync<boolean, RepositoryError>
existManyByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<boolean, RepositoryError>
create(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>
update(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>
deleteById(transaction: Transaction, orderId: OrderId): ResultAsync<void, RepositoryError>
}
Not this:
interface OrderRepository {
save(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>
}
Python:
@dataclass(frozen=True)
class SortClause(Generic[T]):
attribute: str
direction: Literal["asc", "desc"]
@dataclass(frozen=True)
class FilterClause(Generic[T]):
attribute: str
operator: Literal["eq", "neq", "gt", "gte", "lt", "lte", "contains"]
value: object
@dataclass(frozen=True)
class PaginatedResult(Generic[T]):
items: list[T]
total_count: int
class OrderRepository(Protocol):
def find_by_id(self, tx: Transaction, order_id: OrderId) -> Order | None: ...
def find_many_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> list[Order]: ...
def search(
self,
tx: Transaction,
filters: list[FilterClause[Order]],
page_number: int,
page_size: int,
sort_by: list[SortClause[Order]],
) -> PaginatedResult[Order]: ...
def count_by_id(self, tx: Transaction, order_id: OrderId) -> int: ...
def count_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> int: ...
def exists_by_id(self, tx: Transaction, order_id: OrderId) -> bool: ...
def exist_many_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> bool: ...
def create(self, tx: Transaction, order: Order) -> Order: ...
def update(self, tx: Transaction, order: Order) -> Order: ...
def delete_by_id(self, tx: Transaction, order_id: OrderId) -> None: ...
Not this:
class OrderRepository(Protocol):
def save(self, tx: Transaction, order: Order) -> Order: ...
Kotlin:
enum class SortDirection { ASC, DESC }
data class SortClause<T>(
val attribute: String,
val direction: SortDirection,
)
enum class FilterOperator { EQ, NEQ, GT, GTE, LT, LTE, CONTAINS }
data class FilterClause<T>(
val attribute: String,
val operator: FilterOperator,
val value: Any,
)
data class PaginatedResult<T>(
val items: List<T>,
val totalCount: Int,
)
interface OrderRepository {
fun findById(tx: Transaction, orderId: OrderId): Order?
fun findManyByCustomerId(tx: Transaction, customerId: CustomerId): List<Order>
fun search(
tx: Transaction,
filters: List<FilterClause<Order>>,
pageNumber: Int,
pageSize: Int,
sortBy: List<SortClause<Order>>,
): PaginatedResult<Order>
fun countById(tx: Transaction, orderId: OrderId): Int
fun countByCustomerId(tx: Transaction, customerId: CustomerId): Int
fun existsById(tx: Transaction, orderId: OrderId): Boolean
fun existManyByCustomerId(tx: Transaction, customerId: CustomerId): Boolean
fun create(tx: Transaction, order: Order): Order
fun update(tx: Transaction, order: Order): Order
fun deleteById(tx: Transaction, orderId: OrderId)
}
Not this:
interface OrderRepository {
fun save(tx: Transaction, order: Order): Order
}
Review Questions
When reading or reviewing code, ask:
- Does this repository define a
save method? If so, replace it with explicit create and update.
- Does each
findBy* operation return an absence-permitting type (T | null, Optional<T>, etc.) instead of throwing on not-found?
- Does each multi-row query use
findManyBy<Field> naming (never findBy<Field> with a collection return)?
- Does each
countBy<Field> use the engine's native aggregation, and does its predicate match the corresponding findManyBy<Field> (same fields, same combinators)?
- When
countBy* does not delegate to findManyBy*, is there a test asserting that countBy*(args) equals findManyBy*(args).length on representative inputs?
- Is the
existsBy* / existManyBy* naming applied with the singular/plural s rule (exists vs exist)?
- Does each
existsBy* / existManyBy* implementation delegate to the corresponding countBy* of the same repository (returning countBy* > 0), instead of running its own query or delegating to findBy* / findManyBy*?
- Does
create return the created domain entity, and does update return the updated one?
- Does
deleteById return nothing and treat a missing id as a valid outcome?
- Does
search accept filter clauses, pagination, and sort clauses, and return a paginated result with both the collection and the total count?
- Do the repository's error types describe only infrastructure concerns (connectivity, integrity, serialization, transport, timeouts) and never domain concerns (not-found, invalid, taken, etc.)?
If any repository operation violates these conventions, apply this skill.
Report the Outcome
When finishing the task:
- state which repository interfaces were identified or changed
- state which operations were added, renamed, or corrected
- state whether any
save method was split into create and update
- state which return types were corrected to match the absence/empty/zero/boolean conventions
- state whether any
countBy* was added, and whether its predicate aligns with the corresponding findManyBy*
- state whether any
existsBy* / existManyBy* implementations were rewritten to delegate to countBy*
- state whether any domain-shaped error types were removed from repository signatures
1---2name: business-logic-entry-point-repository-operations3description: Require repository interfaces to expose a standard set of operations with specific signatures, naming conventions, and a strict error model. Use when an agent needs to create, modify, review, or interpret repository interfaces used by business-logic entry points. Repositories must offer findBy<Field> (single-row, returns an absence-permitting type), findManyBy<Field> (multi-row, collection), search (dynamic filters, pagination, sorting), countBy<Field> (numeric aggregation, AND/OR), existsBy<Field> / existManyBy<Field> (booleans derived from countBy<Field>), create, update, and deleteById. Errors describe only infrastructure concerns; any domain-level outcome (not found, empty, zero, false) is a valid return value.4---56# Repository Operations for Business Logic Entry Points78## Goal910Repository interfaces must expose a standard set of operations with clear naming, explicit intent, predictable return types, and a strict error model.1112Each operation must make its purpose unambiguous. A caller reading the repository interface must know immediately whether it is creating a new entity, updating an existing one, retrieving zero-or-one by identity or unique key, retrieving zero-or-many by criteria, performing a dynamic listing, counting matches, asking whether something exists, or deleting by identity.1314The operation set is organized in two families:1516- **Entity fetch family** (`findBy*`, `findManyBy*`, `search`) — returns domain entities.17- **Aggregation family** (`countBy*`, `existsBy*`, `existManyBy*`) — returns scalars (number, boolean) computed over the same predicates.1819Two cross-cutting rules apply to every operation:2021- **Domain outcomes are valid return values, never errors.** Not found, empty list, zero count, false existence — all are valid results. Errors are reserved for infrastructure failures.22- **`save` is forbidden.** Always distinguish between `create` and `update` explicitly so the caller's intent is never ambiguous.2324## What Counts as In Scope2526Apply this skill to code that does one or more of these things:2728- defines a repository interface or repository class29- defines repository method signatures for domain-entity persistence operations30- introduces a `save` method that handles both creation and update31- introduces a method that throws or returns a domain error when an entity is not found32- introduces a `findBy*` method whose return type is a collection33- introduces an `existsBy*` / `existManyBy*` method whose implementation does not delegate to the corresponding `countBy*`34- introduces a `countBy*` whose predicate visibly diverges from the corresponding `findManyBy*`35- exposes a domain-shaped error type (`*NotFoundError`, `*AlreadyTakenError`, etc.) in a repository signature36- defines find, search, count, exists, create, update, or delete operations on a repository3738## The Operations3940### 1. `findBy<Field>` (single-row)4142Retrieves zero or one domain entity by a single field or by a conjunction of fields.4344- **Naming**: `findBy<Field>` for one field; `findBy<Field1>And<Field2>` for two fields conjoined; and so on. `findById` is the most common case (`Field = Id`). Do not use `findBy<Field1>Or<Field2>` for single-row queries — `OR` over fields breaks the uniqueness contract and must be expressed as a multi-row query instead.45- **Parameter**: the value(s) for the field(s).46- **Returns**: the domain entity, or an absence value idiomatic to the stack — `T | null`, `T | undefined`, `Optional<T>`, `Maybe<T>`, `T?`. **Never throws or produces a domain error when the entity does not exist; absence is a valid return value.**4748### 2. `findManyBy<Field>` (multi-row)4950Retrieves zero or more domain entities by one or more fields, optionally combining them with AND, OR, or both.5152- **Naming**: `findManyBy<Field>` for one field; `findManyBy<Field1>And<Field2>`, `findManyBy<Field1>Or<Field2>`, and combinations of AND and OR for multiple fields.53- **Parameter**: the value(s) for the field(s).54- **Returns**: a collection of domain entities — array, list, sequence, idiomatic to the stack. **An empty collection when nothing matches is a valid return value, not an error.**55- A multi-row method must never be named `findBy*`. The `Many` segment is part of the contract and signals expected cardinality at every call site.5657### 3. `search`5859Retrieves a collection of domain entities matching a dynamic combination of filter clauses, with pagination and sorting.6061- **Name**: `search` or the project's idiomatic equivalent.62- **Parameters**: a list of filter clauses, pagination parameters (page number and page size), and a list of sort clauses.63- **Returns**: a paginated result containing the collection of domain entities and the total count of matching entities.64- **Use `search` only when the filter or sort fields are not known a priori** — for example, dynamic UI listings, admin tables. When the filter fields are fixed and known at definition time, use `findManyBy<Field>` with named parameters instead.65- One `search` method per repository. Express filter criteria as filter clauses that reference an attribute, a comparison operator, and a value. This avoids creating a separate method for each filter combination.66- When no entities match, return a paginated result with an empty collection and a total count of zero, **never an error**.6768The search operation combines filtering, pagination, and sorting in a single method:6970- **Filtering**: a list of filter clauses, each referencing an attribute of the domain entity, a comparison operator (e.g., `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`), and a value.71- **Pagination**: a page number and a page size. The return type must include both the collection of domain entities and the total count of matching entities so the caller can compute the total number of pages.72- **Sorting**: a list of sort clauses, each specifying an attribute name and a direction (ascending or descending).7374### 4. `countBy<Field>` (numeric aggregation)7576Returns the number of domain entities matching one or more fields combined with AND, OR, or both.7778- **Naming**: `countBy<Field>` for one field; `countBy<Field1>And<Field2>`, `countBy<Field1>Or<Field2>`, and combinations of AND and OR for multiple fields. There is no `countManyBy*` form: `count` is intrinsically about cardinality, so the single-vs-multi distinction does not apply. AND/OR combinators are allowed for any field count.79- **Parameter**: the value(s) for the field(s).80- **Returns**: a non-negative integer idiomatic to the stack (`number`, `int`, `Int`).81- **Contract**: `countBy<Field>(args)` must return the same number that `findManyBy<Field>(args).length` would return for the same arguments — same predicate, same semantics. Where a `findBy<Field>` exists for a unique field, `countBy<Field>` must return `0` or `1` according to whether the entity is present.82- **Implementation**: free, and **encouraged to use the engine's native aggregation** (`COUNT(*)` in SQL, `prisma.model.count`, `countDocuments` in MongoDB, etc.). Forced delegation to `findManyBy*` is not required because the cost would be O(n) — loading every matching row in memory just to count it defeats the purpose of having a separate aggregation operation.83- **Verification when not delegating**: tests must verify that `countBy<Field>(args)` equals `findManyBy<Field>(args).length` (or `findBy<Field>(args) !== null ? 1 : 0` for unique fields) on a representative set of inputs. The contract is enforced by tests, not by implementation form.84- **An empty result is `0`, never an error.**85- **Forbidden**: a `countBy<Field>` whose predicate visibly diverges from the corresponding `findManyBy<Field>` (for example, a hidden status filter). That is no longer a "count derived from find" — it is a different operation and must be renamed accordingly, or the dynamic filter must be moved to `search`.8687### 5. `existsBy<Field>` (single-row exists) / `existManyBy<Field>` (multi-row exists)8889Returns whether at least one domain entity exists matching a criterion.9091- **Naming**: `existsBy<Field>` (verb in third-person singular: "does it exist") for the single-row form; `existManyBy<Field>` (verb in plural, **without the trailing `s`**: "do they exist") for the multi-row form. The presence/absence of the `s` is semantic, not stylistic.92- **Combinator rules**: same as `findBy*` / `findManyBy*` — single-row admits only AND between fields; multi-row admits AND, OR, and combinations.93- **Parameter**: identical to the corresponding `countBy*` method.94- **Returns**: a boolean idiomatic to the stack (`boolean`, `bool`).95- **Implementation requirement**: every `existsBy*` / `existManyBy*` method **must delegate** to the corresponding `countBy*` of the same repository:96 - `existsBy<Field>(args)` returns `countBy<Field>(args) > 0`.97 - `existManyBy<Field>(args)` returns `countBy<Field>(args) > 0`.98 - Running an independent query is forbidden. The reason: the boolean contract is bound to the count contract — same filters, same domain semantics. Delegating prevents the two from diverging over time, and `count` already encapsulates the engine-native aggregation, so there is no performance reason to bypass it.99- **A negative result is never an error.** `false` is a valid return value.100101### 6. `create`102103Persists a new domain entity and returns the created entity.104105- **Name**: `create` or the project's idiomatic equivalent.106- **Parameter**: the domain entity to persist.107- **Returns**: the created domain entity. The returned entity must reflect the state after persistence, including any identity or field assigned during creation.108109### 7. `update`110111Persists changes to an existing domain entity and returns the updated entity.112113- **Name**: `update` or the project's idiomatic equivalent.114- **Parameter**: the domain entity with updated state.115- **Returns**: the updated domain entity. The returned entity must reflect the state after persistence.116117### 8. `deleteById`118119Removes a domain entity by its identity.120121- **Name**: `deleteById` or the project's idiomatic equivalent (e.g., `delete_by_id`).122- **Parameter**: the entity's identity value.123- **Returns**: nothing (void, unit, `None`, or the project's empty equivalent).124- **Idempotent**: deleting a non-existent id is a valid outcome, not an error.125126## Forbidden Operations127128The following operations or implementations must not appear on a repository. Each has a single canonical replacement.129130- **`save`** — ambiguous. Replace with explicit `create` and `update`.131- **`getBy*` / `getById` that throws when the entity does not exist** — pushes absence handling onto exceptions. Replace with `findBy*` returning an absence-permitting type. The caller decides whether absence is an error in its use case.132- **`findBy<Field>` whose return type is a collection** — multi-row query with single-row naming. Rename to `findManyBy<Field>`.133- **`existsBy*` / `existManyBy*` that does not delegate to the corresponding `countBy*`** — must call `countBy<Field>` and return `> 0`.134- **`countBy<Field>` whose predicate diverges from the corresponding `findManyBy<Field>`** — that is a different operation; rename it explicitly or move the dynamic filter to `search`.135- **`existsWith*`, `hasBy*`, `countMany*`, and other non-canonical names** — rename to follow the `existsBy*` / `existManyBy*` / `countBy*` conventions, including the singular/plural `s` rule for `exists` / `exist`.136137## Error Model138139Repository operations are allowed to produce errors **only** for infrastructure failures. Any other outcome is a valid return value.140141**Infrastructure failures (allowed):**142143- The database is unreachable, the connection is lost, or the operation times out.144- The persistence engine reports an integrity violation: foreign-key broken, unique-constraint duplicated, NOT NULL constraint violated, deadlock detected.145- Serialization or deserialization fails when mapping between domain types and persistence representations.146- Transport-level failures: driver error, connection pool exhausted, transaction aborted by the engine.147- Any uncaught exception originating in the underlying infrastructure layer.148149**Domain outcomes (never errors):**150151- `findBy*` does not find a row → returns an absence value.152- `findManyBy*` / `search` does not find any rows → returns an empty collection / a page with total 0.153- `countBy*` finds nothing → returns `0`.154- `existsBy*` / `existManyBy*` finds nothing → returns `false`.155- `deleteById` is called with an id that does not exist → succeeds (idempotent).156157The error type a repository signature exposes (`RepositoryError` or equivalent) must describe **only** infrastructure categories. Domain-shaped error names — `UserNotFoundError`, `OrderInvalidError`, `EmailAlreadyTakenError`, `OrderCannotBeCancelledError` — **must not appear in repository signatures**. They belong in the entry point or business-logic layer, which translates the repository's return value into a domain error when the use case requires it.158159## Detection Workflow1601611. Find repository interfaces and classes used by business-logic entry points.1621632. Check for `save`, `persist`, `upsert`, or any method that conflates creation and update — flag for split into `create` / `update`.1641653. Check single-row queries.166 - Verify `findBy*` returns an absence-permitting type and never throws on not-found.167 - Verify single-row queries combine fields with AND only.1681694. Check multi-row queries.170 - Verify multi-row queries are named `findManyBy*`, never `findBy*` with a collection return type.171 - Verify they return an empty collection (not an error, not null) when nothing matches.1721735. Check `search`.174 - Verify it accepts filter clauses, pagination, and sort clauses.175 - Verify it returns a paginated result with collection and total count.176 - Verify it returns an empty result (not an error) when nothing matches.1771786. Check `countBy*`.179 - Verify it returns a non-negative integer.180 - Verify the predicate matches the corresponding `findManyBy*` (same fields, same combinators).181 - When the implementation does not delegate to `findManyBy*`, verify a test exists that asserts `countBy*(args)` equals `findManyBy*(args).length` for representative inputs.182 - Flag `countBy*` whose predicate diverges silently from `findManyBy*`.1831847. Check existence operations.185 - Verify they are named `existsBy*` (singular form) or `existManyBy*` (plural form, no trailing `s` on the verb).186 - Verify the implementation delegates to the corresponding `countBy*` and returns `> 0`. Flag any independent query.187 - Flag any `existsWith*`, `hasBy*`, or other non-canonical names.1881898. Check write operations.190 - Verify `create` accepts a domain entity and returns the created one.191 - Verify `update` accepts a domain entity and returns the updated one.192 - Verify `deleteById` accepts an identity, returns nothing, and is idempotent.1931949. Check the error type.195 - Verify the repository's error type lists only infrastructure categories.196 - Flag any domain-shaped error name in a repository signature (`*NotFoundError`, `*AlreadyTakenError`, `*InvalidError`, etc.).197198## Writing or Changing Repository Interfaces1992001. Define the operations the entry point needs.201 - Start from the business-logic entry point's requirements.202 - Add only the operations that are needed. Not every repository needs every operation.2032042. Name each operation by the canonical pattern.205 - `findBy<Field>` for single-row, `findManyBy<Field>` for multi-row.206 - `search` for dynamic listings.207 - `countBy<Field>` for numeric aggregation (AND/OR allowed, no `countManyBy*` form).208 - `existsBy<Field>` / `existManyBy<Field>` for booleans (with the singular/plural `s` rule), implemented as `countBy<Field> > 0`.209 - `create`, `update`, `deleteById` for writes.2102113. Choose return types that respect the error model.212 - Single-row find: absence-permitting type (`T | null`, `Optional<T>`, etc.).213 - Multi-row find / search: collection / paginated result (empty when nothing matches).214 - Count: non-negative integer.215 - Existence: boolean.216 - Errors only for infrastructure — never for domain outcomes.2172184. Follow the project's naming convention.219 - Adapt to the project's casing style: `findById`, `find_by_id`, `FindById`.220 - Adapt to the project's collection types, error handling, and result conventions.2212225. Add operations incrementally.223 - Add a repository operation only when a business-logic entry point needs it.224 - Do not pre-populate repositories with operations that no entry point uses yet.225226## Delegation to Execution Context227228When the `business-logic-entry-point-execution-context` skill is active in the project, repository methods do not receive the transaction as a parameter. The repository implementation retrieves the transaction from the execution context internally. When the transaction from the execution context is `undefined` or `null`, the repository must create a new standalone transaction for that operation. All other rules from this skill still apply: the same operations, naming conventions, return types, error model, and the no-save rule.229230## Examples231232TypeScript with execution context:233234```ts235type SortDirection = 'asc' | 'desc'236237type SortClause<T> = {238 attribute: keyof T239 direction: SortDirection240}241242type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'243244type FilterClause<T> = {245 attribute: keyof T246 operator: FilterOperator247 value: unknown248}249250type PaginatedResult<T> = {251 items: T[]252 totalCount: number253}254255interface OrderRepository {256 findById(orderId: OrderId): ResultAsync<Order | null, RepositoryError>257 findManyByCustomerId(customerId: CustomerId): ResultAsync<Order[], RepositoryError>258 search(259 filters: FilterClause<Order>[],260 pageNumber: number,261 pageSize: number,262 sortBy: SortClause<Order>[],263 ): ResultAsync<PaginatedResult<Order>, RepositoryError>264 countById(orderId: OrderId): ResultAsync<number, RepositoryError>265 countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError>266 existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError>267 existManyByCustomerId(customerId: CustomerId): ResultAsync<boolean, RepositoryError>268 create(order: Order): ResultAsync<Order, RepositoryError>269 update(order: Order): ResultAsync<Order, RepositoryError>270 deleteById(orderId: OrderId): ResultAsync<void, RepositoryError>271}272```273274A correct implementation uses the engine's native count aggregation and derives existence from count:275276```ts277class PrismaOrderRepository implements OrderRepository {278 // ... findById, findManyByCustomerId, etc.279280 countById(orderId: OrderId): ResultAsync<number, RepositoryError> {281 // Native aggregation — no entities materialized.282 return ResultAsync.fromPromise(283 this.prisma.order.count({ where: { id: orderId } }),284 (error) => new RepositoryError(error),285 )286 }287288 countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError> {289 return ResultAsync.fromPromise(290 this.prisma.order.count({ where: { customerId } }),291 (error) => new RepositoryError(error),292 )293 }294295 existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError> {296 return this.countById(orderId).map((n) => n > 0)297 }298299 existManyByCustomerId(customerId: CustomerId): ResultAsync<boolean, RepositoryError> {300 return this.countByCustomerId(customerId).map((n) => n > 0)301 }302}303```304305Not this:306307```ts308interface OrderRepository {309 // Bad: throws on not-found instead of returning an absence value310 findById(orderId: OrderId): ResultAsync<Order, OrderNotFoundError>311 // Bad: multi-row with single-row naming312 findByCustomerId(customerId: CustomerId): ResultAsync<Order[], RepositoryError>313 // Bad: ambiguous `save`314 save(order: Order): ResultAsync<Order, RepositoryError>315 // Bad: domain-shaped error in a repository signature316 update(order: Order): ResultAsync<Order, OrderInvalidError>317}318319class BadOrderRepository implements OrderRepository {320 // Bad: existsBy* runs its own query instead of delegating to countBy*321 existsById(orderId: OrderId): ResultAsync<boolean, RepositoryError> {322 return ResultAsync.fromPromise(323 this.prisma.order.findUnique({ where: { id: orderId } }),324 (error) => new RepositoryError(error),325 ).map((r) => r !== null)326 }327328 // Bad: countBy* with a hidden domain filter ("active") that does not match findManyByCustomerId329 countByCustomerId(customerId: CustomerId): ResultAsync<number, RepositoryError> {330 return ResultAsync.fromPromise(331 this.prisma.order.count({332 where: { customerId, status: { not: 'cancelled' } },333 }),334 (error) => new RepositoryError(error),335 )336 }337}338```339340TypeScript (explicit passing, for languages without execution context):341342```ts343interface OrderRepository {344 findById(transaction: Transaction, orderId: OrderId): ResultAsync<Order | null, RepositoryError>345 findManyByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<Order[], RepositoryError>346 search(347 transaction: Transaction,348 filters: FilterClause<Order>[],349 pageNumber: number,350 pageSize: number,351 sortBy: SortClause<Order>[],352 ): ResultAsync<PaginatedResult<Order>, RepositoryError>353 countById(transaction: Transaction, orderId: OrderId): ResultAsync<number, RepositoryError>354 countByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<number, RepositoryError>355 existsById(transaction: Transaction, orderId: OrderId): ResultAsync<boolean, RepositoryError>356 existManyByCustomerId(transaction: Transaction, customerId: CustomerId): ResultAsync<boolean, RepositoryError>357 create(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>358 update(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>359 deleteById(transaction: Transaction, orderId: OrderId): ResultAsync<void, RepositoryError>360}361```362363Not this:364365```ts366interface OrderRepository {367 save(transaction: Transaction, order: Order): ResultAsync<Order, RepositoryError>368}369```370371Python:372373```py374@dataclass(frozen=True)375class SortClause(Generic[T]):376 attribute: str377 direction: Literal["asc", "desc"]378379@dataclass(frozen=True)380class FilterClause(Generic[T]):381 attribute: str382 operator: Literal["eq", "neq", "gt", "gte", "lt", "lte", "contains"]383 value: object384385@dataclass(frozen=True)386class PaginatedResult(Generic[T]):387 items: list[T]388 total_count: int389390class OrderRepository(Protocol):391 def find_by_id(self, tx: Transaction, order_id: OrderId) -> Order | None: ...392 def find_many_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> list[Order]: ...393 def search(394 self,395 tx: Transaction,396 filters: list[FilterClause[Order]],397 page_number: int,398 page_size: int,399 sort_by: list[SortClause[Order]],400 ) -> PaginatedResult[Order]: ...401 def count_by_id(self, tx: Transaction, order_id: OrderId) -> int: ...402 def count_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> int: ...403 def exists_by_id(self, tx: Transaction, order_id: OrderId) -> bool: ...404 def exist_many_by_customer_id(self, tx: Transaction, customer_id: CustomerId) -> bool: ...405 def create(self, tx: Transaction, order: Order) -> Order: ...406 def update(self, tx: Transaction, order: Order) -> Order: ...407 def delete_by_id(self, tx: Transaction, order_id: OrderId) -> None: ...408```409410Not this:411412```py413class OrderRepository(Protocol):414 def save(self, tx: Transaction, order: Order) -> Order: ...415```416417Kotlin:418419```kt420enum class SortDirection { ASC, DESC }421422data class SortClause<T>(423 val attribute: String,424 val direction: SortDirection,425)426427enum class FilterOperator { EQ, NEQ, GT, GTE, LT, LTE, CONTAINS }428429data class FilterClause<T>(430 val attribute: String,431 val operator: FilterOperator,432 val value: Any,433)434435data class PaginatedResult<T>(436 val items: List<T>,437 val totalCount: Int,438)439440interface OrderRepository {441 fun findById(tx: Transaction, orderId: OrderId): Order?442 fun findManyByCustomerId(tx: Transaction, customerId: CustomerId): List<Order>443 fun search(444 tx: Transaction,445 filters: List<FilterClause<Order>>,446 pageNumber: Int,447 pageSize: Int,448 sortBy: List<SortClause<Order>>,449 ): PaginatedResult<Order>450 fun countById(tx: Transaction, orderId: OrderId): Int451 fun countByCustomerId(tx: Transaction, customerId: CustomerId): Int452 fun existsById(tx: Transaction, orderId: OrderId): Boolean453 fun existManyByCustomerId(tx: Transaction, customerId: CustomerId): Boolean454 fun create(tx: Transaction, order: Order): Order455 fun update(tx: Transaction, order: Order): Order456 fun deleteById(tx: Transaction, orderId: OrderId)457}458```459460Not this:461462```kt463interface OrderRepository {464 fun save(tx: Transaction, order: Order): Order465}466```467468## Review Questions469470When reading or reviewing code, ask:471472- Does this repository define a `save` method? If so, replace it with explicit `create` and `update`.473- Does each `findBy*` operation return an absence-permitting type (`T | null`, `Optional<T>`, etc.) instead of throwing on not-found?474- Does each multi-row query use `findManyBy<Field>` naming (never `findBy<Field>` with a collection return)?475- Does each `countBy<Field>` use the engine's native aggregation, and does its predicate match the corresponding `findManyBy<Field>` (same fields, same combinators)?476- When `countBy*` does not delegate to `findManyBy*`, is there a test asserting that `countBy*(args)` equals `findManyBy*(args).length` on representative inputs?477- Is the `existsBy*` / `existManyBy*` naming applied with the singular/plural `s` rule (`exists` vs `exist`)?478- Does each `existsBy*` / `existManyBy*` implementation delegate to the corresponding `countBy*` of the same repository (returning `countBy* > 0`), instead of running its own query or delegating to `findBy*` / `findManyBy*`?479- Does `create` return the created domain entity, and does `update` return the updated one?480- Does `deleteById` return nothing and treat a missing id as a valid outcome?481- Does `search` accept filter clauses, pagination, and sort clauses, and return a paginated result with both the collection and the total count?482- Do the repository's error types describe only infrastructure concerns (connectivity, integrity, serialization, transport, timeouts) and never domain concerns (not-found, invalid, taken, etc.)?483484If any repository operation violates these conventions, apply this skill.485486## Report the Outcome487488When finishing the task:489490- state which repository interfaces were identified or changed491- state which operations were added, renamed, or corrected492- state whether any `save` method was split into `create` and `update`493- state which return types were corrected to match the absence/empty/zero/boolean conventions494- state whether any `countBy*` was added, and whether its predicate aligns with the corresponding `findManyBy*`495- state whether any `existsBy*` / `existManyBy*` implementations were rewritten to delegate to `countBy*`496- state whether any domain-shaped error types were removed from repository signatures