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

Singleton и Dependency Injection

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

Проблем

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

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

class SystemClock
{
public static readonly SystemClock Instance = new SystemClock();
private SystemClock()
{
}
public DateTime Today => DateTime.Today;
}
class OrderService
{
public decimal Price(decimal amount)
{
if (SystemClock.Instance.Today.DayOfWeek == DayOfWeek.Friday)
{
return amount * 0.9m;
}
return amount;
}
}

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

var service = new OrderService();
Console.WriteLine($"Петъчна отстъпка: очаквам 90.00 €, получих {service.Price(100):F2} €");

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

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

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

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

Идеята

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

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

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

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

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

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

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

Виж го

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

В урока „Шаблони за дизайн“ видя Singleton с if (instance == null). Ако две нишки стигнат до проверката едновременно, обектите стават два. Езикът има по-сигурни начини.Часовникът от проблема е в static readonly поле. Средата го създава веднъж, преди класът да се ползва, и това е безопасно и при много нишки. Ако създаването е скъпо и трябва да стане чак при първото искане, има Lazy<T>: обектът се прави при първото .Value, също веднъж:
class SystemClock
{
private static readonly Lazy<SystemClock> instance = new Lazy<SystemClock>(() => new SystemClock());
public static SystemClock Instance => instance.Value;
private SystemClock()
{
Console.WriteLine("Създавам часовника");
}
}
Console.WriteLine("Старт");
var a = SystemClock.Instance;
var b = SystemClock.Instance;
Console.WriteLine(a == b);
Изход
Старт
Създавам часовника
True
Часовникът се създава едва когато го поискат, и веднъж. Двете променливи сочат един и същ обект.
По-точно, за любопитните
static readonly и Lazy<T> пазят само от второ създаване. От скритата зависимост не пазят: SystemClock.Instance пак може да се вика отвсякъде.

Виж го

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

Услугата иска IClock: интерфейс с едно свойство. SystemClock от проблема само добавя : IClock:
interface IClock
{
DateTime Today { get; }
}
class OrderService
{
private readonly IClock clock;
public OrderService(IClock clock)
{
this.clock = clock;
}
public decimal Price(decimal amount)
{
if (clock.Today.DayOfWeek == DayOfWeek.Friday)
{
return amount * 0.9m;
}
return amount;
}
}

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

var service = new OrderService(SystemClock.Instance);

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

В ASP.NET Core подаването го прави контейнерът на рамката. Регистрираш часовника веднъж, builder.Services.AddSingleton<IClock>(SystemClock.Instance), и всеки контролер, който поиска IClock в конструктора си, го получава. Така работи IProductService в уроците MVC, Изгледи с Razor и WebAPI. AddSingleton там значи „един обект за цялото приложение“, но никой не го взима сам: рамката го подава.

Виж го

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

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

class FakeClock : IClock
{
public DateTime Today { get; set; }
public FakeClock(DateTime today)
{
Today = today;
}
}

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

var clock = new FakeClock(new DateTime(2026, 10, 30));
var service = new OrderService(clock);
Console.WriteLine($"Петък: {service.Price(100):F2} €");
clock.Today = new DateTime(2026, 10, 27);
Console.WriteLine($"Вторник: {service.Price(100):F2} €");
Изход
Петък: 90.00 €
Вторник: 100.00 €

var clock = new FakeClock(new DateTime(2026, 10, 30));
var service = new OrderService(clock);
Console.WriteLine($"Петък: {service.Price(100):F2} €");
clock.Today = new DateTime(2026, 10, 27);
Console.WriteLine($"Вторник: {service.Price(100):F2} €");
Стъпка по стъпка, с думи
  1. Преди Price сам посягаше към SystemClock.Instance: скрита връзка към глобален обект, която тестът не може да смени.
  2. Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
  3. Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
  4. Price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
  5. Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
  6. Същото Price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.

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

По-точно, за любопитните
От .NET 8 има вграден абстрактен клас TimeProvider с GetLocalNow(): в програмата се подава TimeProvider.System, а в тестовете свой наследник. Идеята е същата като IClock.

Пробвай

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

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

var clock = new FakeClock(new DateTime(2026, 10, 30));
var shop = new OrderService(clock);
clock.Today = new DateTime(2026, 10, 31);
var app = new OrderService(clock);
Console.WriteLine($"{shop.Price(50):F2} €");
Console.WriteLine($"{app.Price(50):F2} €");
Отговор

Пробвай

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

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

class Cart
{
public static readonly Cart Instance = new Cart();
private readonly List<string> items = new List<string>();
private Cart()
{
}
public void Add(string item)
{
items.Add(item);
}
public int Count => items.Count;
}

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

var first = new CheckoutPage();
first.Add("Кафе");
first.Add("Чаша");
Check("Тест 1", first.Count, 2);
var 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%. Часовникът вече трябва да дава и часа. Какво сменяш в IClock, и колко теста трябват, за да си сигурен?

Тест

Провери се

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

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