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

Эти антипаттерны не привязаны к языку программирования — они встречаются и в Java, и в Go, и в Python. Примеры покажу на C#/.NET, но суть та же для любого стека. Три конкретных случая из реальных проектов — и как их исправить.

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

public OrderService(
    IImageService imageService,
    IDataProvider dbProvider,
    ILocalizer localizer,
    IMapper mapper,
    IDocumentService documentService,
    IRoleService roleService,
    IExternalApi externalApi,
    IProfileService profileService,
    EventProfileChangedRaiser eventRaiser,
    ICompanyInfoService companyInfoService,
    IAuthenticationService authenticationService,
    IEventSystemRaiser eventSystemRaiser,
    PagedResultService pagedResultService,
    IMessageOutbox outboxService,
    IHtmlSanitizer htmlSanitizer)
{
    _imageService = imageService ?? throw new ArgumentNullException(nameof(imageService));
    _dbProvider = dbProvider ?? throw new ArgumentNullException(nameof(dbProvider));
    _localizer = localizer ?? throw new ArgumentNullException(nameof(localizer));
    _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    _documentService = documentService ?? throw new ArgumentNullException(nameof(documentService));
    _roleService = roleService ?? throw new ArgumentNullException(nameof(roleService));
    _externalApi = externalApi ?? throw new ArgumentNullException(nameof(externalApi));
    _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));
    _outbox = 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 UserRatingCalculator : IUserRatingCalculator
{
    private const int DefaultRating = 10;
    private const int ImageRating = 50;

    public int Calculate(UserDb user)
    {
        if (user == null)
            throw new ArgumentNullException(nameof(user));
        if (user.JobExperiences == null)
            throw new ArgumentNullException(nameof(user.JobExperiences));

        var rating = 0;

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

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

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

        return rating;
    }
}

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

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

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

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

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

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

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

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

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

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

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

    public int Calculate(IUser user)
    {
        var rating = 0;

        if (user.HasImage)
            rating += ImageRating;

        if (user.HasParentName)
            rating += DefaultRating;

        if (user.HasJobExperience)
            rating += DefaultRating;

        return rating;
    }
}

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

В идеале стоит пойти дальше — к полноценной ООП-модели, где логика расчёта рейтинга инкапсулирована в самом объекте User. Но даже предложенный вариант с интерфейсом — это бесплатный рефакторинг: он не требует менять архитектуру, только ввести прослойку между БД и бизнес-логикой.


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

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

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

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

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

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

        case OrderStatus.Pending:
            if (!orderResponse.OrderId.HasValue)
                throw new InvalidDataException("Order id was null");
            result.OrderId = (long)orderResponse.OrderId;
            result.Amount = orderResponse.Amount;
            break;
    }

    // 5. Инфраструктура: создание записи в БД
    var payment = new PaymentDb
    {
        OriginalOrderId = orderId,
        PaymentOrderId = orderResponse.OrderId.Value,
        Description = $"Payment for order {orderId}",
        Amount = orderResponse.Amount.Value
    };
    _dbContext.Add(payment);

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

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

    // 8. Инфраструктура: логирование
    await _logService.SaveProcessLog(payment.Id,
        $"Payment started, amount: {orderResponse.Amount}");

    return result;
}

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

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

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

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

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

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

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

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

            OrderStatus.Pending when !order.OrderId.HasValue
                => throw new InvalidDataException("Order id was null"),

            OrderStatus.Pending
                => PaymentDecision.Proceed(order.OrderId.Value, order.Amount),

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

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

        var orderInfo = await _orderService.GetOrderDetails(orderId);
        var decision = _paymentProcessor.Evaluate(orderInfo);

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

        var paymentId = await _paymentRepository.Create(order, decision);
        var payUrl = _paymentGateway.GenerateUrl(paymentId);

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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