Generic Repository: параметризованный базовый контракт
Решение проблемы дублирования через параметризацию типов. Базовый интерфейс Repository<A, Id>, ограничения типов (Identifiable). Generic InMemory-фабрика, Generic SQL-фабрика. Композиция через расширение: CrudRepository → QueryableRepository → BatchRepository. Когда НЕ использовать Generic Repository. Антипаттерны: God Repository, чрезмерное абстрагирование.
Введение: проблема дублирования
Когда в системе появляется несколько агрегатов — Todo, User, Project — каждый требует свой Repository. Без обобщений мы получим дублирование:
// TodoRepository
class TodoRepository extends Context.Tag("TodoRepository")<TodoRepository, {
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}>() {}
// UserRepository — те же методы, другие типы
class UserRepository extends Context.Tag("UserRepository")<UserRepository, {
readonly save: (user: User) => Effect.Effect<void, RepositoryError>
readonly findById: (id: UserId) => Effect.Effect<Option.Option<User>, RepositoryError>
readonly delete: (id: UserId) => Effect.Effect<void, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<User>, RepositoryError>
}>() {}
// ProjectRepository — и снова...
class ProjectRepository extends Context.Tag("ProjectRepository")<ProjectRepository, {
readonly save: (project: Project) => Effect.Effect<void, RepositoryError>
readonly findById: (id: ProjectId) => Effect.Effect<Option.Option<Project>, RepositoryError>
readonly delete: (id: ProjectId) => Effect.Effect<void, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
}>() {}
Три Repository — один и тот же паттерн, отличаются только типы Entity и Id. Generic Repository решает эту проблему через параметризацию типов.
Базовый Generic Repository: интерфейс
Определение параметризованного интерфейса
import { Effect, Option } from "effect"
/**
* Базовый контракт Repository для любого агрегата.
*
* @typeParam A - тип агрегата (Aggregate Root)
* @typeParam Id - тип идентификатора агрегата
* @typeParam E - тип ошибки (по умолчанию RepositoryError)
*/
interface Repository<
in out A,
in out Id,
out E = RepositoryError
> {
/** Сохранить агрегат (upsert) */
readonly save: (aggregate: A) => Effect.Effect<void, E>
/** Найти по идентификатору */
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
/** Удалить по идентификатору (идемпотентно) */
readonly delete: (id: Id) => Effect.Effect<void, E>
/** Получить все агрегаты */
readonly findAll: () => Effect.Effect<ReadonlyArray<A>, E>
/** Проверить существование */
readonly exists: (id: Id) => Effect.Effect<boolean, E>
/** Подсчитать количество */
readonly count: () => Effect.Effect<number, E>
}
Использование для конкретных агрегатов
Теперь конкретные Repository наследуют базовый интерфейс и добавляют доменно-специфичные методы:
// ── TodoRepository ──────────────────────────────
// Расширяет базовый Repository + доменные методы
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findByPriority: (priority: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findOverdue: (asOf: Date) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
TodoRepositoryShape
>() {}
// ── UserRepository ──────────────────────────────
interface UserRepositoryShape extends Repository<User, UserId> {
readonly findByEmail: (email: Email) => Effect.Effect<Option.Option<User>, RepositoryError>
readonly findByRole: (role: UserRole) => Effect.Effect<ReadonlyArray<User>, RepositoryError>
}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
UserRepositoryShape
>() {}
// ── ProjectRepository ───────────────────────────
interface ProjectRepositoryShape extends Repository<Project, ProjectId> {
readonly findByOwner: (ownerId: UserId) => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
readonly findPublic: () => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
}
class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
ProjectRepositoryShape
>() {}
Ограничения типов: что можно передавать
Ограничение на агрегат
Не любой тип может быть агрегатом. Агрегат должен иметь идентификатор. Выразим это через ограничение типа:
/**
* Базовое ограничение: агрегат должен иметь поле id.
* Это гарантирует, что Repository может идентифицировать агрегат.
*/
interface Identifiable<Id> {
readonly id: Id
}
/**
* Repository работает только с Identifiable-объектами.
*/
interface Repository<
A extends Identifiable<Id>,
Id,
E = RepositoryError
> {
readonly save: (aggregate: A) => Effect.Effect<void, E>
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
readonly delete: (id: Id) => Effect.Effect<void, E>
readonly findAll: () => Effect.Effect<ReadonlyArray<A>, E>
}
Теперь невозможно создать Repository для типа без идентификатора:
// ✅ Todo имеет id — компилируется
interface Todo extends Identifiable<TodoId> {
readonly id: TodoId
readonly title: TodoTitle
}
type TodoRepo = Repository<Todo, TodoId> // OK
// ❌ Тип без id — не компилируется
interface Config {
readonly theme: string
readonly language: string
}
type ConfigRepo = Repository<Config, string>
// Error: Type 'Config' does not satisfy the constraint 'Identifiable<string>'
Ограничение на идентификатор
Идентификатор должен быть сравниваемым. В Effect для этого есть Equal:
import { Equal } from "effect"
/**
* Идентификатор должен поддерживать сравнение.
* Branded types из Schema автоматически это обеспечивают.
*/
interface Repository<
A extends Identifiable<Id>,
Id extends Equal.Equal,
E = RepositoryError
> {
readonly save: (aggregate: A) => Effect.Effect<void, E>
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
// ...
}
На практике branded types из Effect Schema уже реализуют необходимые протоколы, поэтому строгое ограничение через Equal.Equal может быть избыточным. Часто достаточно просто string-based branded types:
// Простой подход — branded string как Id
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type
// TodoId — это string & Brand<"TodoId">
// Сравнение через === работает корректно для строк
Паттерны реализации Generic Repository
Паттерн 1: Generic InMemory Repository
Создаём одну generic-реализацию InMemory Repository, переиспользуемую для любого агрегата:
import { Effect, Layer, Option, Context } from "effect"
/**
* Фабрика InMemory Repository для любого агрегата.
* Используется для тестов и прототипирования.
*
* @param tag - Context.Tag сервиса
* @param getId - функция извлечения Id из агрегата
*/
const makeInMemoryRepository = <
A extends Identifiable<Id>,
Id extends string,
Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
tag: Tag,
getId: (aggregate: A) => Id,
) =>
Layer.sync(tag, () => {
const store = new Map<Id, A>()
return {
save: (aggregate: A) =>
Effect.sync(() => {
store.set(getId(aggregate), aggregate)
}),
findById: (id: Id) =>
Effect.sync(() =>
Option.fromNullable(store.get(id))
),
delete: (id: Id) =>
Effect.sync(() => {
store.delete(id)
}),
findAll: () =>
Effect.sync(() =>
Array.from(store.values()) as ReadonlyArray<A>
),
exists: (id: Id) =>
Effect.sync(() => store.has(id)),
count: () =>
Effect.sync(() => store.size),
} satisfies Repository<A, Id>
})
Использование:
// InMemory Repository для Todo — одна строка!
const TodoRepositoryInMemory = makeInMemoryRepository(
TodoRepository,
(todo: Todo) => todo.id,
)
// InMemory Repository для User — ещё одна строка!
const UserRepositoryInMemory = makeInMemoryRepository(
UserRepository,
(user: User) => user.id,
)
Паттерн 2: Generic адаптер с маппингом
Для реальных адаптеров (SQLite) нужен маппинг между доменными и инфраструктурными типами:
/**
* Конфигурация для generic SQL-адаптера.
*/
interface SqlRepositoryConfig<A, Id extends string, Row> {
readonly tableName: string
readonly toRow: (aggregate: A) => Row
readonly fromRow: (row: Row) => Effect.Effect<A, RepositoryError>
readonly getId: (aggregate: A) => Id
readonly columns: ReadonlyArray<string>
}
/**
* Фабрика SQL Repository.
*/
const makeSqlRepository = <
A extends Identifiable<Id>,
Id extends string,
Row extends Record<string, unknown>,
Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
tag: Tag,
config: SqlRepositoryConfig<A, Id, Row>,
) =>
Layer.effect(tag,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
save: (aggregate: A) =>
Effect.try({
try: () => {
const row = config.toRow(aggregate)
const columns = config.columns.join(", ")
const placeholders = config.columns.map(() => "?").join(", ")
const values = config.columns.map((col) => (row as any)[col])
db.run(
`INSERT OR REPLACE INTO ${config.tableName} (${columns}) VALUES (${placeholders})`,
values
)
},
catch: (error) =>
new RepositoryError({
operation: "save",
message: `Failed to save to ${config.tableName}`,
cause: error,
}),
}),
findById: (id: Id) =>
pipe(
Effect.try({
try: () =>
db.query(`SELECT * FROM ${config.tableName} WHERE id = ?`).get(id),
catch: (error) =>
new RepositoryError({
operation: "findById",
message: `Failed to find in ${config.tableName}`,
cause: error,
}),
}),
Effect.flatMap((row) =>
row
? pipe(config.fromRow(row as Row), Effect.map(Option.some))
: Effect.succeed(Option.none())
),
),
delete: (id: Id) =>
Effect.try({
try: () => {
db.run(`DELETE FROM ${config.tableName} WHERE id = ?`, [id])
},
catch: (error) =>
new RepositoryError({
operation: "delete",
message: `Failed to delete from ${config.tableName}`,
cause: error,
}),
}),
findAll: () =>
pipe(
Effect.try({
try: () =>
db.query(`SELECT * FROM ${config.tableName}`).all() as ReadonlyArray<Row>,
catch: (error) =>
new RepositoryError({
operation: "findAll",
message: `Failed to query ${config.tableName}`,
cause: error,
}),
}),
Effect.flatMap((rows) =>
Effect.all(rows.map(config.fromRow))
),
),
exists: (id: Id) =>
pipe(
Effect.try({
try: () => {
const row = db.query(
`SELECT 1 FROM ${config.tableName} WHERE id = ? LIMIT 1`
).get(id)
return row !== null
},
catch: (error) =>
new RepositoryError({
operation: "exists",
message: `Failed to check existence in ${config.tableName}`,
cause: error,
}),
}),
),
count: () =>
Effect.try({
try: () => {
const row = db.query(
`SELECT COUNT(*) as cnt FROM ${config.tableName}`
).get() as { cnt: number }
return row.cnt
},
catch: (error) =>
new RepositoryError({
operation: "count",
message: `Failed to count in ${config.tableName}`,
cause: error,
}),
}),
} satisfies Repository<A, Id>
})
)
Использование:
// SQLite Repository для Todo
const TodoRepositorySqlite = makeSqlRepository(
TodoRepository,
{
tableName: "todos",
columns: ["id", "title", "priority", "status", "created_at", "completed_at"] as const,
getId: (todo: Todo) => todo.id,
toRow: (todo: Todo) => ({
id: todo.id,
title: todo.title,
priority: todo.priority,
status: todo.status,
created_at: todo.createdAt.toISOString(),
completed_at: pipe(todo.completedAt, Option.map(d => d.toISOString()), Option.getOrNull),
}),
fromRow: (row) =>
Schema.decode(Todo)({
id: row.id,
title: row.title,
priority: row.priority,
status: row.status,
createdAt: new Date(row.created_at as string),
completedAt: row.completed_at ? Option.some(new Date(row.completed_at as string)) : Option.none(),
}),
}
)
Когда НЕ использовать Generic Repository
Проблема: Generic Repository может стать Leaky Abstraction
Generic Repository опасен тем, что навязывает одинаковый интерфейс всем агрегатам. Но разные агрегаты имеют разные потребности:
// TodoRepository: нужны доменные запросы
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
readonly findOverdue: (asOf: Date) => Effect.Effect<ReadonlyArray<Todo>>
readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>>
}
// UserRepository: нужен поиск по email
interface UserRepositoryShape extends Repository<User, UserId> {
readonly findByEmail: (email: Email) => Effect.Effect<Option.Option<User>>
}
// EventStoreRepository: вообще другая семантика — append-only
interface EventStore extends Repository<DomainEvent, EventId> {
// ❌ delete не имеет смысла для Event Store!
// ❌ save должен быть append-only, не upsert!
}
Правило: Generic для утилит, Specific для контрактов
Generic Repository — хорош для:
- Утилитарного кода (InMemory-адаптер для тестов)
- Базового набора методов, который гарантированно есть у всех Repository
- Фабрик и хелперов
Specific Repository — обязателен для:
- Доменных контрактов (каждый агрегат уникален)
- Публичного API порта (то, что видит Application Layer)
- Доменно-специфичных методов
// ✅ Рекомендуемый подход: Generic для базы + Specific для домена
// Базовый интерфейс — минимальный набор
interface Repository<A extends Identifiable<Id>, Id> {
readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}
// Конкретный контракт — расширяет базу доменными методами
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
// Tag всегда с конкретным типом
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
TodoRepositoryShape
>() {}
Когда НЕ наследовать от Generic
- Event Store — другая семантика (append-only),
deleteиsave-as-upsertнеприменимы - Read Model Repository — только чтение,
save/deleteне нужны - Aggregate с составным ключом —
findById(key1, key2)не вписывается вfindById(Id)
// Event Store — НЕ наследует от Repository
interface EventStoreShape {
readonly append: (
streamId: string,
events: ReadonlyArray<DomainEvent>,
expectedVersion: number,
) => Effect.Effect<void, ConcurrencyError | RepositoryError>
readonly loadStream: (
streamId: string
) => Effect.Effect<ReadonlyArray<DomainEvent>, RepositoryError>
}
// Read Model — НЕ наследует от Repository
interface TodoReadModelShape {
readonly getById: (id: TodoId) => Effect.Effect<Option.Option<TodoView>, RepositoryError>
readonly list: (filter: TodoFilter) => Effect.Effect<PagedResult<TodoView>, RepositoryError>
readonly stats: () => Effect.Effect<TodoStats, RepositoryError>
// Нет save/delete — обновляется через проекции
}
Продвинутые паттерны
Паттерн: Repository с Scope
Для Repository, которые управляют ресурсами (connection pool, файловые хэндлы), используем Layer.scoped:
const makeScopedRepository = <
A extends Identifiable<Id>,
Id extends string,
Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
tag: Tag,
config: SqlRepositoryConfig<A, Id, any>,
) =>
Layer.scoped(tag,
Effect.gen(function* () {
// Acquire: открываем connection pool
const pool = yield* Effect.acquireRelease(
Effect.sync(() => createConnectionPool(config.tableName)),
(pool) => Effect.sync(() => pool.close())
)
// Return: repository с managed pool
return {
save: (aggregate: A) =>
pool.withConnection((conn) =>
Effect.try(() => { /* ... */ })
),
// ... остальные методы
} satisfies Repository<A, Id>
})
)
Паттерн: Composition через расширение
Строим сложные Repository через композицию:
// Базовый Repository
interface CrudRepository<A extends Identifiable<Id>, Id> {
readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}
// Расширение: поиск
interface QueryableRepository<A extends Identifiable<Id>, Id>
extends CrudRepository<A, Id> {
readonly findAll: () => Effect.Effect<ReadonlyArray<A>, RepositoryError>
readonly count: () => Effect.Effect<number, RepositoryError>
readonly exists: (id: Id) => Effect.Effect<boolean, RepositoryError>
}
// Расширение: batch-операции
interface BatchRepository<A extends Identifiable<Id>, Id>
extends QueryableRepository<A, Id> {
readonly saveAll: (aggregates: ReadonlyArray<A>) => Effect.Effect<void, RepositoryError>
readonly deleteAll: (ids: ReadonlyArray<Id>) => Effect.Effect<void, RepositoryError>
}
// Конкретный Repository выбирает нужный уровень
interface TodoRepositoryShape extends BatchRepository<Todo, TodoId> {
readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
Антипаттерны Generic Repository
Антипаттерн 1: God Repository
// ❌ Один Repository для всего
interface GenericRepository {
readonly save: <A>(collection: string, aggregate: A) => Effect.Effect<void>
readonly findById: <A>(collection: string, id: string) => Effect.Effect<A | null>
readonly query: <A>(collection: string, filter: Record<string, unknown>) => Effect.Effect<A[]>
}
// Проблемы: потеря типобезопасности, нет доменных методов,
// collection — строка (typo → runtime error)
Антипаттерн 2: Чрезмерное абстрагирование
// ❌ Абстракция ради абстракции
interface Repository<A, Id, E, C extends Context, T extends Transaction> {
readonly save: (aggregate: A, ctx: C, tx?: T) => Effect.Effect<void, E>
readonly findById: (id: Id, ctx: C, tx?: T) => Effect.Effect<Option.Option<A>, E>
// ...
}
// 5 параметров типа — слишком сложно, сложно использовать
Антипаттерн 3: Generic без доменных методов
// ❌ Только generic методы, без доменной специфики
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
Repository<Todo, TodoId> // ← ТОЛЬКО базовые методы
>() {}
// Проблема: где findActive()? findByPriority()? findOverdue()?
// Application Layer вынужден фильтровать в памяти:
const active = yield* repo.findAll().pipe(
Effect.map(todos => todos.filter(t => t.status === "active"))
)
// ❌ Загружает ВСЕ todos в память, потом фильтрует — неэффективно
Практический рецепт
Рекомендуемый подход для реальных проектов:
1. Определите базовый интерфейс с 3-4 методами:
interface Repository<A extends Identifiable<Id>, Id> {
readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}
2. Для каждого агрегата — свой интерфейс, расширяющий базу:
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}
3. Tag всегда типизирован конкретным интерфейсом:
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
TodoRepositoryShape
>() {}
4. Generic фабрики — для утилитарного кода (тесты, прототипы):
const TodoRepositoryTest = makeInMemoryRepository(TodoRepository, t => t.id)
Итоги
- Generic Repository решает проблему дублирования базовых методов (save, findById, delete)
- Ограничения типов (
Identifiable<Id>) гарантируют, что только корректные агрегаты используются - Конкретные Repository расширяют Generic доменными методами (findActive, findByPriority)
- Generic фабрики полезны для тестовых InMemory-адаптеров и SQL-адаптеров
- Не злоупотребляйте абстракцией: Event Store, Read Model — не наследуют от Repository
- Tag всегда конкретный:
TodoRepository extends Context.Tag<..., TodoRepositoryShape> - Композиция через расширение: CrudRepository → QueryableRepository → BatchRepository
- Минимальный базовый контракт: 3-4 метода, расширяйте по необходимости