Transient, Scoped, Singleton — cykle życia DI w .NET

Transient, Scoped, Singleton — trzy cykle życia Dependency Injection w .NET. Kiedy który wybrać, co przechowują w pamięci i jakich pułapek unikać na rekrutacji.

JAJarosław Armatys 9 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

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:

  • DbContext z 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 życiaKiedy tworzona instancjaTypowe zastosowaniaNajwiększe ryzyka
TransientPrzy każdym Resolve z konteneraMappery, walidatory, strategie, lekkie bezstanowe serwisyTworzenie ciężkich obiektów w kółko, wyciek jeśli implementuje IDisposable i jest trzymany w długożyjącym rodzicu
ScopedRaz na HTTP request (lub ręcznie tworzony scope)DbContext, repozytoria, unit-of-work, serwisy domenowe ze stanem requestuZapomniane scope w background services, wstrzyknięcie do singletona (captive dependency)
SingletonRaz na cały cykl życia aplikacjiPołączenia do zewnętrznych systemów (Slack, SMTP, HttpClient factory), konfiguracje, cacheRace 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:

  1. Czy serwis jest stateless? Jeśli tak — transient. Koszt minimalny, zero ryzyka.
  2. 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.
  3. 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.

Tagi:#recruitment#dotnet#dependency-injection#csharp

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.