Під час рев’ю pull request’ів я дивлюся на різні аспекти коду, і один із ключових — рішення щодо його дизайну та те, наскільки вони вписуються в наявну кодову базу. Нижче — поради з проєктування коду (code design), що виросли з найпоширеніших проблем, які я бачив за роки досвіду й сотні переглянутих pull request’ів. Приклади коду — мовою C#.
-
Проєктуючи новий клас або переглядаючи наявний, спробуйте описати одним реченням, що він робить. Якщо в реченні кілька сполучників «і» чи «або», клас, найімовірніше, порушує принцип єдиної відповідальності (Single Responsibility Principle), і його варто перепроєктувати або відрефакторити.
-
Створюючи об’єкт через ключове слово
new, розгляньте й інші підходи: делегувати створення об’єкта DI-контейнеру, реалізувати фабрику (factory), взяти заздалегідь створений об’єкт із пулу об’єктів (object pool) тощо.new— це «клей», який створює жорстке зчеплення (tight coupling) між класами. -
Розширюючи поведінку наявного об’єкта через зміну його коду, подумайте, чи не порушуєте ви принцип відкритості/закритості (Open-Closed Principle). Нову поведінку можна тримати окремо від наявного об’єкта за допомогою патерну «Декоратор» (Decorator), методів розширення (extension methods) або технік аспектно-орієнтованого програмування (aspect-oriented programming).
// Кешування додано декоратором — сам ProductRepository не змінюється public class CachedProductRepository(IProductRepository inner, IMemoryCache cache) : IProductRepository { public Product? GetById(int id) => cache.GetOrCreate($"product:{id}", _ => inner.GetById(id)); } -
Переконайтеся, що клас, який ви реалізуєте, не залежить від низькорівневих абстракцій (low-level abstractions). Наприклад,
IOrderService, що реалізує бізнес-сценарії, не повинен залежати відISmtpServerнапряму. МіжIOrderServiceтаISmtpServerмає бути щонайменше ще одна абстракція —IMailService. -
Жорстке зчеплення між класами може суттєво ускладнити їх повторне використання та супровід системи загалом. Щоб цього уникнути, використовуйте інтерфейси, впровадження залежностей (dependency injection), патерн «Посередник» (Mediator), механізм публікації/підписки (publish/subscribe) та інші техніки, що забезпечують слабке зчеплення (loose coupling) між компонентами.
-
Якщо ту саму групу інтерфейсів впроваджують у багато різних класів, це може свідчити про відсутню абстракцію (missing abstraction). Наприклад, інтерфейси
IPdfReaderіIPdfWriterварто об’єднати під абстракцією вищого рівня, як-отIPdfProvider, щоб клієнтам було простіше ними користуватися. -
Усі залежності класу мають впроваджуватися через його конструктор — це явні залежності (explicit dependencies). Клас не повинен використовувати неявних залежностей (implicit dependencies), тобто створювати залежності безпосередньо в методах чи викликати статичні класи, або принаймні має звести їх до мінімуму.
// Неявні залежності: їх не видно ззовні й не підмінити в тестах public class InvoiceService { public void Send(Invoice invoice) { var number = InvoiceNumberGenerator.Next(); var sender = new SmtpEmailSender(); // ... } } // Явні залежності: усе, що потрібно класу, видно в конструкторі public class InvoiceService(IInvoiceNumberGenerator numbers, IEmailSender sender) { // ... } -
Якщо в класі багато перевантажених конструкторів, їх можна замінити статичними фабричними методами (static factory methods). Головна перевага в тому, що статичний фабричний метод, на відміну від конструктора, може мати унікальну назву, яка сама пояснює, що він робить.
public class Discount { private Discount(decimal value, bool isPercentage) { /* ... */ } public static Discount Percentage(decimal percent) => new(percent, isPercentage: true); public static Discount FixedAmount(decimal amount) => new(amount, isPercentage: false); } // Зрозуміліше, ніж new Discount(10, true) var discount = Discount.Percentage(10); -
Абстрагуйте код від залежностей, що дають недетерміновані (non-deterministic) результати, як-от
DateTime. Так код простіше покрити модульними тестами (unit tests), а результати методів і функцій загалом стають передбачуванішими.// TimeProvider (.NET 8+) замість прямого виклику DateTime.UtcNow public class SubscriptionService(TimeProvider time) { public bool IsExpired(Subscription subscription) => subscription.ExpiresAt <= time.GetUtcNow(); } // У тесті час фіксований (пакет Microsoft.Extensions.TimeProvider.Testing) var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); var service = new SubscriptionService(time); -
Замініть примітивні типи (наприклад,
string), які представляють доменні поняття (наприклад, email клієнта), об’єктами-значеннями (value objects), щоб уникнути проблеми одержимості примітивами (Primitive Obsession). Об’єкти-значення інкапсулюють логіку валідації й роблять код значно читабельнішим.public sealed record Email { public string Value { get; } public Email(string value) { if (!MailAddress.TryCreate(value, out _)) throw new ArgumentException($"Invalid email: {value}", nameof(value)); Value = value; } } // Замість Register(string email): невалідний email сюди вже не потрапить public void Register(Email email) { /* ... */ } -
Залежно від ситуації розгляньте рефакторинг конструкцій if-else з використанням різних патернів (англ.): табличних методів (table-driven methods), патернів «Стратегія» (Strategy), «Шаблонний метод» (Template Method), «Стан» (State), «Фабрика» (Factory) тощо.
// Табличний метод замість ланцюжка if (tier == ...) else if (tier == ...) private static readonly Dictionary<CustomerTier, decimal> DiscountRates = new() { [CustomerTier.Regular] = 0m, [CustomerTier.Silver] = 0.05m, [CustomerTier.Gold] = 0.10m, }; public decimal GetDiscount(CustomerTier tier, decimal total) => total * DiscountRates[tier]; -
За можливості використовуйте незмінні (immutable) типи даних замість змінних (mutable), щоб мінімізувати проблеми конкурентного доступу (concurrency issues) та побічні ефекти (side effects) у застосунку.
public record Address(string City, string Street); var home = new Address("Київ", "Хрещатик, 1"); // with створює новий об’єкт, home залишається незмінним var office = home with { Street = "Сагайдачного, 25" }; -
Якомога ширше використовуйте чисті функції (pure functions): їхній результат залежить лише від вхідних параметрів, і вони не змінюють зовнішній стан системи. Такий код передбачуваний і простіший у супроводі.
// Нечиста: читає поле _discount і змінює поле _lastTotal public decimal CalculateTotal(Order order) { _lastTotal = order.Lines.Sum(l => l.Price * l.Quantity) * (1 - _discount); return _lastTotal; } // Чиста: результат залежить лише від аргументів public static decimal CalculateTotal(IEnumerable<OrderLine> lines, decimal discount) => lines.Sum(l => l.Price * l.Quantity) * (1 - discount); -
Обирайте структуру даних з огляду на асимптотичну складність (asymptotic complexity) її операцій вставки, оновлення, видалення тощо та на те, як саме ваш застосунок працює з даними.
-
Розбийте метод чи функцію на менші, якщо в неї довгий список вхідних параметрів, багато рядків коду або якщо ви не можете описати одним реченням без сполучників, що вона робить.
-
Якщо протягом усього життя застосунку має існувати лише один екземпляр класу, зареєструйте його як singleton у DI-контейнері замість того, щоб реалізовувати патерн «Одинак» (Singleton) на основі статичного поля.
// Замість статичного поля RateCache.Instance — один екземпляр від DI-контейнера builder.Services.AddSingleton<IRateCache, RateCache>(); -
Якщо потрібно реалізувати глибоке копіювання (deep copy) складного графа об’єктів (object graph), розгляньте серіалізацію/десеріалізацію (англ.). Інші підходи, як-от метод клонування в кожному класі, що бере участь у копіюванні, можуть перетворитися на доволі одноманітну роботу.
// Копіюється весь граф: Order, його Lines, Customer тощо. // Працює для типів, які коректно серіалізуються в JSON. public static T DeepClone<T>(T source) => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(source))!; -
Уникайте singleton-об’єктів зі станом (stateful), щоб не отримати проблем через одночасний доступ до об’єкта з кількох потоків. Якщо стан у singleton усе ж потрібен, реалізуйте критичні секції (critical sections) або інші механізми синхронізації потоків (thread synchronization).
// Зареєстровано як singleton: до нього одночасно звертаються різні потоки public class InMemoryRateLimiter { private readonly Lock _lock = new(); // System.Threading.Lock, .NET 9+ private readonly Dictionary<string, int> _requests = new(); public bool TryAcquire(string clientId, int limit) { lock (_lock) { _requests.TryGetValue(clientId, out var count); if (count >= limit) return false; _requests[clientId] = count + 1; return true; } } } -
Не впроваджуйте в singleton екземпляр із часом життя в межах запиту (per-request, або scoped, lifetime), щоб уникнути проблеми, відомої як захоплена залежність (captive dependency).
// scoped: новий екземпляр на кожен запит builder.Services.AddDbContext<AppDbContext>(); // singleton: один екземпляр на весь застосунок builder.Services.AddSingleton<ReportCache>(); // ReportCache створиться один раз і назавжди «захопить» DbContext першого запиту public class ReportCache(AppDbContext db) { /* ... */ } // Рішення: впровадити IDbContextFactory<AppDbContext> або IServiceScopeFactory // і створювати короткоживучий екземпляр щоразу, коли він потрібен -
Коли ви копіюєте код або впроваджуєте швидкий обхідний шлях (workaround) через брак часу, тиск бізнесу тощо, завжди створюйте задачу на технічний борг (tech debt) у системі відстеження задач (issue tracker).
Це переклад моєї англомовної статті «20 Code Design Tips for Developers for Everyday Use», опублікованої на Medium у серпні 2022 року. Приклади коду додано в українській версії.