Generic-репозиторий в том проекте прожил полгода, прежде чем я понял, что он стоит нам дороже, чем экономит. Поиск отелей, Mongo, две тысячи строк вокруг одного God-сервиса – и поверх всего этого аккуратный IRepository<T>, который обещал, что про Mongo знает только инфраструктура.

Сломалось на простой просьбе продакта. Рядом с результатами поиска – счётчики по городам и гистограмма цен, одним запросом. В Mongo это один aggregation pipeline через $facet: база сама группирует и считает, наружу отдаёт готовые числа. Написать это через наш репозиторий оказалось нельзя – не сломав ровно ту абстракцию, ради которой его и заводили.

Развилка была такая. Репозиторий отдавал наружу IReadOnlyList<Hotel> через Find(predicate) – честный контракт, не светящий ни IQueryable, ни Mongo. Чтобы построить фасеты, разработчик вызывал Find с фильтром, получал всю выборку в память и группировал её LINQ’ом. На стейджинге с двумя сотнями отелей это летало. На проде с полным инвентарём – клало эндпоинт: вся выборка ехала из Mongo в приложение, чтобы посчитать то, что Mongo посчитал бы сам за один проход.

Очевидная починка – добавить в репозиторий метод SearchWithFacets(...). Но этот метод чистый Mongo, его не реализуешь над SQL без слёз. В ту секунду, когда он появляется в IRepository<T>, репозиторий перестаёт быть тем, чем себя объявлял – хранилищенезависимой абстракцией. Либо он течёт, либо тащит всё в память. Третьего наш дизайн не предлагал.

Это был не баг реализации, а природа самого паттерна – и за полгода он успел набрать ещё два таких же провала. Generic Repository продаётся под три обещания:

  • подменяемость хранилища – домен не знает, Mongo под ним или Postgres;

  • тестируемость – подменим базу моком и проверим логику без неё;

  • меньше кода – один набор CRUD-методов на все типы сразу.

На том проекте он не сдержал ни одного – и попутно забрал доменную модель. Корень один: персистентность тут – не одна задача, а две: запись агрегатов с инвариантами и произвольные проекции на чтение. Натянешь на них одну универсальную абстракцию – и она рвётся по очереди на каждом обещании. Разберу по обещанию, а в конце покажу, чем мы его заменили.

Обещание первое: подменяемость хранилища

Сила, ради которой репозиторий обычно и продают: домен не знает, что под ним – Mongo, Postgres или in-memory в тестах. Захотим сменить хранилище – перепишем инфраструктуру, домен не заметит.

Красивая идея с одним изъяном. Чтобы домен действительно не знал о хранилище, репозиторий обязан отдавать наружу полностью материализованные объекты. Как только он отдаёт IQueryable<T> – а это самый частый способ сделать generic-репозиторий удобным, – наружу протекает провайдер. IQueryable под Mongo и IQueryable под EF транслируются в разные запросы, поддерживают разный набор операций и по-разному падают. Код, который строит запрос поверх такого IQueryable, де-факто привязан к провайдеру, просто привязка спрятана и всплывает в рантайме.

// generic-репозиторий, который обещает не знать о хранилище
public interface IRepository<T> where T : class
{
    Task<T?> GetById(string id);
    Task<IReadOnlyList<T>> Find(Expression<Func<T, bool>> predicate);
    Task Add(T entity);
    Task Update(T entity);
}

// фасетная выдача: чтобы не светить IQueryable, отдаём материализованный список –
// и платим за это выгрузкой всей выборки в память
var hotels = await _hotels.Find(h => h.City == c.City && h.Price <= c.MaxPrice);
var byCity = hotels.GroupBy(h => h.City).ToDictionary(g => g.Key, g => g.Count());
var byBucket = hotels.GroupBy(h => PriceBucket(h.Price)).ToDictionary(g => g.Key, g => g.Count());
// то, что Mongo сделал бы одним $facet за проход, делаем в приложении после выгрузки

Уберёшь IQueryable, оставишь Find с материализацией – получишь историю из вступления: либо выгрузка в память, либо метод под конкретное хранилище прямо в интерфейсе. Specification-паттерн, к которому здесь тянутся руки, развилку не убирает, а отодвигает в рантайм. На вид спецификация провайдер-нейтральна: потребитель собирает её из выражений, ничего не зная о хранилище. Но переведётся ли это выражение, решает провайдер – поддержит он именно такую форму или кинет NotSupportedException уже на проде. Абстракция остаётся в сигнатуре и теряется в семантике; протечка не исчезает, а становится невидимой до первого прода. Остальное – фильтровать в памяти, то есть вернуться к выгрузке. Куда ни поверни, абстракция либо дырявая, либо дорогая.

И вот что обиднее всего. Подменяемость – это опцион, за который платишь каждый день: лишним слоем, методами под каждый чих, выгрузками в память. А исполняется он примерно никогда. За карьеру я видел живую смену хранилища всего дважды, и оба раза репозиторий её не пережил: модель данных в новом сторе была другой, и запросы переписывали с нуля вместе со всем слоем. Страховку платили годами, выплату не получили ни разу.

Обещание второе: тестируемость

Второй довод в пользу репозитория – его легко подменить в тестах. Мокаешь IRepository<Hotel>, отдаёшь фейковую коллекцию, проверяешь логику без базы.

На узких доменных репозиториях это правда, к ней ещё вернусь. На generic-репозитории с IQueryable это ловушка, и я в неё наступал. Мок отдаёт IQueryable поверх List<T> – то есть LINQ-to-objects. Прод отдаёт IQueryable поверх Mongo – то есть LINQ-to-provider. Две разные семантики под одним интерфейсом.

Тест на сортировку строк был зелёным: in-memory LINQ сортировал по правилам .NET. Прод сортировал по collation базы – регистр и кириллица вставали иначе, и выдача в реальности отличалась от той, что проверял тест. Другой классический случай – GroupBy с проекцией: in-memory отрабатывал как ни в чём не бывало, провайдер кидал NotSupportedException, потому что не умел перевести именно эту форму. Зелёный тест там, где прод падает, – полбеды. Хуже, что он давал ложную уверенность на запросах, то есть на самом частом источнике багов в работе с данными.

Стандартный ответ – EF InMemory или его аналог – болеет тем же. Это не реляционный провайдер: другая трансляция, нет констрейнтов, своё поведение на том же GroupBy. Команда EF прямым текстом не рекомендует его для тестов, которые должны что-то гарантировать. Честный вариант – Testcontainers с настоящей базой. Но тогда абстракция не заработала для тестов ничего: ты и так на реальном сторе, ради чего была обёртка.

Здесь важно не перегнуть. Узкий доменный IBookingRepository, который отдаёт целый агрегат и принимает целый агрегат, мокается прекрасно, и тесты на нём честные – именно потому, что он не торчит наружу IQueryable и не обещает транслировать произвольный запрос. Ловушка не в моканье репозитория как такового. Ловушка в моканье generic-репозитория, который под капотом транслятор запросов.

Обещание третье: меньше кода

Третий аргумент – DRY. Без generic-базы придётся писать Add, GetById, Update руками для каждого типа; с ней – один раз и навсегда.

Проблема в том, что именно этот код и так уже написан за тебя. В EF Core DbContext это и есть Unit of Work, а DbSet<T> – это и есть репозиторий: Add, Find, Remove, отслеживание изменений, сохранение пачкой. Это не моя трактовка, это формулировка из документации EF и из PoEAA Фаулера. Generic Repository поверх EF – это Unit of Work поверх Unit of Work и репозиторий поверх репозитория. Сэкономленный код измеряется десятком тривиальных методов. Добавленный код – это спецификации, обёртки и провайдер-специфичные методы, которые приходится протаскивать через интерфейс, чтобы заткнуть протечки из первого раздела. По итогу кода не меньше – он просто другой формы.

Тут обычно возражают про Clean Architecture: интерфейс репозитория нужен в домене, чтобы инвертировать зависимость на инфраструктуру. Возражение верное – но оно про узкий IBookingRepository, объявленный в домене и реализованный в инфраструктуре. Dependency Inversion требует интерфейс, выражающий намерение, а не Repository<T> с дженериком. Инверсия зависимости – аргумент за доменный репозиторий, не за generic.

Чтобы было честно, оговорю границу. Generic-база как приватная деталь реализации, спрятанная под конкретными доменными репозиториями, – нормально; никто не пишет один и тот же SaveChangesAsync десять раз ради чистоты. Анти-паттерн – это generic-репозиторий как публичный интерфейс, который потребляет прикладной код. Разница в том, торчит ли дженерик наружу как контракт или живёт внутри как сантехника.

Что он забрал: доменную модель

Три обещания – это цена в коде. Настоящий ущерб был тише и обнаружился позже.

Generic-репозиторий раздаёт Add, GetById, Update, Delete на каждый тип T. Значит, прикладной код может загрузить и сохранить любую сущность напрямую – в том числе ту, что должна меняться только через свой корень агрегата. Booking с его строками, ценами и правилами отмены и какой-нибудь BookingLine внутри него – для generic-репозитория просто два T. Хочешь – правь BookingLine мимо Booking, репозиторий не возразит. Граница агрегата, которая должна была держать инвариант, перестаёт быть защитимой на уровне хранения: сохранить кусок агрегата в обход целого можно в одну строку.

Я не утверждаю, что репозиторий пишет за тебя анемичную модель. Он её не пишет – он убирает структурное давление, которое держало модель богатой. Когда любую сущность можно достать и сохранить отдельно, инвариантам становится негде жить, и они утекают в сервисы: BookingService на восемьсот строк, который руками проверяет то, что должен был гарантировать агрегат. Так и получается анемичная модель – как путь наименьшего сопротивления, который этот репозиторий аккуратно расчистил.

Корень в том, что мы неправильно прочли паттерн. Repository у Эванса – это коллекция корней агрегатов: по одному репозиторию на агрегат, и отдаёт он только корни. Узкая дверь к одному агрегату целиком, а не дженерик-CRUD над таблицей. Мы же реализовали table-gateway, назвали его Repository и удивлялись, почему получили ровно то, что свойственно table-gateway: доступ к каждой строке в обход всякой доменной логики.

Чем заменили

Вместо одного generic-репозитория стало два разных механизма – по разные стороны от того, читаем мы или пишем.

На записи – узкий репозиторий на агрегат. Никакого дженерика наружу, никакого Find(predicate), никакого Update произвольного поля. GetById отдаёт целый Booking, менять его можно только его же методами, добавление и сохранение работают с корнем как с единицей.

// доменный репозиторий: только корень агрегата, только намерение
public interface IBookingRepository
{
    Task<Booking?> GetById(BookingId id);  // отдаёт агрегат целиком
    Task Add(Booking booking);
    Task Save(Booking booking);  // сохраняет корень как единицу
    // нет Find(predicate), нет Update произвольного поля:
    // менять Booking можно только через его собственные методы
}

На чтении – тонкий запрос прямо на провайдере, мимо агрегата. Та самая фасетная выдача из вступления превращается в один Mongo aggregation pipeline, который отдаёт сразу read-DTO. Агрегат Booking здесь не нужен вообще: читать не значит проводить через доменную модель.

// read-side: тонкий запрос на провайдере, мимо агрегата, сразу в read-DTO
public sealed class HotelSearchQuery
{
    // CountByStage – локальный хелпер, возвращает пайплайн с $group; тело опущено
    public Task<HotelFacets> Facets(SearchCriteria c) =>
        _hotels.Aggregate()
            .Match(h => h.City == c.City && h.Price <= c.MaxPrice)
            // типизированная перегрузка Facet<TInput, TOut>: одна поездка в Mongo,
            // группировка на стороне БД, на выходе сразу read-DTO
            .Facet<Hotel, HotelFacets>(
                AggregateFacet.Create("byCity", CountByStage(h => h.City)),
                AggregateFacet.Create("byBucket", CountByStage(h => h.PriceBucket)))
            .SingleAsync();
}

Это и есть та развязка, которую generic-репозиторий не давал. Он существовал, потому что одну абстракцию заставляли обслуживать и команды, и запросы сразу. Разведёшь их – и он растворяется: запись идёт через агрегатные репозитории, чтение через тонкие запросы, и каждой стороне можно дать ровно то, что ей нужно, не ломая другую.

Оговорюсь, чтобы не продать больше, чем есть. Это не event sourcing, не отдельная база на чтение и не шина – всё та же одна Mongo. Это разделение чтения и записи на уровне запросов, не больше. И да, на чтении ты теперь работаешь с провайдером напрямую, без универсального фасада – зато читаешь так, как умеет хранилище, а не так, как разрешает абстракция.

Запись идёт через узкий агрегатный репозиторий, который защищает инвариант. Чтение идёт мимо агрегата, тонким запросом прямо на провайдере. База под обеими сторонами одна – generic-репозиторий растворяется, как только перестаёшь обслуживать оба пути одной абстракцией.
Запись идёт через узкий агрегатный репозиторий, который защищает инвариант. Чтение идёт мимо агрегата, тонким запросом прямо на провайдере. База под обеими сторонами одна – generic-репозиторий растворяется, как только перестаёшь обслуживать оба пути одной абстракцией.

Останется законный вопрос: а как же сквозные кэш, аудит, soft-delete, мультитенантность, ради централизации которых многие generic-репозиторий и держат? Короткий ответ: у каждого из этих швов есть своё место – global query filters, перехватчики SaveChanges, декораторы, – и репозиторий им не нужен. Длинный ответ тянет на отдельный пост, и я к нему ещё вернусь.

Что в итоге

Generic Repository – не зло и не ошибка джуна. Это ошибка категории: одна универсальная абстракция натянута на две разные задачи. Универсальный Repository<T> плох не тем, что универсальный, а тем, что притворяется, будто задача одна.

Писал я на C# с Mongo и EF, но Generic Repository от языка не зависит: это Spring Data JpaRepository, TypeORM Repository, Doctrine. Аргумент везде тот же.

Я не призываю выкинуть репозиторий. Я предлагаю перестать делать его дженериком наружу – и почти всегда на его месте оказываются две честные вещи: узкая дверь к агрегату на запись и тонкий запрос на чтение.

Что почитать

  • Vlad Khononov – “Learning Domain-Driven Design” (O’Reilly, 2021). Современный разбор агрегатов и их границ: откуда правило “репозиторий на корень агрегата” и почему доступ к сущностям в обход корня ломает модель. По сути актуальный пересказ того места у Эванса, на которое я ссылаюсь выше – если из всего списка читать что-то одно, то точно это.

  • Vladimir Khorikov – “Unit Testing: Principles, Practices, and Patterns” (Manning, 2020). Прямо под раздел про тестируемость: почему репозитории не стоит мокать, почему “подменим базу в тестах” – ловушка и почему управляемые зависимости проверяют на настоящей БД, а не на in-memory. Его блог enterprisecraftsmanship.com – то же короткими заметками.

  • Jimmy Bogard – “Vertical Slice Architecture” (jimmybogard.com). Чем заменить универсальный репозиторий: не абстрагировать то, что не требует абстракции, и пускать чтение в провайдер напрямую. Корень идеи read/write-разреза из финала поста.

  • Derek Comartin, “Avoiding the Repository Pattern with an ORM” (CodeOpinion): если хочется ещё по теме.

Комментарии (47)


  1. SolidSnack
    16.06.2026 04:57

    Под конец статьи все собралось в картину.

    Как я вижу - вы всю статью прикрывает свои же ошибки, и ищите их везде кроме своих решений))

    1 В статье вся боль начинается с того что для бизнес аналитики одна база представляет простой механизм, а во второй надо разбираться самому и писать запрос (обратите внимание что здесь эта проблема приписывается репозиторию!!!) Хотя возможно тут проблема что ОРМ через которую пишутся запросы не предоставляет возможности на такой запрос)

    2 По поводу возврашаемых данных через репозиторий - там может быть любой тип, если вы в репозитории просто отправляете ответ ОРМ, то не надо сетовать на то что репозиторий возвращает IQueryable

    По итогу получился какой-то ком грязи (чисто моё мнение по прочитаному из статьи) где вместо решения проблем , которые на прямую к репозиторию не относятся, предлагается разделять запросы по принципу CQRS, мотивируя это тем что по другому думать надо как аналитику писать))

    Прекрасная иллюстрация как проблема привалирует над архитектурой)


    1. Genius_Russian_Coders
      16.06.2026 04:57

      Главная ловушка Generic Repository — IQueryable наружу. Вроде абстракция, а на деле ты привязан к EF жёстче, чем без репо. Для сложных запросов давно перешёл на спецификации, репозитории оставил только для CRUD.


      1. SolidSnack
        16.06.2026 04:57

        А маппинг есть в с#? Нельзя написать датамапер который IQueryable завернет в какую-то структуру которая будет вам удобнее?) Тем самым и абстракция от EF добавится :)