Когда проект молодой, писать «всё в одном классе» кажется быстрее. Создать отдельный интерфейс, вынести слой — это же лишняя минута! Но эта минута потом стоит часов: код невозможно покрыть тестами, замена ORM превращается в переписывание бизнес-логики, а новый человек в команде тратит день только на то, чтобы понять, где в методе заканчивается выборка из базы и начинается бизнес-правило.

Эти антипаттерны не привязаны к версии фреймворка — они встречаются и в legacy на .NET Framework, и в современных проектах на .NET. Покажу три конкретных примера из реальных проектов — и как их исправить.

Антипаттерн 1. Всезнающий класс (God Object)

public OrderService(
    IImageService imageService,
    IDataProvider dbProvider,
    IStringLocalizer<Translations> stringLocalizer,
    IMapper mapper,
    IDocumentService documentService,
    IRoleService roleService,
    IExternalPricingApi pricingApi,
    IProfileService profileService,
    EventProfileChangedRaiser eventRaiser,
    ICompanyInfoService companyInfoService,
    IAuthenticationService authenticationService,
    IEventSystemRaiser eventSystemRaiser,
    PagedResultService pagedResultService,
    IMessageOutbox<AppDbConnection> outboxService,
    IHtmlSanitizer htmlSanitizer)
{
    _imageService = imageService ?? throw new ArgumentNullException(nameof(imageService));
    _dbProvider = dbProvider ?? throw new ArgumentNullException(nameof(dbProvider));
    _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer));
    _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    _documentService = documentService ?? throw new ArgumentNullException(nameof(documentService));
    _roleService = roleService ?? throw new ArgumentNullException(nameof(roleService));
    _pricingApi = pricingApi ?? throw new ArgumentNullException(nameof(pricingApi));
    _profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
    _eventRaiser = eventRaiser ?? throw new ArgumentNullException(nameof(eventRaiser));
    _companyInfoService = companyInfoService ?? throw new ArgumentNullException(nameof(companyInfoService));
    _authenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService));
    _eventSystemRaiser = eventSystemRaiser ?? throw new ArgumentNullException(nameof(eventSystemRaiser));
    _pagedResultService = pagedResultService ?? throw new ArgumentNullException(nameof(pagedResultService));
    _outboxService = outboxService ?? throw new ArgumentNullException(nameof(outboxService));
    _htmlSanitizer = htmlSanitizer ?? throw new ArgumentNullException(nameof(htmlSanitizer));
}

15 зависимостей в конструкторе. Этот класс знает обо всём: и про картинки, и про аутентификацию, и про публикацию событий, и про HTML-санитизацию.

Почему это плохо

Тестирование. Чтобы написать один юнит-тест, нужно замокать 15 зависимостей. Настройка моков может занять больше времени, чем написание самого теста.

15 причин для изменения. Изменилась работа с изображениями — меняем этот класс. Изменился API ценообразования — опять этот класс. Любое изменение в любой части системы с высокой вероятностью затронет этот сервис.

Невозможно понять ответственность. Класс, который умеет и санитизировать HTML, и аутентифицировать, и публиковать события — не имеет чёткой зоны ответственности. Это God Object.

Как исправить

Разделить по ответственности. Если в классе есть работа с заказами, работа с профилями и публикация событий — это три разных сервиса. Каждый со своими 2-3 зависимостями, каждый легко тестируемый.

// Было: один класс на 15 зависимостей
public class OrderService { /* всё */ }

// Стало: каждый класс отвечает за своё
public class OrderProcessor(IOrderRepository orders, IPricingService pricing) { /* бизнес-логика заказа */ }
public class OrderNotifier(IEventSystemRaiser events, IOutboxLogWrapper outbox) { /* публикация событий */ }
public class ProfileUpdater(IProfileService profiles, IMapper mapper) { /* работа с профилями */ }

Антипаттерн 2. Бизнес-логика знает о формате хранения (Leaky Abstraction)

public class PersonRatingCalculator : IPersonRatingCalculator
{
    private const int DefaultRating = 10;
    private const int ImageRating = 50;

    public int Calculate([NotNull] PersonDb person)
    {
        if (person == null)
            throw new ArgumentNullException(nameof(person));
        if (person.JobExperiences == null)
            throw new ArgumentNullException(nameof(person.JobExperiences));

        var rating = 0;

        if (!string.IsNullOrEmpty(person.ImageUrl))
            rating += ImageRating;

        if (!string.IsNullOrEmpty(person.ParentName))
            rating += DefaultRating;

        if (!person.JobExperiences.IsNullOrEmpty())
            rating += DefaultRating;

        return rating;
    }
}

На первый взгляд — чистый, маленький класс. Приятно читать. Но посмотрите на входной параметр: PersonDb. Это объект базы данных, сущность Entity Framework.

Почему это плохо

Изменение схемы БД ломает бизнес-логику. Переименовали колонку ImageUrl в AvatarPath — бизнес-калькулятор рейтинга перестал компилироваться. Хотя бизнес-правило «если у человека есть фото — добавить баллов» не изменилось.

Артефакты ORM просачиваются в домен. Обратите внимание на две строки:

if (person.JobExperiences == null)
    throw new ArgumentNullException(nameof(person.JobExperiences));
// ...
if (!person.JobExperiences.IsNullOrEmpty())
    rating += DefaultRating;

Для первой строки null — ошибка. Для второй — один из допустимых исходов. Это костыль из-за lazy loading в EF: если навигационное свойство не загружено — оно null, и это не значит «нет данных», это значит «забыли сделать Include». Чисто инфраструктурная проблема, которая просочилась в бизнес-логику и создала код, противоречащий сам себе.

Невозможно сменить хранилище. Захотели перейти с реляционной БД на document store — нужно переписывать калькулятор рейтинга, хотя бизнес-правила не менялись.

Как исправить

Ввести доменную модель, которая ничего не знает о базе данных:

// Доменная модель — только бизнес-данные
public interface IPerson
{
    bool HasImage { get; }
    bool HasParentName { get; }
    bool HasJobExperience { get; }
}

// Калькулятор работает с доменной моделью
public class PersonRatingCalculator : IPersonRatingCalculator
{
    private const int DefaultRating = 10;
    private const int ImageRating = 50;

    public int Calculate(IPerson person)
    {
        var rating = 0;

        if (person.HasImage)
            rating += ImageRating;

        if (person.HasParentName)
            rating += DefaultRating;

        if (person.HasJobExperience)
            rating += DefaultRating;

        return rating;
    }
}

Маппинг из PersonDb в IPerson — задача инфраструктурного слоя. Костыль с проверкой lazy loading уходит туда же — калькулятор больше не знает, что такое EF.


Антипаттерн 3. Доменная логика прибита к фреймворку (Dependency Inversion violation)

public async Task<PaymentResult> ProcessPayment(long ticketId, CancellationToken ct)
{
    var result = new PaymentResult { TicketId = ticketId };

    // 1. Инфраструктура: выборка из БД
    var dbTicket = await _dbService.GetTicketById(ticketId, ct);
    if (dbTicket == null)
        throw new TicketNotFoundException(ticketId.ToString());

    // 2. Бизнес-логика: проверка статуса
    var status = DataHelper.GetPaymentStatus(dbTicket.Payment);
    if (status != PaymentStatus.Denied)
        throw new StatusNotAllowedException(ticketId.ToString(), status);

    // 3. Инфраструктура: вызов внешнего API
    var debtResponse = await _externalApi.GetDebtSum(ticketId);
    result.PayStatus = debtResponse.Status;

    // 4. Бизнес-логика: обработка статусов
    switch (debtResponse.Status)
    {
        case DebtStatus.NotFound:
        case DebtStatus.AlreadyPaid:
        case DebtStatus.Locked:
            return result;

        case DebtStatus.Ready:
            if (!debtResponse.TicketId.HasValue)
                throw new InvalidDataException("Ticket id was null");
            result.TicketId = (long)debtResponse.TicketId;
            result.Amount = debtResponse.Amount;
            break;
    }

    // 5. Инфраструктура: создание записи в БД
    var invoice = new InvoiceDb
    {
        OriginalTicketId = ticketId,
        PaymentTicketId = debtResponse.TicketId.Value,
        Description = "Оплата билета " + ticketId,
        Amount = debtResponse.Amount.Value,
        CardNumber = dbTicket.CardNumber
    };
    _dbContext.Add(invoice);

    // 6. Инфраструктура: генерация URL платёжной системы
    result.PayUrl = GeneratePayUrl(invoice.Id.ToString("N"));

    // 7. Инфраструктура: сохранение
    await _dbContext.SaveChangesAsync(ct);

    // 8. Инфраструктура: логирование
    await _logService.SaveProcessLog(invoice.Id,
        $"Начата процедура оплаты, сумма: {debtResponse.Amount}");

    return result;
}

57 строк, в которых бизнес-логика, работа с БД, вызов внешнего API и генерация URL платёжной системы перемешаны в одну кашу.

Почему это плохо

Невозможно протестировать бизнес-правила отдельно. Чтобы проверить логику обработки статусов (switch по DebtStatus, строки 20–33), нужно замокать и БД, и внешний API, и dbContext, и логгер.

Смена платёжной системы ломает бизнес-логику. Метод GeneratePayUrl — это деталь конкретного платёжного провайдера. Если завтра мы переходим на другой банк, придётся лезть в тот же метод, где живёт бизнес-логика обработки статусов.

Смена ORM ломает бизнес-логику. Прямая работа с _dbContext.Add() и SaveChangesAsync() — это привязка к EF. Решили перейти на Dapper или вообще на нереляционную базу — переписываем метод целиком, хотя бизнес-правила не менялись.

Как исправить

Разделить на слои. Бизнес-логика обработки статусов — в доменный слой. Работа с БД, внешним API и платёжной системой — в инфраструктурный. Координация между ними — в слой приложения.

// Доменный слой — чистая бизнес-логика
public class PaymentProcessor
{
    public PaymentDecision Evaluate(DebtInfo debt)
    {
        return debt.Status switch
        {
            DebtStatus.NotFound or DebtStatus.AlreadyPaid or DebtStatus.Locked
                => PaymentDecision.Skip(debt.Status),

            DebtStatus.Ready when !debt.TicketId.HasValue
                => throw new InvalidDataException("Ticket id was null"),

            DebtStatus.Ready
                => PaymentDecision.Proceed(debt.TicketId.Value, debt.Amount),

            _ => throw new InvalidDataException($"Unexpected status: {debt.Status}")
        };
    }
}

// Слой приложения — координация
public class PayTicketHandler
{
    public async Task<PaymentResult> Handle(long ticketId, CancellationToken ct)
    {
        var ticket = await _ticketRepository.GetById(ticketId, ct);
        _statusValidator.EnsurePaymentAllowed(ticket);

        var debt = await _debtService.GetDebtSum(ticketId);
        var decision = _paymentProcessor.Evaluate(debt);

        if (decision.ShouldSkip)
            return PaymentResult.From(decision);

        var invoiceId = await _invoiceRepository.Create(ticket, decision);
        var payUrl = _paymentGateway.GenerateUrl(invoiceId);

        return PaymentResult.Success(invoiceId, payUrl, decision.Amount);
    }
}

Бизнес-логика обработки статусов — в PaymentProcessor. Ноль зависимостей, тестируется тремя строчками. Работа с БД — за интерфейсом ITicketRepository. Платёжная система — за IPaymentGateway. Завтра меняем банк — трогаем только реализацию IPaymentGateway.


Правило, которое всё это объединяет

Все три антипаттерна нарушают одно правило — Dependency Rule из Clean Architecture (Robert C. Martin):

Зависимости в коде должны идти только внутрь — к бизнес-логике.

Бизнес-логика не должна знать, как хранятся данные. Не должна знать, какой у нас ORM. Не должна знать, какую платёжную систему мы используем. Всё это — детали реализации, которые живут снаружи.

На практике это означает разделение на слои:

Доменный слой — бизнес-сущности, бизнес-правила, интерфейсы. Не зависит ни от чего. Здесь живут IPerson, PaymentProcessor, enum-ы статусов. Ни EF, ни ASP.NET, ни внешних API.

Слой приложения — координация и агрегация. Зависит только от домена. Здесь живёт PayTicketHandler, который достаёт данные из нескольких источников и передаёт их в доменную логику. Сегодня данные приходят из трёх API, завтра закэшировали в одну базу — бизнес-логика не меняется.

Инфраструктурный слой — самый «грязный». EF, контроллеры, внешние API, маппинг, DI-контейнер. Знает обо всём. Но от него зависит только точка входа.

Слоёв нужно столько, сколько нужно. Для MailingService хватит двух. Для сложного платёжного сервиса — три. Для простого CRUD — может, и одного достаточно. Не надо городить архитектуру ради архитектуры.


«Но так же быстрее!»

Да, написать всё в одном классе — быстрее. На минуту. Создать интерфейс, вынести слой — это 60 секунд, которые ты экономишь сейчас. А потом:

  • Новый человек в команде тратит день, чтобы разобраться, где в методе бизнес-логика, а где выборка из базы

  • Замена платёжной системы превращается в рефакторинг бизнес-логики

  • Юнит-тест на бизнес-правило требует мока 15 зависимостей

  • Оценка «добавить новый статус — 2 часа» превращается в «2 дня, потому что я не понимаю, что тут происходит»

Эти антипаттерны не появляются сразу. Они накапливаются постепенно — «ну ладно, один раз можно», «тут всего одна зависимость лишняя», «потом отрефакторим». Именно поэтому их так сложно заметить вовремя.

Недели кодирования сохраняют часы проектирования.