Po co w ogóle rozróżniamy cykle życia
Jeżeli chodziłeś kiedyś na techniczną rekrutację na .NET developera, to z prawdopodobieństwem bliskim 100% padło pytanie: „opisz mi trzy cykle życia serwisów w Dependency Injection”. Pytanie wygląda banalnie, ale w praktyce rozstrzyga o tym, czy kandydat rozumie, jak działa silnik, na którym stoi cały ASP.NET Core. I nie, nauczenie się definicji z dokumentacji to za mało — rekruter prawie na pewno dopyta „a dlaczego ten serwis zarejestrowałbyś jako singleton, a tamtego nie?”.
Najprostsza definicja, żebyśmy mieli punkt startowy:
- Transient — nowa instancja przy każdym pobraniu z kontenera.
- Scoped — jedna instancja na cały HTTP request (albo szerzej: jeden „scope”).
- Singleton — jedna instancja na cały cykl życia aplikacji.
Brzmi prosto, prawda? Problem w tym, że to samo pytanie można zadać w dziesięciu różnych scenariuszach biznesowych, a poprawna odpowiedź jest inna dla każdego z nich. W tym wpisie pokażę ci to na przykładzie fikcyjnego sklepu internetowego z trzema serwisami: OrderService, PaymentService i NotificationService. Dla każdego pokażę, który lifetime ma sens i dlaczego.
Żeby móc wizualnie sprawdzić, czy faktycznie dostajemy tę samą instancję, czy nową — użyjemy Guid. To taki „tracker” wstrzyknięty w każdy serwis, który pozwoli nam zobaczyć różnice na własne oczy.
// Serwis z unikalnym ID żeby widzieć różnice
public interface IIdProvider { Guid Id { get; } }
public class IdProvider : IIdProvider
{
public Guid Id { get; } = Guid.NewGuid();
}
// Rejestracja — trzy warianty do wyboru
services.AddTransient<IIdProvider, IdProvider>();
// services.AddScoped<IIdProvider, IdProvider>();
// services.AddSingleton<IIdProvider, IdProvider>();
Guid.NewGuid() jest wywoływany w konstruktorze — więc nowy guid = nowy konstruktor = nowa instancja. To nasza latarka do świecenia po ciemnych zakamarkach DI.
Transient — świeży przy każdym użyciu
Transient to najprostsza z trzech opcji. Za każdym razem, kiedy jakiś konstruktor poprosi kontener DI o serwis zarejestrowany jako transient — dostaje świeżutką instancję. Nowy obiekt, nowy stan, nowy guid.
W przykładzie sklepu to PaymentService. Dlaczego?
Payment service to serwis, w którym nie chcielibyśmy współdzielić pomiędzy różnymi płatnościami stanu. Mamy jedną płatność, chcemy ją dokończyć na jednym określonym stanie, zamknąć. Przychodzi druga płatność — jest na nowo tworzony cały obiekt, dokonywana płatność, zrywamy cykl życia. I tak w kółko.
Przy procesowaniu płatności nie chcesz, żeby ci się gdzieś z boku pałętały pozostałości po poprzedniej transakcji. Czyste konto, czysta głowa, jedna płatność = jedna instancja.
Dowód na to, że każdy resolve daje inny obiekt, zobaczysz uruchamiając endpoint i patrząc na guidy. Payment service wstrzyknięty w OrderService będzie miał inny guid niż payment service wstrzyknięty w kontrolerze — w ramach tego samego requestu. Każde asking = nowa instancja.
Kiedy używać transient w praktyce:
- lekkie serwisy bez stanu (mappery, walidatory, strategie),
- serwisy, w których stan musi być resetowany przy każdym użyciu,
- cokolwiek, co nie powinno być przypadkowo współdzielone.
Scoped — jeden per request
Scoped to kompromis między „świeży za każdym razem” a „jeden na wieki”. W kontekście ASP.NET Core jedna instancja żyje przez cały HTTP request — od wejścia zapytania do wysłania odpowiedzi. Wszystkie serwisy w pipeline, które poproszą o ten sam scoped service w ramach tego samego requestu, dostaną ten sam obiekt.
W naszym sklepie scoped idealnie pasuje do OrderService:
public class OrderService : IOrderService
{
private readonly List<string> _cart = new();
public Guid InstanceId { get; } = Guid.NewGuid();
public void AddToCart(string product) => _cart.Add(product);
public int ItemCount => _cart.Count;
}
// w Program.cs:
builder.Services.AddScoped<IOrderService, OrderService>();
Dlaczego to ma sens? Bo w ramach jednego requestu dodajesz kilka rzeczy do koszyka, a potem robisz checkout. Wszystko ma się dziać na tym samym koszyku. Jeśli w trakcie requestu każda metoda serwisu dostawałaby nową instancję — koszyk byłby za każdym razem pusty. Po prostu by nie działało.
Scoped jest wtedy, kiedy potrzebujemy utrzymać stan w ramach jednego zapytania — coś w ramach jednego endpointa musi żyć cały czas, ale w drugim endpincie ma być świeże, bo to drugie zapytanie, drugi strzał.
Klasyczne przykłady scoped:
DbContextz Entity Framework — i tak musi być scoped, bo nie jest thread-safe,- repozytoria i unit-of-work,
- serwisy domenowe trzymające stan requestu (jak nasz koszyk),
- serwisy wymagające kontekstu zalogowanego użytkownika w ramach jednego zapytania.
Ważne: w aplikacji konsolowej czy w background service scope nie jest tworzony automatycznie — musisz go tworzyć ręcznie przez IServiceScopeFactory. To jeden z najczęstszych punktów, na których ludzie wywalają się w kodzie produkcyjnym.
Singleton — jeden na życie aplikacji
Singleton to najbardziej „trwała” opcja. Jedna instancja, tworzona raz — przy pierwszym żądaniu (albo eagerly przy starcie, jeśli tak skonfigurujesz) — i żyjąca tak długo, jak żyje cała aplikacja. Dopóki nie zrestartujesz procesu, dostajesz ten sam obiekt, ten sam guid, ten sam stan.
W naszym sklepie singletonem jest NotificationService:
public class NotificationService : INotificationService
{
public Guid InstanceId { get; } = Guid.NewGuid();
private readonly SlackClient _slack;
public NotificationService(IConfiguration config)
{
// Ciężkie połączenie — robimy raz
_slack = new SlackClient(config["Slack:Token"]);
}
public Task SendAsync(string message) => _slack.PostAsync(message);
}
builder.Services.AddSingleton<INotificationService, NotificationService>();
Dlaczego singleton ma tu sens? Bo inicjalizacja połączenia do Slacka (albo SMTP, albo bucketu S3, albo cokolwiek ciężkiego) jest droga. Nie chcesz tego robić per request. Robisz raz, trzymasz w pamięci, korzystasz. Dopóki aplikacja żyje.
To, co ładnie widać w debuggerze: robisz jeden request, kopiujesz guid singletona, robisz drugi request — identyczny guid. Trzeci request — nadal ten sam. Dopiero restart aplikacji resetuje licznik.
I tu dochodzimy do najważniejszego ostrzeżenia o singletonach: stan w singletonie jest współdzielony między wszystkimi requestami, a requesty w ASP.NET Core są wielowątkowe. Jeżeli w singletonie trzymasz mutowalną kolekcję (List<T>, Dictionary<K,V>) i piszesz do niej z wielu wątków bez synchronizacji — witaj w krainie race conditions, których nie odtworzysz lokalnie.
Kolejna pułapka: captive dependency. Nie wstrzykuj scoped ani transient do singletona przez konstruktor. Dlaczego? Bo singleton żyje wiecznie — a skoro scoped zostanie „zamknięty” w jego polu, to de facto stanie się też singletonem. Czyli twój DbContext będzie żył całe życie aplikacji. Katastrofa.
Tabela porównawcza — ściąga na rekrutację
| Cykl życia | Kiedy tworzona instancja | Typowe zastosowania | Największe ryzyka |
|---|---|---|---|
| Transient | Przy każdym Resolve z kontenera | Mappery, walidatory, strategie, lekkie bezstanowe serwisy | Tworzenie ciężkich obiektów w kółko, wyciek jeśli implementuje IDisposable i jest trzymany w długożyjącym rodzicu |
| Scoped | Raz na HTTP request (lub ręcznie tworzony scope) | DbContext, repozytoria, unit-of-work, serwisy domenowe ze stanem requestu | Zapomniane scope w background services, wstrzyknięcie do singletona (captive dependency) |
| Singleton | Raz na cały cykl życia aplikacji | Połączenia do zewnętrznych systemów (Slack, SMTP, HttpClient factory), konfiguracje, cache | Race conditions przy mutowalnym stanie, captive dependency scoped/transient, memory leaks |
Jedna uwaga praktyczna: domyślny DI container w .NET (czyli Microsoft.Extensions.DependencyInjection) waliduje scope w trybie deweloperskim — jeśli spróbujesz wstrzyknąć scoped do singletona, dostaniesz wyjątek przy starcie aplikacji. Ale tylko jeśli masz włączone ValidateScopes = true. W production to domyślnie jest wyłączone, więc bugi tego typu mogą spokojnie dojechać na produkcję. Włącz walidację również w production — uratuje ci to kilka nieprzespanych nocy.
Który wybrać w praktyce
Prosta heurystyka, której używam przy dodawaniu nowego serwisu:
- Czy serwis jest stateless? Jeśli tak — transient. Koszt minimalny, zero ryzyka.
- Czy serwis trzyma stan, który ma sens tylko w ramach jednego zapytania? Scoped. Klasyczny przypadek: wszystko, co ma cokolwiek wspólnego z bazą danych.
- Czy serwis nawiązuje ciężkie połączenie lub jest drogi w tworzeniu, a jego stan może być bezpiecznie współdzielony? Singleton — ale tylko jeśli serwis jest thread-safe.
Jeżeli masz wątpliwości między scoped a singletonem — wybierz scoped. Zawsze łatwiej upgrade'ować scoped do singletona, niż debugować race condition w singletonie po pół roku od wdrożenia.
I jeszcze jedno pytanie, które często pada na rekrutacji jako follow-up: „co się stanie, jeśli wstrzyknę IOptions<T> do singletona a konfiguracja zmieni się w runtime?”. Odpowiedź: IOptions<T> jest singletonem i nie reaguje na zmiany. Jeśli chcesz żywych zmian — IOptionsMonitor<T>. A IOptionsSnapshot<T> jest scoped. Taki smaczek na koniec.
Co dalej?
Jeśli ten temat cię zainteresował i chcesz zobaczyć cały przykład w akcji — kod z tego wpisu jest dostępny na moim GitHubie (jarmatys/TUTORIALS), projekt nazywa się DI_Lifecycle. Ściągnij, uruchom w debugu, pobaw się guidami — zobaczysz to własnymi rękami, a to jest warte dziesięć razy więcej niż przeczytanie artykułu.
W kolejnych wpisach z serii „.NET na rekrutacji” rozbieramy na części pierwsze pytania, które padają w realnych rozmowach technicznych. Jeżeli masz własne pytanie, z którym się spotkałeś i chciałbyś, żebym je omówił — napisz w komentarzu pod filmem na YouTubie. Do zobaczenia w kolejnym wpisie.

