Към съдържанието
wannacode

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, както го дава езикът

В урока „Шаблони за дизайн“ видя Singleton като клас, който пази единствения си обект. В JavaScript рядко се прави така, защото езикът вече има нещо, което се изпълнява само веднъж: модулът.Модулът (файлът 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)} €`);
Стъпка по стъпка, с думи
  1. Преди price сам посягаше към clock на ниво модул: скрита връзка към глобален обект, която тестът не може да смени.
  2. Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
  3. Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
  4. price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
  5. Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
  6. Същото 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

Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Тестовите библиотеки пускат тестовете в ред, който не е гарантиран, понякога и едновременно. Тест, който минава само ако е първи, е тест, който пада случайно. Общото състояние в 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%. Часовникът вече трябва да дава и часа. Какво сменяш в часовника, и колко теста трябват, за да си сигурен?

Тест

Провери се

1. Защо тестът за петъчната отстъпка минава само в петък?
2. Какво променя подаването на зависимостта (Dependency Injection)?
3. Два теста ползват една и съща кошница, Singleton. Първият добавя два артикула, вторият един и очаква 1. Какво става?
4. Кога Singleton е на място?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·