business-logic-entry-point-repository-operations
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.
saveis forbidden. Always distinguish betweencreateandupdateexplicitly 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
savemethod 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 correspondingcountBy* - introduces a
countBy*whose predicate visibly diverges from the correspondingfindManyBy* - 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.findByIdis the most common case (Field = Id). Do not usefindBy<Field1>Or<Field2>for single-row queries —ORover 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*. TheManysegment 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:
searchor 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
searchonly 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, usefindManyBy<Field>with named parameters instead. - One
searchmethod 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 nocountManyBy*form:countis 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 thatfindManyBy<Field>(args).lengthwould return for the same arguments — same predicate, same semantics. Where afindBy<Field>exists for a unique field,countBy<Field>must return0or1according to whether the entity is present. - Implementation: free, and encouraged to use the engine's native aggregation (
COUNT(*)in SQL,prisma.model.count,countDocumentsin MongoDB, etc.). Forced delegation tofindManyBy*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)equalsfindManyBy<Field>(args).length(orfindBy<Field>(args) !== null ? 1 : 0for 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 correspondingfindManyBy<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 tosearch.
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 trailings: "do they exist") for the multi-row form. The presence/absence of thesis 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 correspondingcountBy*of the same repository:existsBy<Field>(args)returnscountBy<Field>(args) > 0.existManyBy<Field>(args)returnscountBy<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
countalready encapsulates the engine-native aggregation, so there is no performance reason to bypass it.
- A negative result is never an error.
falseis a valid return value.
6. create
Persists a new domain entity and returns the created entity.
- Name:
createor 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:
updateor 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:
deleteByIdor 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 explicitcreateandupdate.getBy*/getByIdthat throws when the entity does not exist — pushes absence handling onto exceptions. Replace withfindBy*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 tofindManyBy<Field>.existsBy*/existManyBy*that does not delegate to the correspondingcountBy*— must callcountBy<Field>and return> 0.countBy<Field>whose predicate diverges from the correspondingfindManyBy<Field>— that is a different operation; rename it explicitly or move the dynamic filter tosearch.existsWith*,hasBy*,countMany*, and other non-canonical names — rename to follow theexistsBy*/existManyBy*/countBy*conventions, including the singular/pluralsrule forexists/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*/searchdoes not find any rows → returns an empty collection / a page with total 0.countBy*finds nothing → returns0.existsBy*/existManyBy*finds nothing → returnsfalse.deleteByIdis 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 intocreate/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.
- Verify
-
Check multi-row queries.
- Verify multi-row queries are named
findManyBy*, neverfindBy*with a collection return type. - Verify they return an empty collection (not an error, not null) when nothing matches.
- Verify multi-row queries are named
-
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 assertscountBy*(args)equalsfindManyBy*(args).lengthfor representative inputs. - Flag
countBy*whose predicate diverges silently fromfindManyBy*.
-
Check existence operations.
- Verify they are named
existsBy*(singular form) orexistManyBy*(plural form, no trailingson 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.
- Verify they are named
-
Check write operations.
- Verify
createaccepts a domain entity and returns the created one. - Verify
updateaccepts a domain entity and returns the updated one. - Verify
deleteByIdaccepts an identity, returns nothing, and is idempotent.
- Verify
-
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.searchfor dynamic listings.countBy<Field>for numeric aggregation (AND/OR allowed, nocountManyBy*form).existsBy<Field>/existManyBy<Field>for booleans (with the singular/pluralsrule), implemented ascountBy<Field> > 0.create,update,deleteByIdfor 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.
- Single-row find: absence-permitting type (
-
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.
- Adapt to the project's casing style:
-
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
savemethod? If so, replace it with explicitcreateandupdate. - 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 (neverfindBy<Field>with a collection return)? - Does each
countBy<Field>use the engine's native aggregation, and does its predicate match the correspondingfindManyBy<Field>(same fields, same combinators)? - When
countBy*does not delegate tofindManyBy*, is there a test asserting thatcountBy*(args)equalsfindManyBy*(args).lengthon representative inputs? - Is the
existsBy*/existManyBy*naming applied with the singular/pluralsrule (existsvsexist)? - Does each
existsBy*/existManyBy*implementation delegate to the correspondingcountBy*of the same repository (returningcountBy* > 0), instead of running its own query or delegating tofindBy*/findManyBy*? - Does
createreturn the created domain entity, and doesupdatereturn the updated one? - Does
deleteByIdreturn nothing and treat a missing id as a valid outcome? - Does
searchaccept 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
savemethod was split intocreateandupdate - 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 correspondingfindManyBy* - state whether any
existsBy*/existManyBy*implementations were rewritten to delegate tocountBy* - state whether any domain-shaped error types were removed from repository signatures
More from code-sherpas/agent-skills
neverthrow-return-types
Require `neverthrow`-based return types in TypeScript and JavaScript code whenever the surrounding technology allows it. Use when creating, refactoring, reviewing, or extending standalone functions, exported module functions, class methods, object methods, service methods, repository methods, and similar APIs that should expose explicit success and failure result types in their signatures. Prefer `Result<T, E>` for synchronous code and `ResultAsync<T, E>` for asynchronous code. Only skip a `neverthrow` return type when a framework, library, runtime interface, or externally imposed contract is incompatible and requires a different return shape.
17neverthrow-wrap-exceptions
Capture exceptions and promise failures with `neverthrow` instead of hand-written `try/catch` in TypeScript and JavaScript code. Use when wrapping synchronous functions that may throw, promise-returning functions that may throw before returning, existing `PromiseLike` values that may reject, or third-party APIs such as parsers, database clients, HTTP clients, file-system helpers, serializers, and SDK calls. Prefer `Result.fromThrowable` for synchronous throwers, `ResultAsync.fromThrowable` for promise-returning functions that may throw or reject, and `ResultAsync.fromPromise` when you already have a `PromiseLike` value in hand. Only keep `try/catch` when the language construct, cleanup requirement, or framework boundary truly requires it.
12atomic-design
Create or update web UI components with a strict reuse-first workflow. Use when building, refactoring, restyling, or extending frontend or template components while minimizing raw DOM or HTML by reusing or generalizing existing components first.
11write-persistence-representations
Create or update persistence-layer data representations in any stack, including ORM entities, schema definitions, table mappings, document models, collection definitions, and similar database-facing code. Use when agents needs to add or change persisted fields, identifiers, relationships, indexes, timestamps, auditing fields, or storage mappings in frameworks, libraries, or ORMs such as Prisma, TypeORM, Sequelize, Drizzle, Mongoose, Hibernate/JPA, Doctrine, Ecto, Active Record, or equivalent persistence technologies.
8business-logic
Identify, interpret, review, or write business logic in code. Use when an agent needs to decide whether code expresses business rules, business algorithms, or business workflows, or when it must implement, preserve, or refactor code that creates, stores, or transforms data according to real business policies.
8immutable-domain-entities
Require the immutable design pattern for domain entities. Use when an agent needs to create, modify, review, or interpret domain entities and should preserve identity while expressing state changes through new immutable instances. Domain entities must be modeled as immutable classes, not as plain type aliases or interfaces paired with standalone functions.
8