Дизайн-патерни через призму болю — Частина 1

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Коли болить створення

Більшість проблем із патернами починається не з неправильного вибору.

Вони починаються з неправильного моменту.

Ми додаємо патерн не тоді, коли наш код його потребує, а тоді, коли нам здається, що «так красивіще, правильніше». І одне із перших місць, де це відбувається — створення об’єктів.

Поки наша система маленька, тут все просто.

Але з часом створення починає обростати логікою.

З’являються дефолти.

З’являються залежності між параметрами.

З’являється вибір реалізації.

І вже наш конструктор перестає бути просто конструктором.

Сreational patterns, ваш вихід :D

P.S. я тут не буду розписувати деталі реалізації кожного із патернів, це можна загуглити, тут тріши про інше.

Як зрозуміти, що болить саме створення?

  • у конструкторі 5+ параметрів, і ти вже не розумієш де і шо
  • частина аргументів опціональна, але деякі «опціональні» чомусь обовʼязкові
  • логіка розмазана по різних місцях
  • ініціалізація починає ламати тести

Якщо створення об’єкта стало складнішим за його використання — це вже сигнал.

Singleton — звучить логічно, але...

Логіка тут проста: нам потрібен лише один екземпляр.

Візьмемо за приклад щось типу такого:

class AppConfig {
  private static instance: AppConfig;
  private constructor(
    public readonly apiUrl: string,
    public readonly env: string
  ) {}
  static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig(
        process.env.API_URL!,
        process.env.NODE_ENV!
      );
    }
    return AppConfig.instance;
  }
}

На перший погляд тут все окей.

«нам же не потрібно два конфіги».

Проблема тут не в самому патерні, а в причині.

Коли це нормально

Singleton має сенс, коли об’єкт:

  • конфіг, який читається один раз при старті і більше не змінюється
  • або має read-only стан
  • або це просто обгортка над чимось типу (логера або конфіга)

Тобто коли він не перетворюється на shared store для всіх підряд.

Коли починається біль

class CartStore {

  private static instance: CartStore;
  private items: string[] = [];

  private constructor() {}

  static getInstance(): CartStore {
    if (!CartStore.instance) {
      CartStore.instance = new CartStore();
    }
    return CartStore.instance;
  }

  addItem(item: string): void {
    this.items.push(item);
  }

  getItems(): string[] {
    return [...this.items]; // повертаємо копію, щоб не мутували ззовні
  }
}

// десь у хендлері запиту
const cart = CartStore.getInstance();
cart.addItem("milk");

// десь в іншому місці (або в наступному запиті / тесті)
const sameCart = CartStore.getInstance();
console.log(sameCart.getItems()); // ["milk"] 😬

Тепер уявимо:

  • наш тест 1 додав товар
  • далі тест 2 стартує вже з непорожнім кошиком
  • а ще у Node-сервері цей інстанс живе на рівні процесу, тому всі HTTP-запити працюють з одним і тим самим станом

Хто змінив мій state? Я думав, це ти!

По факту, ми не створили «зручний доступ».

У нас вийшла глобальна змінна з прикольною назвою.

Singleton — це в першу чергу про контроль життєвого циклу.

Якщо ми не можемо чітко пояснити, чому має існувати рівно один інстанс — швидше за все, тобі потрібен не Singleton, а нормальна інʼєкція залежностей (DI).

Builder — коли конструктор став квестом

Оце класика.

Спочатку конструктор простий:

new ApiClient(url, apiKey);

Потім додається таймаут.

Потім retry.

Кеш.

Логування.

І в якийсь момент це мем стає реальністью:

const client = new ApiClient(
  "https://api.payment.com",
  process.env.API_KEY!,
  2000,
  5000,
  3,
  true,
  false,
  true
);

Так, зараз можуть з’явитися адепти IDE, і сказати, так а шо тут за проблема? Всеж і так підсвічується)

Я не з такої сім'ї, як другі. Я із багатої»: як зараз живе ...

Але миж не з такої сім’ї, ми за гарний читабельний код.

Коли бачиш це, виникають питання:

  • що означає третій параметр?
  • який з цих boolean за що відповідає?
  • які комбінації тут валідні?

Ти вгадуєш!

Окей, якщо ми пишемо на TypeScript, ми можемо вирішити цей кейс через деструктуризації об’єкта, і це буде валідно:

function createApiClient({
  baseUrl,
  apiKey,
  connectTimeout = 2000,
  readTimeout = 5000,
  retries = 3,
  useSsl = true,
  debug = false,
  cache = true,
}: ApiConfig) {...}

але, якщо ми захочемо

  • пресети
  • multi-phase ініціалізація
  • валідація
  • залежні поля
  • lazy-ініт
  • override логіка
  • різні режими (prod / test / sandbox)

Тут без Builder — починається біль.

const client = await new ApiClientBuilder()
  .usePreset("qa")
  .withApiKey("secret-key")
  .timeouts(2500)
  .withRetries(10, "exponential")
  .withHeader("x-client", "web")
  .withHeader("x-trace-id", "123")
  .addMiddleware((ctx) => ctx)
  .enableDebug()
  .warmup()
  .healthCheck()
  .build();

Тепер тут видно:

  • важливі параметри
  • можна валідувати перед build()
  • легко додати пресети і тд

Ви контролюєте процес, етапи.

Через «staged builder» — кожен метод повертав інший тип, у якому доступні лише дозволені наступні кроки.

const client = await ApiClientBuilder()
  .usePreset("qa")
  .timeouts(2500)
  .build();

⛔ TS помилка:

  • Property ’timeouts’ does not exist on type ’StageNeedApiKey’.
  • або Property ’build’ does not exist on type ’StageNeedApiKey’.

(бо після usePreset() ти ще на етапі «NeedApiKey».)

Builder не прибирає складність.

Він концентрує її в одному місці.

Simple Factory — коли вибір розмазаний

Інший тип болю — не кількість параметрів, а кількість реалізацій.

Наприклад, різні платіжні провайдери:

async function checkout(amount: number): Promise<void> {
  let provider: { charge(amount: number): Promise<void> };
  if (process.env.PAYMENT_PROVIDER === "stripe") {
    provider = new StripeProvider();
  } else if (process.env.PAYMENT_PROVIDER === "paypal") {
    provider = new PaypalProvider();
  } else {
    throw new Error("Unsupported payment provider");
  }
    await provider.charge(amount);
}

І так у трьох різних сервісах.

Коли правило вибору повторюється — його потрібно централізувати.

class PaymentProviderFactory {
  static create(): PaymentProvider {
    const provider = process.env.PAYMENT_PROVIDER;
    switch (provider) {
      case "stripe":
        return new StripeProvider();
      case "paypal":
        return new PaypalProvider();
      default:
        throw new Error(
          `Unsupported payment provider: ${provider ?? "undefined"}`
        );
    }
  }
}
const provider = PaymentProviderFactory.create();
await provider.charge(amount);

Тепер:

  • логіка вибору в одному місці
  • додати нового провайдера — означає змінити одне місце
  • код, який використовує провайдер, не знає деталей

Factory — це не про «красиву абстракцію».

Це про те, щоб перестати приймати одне й те саме рішення в десяти різних місцях.

Підсумок

Коли болить створення, варто ставити три питання:

  1. Чи справді потрібен рівно один інстанс?
  2. Чи стало створення настільки складним, що його легко зламати?
  3. Чи повторюється логіка вибору реалізації?

Creational patterns не роблять систему складнішою.

Вони локалізують складність.

Але тільки якщо складність уже існує.

У наступній частині розберемо інший тип болю — коли код формально правильний, але межі між об’єктами починають ламатися.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Пару зауважень:

Singletone — взагалі вважається антипатерном. Себто, створює більше граблів, ніж прибирає. Розберемо ваші приклади:
— Конфіг — а чому він не є глобальною змінною? Ви ж все одно десь стартуєте свій софт, мабуть. Там він ще однопоточний, створюються глобальні сутності. От саме там є функція init() котра підіймає систему — і в ній прописується усе, що треба зробити на старті, в правильному порядку. І читання конфігу також там буде — і воно буде впорядковане відносно інший глобальних об’єктів. Бо відоме фіаско з сінглтонами — коли десь з конфігом проблема, і сінглтон конфіга в конструкторі намагається залогувати помилку через сінглтон логера, котрий в конструкторі намагається отримати конфіг для логування з сінглтона конфіга...
— Константи — так само легше робляться через глобальні змінні. Коротший синтаксис, ІДЕ одразу підсвітить.
— Обгортка над системним компонентом — так само, простіше через глобальну змінну. І не потрапляєте в циклічну залежність або недетермінізм порядку створення на старті системи.

Builder — те, що у вас прописано — це звичайна фабрика. IRC, Білдер збирає складну фігню з кількох простих фігонь. Наприклад, він може парсити вираз в синтаксичне дерево, або навпаки — з дерева команд чи рішень писати математичний вираз. Умовно — білдер прикручує колеса до корпуса машини, але доволі часто не створює ані корпус, ані колеса.
У вас це не показано — що там всередині відбувається і з чого та як збирають АПІ клієнт.

По суті — патерни — це хаки, коли не вистачає механізмів мови програмування та ООП, щоб відносно просто вирішити певну проблему (або клас проблем). Якщо воно робиться простіше — наприклад, глобальною змінною — то патерн є переускладненням, в той час як KISS не відміняли (хоча й багато хто зневажає).

P.S. Заходьте до чатику t.me/swarchua

Дякую, погоджуюсь, що патерни потрібні лише там, де просте рішення вже не працює.

патерни — це хаки, коли не вистачає механізмів мови програмування та ООП

паттерны это про систематизацию/классификацию тех вещей, которые встречаются в том или ином виде практически в любой мало-мальски большой программе практически на любом языке

И?
Поэтому нужно делать сложно то, что можно сделать просто?

далеко не все паттерны про переусложнение

Да, и далеко не все

встречаются в том или ином виде практически в любой мало-мальски большой программе практически на любом языке

Примеры доменно-специфических патернов:
Proactor — real-time системы управления.
Blackboard — fuzzy logic в нечетко сформулированных задачах.
Вообще, раньше работало Rule of Three — если есть 3 примера похожих решений, то это — паттерн. Сейчас на него подзабили.

Но вот в данной статье как раз про Синглтон, которые практически во всех случаях вносит больше проблем, чем решает. И обычно заменим глобальной переменной.

просто выводы в исходном комменте звучали так, как будто это не про эту пару паттернов, а вообще про все паттерны в мире

Да, любой паттерн сложнее идиомы — того, что встроено в язык программирования.
Соответственно, если проблема решается в одну строчку средствами языка — то обычно писать 10 строчек паттерна — лишнее усложнение.

Пример — паттерн Итератор из GoF. Если в С++ завезли встроенные итераторы в STL, то писать свой, еще и с нестандартным интерфейсом из GoF, оправдано в 1% случаев.

Кстати, тут пример, как паттерн со временем стал идиомой. И его перестали использовать как паттерн — потому что паттерн — это хак, когда язык не предоставляет нужной механики.

Підписатись на коментарі