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, както го дава езикът
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} €");Стъпка по стъпка, с думи
- Преди Price сам посягаше към SystemClock.Instance: скрита връзка към глобален обект, която тестът не може да смени.
- Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
- Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
- Price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
- Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
- Същото Price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.
Тестът дава тези две числа в понеделник, в петък и в неделя. OrderService е същият клас, който върви в магазина: тестът сменя само това, което му подава.
По-точно, за любопитните
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Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?
Избери реда с грешката.
Страницата сама взима единствената кошница. Двете страници, и двата теста, пълнят един и същ обект: вторият тест вижда кафето и чашата на първия. В магазина е още по-лошо: всички клиенти пазаруват в една кошница. Кошницата не е нещо, което е едно за програмата, а е по една на клиент. Поправката: кошницата е обикновен клас с публичен конструктор, а страницата я получава в своя: new CheckoutPage(new Cart()). Всеки тест си прави своя кошница, и двата минават.
По-точно, за любопитните
Тестовите библиотеки пускат тестовете в ред, който не е гарантиран, понякога и едновременно. Тест, който минава само ако е първи, е тест, който пада случайно. Общото състояние в 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, и колко теста трябват, за да си сигурен?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове · Ламбди и делегати · Шаблони за дизайн · Strategy
- Продължава в: Adapter и Facade · Repository · Магазинът: кой шаблон къде · SOLID без догма
- Задачи по темата: Шаблони за дизайн