Назад до блогу
8 жовтня 2026 5 хв читання

20 порад із проєктування коду для щоденної роботи

Під час рев’ю pull request’ів я дивлюся на різні аспекти коду, і один із ключових — рішення щодо його дизайну та те, наскільки вони вписуються в наявну кодову базу. Нижче — поради з проєктування коду (code design), що виросли з найпоширеніших проблем, які я бачив за роки досвіду й сотні переглянутих pull request’ів. Приклади коду — мовою C#.

  1. Проєктуючи новий клас або переглядаючи наявний, спробуйте описати одним реченням, що він робить. Якщо в реченні кілька сполучників «і» чи «або», клас, найімовірніше, порушує принцип єдиної відповідальності (Single Responsibility Principle), і його варто перепроєктувати або відрефакторити.

  2. Створюючи об’єкт через ключове слово new, розгляньте й інші підходи: делегувати створення об’єкта DI-контейнеру, реалізувати фабрику (factory), взяти заздалегідь створений об’єкт із пулу об’єктів (object pool) тощо. new — це «клей», який створює жорстке зчеплення (tight coupling) між класами.

  3. Розширюючи поведінку наявного об’єкта через зміну його коду, подумайте, чи не порушуєте ви принцип відкритості/закритості (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));
    }
  4. Переконайтеся, що клас, який ви реалізуєте, не залежить від низькорівневих абстракцій (low-level abstractions). Наприклад, IOrderService, що реалізує бізнес-сценарії, не повинен залежати від ISmtpServer напряму. Між IOrderService та ISmtpServer має бути щонайменше ще одна абстракція — IMailService.

  5. Жорстке зчеплення між класами може суттєво ускладнити їх повторне використання та супровід системи загалом. Щоб цього уникнути, використовуйте інтерфейси, впровадження залежностей (dependency injection), патерн «Посередник» (Mediator), механізм публікації/підписки (publish/subscribe) та інші техніки, що забезпечують слабке зчеплення (loose coupling) між компонентами.

  6. Якщо ту саму групу інтерфейсів впроваджують у багато різних класів, це може свідчити про відсутню абстракцію (missing abstraction). Наприклад, інтерфейси IPdfReader і IPdfWriter варто об’єднати під абстракцією вищого рівня, як-от IPdfProvider, щоб клієнтам було простіше ними користуватися.

  7. Усі залежності класу мають впроваджуватися через його конструктор — це явні залежності (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)
    {
        // ...
    }
  8. Якщо в класі багато перевантажених конструкторів, їх можна замінити статичними фабричними методами (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);
  9. Абстрагуйте код від залежностей, що дають недетерміновані (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);
  10. Замініть примітивні типи (наприклад, 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) { /* ... */ }
  11. Залежно від ситуації розгляньте рефакторинг конструкцій 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];
  12. За можливості використовуйте незмінні (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" };
  13. Якомога ширше використовуйте чисті функції (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);
  14. Обирайте структуру даних з огляду на асимптотичну складність (asymptotic complexity) її операцій вставки, оновлення, видалення тощо та на те, як саме ваш застосунок працює з даними.

  15. Розбийте метод чи функцію на менші, якщо в неї довгий список вхідних параметрів, багато рядків коду або якщо ви не можете описати одним реченням без сполучників, що вона робить.

  16. Якщо протягом усього життя застосунку має існувати лише один екземпляр класу, зареєструйте його як singleton у DI-контейнері замість того, щоб реалізовувати патерн «Одинак» (Singleton) на основі статичного поля.

    // Замість статичного поля RateCache.Instance — один екземпляр від DI-контейнера
    builder.Services.AddSingleton<IRateCache, RateCache>();
  17. Якщо потрібно реалізувати глибоке копіювання (deep copy) складного графа об’єктів (object graph), розгляньте серіалізацію/десеріалізацію (англ.). Інші підходи, як-от метод клонування в кожному класі, що бере участь у копіюванні, можуть перетворитися на доволі одноманітну роботу.

    // Копіюється весь граф: Order, його Lines, Customer тощо.
    // Працює для типів, які коректно серіалізуються в JSON.
    public static T DeepClone<T>(T source) =>
        JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(source))!;
  18. Уникайте 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;
            }
        }
    }
  19. Не впроваджуйте в 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
    // і створювати короткоживучий екземпляр щоразу, коли він потрібен
  20. Коли ви копіюєте код або впроваджуєте швидкий обхідний шлях (workaround) через брак часу, тиск бізнесу тощо, завжди створюйте задачу на технічний борг (tech debt) у системі відстеження задач (issue tracker).

Це переклад моєї англомовної статті «20 Code Design Tips for Developers for Everyday Use», опублікованої на Medium у серпні 2022 року. Приклади коду додано в українській версії.

ЩО ВИВЧАТИ ДАЛІ