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. Это разделение чтения и записи на уровне запросов, не больше. И да, на чтении ты теперь работаешь с провайдером напрямую, без универсального фасада – зато читаешь так, как умеет хранилище, а не так, как разрешает абстракция.

Останется законный вопрос: а как же сквозные кэш, аудит, 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): если хочется ещё по теме.
SolidSnack
Под конец статьи все собралось в картину.
Как я вижу - вы всю статью прикрывает свои же ошибки, и ищите их везде кроме своих решений))
1 В статье вся боль начинается с того что для бизнес аналитики одна база представляет простой механизм, а во второй надо разбираться самому и писать запрос (обратите внимание что здесь эта проблема приписывается репозиторию!!!) Хотя возможно тут проблема что ОРМ через которую пишутся запросы не предоставляет возможности на такой запрос)
2 По поводу возврашаемых данных через репозиторий - там может быть любой тип, если вы в репозитории просто отправляете ответ ОРМ, то не надо сетовать на то что репозиторий возвращает IQueryable
По итогу получился какой-то ком грязи (чисто моё мнение по прочитаному из статьи) где вместо решения проблем , которые на прямую к репозиторию не относятся, предлагается разделять запросы по принципу CQRS, мотивируя это тем что по другому думать надо как аналитику писать))
Прекрасная иллюстрация как проблема привалирует над архитектурой)
Genius_Russian_Coders
Главная ловушка Generic Repository — IQueryable наружу. Вроде абстракция, а на деле ты привязан к EF жёстче, чем без репо. Для сложных запросов давно перешёл на спецификации, репозитории оставил только для CRUD.
SolidSnack
А маппинг есть в с#? Нельзя написать датамапер который IQueryable завернет в какую-то структуру которая будет вам удобнее?) Тем самым и абстракция от EF добавится :)