Singleton и Dependency Injection
Накратко: Singleton гарантира един-единствен обект от клас, а Dependency Injection подава зависимостите отвън, затова кодът се тества с подменена зависимост, например часовник.
Проблем
Тестът, който минава само в петък
В петък магазинът дава 10% отстъпка. Часовникът на програмата е един, затова е Singleton: клас, от който има само един обект, достъпен отвсякъде. Услугата за поръчки го пита кой ден е:
class SystemClock { today() { return new Date(); }}
const clock = new SystemClock();
const FRIDAY = 5;
class OrderService { price(amount) { if (clock.today().getDay() === FRIDAY) { return amount * 0.9; } return amount; }}Някой пише тест, за да е сигурен, че отстъпката работи:
const service = new OrderService();console.log(`Петъчна отстъпка: очаквам 90.00 €, получих ${service.price(100).toFixed(2)} €`);Пуснат в понеделник, 26 октомври 2026, тестът печата:
Петъчна отстъпка: очаквам 90.00 €, получих 100.00 €В петък същият тест печата 90.00. Тестът минава един ден в седмицата и пада в останалите шест, а кодът е същият. Да провериш отстъпката значи да чакаш петък или да местиш часовника на компютъра.
Часовникът сам по себе си е направен правилно. Проблемът е, че price сам посяга към него. Отвън не се вижда, че услугата зависи от часовник: new OrderService() не иска нищо. Тестът не може да ѝ даде друг ден, защото не може да ѝ даде нищо.
Идеята
Единственият обект и подаването отвън
Singleton (единак) пази две неща: обектът е само един, и всеки може да го вземе отвсякъде. Първото понякога е нужно. Второто е цената:
- скрита зависимост. Кой ползва часовника, личи само ако прочетеш всеки ред от всеки метод;
- общо състояние. Каквото един сменя в обекта, вижда всеки друг, включително следващият тест;
- не се сменя. Кодът сочи конкретния клас по име. Друг часовник за теста няма откъде да влезе.
Поправката не е да махнеш часовника, а да го подадеш отвън. Услугата не търси зависимостта си, а я получава в конструктора. Кой часовник ще е, решава този, който създава услугата: програмата подава истинския, а тестът свой, с дата, която той избира.
Това се казва Dependency Injection (подаване на зависимостите, DI). Ролите са три:
- услугата казва какво ѝ трябва: параметър в конструктора. Какъв е обектът, не я интересува, стига да има метод
today(); - зависимостта е обектът, който ѝ се дава: истинският часовник или тестовият;
- сглобяването става на едно място, в началото на програмата. Там се решава кой обект при кого отива.
Singleton може и да остане: единственият часовник се подава като всеки друг обект. Просто никой не посяга към него сам.
Виж го
Singleton, както го дава езикът
clock.js) се изпълнява веднъж, при първото import. Всяко следващо import { clock } from "./clock.js" дава същия обект. Обект, изнесен с export от модула, е Singleton без нито ред допълнителен код:class SystemClock { constructor() { console.log("Създавам часовника"); }
today() { return new Date(); }}
export const clock = new SystemClock();console.log("Старт");Създавам часовникаСтартПо-точно, за любопитните
require (CommonJS, по-старият вид модули в Node.js) е същото: модулът се изпълнява при първото require, а следващите получават вече готовия му резултат.Виж го
Часовникът идва отвън
today(). Модулът с clock остава същият:const FRIDAY = 5;
class OrderService { constructor(clock) { this.clock = clock; }
price(amount) { if (this.clock.today().getDay() === FRIDAY) { return amount * 0.9; } return amount; }}В програмата истинският часовник се подава веднъж, при създаването:
const service = new OrderService(clock);Сега new OrderService(...) казва на глас, че без часовник не може. Зависимостта се вижда в подписа на конструктора, а не някъде вътре в метод.
Виж го
Тестът сам избира деня
За теста е нужен часовник, на който казваш коя дата да връща:
class FakeClock { constructor(date) { this.date = date; }
today() { return this.date; }}Тестът подава него вместо истинския:
const clock = new FakeClock(new Date(2026, 9, 30));const service = new OrderService(clock);console.log(`Петък: ${service.price(100).toFixed(2)} €`);
clock.date = new Date(2026, 9, 27);console.log(`Вторник: ${service.price(100).toFixed(2)} €`);Петък: 90.00 €Вторник: 100.00 €const clock = new FakeClock(new Date(2026, 9, 30));const service = new OrderService(clock);console.log(`Петък: ${service.price(100).toFixed(2)} €`);
clock.date = new Date(2026, 9, 27);console.log(`Вторник: ${service.price(100).toFixed(2)} €`);Стъпка по стъпка, с думи
- Преди price сам посягаше към clock на ниво модул: скрита връзка към глобален обект, която тестът не може да смени.
- Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
- Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
- price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
- Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
- Същото price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.
Date се броят от 0: new Date(2026, 9, 30) е 30 октомври.Тестът дава тези две числа в понеделник, в петък и в неделя. OrderService е същият клас, който върви в магазина: тестът сменя само това, което му подава.
По-точно, за любопитните
{ today: () => new Date(2026, 9, 30) }. Клас не е нужен: на услугата ѝ трябва само метод today().Пробвай
Предвиди изхода
Класовете са от картите преди тази. 31 октомври 2026 е събота. Какво печата програмата?
const clock = new FakeClock(new Date(2026, 9, 30));const shop = new OrderService(clock);clock.date = new Date(2026, 9, 31);const app = new OrderService(clock);
console.log(`${shop.price(50).toFixed(2)} €`);console.log(`${app.price(50).toFixed(2)} €`);Пробвай
Намери грешката
Кошницата е Singleton, „за да е една“:
class Cart { constructor() { this.items = []; }
add(item) { this.items.push(item); }}
const cart = new Cart();Два теста, всеки със своя страница за плащане. check печата OK или какво е очаквал тестът:
const first = new CheckoutPage();first.add("Кафе");first.add("Чаша");check("Тест 1", first.count(), 2);
const second = new CheckoutPage();second.add("Кафе");check("Тест 2", second.count(), 1);Тест 1: OKТест 2: FAIL, очаквах 1, получих 3Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?
Избери реда с грешката.
Страницата сама взима единствената кошница. Двете страници, и двата теста, пълнят един и същ обект: вторият тест вижда кафето и чашата на първия. В магазина е още по-лошо: всички клиенти пазаруват в една кошница. Кошницата не е нещо, което е едно за програмата, а е по една на клиент. Поправката: без обекта на ниво модул; страницата получава кошницата в конструктора: new CheckoutPage(new Cart()). Всеки тест си прави своя кошница, и двата минават.
По-точно, за любопитните
Тестовите библиотеки пускат тестовете в ред, който не е гарантиран, понякога и едновременно. Тест, който минава само ако е първи, е тест, който пада случайно. Общото състояние в Singleton е най-честата причина.
Идеята
Кога не ти трябва
Singleton има смисъл, когато второ копие би било грешка, а обектът не пази нищо, което се сменя: настройките, прочетени веднъж при старта, логът, пулът от връзки към базата. Дори тогава е по-добре да се подава отвън, отколкото всеки да посяга към него сам.
Подаването отвън също има цена. Не си струва, когато:
- зависимостта никога не се сменя и не пречи на тестовете.
Math.roundне е зависимост, която се подава; - програмата е малка. Сглобяване на ръка в началото на програмата стига; рамка за DI е за големи приложения;
- конструкторът иска осем зависимости. Това не е проблем на DI: класът прави твърде много.
Упражнение
Black Friday
Реши задачата в редактора си.
Black Friday. На 27 ноември 2026 магазинът дава 30% отстъпка, а в другите петъци пак 10%. Добави правилото в OrderService и го провери с FakeClock за три дати: Black Friday, 30 октомври (петък) и 27 октомври (вторник). Тестът дава еднакъв изход всеки ден.
Black Friday: 70.00 €Петък: 90.00 €Вторник: 100.00 €Още задачи
- Номера на поръчки. Броячът на номерата е Singleton и започва от 1001. Направи го зависимост на
OrderServiceи напиши два теста, които и двата получават 1001 като първи номер. - Кошница за всеки клиент. Поправи грешката от „Намери грешката“ докрай: кошницата не е Singleton, а всяка страница получава своята. Двама клиенти добавят различни артикули и всеки вижда само своите.
- Нощна отстъпка. Между 22:00 и 6:00 отстъпката е 5%. Часовникът вече трябва да дава и часа. Какво сменяш в часовника, и колко теста трябват, за да си сигурен?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове и данни · Стрелкови функции · Шаблони за дизайн · Strategy
- Продължава в: Adapter и Facade · Repository · Магазинът: кой шаблон къде · SOLID без догма
- Задачи по темата: Шаблони за дизайн