Когда проект молодой, писать «всё в одном классе» кажется быстрее. Создать отдельный интерфейс, вынести слой — это же лишняя минута! Но эта минута потом стоит часов: код невозможно покрыть тестами, замена 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 дня, потому что я не понимаю, что тут происходит»
Эти антипаттерны не появляются сразу. Они накапливаются постепенно — «ну ладно, один раз можно», «тут всего одна зависимость лишняя», «потом отрефакторим». Именно поэтому их так сложно заметить вовремя.
Недели кодирования сохраняют часы проектирования.
