Repository в Clean Architecture: всегда ли он нужен?

Repository защищает бизнес-правила при записи, но тормозит чтение и раздувает код. Где без него лучше и почему?
Представь, что тебе прилетает задача: показать на странице "профиль клиента": имя, телефон, число заказов, бонусный баланс и активную подписку. Делаешь как положено: в Clean Architecture с базой работают через Repository. Получается четыре репозитория, четыре полных агрегата, склейка в памяти и обрезка лишнего. Простое чтение отняло кучу времени, а экран открывается заметно медленнее соседних.
Разберёмся, где правило «с базой работаем только через Repository» защищает систему, а где превращается в ритуал. В конце будет таблица: для каких задач Repository нужен и чем обойтись в остальных.

Короткий ответ: не всегда

Repository нужен там, где ты меняешь состояние системы. Когда ты только отдаёшь данные на экран, правильно идти в базу одним запросом и возвращать плоскую DTO. Развести эти случаи помогает CQS (Command-Query Separation).
Возьмём экран профиля в интернет-магазине. Ему нужны данные из четырёх таблиц, из каждой по два-три поля.
Четыре таблицы передают выбранные поля в DTO для экрана профиля
Экран получает только нужные поля из четырёх источников.
За каждой таблицей в домене стоит свой агрегат, а экрану нужна тонкая выборка из каждого.

Что происходит, если читать через репозитории

Если следовать правилу буквально, путь такой:
Чтение через четыре Repository загружает полные агрегаты и склеивает их в памяти
Repository заставляет загрузить и склеить больше данных, чем требуется экрану.
Ради девяти полей приложение поднимает целые графы объектов. Последствия копятся:
  • Репозитории распухают. В IOrderRepository появляются CountByUser, GetLastOrderDate, GetForAdminList, GetForExport. Домену они не нужны, их просит фронтенд. По моему опыту, если вынести чтение, в репозитории остаётся 5–6 методов вместо 30.
  • Агрегат грузится целиком. Чтобы посчитать заказы, из базы приезжают позиции и история статусов. Отчёты, собранные так же, работают медленно.
  • Join переезжает в оперативку. Таблицы соединяет не база, а приложение: тянет лишние строки по сети и склеивает их в памяти.
  • UI меняет доменную модель. Экрану понадобилось название последнего заказа, и в агрегат добавляют свойство, которое не нужно ни одному бизнес-правилу.
  • Простое чтение стоит как фича. Метод в каждом репозитории, маппинг, тесты, склейка.

Откуда взялось правило «всё через Repository»

Многие считают Repository обязательной обёрткой над любой работой с базой. Его придумали для другой задачи.
Repository — коллекция агрегатов для доменной логики. Use case достаёт из неё агрегат, вызывает его поведение и кладёт обратно. Инварианты проверяет сам агрегат, а Repository гарантирует, что тот загружен и сохранён целиком.
Пока ты меняешь состояние, ходить в базу мимо Repository опасно. Прямой UPDATE может списать больше бонусов, чем есть на счёте: проверка живёт в агрегате, а запрос её не вызывает.
У чтения такого риска нет. SELECT ничего не меняет и не может нарушить бизнес-правило, поэтому агрегаты и их коллекция на стороне чтения не нужны.
К тому же выводу приходят Скотт Миллет и Ник Тюн в книге «Patterns, Principles, and Practices of Domain-Driven Design». В антипаттерне «Don't Use Repositories for Reporting Needs» они пишут:
Don't abstract the persistence framework when querying your data model for reporting purposes.

CQS: два вида use case

CQS делит use case на два типа:
  • Command меняет состояние системы и возвращает только результат операции;
  • Query возвращает данные и ничего не меняет.
Командам нужна защита инвариантов, запросам нужны скорость и данные в форме экрана. CQS разводит эти требования по разным веткам.
Команда проходит через Repository и агрегат, а Query читает базу напрямую и возвращает DTO
CQS сохраняет защиту инвариантов на записи и упрощает чтение.

Сторона команд

Command handler всегда работает через Repository: загрузить агрегат, вызвать его поведение, сохранить. Например, списание бонусов при оплате заказа:
public sealed class RedeemBonusesHandler
{
    private readonly ILoyaltyAccountRepository _accounts;

    public RedeemBonusesHandler(ILoyaltyAccountRepository accounts) =>
        _accounts = accounts;

    public async Task Handle(RedeemBonusesCommand command)
    {
        var account = await _accounts.GetByUserId(command.UserId);

        // Агрегат бросит исключение, если бонусов не хватает
        account.Redeem(command.Points, command.OrderId);

        await _accounts.Save(account);
    }
}
Правило «баланс не уходит в минус» живёт в LoyaltyAccount.Redeem и срабатывает, только если запись идёт через агрегат.

Сторона запросов

Query handler должен быстро отдать экрану нужные поля. Доменная модель здесь не участвует, и абстракция над хранилищем только мешает: хватает прямого запроса и плоской DTO.
Сравни два варианта для экрана профиля. Через репозитории:
public async Task<UserProfileView> Handle(Guid userId)
{
    var profile = await _profiles.GetByUserId(userId);
    var orders = await _orders.GetAllByUserId(userId);       // с позициями и историей
    var loyalty = await _loyaltyAccounts.GetByUserId(userId); // с историей операций
    var subscription = await _subscriptions.GetActiveByUserId(userId);

    return new UserProfileView
    {
        FullName = profile.FullName,
        AvatarUrl = profile.AvatarUrl,
        Phone = profile.Phone,
        OrdersCount = orders.Count,
        LastOrderAt = orders.Max(o => (DateTime?)o.CreatedAt),
        BonusBalance = loyalty.Balance,
        LoyaltyLevel = loyalty.Level.ToString(),
        SubscriptionPlan = subscription?.Plan.ToString(),
        SubscriptionExpiresAt = subscription?.ExpiresAt
    };
}
Четыре обращения к базе, четыре полных агрегата и подсчёт заказов в памяти. Теперь то же самое одним запросом:
public sealed class UserProfileView
{
    public string FullName { get; init; } = "";
    public string? AvatarUrl { get; init; }
    public string Phone { get; init; } = "";
    public long OrdersCount { get; init; }
    public DateTime? LastOrderAt { get; init; }
    public int BonusBalance { get; init; }
    public string LoyaltyLevel { get; init; } = "";
    public string? SubscriptionPlan { get; init; }
    public DateTime? SubscriptionExpiresAt { get; init; }
}

public sealed class GetUserProfileQueryHandler
{
    private const string Sql = """
        SELECT p.full_name    AS FullName,
               p.avatar_url   AS AvatarUrl,
               p.phone        AS Phone,
               COALESCE(o.orders_count, 0) AS OrdersCount,
               o.last_order_at AS LastOrderAt,
               l.balance      AS BonusBalance,
               l.level        AS LoyaltyLevel,
               s.plan         AS SubscriptionPlan,
               s.expires_at   AS SubscriptionExpiresAt
        FROM profiles p
        LEFT JOIN (
            SELECT user_id, COUNT(*) AS orders_count, MAX(created_at) AS last_order_at
            FROM orders
            GROUP BY user_id
        ) o ON o.user_id = p.user_id
        LEFT JOIN loyalty_accounts l ON l.user_id = p.user_id
        LEFT JOIN subscriptions s ON s.user_id = p.user_id AND s.status = 'active'
        WHERE p.user_id = @UserId
        """;

    private readonly IDbConnection _db;

    public GetUserProfileQueryHandler(IDbConnection db) => _db = db;

    public Task<UserProfileView?> Handle(Guid userId) =>
        _db.QuerySingleOrDefaultAsync<UserProfileView>(Sql, new { UserId = userId });
}
Здесь Dapper, но подойдёт любой лёгкий инструмент или ORM в режиме проекций. База сама считает заказы и соединяет таблицы, по сети едут девять полей, а в IOrderRepository не появляется ни одного метода ради экрана.

Почему Repository для Query — антипаттерн

На стороне чтения Repository ничего не защищает: агрегат не меняется, инвариантов нет. Он возвращает агрегаты, когда экрану нужна проекция, и прячет за абстракцией то, что чтению как раз нужно: JOIN, GROUP BY, индексы.
Если сделать «репозиторий для чтения», который возвращает DTO, получится тот же query handler с лишним интерфейсом посередине.

Шпаргалка: когда Repository нужен, а когда нет

Чего не делать:
  • Писать мимо Repository. Прямой UPDATE обходит инварианты агрегата, и свобода чтения на запись не распространяется.
  • Принимать бизнес-решения по query-DTO. Она собрана под экран и не гарантирует целостность, поэтому команда загружает агрегат.
  • Тащить read-запросы в репозиторий «для единообразия». Так он и дорастает до 30 методов.
  • Заводить отдельное хранилище и события, когда хватает одного SELECT. Read-база с проекциями по событиям — это уже CQRS, он нужен, когда чтение упирается в нагрузку. Экрану профиля хватит запроса к той же базе.
В CRUD-модуле без доменной логики, например в справочнике городов, Repository может быть не нужен и на запись: защищать нечего.
ЗадачаRepository нужен?Чем читать и писать
Изменить состояние агрегатаДаCommand handler: загрузить агрегат через Repository, вызвать метод, сохранить
Показать списокНетQuery handler: SQL-запрос, плоская DTO под экран
Отчёт по нескольким агрегатамНетQuery handler: один запрос с JOIN и агрегатными функциями к той же базе, отдельная read-модель только при проблемах с нагрузкой
Проверить существование для бизнес-правилаДаМетод Repository или доменный сервис внутри команды, решение принимает домен
CRUD-справочник без доменной логикиНетПрямые запросы или ORM без агрегатов и Repository
Для выбора хватит одного вопроса: может ли операция нарушить бизнес-правило? Если может, нужен Repository. Если нет, обойдись без него.

Итог

  • Repository в Clean Architecture — коллекция агрегатов для изменения состояния, а не обязательная обёртка над базой.
  • Чтение не нарушает бизнес-правил, поэтому query handler идёт в базу напрямую и отдаёт плоскую DTO.
  • Запись мимо Repository по-прежнему под запретом.
Открой самый большой репозиторий в своём проекте и отметь методы, которые вызываются только ради ответа фронтенду. Каждый из них — кандидат в query handler.