Паттерны проектирования
Что такое паттерны проектирования, где они уже встроены в .NET, чем похожие паттерны отличаются друг от друга и какие антипаттерны чаще всего встречаются в живом коде. На собеседовании проверяют не знание каталога наизусть, а умение назвать проблему, которую паттерн решает, и его цену.
Перед этой темой: SOLID и принципы.
Что такое паттерны проектирования и как они классифицируются?
Заголовок раздела «Что такое паттерны проектирования и как они классифицируются?»Что это
Паттерн проектирования — описание решения типовой задачи: какая проблема, какие классы участвуют, как они связаны и к чему это приводит. Это не библиотека и не код для копирования: паттерн каждый раз реализуют под свою задачу.
Зачем
- Общий словарь: «оберни в декоратор» короче, чем объяснять схему классов.
- Проверенные решения: не нужно заново придумывать, как добавить поведение, не меняя класс.
Как классифицируются
Книга «Design Patterns» (1994) Гаммы, Хелма, Джонсона и Влиссидеса — «банды четырёх», GoF — описывает 23 паттерна в трёх группах:
| Группа | Что решает | Примеры |
|---|---|---|
| Порождающие (5) | как создавать объекты | Singleton, Factory Method, Builder |
| Структурные (7) | как собирать объекты в структуры | Adapter, Decorator, Facade |
| Поведенческие (11) | как объекты взаимодействуют | Strategy, Observer, Iterator |
Частая ошибка
Учить паттерны как шаблоны кода и вставлять их везде. Паттерн нужен там, где есть проблема, которую он решает.
Как устроено
Каждый паттерн GoF описан по одной схеме: назначение, мотивация, применимость, участники, последствия, реализация. Важнее всего назначение и последствия: что паттерн решает и чем за это платят.
Где встретить в .NET
| Паттерн | Где в .NET |
|---|---|
| Builder | StringBuilder, WebApplicationBuilder |
| Abstract Factory | DbProviderFactory: CreateConnection, CreateCommand |
| Prototype | выражение with у record |
| Decorator | BufferedStream, GZipStream поверх Stream |
| Iterator | IEnumerable<T>, yield return |
| Observer | event, IObservable<T> |
| Strategy | IComparer<T> в List<T>.Sort |
Ловушки
- Похожие схемы. Адаптер, декоратор и прокси выглядят одинаково — обёртка над объектом. Различаются они намерением, а не кодом.
- Название — не паттерн. Статический метод
Create— просто фабричный метод-конструктор, а не Factory Method из GoF: тот предполагает, что класс создаваемого объекта выбирает подкласс.
Правило
Сначала проблема, потом паттерн. Если не получается назвать проблему, которую паттерн решает в этом месте, он лишний.
Под капотом
- GoF классифицирует паттерны по двум осям: назначение (порождающие, структурные, поведенческие) и уровень (класса или объекта).
- Паттерны уровня класса — Factory Method, Template Method, Interpreter, адаптер класса — строятся на наследовании и фиксируются при компиляции. Остальные строятся на композиции и меняются во время выполнения.
Паттерны и язык
Питер Норвиг ещё в 1996 году показал, что в динамических языках 16 из 23 паттернов становятся проще или исчезают. В C# то же сделали возможности языка: стратегия и команда — Func<T> и Action, итератор — yield return, наблюдатель — event, прототип — with, ленивый singleton — Lazy<T>.
Компромиссы
- Каждый паттерн добавляет типы и косвенность: навигация по коду сложнее, стек вызовов длиннее. Взамен появляется точка расширения.
- Точка расширения окупается, только если ею пользуются: вторая реализация, подмена в тесте, частые изменения в прошлом.
- GoF — не единственный каталог: есть паттерны корпоративных приложений Фаулера (Repository, Unit of Work), интеграционные паттерны, облачные паттерны Microsoft (Retry, Circuit Breaker).
Как обсуждать в команде
Название паттерна на ревью — сокращение для разговора о последствиях: «тут декоратор, значит, порядок регистрации важен». Если паттерн нужно знать, чтобы понять класс, а сам он ничего не даёт, — это «паттерн ради паттерна».
Итог: паттерн — словарь для обсуждения дизайна; в C# многие паттерны GoF стали возможностями языка, а выбирать их стоит по последствиям, а не по каталогу.
using System.IO.Compression;
// Итератор: yield return генерирует IEnumerator<T>foreach (var n in Evens(6)) Console.Write($"{n} "); // 0 2 4Console.WriteLine();
// Стратегия: алгоритм сравнения передаётся снаружиvar words = new List<string> { "ccc", "a", "bb" };words.Sort(Comparer<string>.Create((x, y) => x.Length.CompareTo(y.Length)));Console.WriteLine(string.Join(",", words)); // a,bb,ccc
// Декоратор: GZipStream добавляет сжатие к любому Streamusing var buffer = new MemoryStream();using (var gzip = new GZipStream(buffer, CompressionLevel.Optimal, leaveOpen: true)) gzip.Write(new byte[1000]);Console.WriteLine(buffer.Length < 1000); // True
static IEnumerable<int> Evens(int max){ for (var i = 0; i < max; i += 2) yield return i;}- Сколько паттернов в GoF и как они делятся? — 23: 5 порождающих, 7 структурных, 11 поведенческих.
- Назовите паттерны в BCL. —
IEnumerable<T>— итератор,StringBuilder— строитель, обёртки надStream— декоратор,event— наблюдатель. - Чем паттерн отличается от алгоритма? — Алгоритм — последовательность шагов, паттерн — роли объектов и связи между ними.
- Чем паттерн отличается от архитектурного стиля? — Масштабом: паттерн — несколько классов, стиль вроде слоёной архитектуры — всё приложение.
- Какие два принципа GoF выносит в начало книги? — Программировать на уровне интерфейса и предпочитать композицию наследованию.
Singleton, фабрика и строитель: когда что и почему Singleton часто называют антипаттерном?
Заголовок раздела «Singleton, фабрика и строитель: когда что и почему Singleton часто называют антипаттерном?»Что это
| Паттерн | Что делает | Пример в .NET |
|---|---|---|
| Singleton | один экземпляр на приложение | AddSingleton в DI |
| Фабрика | создаёт объект и выбирает класс | IHttpClientFactory.CreateClient() |
| Строитель | собирает объект по шагам | StringBuilder, WebApplicationBuilder |
Зачем
Коду, которому нужен объект, незачем знать, как его собрать: какой класс выбрать, какие параметры передать, в каком порядке настроить.
Как пользоваться
- Нужен один общий объект — зарегистрируйте его в DI как singleton, а не пишите статическое свойство
Instance. - Класс выбирается по условию — фабрика.
- Много необязательных настроек — строитель с цепочкой вызовов.
Частая ошибка
Писать singleton со static Instance для сервиса с зависимостями. Его нельзя подменить в тесте, и все тесты делят одно состояние.
Как устроено
- Singleton. Приватный конструктор и статическое свойство. Потокобезопасно и лениво — через
Lazy<T>: по умолчанию его фабрика выполняется ровно один раз даже при гонке потоков. - Factory Method. Базовый класс объявляет виртуальный метод создания, а подкласс решает, какой объект вернуть.
- Abstract Factory. Интерфейс с несколькими методами создания для семейства согласованных объектов:
DbProviderFactoryсоздаёт соединение, команду и параметр одного провайдера. - Builder. Объект собирается вызовами методов, а
Build()проверяет данные и отдаёт результат.
Когда что выбирать
| Задача | Решение |
|---|---|
| Один экземпляр на приложение | AddSingleton в DI |
| Объект с параметрами времени выполнения | фабрика-делегат или интерфейс фабрики |
| Семейство согласованных объектов | Abstract Factory |
| Много необязательных частей и проверка | Builder |
Ловушки
- Почему Singleton — антипаттерн. Глобальная точка доступа прячет зависимость, состояние переживает тесты, а класс сам управляет своим временем жизни — это лишняя обязанность.
- Fluent API — не всегда Builder. Цепочка методов — стиль интерфейса. Builder — когда есть отдельный объект-сборщик и итоговый
Build().
Правило
Время жизни решает контейнер, создание с параметрами — фабрика, сложную сборку — строитель.
Под капотом
- Поле
static readonlyинициализирует конструктор типа, и CLR гарантирует, что он выполнится один раз. Но без явного статического конструктора момент инициализации выбирает рантайм, поэтому для настоящей ленивости берутLazy<T>. Lazy<T>по умолчанию работает в режимеExecutionAndPublication: фабрика выполняется под блокировкой одним потоком. Исключение фабрики кешируется — каждый следующийValueвыбросит его же.PublicationOnlyисключения не кеширует, но при гонке может создать лишние экземпляры.IHttpClientFactory— фабрика с пулом:HttpClientсоздаётся каждый раз, аHttpMessageHandlerпереиспользуется и пересоздаётся по таймеру, по умолчанию раз в две минуты.
Компромиссы
- Статика против DI. Статический singleton доступен отовсюду, DI делает зависимость видимой и заменяемой. Статика оправдана для неизменяемых значений без зависимостей:
Comparer<T>.Default,Array.Empty<T>(). - Своя фабрика. Контейнер уже фабрика, и интерфейс фабрики на каждый сервис — лишний слой. Своя фабрика нужна, если объект зависит от данных запроса или должен жить меньше долгоживущего потребителя.
- Builder против
record. Builder даёт пошаговую проверку и неизменяемый результат, но удваивает число типов.
Как проверить в проде
- Singleton с изменяемым состоянием выдаёт себя гонками под нагрузкой. На ревью ищут
static-поля с изменяемыми коллекциями и свойстваInstance. - Упавшая инициализация
Lazy<T>видна как одно и то же исключение на каждом запросе до перезапуска процесса.
Итог: «один экземпляр» — решение composition root, фабрика — для создания во время выполнения, строитель — для сложной сборки, и за каждым стоит цена косвенности.
// Singleton через Lazy<T>: фабрика выполнится один раз, даже из нескольких потоковParallel.For(0, 8, i => { var config = Config.Instance; });Console.WriteLine(Config.Created); // 1
// Строитель: пошаговая сборка, проверка в Build()var mail = new EmailBuilder().To("ann@example.com").Subject("Hi").Build();Console.WriteLine(mail); // Email { To = ann@example.com, Subject = Hi }
public sealed class Config{ private static readonly Lazy<Config> instance = new(() => new Config()); public static int Created; public static Config Instance => instance.Value; private Config() => Interlocked.Increment(ref Created);}
public record Email(string To, string Subject);
public class EmailBuilder{ private string? to, subject; public EmailBuilder To(string value) { to = value; return this; } public EmailBuilder Subject(string value) { subject = value; return this; } public Email Build() => new(to ?? throw new InvalidOperationException("To is required"), subject ?? "");}Exporter exporter = new CsvExporter();Console.WriteLine(exporter.Export([1, 2, 3])); // 1;2;3
// Базовый класс задаёт порядок работы, а подкласс выбирает, какой объект создатьpublic abstract class Exporter{ public string Export(int[] rows) => CreateFormatter().Format(rows); protected abstract IFormatter CreateFormatter(); // фабричный метод}
public class CsvExporter : Exporter{ protected override IFormatter CreateFormatter() => new CsvFormatter();}
public interface IFormatter { string Format(int[] rows); }
public class CsvFormatter : IFormatter{ public string Format(int[] rows) => string.Join(";", rows);}- Как сделать потокобезопасный ленивый singleton в C#? —
Lazy<T>в статическом поле или просто полеstatic readonly. - Чем
AddSingletonлучше статическогоInstance? — Зависимость видна в конструкторе и подменяется в тесте, а время жизни задаёт composition root. - Чем Factory Method отличается от Abstract Factory? — Первый — один виртуальный метод, выбор в подклассе; второй — объект с методами для целого семейства.
- Зачем
IHttpClientFactory, если естьnew HttpClient()? — Он переиспользует и обновляетHttpMessageHandler: нет исчерпания сокетов и устаревшего DNS. - Что будет, если фабрика
Lazy<T>выбросит исключение? — В режиме по умолчанию оно закешируется и повторится при каждом обращении кValue.
Adapter, Decorator, Facade и Proxy: чем отличаются?
Заголовок раздела «Adapter, Decorator, Facade и Proxy: чем отличаются?»Что это
Все четыре паттерна оборачивают другой объект. Различие — в том, зачем.
| Паттерн | Зачем обёртка | Пример в .NET |
|---|---|---|
| Адаптер | привести чужой интерфейс к нужному | StreamReader поверх Stream |
| Декоратор | добавить поведение, интерфейс тот же | BufferedStream, GZipStream |
| Фасад | простой вход в сложную подсистему | DbContext |
| Прокси | управлять доступом, интерфейс тот же | ленивая загрузка в EF Core |
Как пользоваться
- Подключаете библиотеку с неудобным API — напишите адаптер под свой интерфейс.
- Нужны логирование или кеш без правки класса — декоратор.
Частая ошибка
Называть любую обёртку декоратором. Если интерфейс меняется — это адаптер. Если обёртка не добавляет поведение, а решает, вызывать ли объект, — прокси.
Как устроено
- Адаптер. Реализует интерфейс клиента и переводит вызовы в API адаптируемого объекта. В своём коде это обычно обёртка над SDK внешнего сервиса.
- Декоратор. Реализует тот же интерфейс и получает его в конструкторе, поэтому декораторы вкладываются друг в друга.
DelegatingHandler— декоратор дляHttpMessageHandler: он может что-то сделать до и послеbase.SendAsync. - Фасад. Свой интерфейс, за которым несколько объектов.
DbContextскрывает соединение, отслеживание изменений и генерацию SQL, а свойствоDatabaseвозвращает объектDatabaseFacadeдля операций с самой базой. - Прокси. Тот же интерфейс, но решает, вызывать ли реальный объект и когда: ленивое создание, удалённый вызов, проверка прав.
Когда что выбирать
| Задача | Паттерн |
|---|---|
| Чужой API не совпадает с вашим интерфейсом | Адаптер |
| Добавить логирование, повторы, кеш | Декоратор |
| Скрыть пять вызовов за одним | Фасад |
| Отложить загрузку или вызвать удалённо | Прокси |
Ловушки
- Прокси прячет стоимость. Обращение к навигационному свойству с ленивой загрузкой — запрос в базу, и в цикле получается N+1.
- Порядок декораторов. Логирование снаружи повторов пишет одну запись, внутри — запись на каждую попытку.
Как зарегистрировать декоратор во встроенном контейнере, разобрано в теме Внедрение зависимостей.
Правило
Сформулируйте, что меняет обёртка: интерфейс, поведение, сложность или доступ, — от этого и название.
Под капотом
- Динамический прокси — тип, сгенерированный во время выполнения через
System.Reflection.Emit. Castle DynamicProxy создаёт подкласс с переопределённымиvirtual-членами или реализацию интерфейса. Так работают моки Moq и прокси ленивой загрузки изMicrosoft.EntityFrameworkCore.Proxies. DispatchProxyиз BCL создаёт тип, реализующий интерфейс, и направляет все вызовы вInvoke(MethodInfo, object[]).- Генерация кода во время выполнения недоступна в Native AOT, поэтому такие прокси там не работают.
Цена
- Каждый слой — лишний вызов через интерфейс или виртуальный метод. На фоне ввода-вывода это незаметно, в горячем цикле — заметно.
DispatchProxyна каждый вызов создаёт массив аргументов и упаковывает значимые типы.
Компромиссы
- Ручной декоратор явный и проверяется компилятором, но его пишут под каждый интерфейс. Прокси-перехватчик один на все интерфейсы, но его поведение не видно в коде.
- Source generators — третий путь: обёртка генерируется при сборке, видна в отладчике и совместима с AOT.
- Фасад на границе модуля снижает связанность, но толстый фасад со временем собирает всю логику.
Как проверить в проде
- Ленивая загрузка видна как поток мелких одинаковых запросов в логе EF Core на одну операцию.
- Типы вида
Castle.Proxies.OrderProxyв стеке исключения или в дампе показывают, что вызов шёл через перехватчик.
Итог: обёртки различаются намерением и контрактом; динамический прокси мощнее ручного декоратора, но прячет поведение и несовместим с AOT.
INotifier notifier = new LoggingNotifier(new SmsAdapter(new LegacySmsClient()));notifier.Send("Order shipped");// log: Order shipped// SMS: ORDER SHIPPED
public interface INotifier { void Send(string message); }
// Чужой класс с неудобным API: менять его нельзяpublic class LegacySmsClient{ public void Transmit(string upperText) => Console.WriteLine($"SMS: {upperText}");}
// Адаптер: интерфейс INotifier поверх чужого APIpublic class SmsAdapter(LegacySmsClient client) : INotifier{ public void Send(string message) => client.Transmit(message.ToUpperInvariant());}
// Декоратор: тот же интерфейс плюс логированиеpublic class LoggingNotifier(INotifier inner) : INotifier{ public void Send(string message) { Console.WriteLine($"log: {message}"); inner.Send(message); }}using System.Reflection;
var greeter = DispatchProxy.Create<IGreeter, LoggingProxy>();((LoggingProxy)(object)greeter).Target = new Greeter();Console.WriteLine(greeter.Greet("Ann"));// call Greet// Hello, Ann
public interface IGreeter { string Greet(string name); }
public class Greeter : IGreeter{ public string Greet(string name) => $"Hello, {name}";}
// Тип прокси генерируется во время выполнения, все вызовы приходят в Invokepublic class LoggingProxy : DispatchProxy{ public IGreeter? Target { get; set; }
protected override object? Invoke(MethodInfo? method, object?[]? args) { Console.WriteLine($"call {method!.Name}"); return method.Invoke(Target, args); }}- Чем декоратор отличается от прокси, если интерфейс одинаковый? — Декоратор добавляет поведение, прокси управляет доступом: вызывать ли объект и когда.
- Почему для ленивой загрузки EF Core свойства должны быть
virtual? — Прокси — сгенерированный подкласс, он переопределяет навигационные свойства. - Где декораторы в
HttpClient? —DelegatingHandlerв цепочке обработчиков: каждый вызывает следующий черезbase.SendAsync. - Чем фасад отличается от адаптера? — Адаптер подгоняет один интерфейс под ожидаемый, фасад вводит новый упрощённый интерфейс к нескольким объектам.
- Почему динамические прокси не работают в Native AOT? — Они генерируют типы во время выполнения, а AOT компилирует всё заранее.
Strategy, Observer, Command, Chain of Responsibility и Template Method: где они в повседневном коде?
Заголовок раздела «Strategy, Observer, Command, Chain of Responsibility и Template Method: где они в повседневном коде?»Что это
Поведенческие паттерны описывают, как объекты делят обязанности и общаются.
| Паттерн | Идея | Где в .NET |
|---|---|---|
| Стратегия | алгоритм передаётся снаружи | IComparer<T>, лямбды в LINQ |
| Наблюдатель | объект оповещает подписчиков | event, IObservable<T> |
| Команда | запрос — это объект | ICommand в WPF, обработчики команд |
| Цепочка обязанностей | запрос проходит звенья по очереди | middleware ASP.NET Core |
| Шаблонный метод | скелет алгоритма в базовом классе | BackgroundService.ExecuteAsync |
Как пользоваться
list.Sort(comparer)— вы передаёте стратегию сравнения.button.Click += OnClick— подписываетесь как наблюдатель.- Наследуете
BackgroundServiceи пишетеExecuteAsync— заполняете шаг шаблонного метода.
Частая ошибка
Подписаться на событие долгоживущего объекта и не отписаться. Издатель держит ссылку на подписчика, и сборщик мусора его не освободит.
Как устроено
- Стратегия. Интерфейс или делегат с одним методом; контекст вызывает его, не зная реализацию.
- Наблюдатель.
event— список делегатов.IObservable<T>добавляет сигналы завершения и ошибки и отписку черезIDisposable. - Команда. Запрос с данными — отдельный объект, исполняет его обработчик. На этом строятся очереди и CQRS-обработчики, подробнее — в теме Архитектурные стили.
- Цепочка обязанностей. Звено получает запрос и ссылку на следующее и решает: обработать, передать дальше или оборвать цепочку. Так устроены middleware, подробнее — в теме Хостинг и конвейер.
- Шаблонный метод. Невиртуальный метод вызывает виртуальные шаги.
BackgroundServiceреализует запуск и остановку и вызывает вашExecuteAsync.
Когда что выбирать
| Задача | Паттерн |
|---|---|
| Алгоритм меняется по настройке | Стратегия |
| Несколько реакций на одно событие | Наблюдатель |
| Запрос надо отложить, повторить, залогировать | Команда |
| Последовательные проверки и обработчики | Цепочка |
| Общий порядок шагов, разные детали | Шаблонный метод |
Ловушки
- Исключение в одном обработчике
eventпрерывает вызов остальных. - Шаблонный метод с десятком хуков делает базовый класс хрупким: изменение порядка шагов ломает наследников.
Правило
Одна операция — делегат, несколько операций или состояние — интерфейс, общий скелет — шаблонный метод, если композиции не хватает.
Под капотом
- Событие-поле компилируется в приватный делегат и методы
addиremove. Они атомарно заменяют неизменяемый многоадресный делегат черезInterlocked.CompareExchange, а вызов идёт по снимку списка, поэтому отписка во время оповещения безопасна. - Лямбда-стратегия, захватившая переменные, — это аллокация замыкания. Лямбду без захвата компилятор кеширует.
- Конвейер middleware собирается один раз при старте: каждое звено оборачивает следующее, и запрос идёт по готовой цепочке делегатов.
Цена
- Подписка держит подписчика сильной ссылкой: объект, подписанный на событие singleton, живёт, пока живёт singleton.
- Команда-объект и диспетчер добавляют аллокацию и косвенный вызов, а поиск обработчиков через рефлексию — ещё и время старта.
Компромиссы
- Шаблонный метод против стратегии. Наследование гарантирует порядок шагов, композиция позволяет менять и тестировать шаги независимо.
eventпротивIObservable<T>и каналов. Событие синхронно и без обратного давления; для асинхронных потоков данных есть Rx иSystem.Threading.Channels.- Свой диспетчер против MediatR. Библиотека даёт общий конвейер обработчиков, но прячет связь «команда — обработчик» от навигации по коду. С версии 13 у неё коммерческая лицензия.
Как проверить в проде
- Утечку через события видно в
dotnet-gcdump: путь удержания подписчика идёт через делегат в поле издателя. - Порядок middleware фиксируют интеграционным тестом на
WebApplicationFactory.
Итог: поведенческие паттерны в .NET — это в основном делегаты и конвейеры; их цена — неявный поток управления и сильные ссылки подписок.
// Стратегия: правило скидки передаётся делегатомvar checkout = new Checkout(price => price * 90 / 100);
// Наблюдатель: подписчики узнают об оплате и не знают друг о другеcheckout.Paid += total => Console.WriteLine($"email: paid {total}");checkout.Paid += total => Console.WriteLine($"audit: {total}");
checkout.Pay(200);// email: paid 180// audit: 180
// Шаблонный метод: порядок шагов задан в базовом классеnew CsvReport().Print(); // header | csv rows | footer
public class Checkout(Func<int, int> discount){ public event Action<int>? Paid; public void Pay(int price) => Paid?.Invoke(discount(price));}
public abstract class Report{ public void Print() => Console.WriteLine($"header | {Body()} | footer"); protected abstract string Body(); // шаг, который задаёт подкласс}
public class CsvReport : Report{ protected override string Body() => "csv rows";}- Чем стратегия отличается от шаблонного метода? — Стратегия меняет алгоритм композицией во время выполнения, шаблонный метод — наследованием при компиляции.
- Почему событие может вызвать утечку памяти? — Издатель держит подписчика через делегат; без отписки тот живёт столько же, сколько издатель.
- Чем
IObservable<T>богачеevent? — Есть завершение, ошибка, отписка черезIDisposableи операторы Rx. - Middleware — это Chain of Responsibility? — Да, вариант: звено может обработать запрос, передать его дальше или оборвать цепочку.
- MediatR — это реализация CQRS? — Нет, это паттерн Mediator для доставки сообщений обработчикам; CQRS — про раздельные модели чтения и записи.
Какие антипаттерны встречаются чаще всего и как от них уходить?
Заголовок раздела «Какие антипаттерны встречаются чаще всего и как от них уходить?»Что это
Антипаттерн — решение, которое часто встречается и кажется удобным, но со временем делает код дороже в изменении. У него есть известная замена.
Самые частые
- God object. Один
OrderManagerна 3000 строк делает всё: проверки, расчёт, SQL, отправку писем. - Магические числа.
if (status == 3)— что такое 3, знает только автор. - Копипаста. Одна и та же логика в пяти местах, а исправили её в четырёх.
- Service Locator.
provider.GetService<T>()внутри методов вместо параметров конструктора.
Как уходить
- Покрыть код тестами, чтобы рефакторинг ничего не сломал.
- Менять малыми шагами: вынести константу, вынести метод, вынести класс.
Частая ошибка
Переписать всё с нуля. Старый код хранит знания о крайних случаях, которые новый потеряет.
Как устроено
Запах кода — видимый симптом: длинный метод, большой класс, длинный список параметров. Антипаттерн — диагноз, который за ним стоит. Каталог запахов и рефакторингов против них собрал Мартин Фаулер в книге «Refactoring», второе издание вышло в 2018 году.
| Антипаттерн | Симптом | Рефакторинг |
|---|---|---|
| God object | Large Class, десятки зависимостей | Extract Class |
| Магические числа | литералы в условиях | Replace Magic Literal |
| Примитивная одержимость | string для email, decimal для денег |
Replace Primitive with Object |
| Shotgun Surgery | одна правка — много файлов | Move Function, Combine Functions into Class |
| Паттерн ради паттерна | Speculative Generality | Inline Class, Collapse Hierarchy |
| Service Locator | GetService в бизнес-коде |
параметр конструктора |
Ловушки
- Анемичная модель — спорный ярлык. Для CRUD без правил это нормально. Проблемой она становится, когда одни и те же проверки дублируются по сервисам; подробнее — в теме Архитектурные стили.
- Лечение порождает новую болезнь. Если раздробить God object на мелкие классы без смысла, получится Shotgun Surgery.
Правило
Рефакторинг — отдельный коммит без изменения поведения: малые шаги и зелёные тесты после каждого.
Как возникают
- Антипаттерны редко пишут намеренно. God object растёт, потому что добавить метод в существующий класс дешевле, чем придумать новый: каждое решение локально разумно, а сумма — нет.
- Big Ball of Mud (Foote и Yoder, 1997) — результат давления сроков, смены людей и роста без рефакторинга.
Цена
- God object и Shotgun Surgery дорожают с каждым изменением: больше конфликтов слияния, регрессий и времени на ревью.
- Примитивная одержимость переносит проверки в рантайм: email проверяют в десяти местах или ни в одном.
Компромиссы
- Не каждый ярлык — приговор. Анемичная модель приемлема для CRUD, а
TimeSpan.FromSeconds(30)читается и без константы. - «Паттерн ради паттерна» и YAGNI тянут в одну сторону, DRY и расширяемость — в другую. Абстракцию вводят на втором-третьем случае, а не на первом.
- Большой рефакторинг — это проект: его режут на шаги, каждый из которых можно выпустить, например через Strangler Fig.
Как проверить в проде
- Анализаторы Roslyn и SonarQube ловят часть запахов: сложность методов, дублирование, магические числа.
- Частота изменений файлов в истории git показывает горячие точки: большой файл, который меняется почти в каждом коммите, — кандидат в God object.
- Архитектурные тесты (NetArchTest, ArchUnitNET) фиксируют границы слоёв, чтобы Big Ball of Mud не вернулся.
Итог: антипаттерны вырастают из локально разумных решений; их ловят метриками и ревью, а лечат постепенным рефакторингом, а не переписыванием.
// До: if (status == 3 && email.Contains('@')) — магическое число и строка вместо типа// После: именованное состояние и тип с проверкой при созданииvar email = Email.Parse("ann@example.com");Console.WriteLine(CanShip(email, OrderStatus.Paid)); // True
try { Email.Parse("not-an-email"); }catch (ArgumentException e) { Console.WriteLine(e.Message); } // Invalid email
static bool CanShip(Email email, OrderStatus status) => status == OrderStatus.Paid;
public enum OrderStatus { New = 1, Reserved = 2, Paid = 3 }
// Replace Primitive with Object: неверный email не создатьpublic sealed record Email{ public string Value { get; } private Email(string value) => Value = value;
public static Email Parse(string value) => value.Contains('@') ? new Email(value) : throw new ArgumentException("Invalid email");}- Чем антипаттерн отличается от запаха кода? — Запах — локальный симптом, антипаттерн — повторяющееся неудачное решение за ним.
- Почему Service Locator — антипаттерн? — Прячет зависимости и переносит ошибки в рантайм; подробно — в теме «Внедрение зависимостей».
- Как разбить God object? — Найти группы полей и методов, которые меняются вместе, и вынести их в отдельные классы (Extract Class) малыми шагами.
- Анемичная модель — всегда плохо? — Нет: для CRUD без инвариантов это нормально; плохо, когда правила дублируются по сервисам.
- Когда переписать с нуля лучше рефакторинга? — Редко; чаще старую систему заменяют по частям через Strangler Fig.