Дизайн-патерни через призму болю — Частина 1
Коли болить створення
Більшість проблем із патернами починається не з неправильного вибору.
Вони починаються з неправильного моменту.
Ми додаємо патерн не тоді, коли наш код його потребує, а тоді, коли нам здається, що «так красивіще, правильніше». І одне із перших місць, де це відбувається — створення об’єктів.
Поки наша система маленька, тут все просто.
Але з часом створення починає обростати логікою.
З’являються дефолти.
З’являються залежності між параметрами.
З’являється вибір реалізації.
І вже наш конструктор перестає бути просто конструктором.
С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 — це не про «красиву абстракцію».
Це про те, щоб перестати приймати одне й те саме рішення в десяти різних місцях.
Підсумок
Коли болить створення, варто ставити три питання:
- Чи справді потрібен рівно один інстанс?
- Чи стало створення настільки складним, що його легко зламати?
- Чи повторюється логіка вибору реалізації?
Creational patterns не роблять систему складнішою.
Вони локалізують складність.
Але тільки якщо складність уже існує.
У наступній частині розберемо інший тип болю — коли код формально правильний, але межі між об’єктами починають ламатися.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів