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

Singleton и Dependency Injection

Накратко: Singleton гарантира един-единствен обект от клас, а Dependency Injection подава зависимостите отвън, затова кодът се тества с подменена зависимост, например часовник.

Проблем

Тестът, който минава само в петък

В петък магазинът дава 10% отстъпка. Часовникът на програмата е един, затова е Singleton: клас, от който има само един обект, достъпен отвсякъде. Услугата за поръчки го пита кой ден е:

class SystemClock {
private static final SystemClock INSTANCE = new SystemClock();
private SystemClock() {
}
public static SystemClock getInstance() {
return INSTANCE;
}
public LocalDate today() {
return LocalDate.now();
}
}
class OrderService {
public double price(double amount) {
if (SystemClock.getInstance().today().getDayOfWeek() == DayOfWeek.FRIDAY) {
return amount * 0.9;
}
return amount;
}
}

Някой пише тест, за да е сигурен, че отстъпката работи:

OrderService service = new OrderService();
System.out.printf("Петъчна отстъпка: очаквам 90.00 €, получих %.2f €%n", service.price(100));

Пуснат в понеделник, 26 октомври 2026, тестът печата:

Изход
Петъчна отстъпка: очаквам 90.00 €, получих 100.00 €

В петък същият тест печата 90.00. Тестът минава един ден в седмицата и пада в останалите шест, а кодът е същият. Да провериш отстъпката значи да чакаш петък или да местиш часовника на компютъра.

Часовникът сам по себе си е направен правилно. Проблемът е, че price сам посяга към него. Отвън не се вижда, че услугата зависи от часовник: new OrderService() не иска нищо. Тестът не може да ѝ даде друг ден, защото не може да ѝ даде нищо.

Идеята

Единственият обект и подаването отвън

Singleton (единак) пази две неща: обектът е само един, и всеки може да го вземе отвсякъде. Първото понякога е нужно. Второто е цената:

  • скрита зависимост. Кой ползва часовника, личи само ако прочетеш всеки ред от всеки метод;
  • общо състояние. Каквото един сменя в обекта, вижда всеки друг, включително следващият тест;
  • не се сменя. Кодът сочи конкретния клас по име. Друг часовник за теста няма откъде да влезе.

Поправката не е да махнеш часовника, а да го подадеш отвън. Услугата не търси зависимостта си, а я получава в конструктора. Кой часовник ще е, решава този, който създава услугата: програмата подава истинския, а тестът свой, с дата, която той избира.

Това се казва Dependency Injection (подаване на зависимостите, DI). Ролите са три:

  • услугата казва какво ѝ трябва: параметър от тип интерфейс в конструктора;
  • зависимостта е обектът, който ѝ се дава: истинският часовник или тестовият;
  • сглобяването става на едно място, в началото на програмата. Там се решава кой обект при кого отива.

Singleton може и да остане: единственият часовник се подава като всеки друг обект. Просто никой не посяга към него сам.

Виж го

Singleton, както го дава езикът

В урока „Шаблони за дизайн“ видя Singleton с if (instance == null). Ако две нишки стигнат до проверката едновременно, обектите стават два. Езикът има по-сигурни начини.Часовникът от проблема е създаден в static final поле. Java го създава веднъж, когато зареди класа, и това е безопасно и при много нишки. Ако създаването е скъпо и трябва да стане чак при първото искане, обектът отива във вложен клас (holder). Java зарежда Holder едва при първото getInstance():
class SystemClock {
private SystemClock() {
System.out.println("Създавам часовника");
}
private static class Holder {
static final SystemClock INSTANCE = new SystemClock();
}
public static SystemClock getInstance() {
return Holder.INSTANCE;
}
}
System.out.println("Старт");
SystemClock a = SystemClock.getInstance();
SystemClock b = SystemClock.getInstance();
System.out.println(a == b);
Изход
Старт
Създавам часовника
true
Часовникът се създава едва когато го поискат, и веднъж. Двете променливи сочат един и същ обект.
По-точно, за любопитните
Най-краткият сигурен Singleton в Java е enum с една стойност, INSTANCE (видя го в „Шаблони за дизайн“). Java не позволява new за enum и създава стойността точно веднъж.

Виж го

Часовникът идва отвън

Услугата иска Clock: интерфейс с един метод. SystemClock от проблема само добавя implements Clock:
interface Clock {
LocalDate today();
}
class OrderService {
private final Clock clock;
public OrderService(Clock clock) {
this.clock = clock;
}
public double price(double amount) {
if (clock.today().getDayOfWeek() == DayOfWeek.FRIDAY) {
return amount * 0.9;
}
return amount;
}
}

В програмата истинският часовник се подава веднъж, при създаването:

OrderService service = new OrderService(SystemClock.getInstance());

Сега new OrderService(...) казва на глас, че без часовник не може. Зависимостта се вижда в подписа на конструктора, а не някъде вътре в метод.

В големите приложения сглобяването го прави рамка: Spring създава обектите и подава всеки на тези, които го искат в конструктора си. Идеята е същата, само че на ръка в main е за малки програми.

Виж го

Тестът сам избира деня

За теста е нужен часовник, на който казваш коя дата да връща:

class FakeClock implements Clock {
private LocalDate date;
public FakeClock(LocalDate date) {
this.date = date;
}
public void set(LocalDate date) {
this.date = date;
}
@Override
public LocalDate today() {
return date;
}
}

Тестът подава него вместо истинския:

FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));
OrderService service = new OrderService(clock);
System.out.printf("Петък: %.2f €%n", service.price(100));
clock.set(LocalDate.of(2026, 10, 27));
System.out.printf("Вторник: %.2f €%n", service.price(100));
Изход
Петък: 90.00 €
Вторник: 100.00 €

FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));
OrderService service = new OrderService(clock);
System.out.printf("Петък: %.2f €%n", service.price(100));
clock.set(LocalDate.of(2026, 10, 27));
System.out.printf("Вторник: %.2f €%n", service.price(100));
Стъпка по стъпка, с думи
  1. Преди price сам посягаше към SystemClock.getInstance(): скрита връзка към глобален обект, която тестът не може да смени.
  2. Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
  3. Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
  4. price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
  5. Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
  6. Същото price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.

Тестът дава тези две числа в понеделник, в петък и в неделя. OrderService е същият клас, който върви в магазина: тестът сменя само това, което му подава.

По-точно, за любопитните
Clock има един метод, затова тестов часовник за фиксиран ден може да е и ламбда: new OrderService(() -> LocalDate.of(2026, 10, 30)). Клас си заслужава, когато тестът мести датата, както тук. В Java има и вграден java.time.Clock с Clock.fixed(...) за тестове, а LocalDate.now(clock) пита него.

Пробвай

Предвиди изхода

Класовете са от картите преди тази. 31 октомври 2026 е събота. Какво печата програмата?

FakeClock clock = new FakeClock(LocalDate.of(2026, 10, 30));
OrderService shop = new OrderService(clock);
clock.set(LocalDate.of(2026, 10, 31));
OrderService app = new OrderService(clock);
System.out.printf("%.2f €%n", shop.price(50));
System.out.printf("%.2f €%n", app.price(50));
Отговор

Пробвай

Намери грешката

Кошницата е Singleton, „за да е една“:

class Cart {
private static final Cart INSTANCE = new Cart();
private final List<String> items = new ArrayList<>();
private Cart() {
}
public static Cart getInstance() {
return INSTANCE;
}
public void add(String item) {
items.add(item);
}
public int size() {
return items.size();
}
}

Два теста, всеки със своя страница за плащане. check печата OK или какво е очаквал тестът:

CheckoutPage first = new CheckoutPage();
first.add("Кафе");
first.add("Чаша");
check("Тест 1", first.count(), 2);
CheckoutPage second = new CheckoutPage();
second.add("Кафе");
check("Тест 2", second.count(), 1);
Изход
Тест 1: OK
Тест 2: FAIL, очаквах 1, получих 3

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

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

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

Тестовите библиотеки пускат тестовете в ред, който не е гарантиран, понякога и едновременно. Тест, който минава само ако е първи, е тест, който пада случайно. Общото състояние в Singleton е най-честата причина.

Идеята

Кога не ти трябва

Singleton има смисъл, когато второ копие би било грешка, а обектът не пази нищо, което се сменя: настройките, прочетени веднъж при старта, логът, пулът от връзки към базата. Дори тогава е по-добре да се подава отвън, отколкото всеки да посяга към него сам.

Подаването отвън също има цена. Не си струва, когато:

  • зависимостта никога не се сменя и не пречи на тестовете. Math.round не е зависимост, която се подава;
  • програмата е малка. Сглобяване на ръка в main стига; рамка за 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%. Часовникът вече трябва да дава и часа. Какво сменяш в Clock, и колко теста трябват, за да си сигурен?

Тест

Провери се

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

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