Патерни проєктування
Що таке патерни проєктування, де вони вже вбудовані в .NET, чим схожі патерни відрізняються один від одного і які антипатерни найчастіше трапляються в живому коді. На співбесіді перевіряють не знання каталогу напам’ять, а вміння назвати проблему, яку патерн розв’язує, і його ціну.
Перед цією темою: SOLID і принципи.
Що таке патерни проєктування і як їх класифікують?
Section titled “Що таке патерни проєктування і як їх класифікують?”Що це
Патерн проєктування — опис розв’язання типової задачі: яка проблема, які класи беруть участь, як вони пов’язані і до чого це призводить. Це не бібліотека і не код для копіювання: патерн щоразу реалізують під свою задачу.
Навіщо
- Спільний словник: «обгорни в декоратор» коротше, ніж пояснювати схему класів.
- Перевірені розв’язання: не треба заново вигадувати, як додати поведінку, не змінюючи клас.
Як класифікують
Книга «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 часто називають антипатерном?
Section titled “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: чим відрізняються?
Section titled “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: де вони в щоденному коді?
Section titled “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 — про окремі моделі читання і запису.
Які антипатерни трапляються найчастіше і як від них відходити?
Section titled “Які антипатерни трапляються найчастіше і як від них відходити?”Що це
Антипатерн — розв’язання, яке часто трапляється і здається зручним, але з часом робить код дорожчим у змінах. Для нього є відома заміна.
Найчастіші
- 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.