Wybór między cyklem życia transient i scoped wpływa na to, ile instancji usług powstaje, jak długo żyją i czy mogą bezpiecznie przechowywać stan. W artykule porównuję oba podejścia w .NET, pokazuję praktyczne przykłady rejestracji, typowe błędy oraz sytuacje, w których jedno rozwiązanie wyraźnie wygrywa z drugim.
Najważniejsze zasady wyboru cyklu życia usług
- Transient tworzy nową instancję przy każdym pobraniu usługi z kontenera.
- Scoped używa jednej instancji w obrębie konkretnego zakresu, najczęściej jednego żądania HTTP.
- DbContext zwykle powinien być rejestrowany jako scoped, ponieważ reprezentuje jednostkę pracy.
- Transient sprawdza się przy usługach bezstanowych i lekkich.
- Scoped jest właściwy, gdy kilka komponentów musi współdzielić stan podczas jednej operacji.
- Nie należy wstrzykiwać usługi scoped bezpośrednio do singletona.

Transient vs scoped w praktyce .NET
Wbudowany kontener dependency injection w .NET obsługuje kilka cykli życia usług. W tym porównaniu najważniejsze są dwa z nich: transient, czyli nowa instancja przy każdym żądaniu, oraz scoped, czyli jedna instancja przypisana do konkretnego zakresu.W aplikacji ASP.NET Core zakresem jest zwykle pojedyncze żądanie HTTP. Jeżeli trzy kontrolery lub serwisy w ramach tego samego żądania pobiorą usługę scoped, otrzymają ten sam obiekt. Usługa transient może w tej samej sytuacji zostać utworzona kilka razy.
| Kryterium | Transient | Scoped |
|---|---|---|
| Tworzenie instancji | Przy każdym pobraniu z kontenera | Raz na zakres |
| Typowy zakres w ASP.NET Core | Pojedyncze żądanie lub konkretna zależność | Jedno żądanie HTTP |
| Współdzielenie stanu | Nie | Tak, w obrębie zakresu |
| Typowe zastosowanie | Lekkie usługi bezstanowe | Jednostka pracy, kontekst operacji |
| Ryzyko | Nadmierna liczba instancji | Przypadkowe przechowywanie stanu zbyt długo |
Najważniejszy szczegół bywa pomijany. Transient nie oznacza, że obiekt powstaje przy każdym wywołaniu metody. Jeśli usługa transient zostanie wstrzyknięta do usługi scoped, jej instancja powstanie podczas tworzenia usługi scoped i będzie używana przez cały czas życia tej zależności.
Kiedy wybrać transient
Po transient sięgam wtedy, gdy usługa jest bezstanowa, szybko się tworzy i nie musi być współdzielona z innymi komponentami. Dobrym przykładem jest formatter, walidator, mapper albo prosty kalkulator reguł biznesowych.
builder.Services.AddTransient();
builder.Services.AddTransient();
Każda instancja może mieć własne zależności i lokalne dane, ale po zakończeniu operacji nie powinna być potrzebna innej części aplikacji. Dzięki temu kod jest łatwiejszy do testowania, a usługa nie zaczyna przypadkowo pełnić roli magazynu wspólnego stanu.
Przykłady dobrych zastosowań
- Walidatory, które analizują przekazany model i zwracają wynik.
- Formatery generujące tekst, dokument lub komunikat.
- Mappery przekształcające encje na modele API.
- Strategie biznesowe, które nie przechowują danych między wywołaniami.
- Lekkie adaptery, których utworzenie nie wymaga kosztownych zasobów.
Nie traktowałbym jednak transient jako domyślnego wyboru dla każdej małej klasy. Jeśli obiekt otwiera połączenie, tworzy ciężki graf zależności albo ma współdzielić kontekst operacji, częste tworzenie nowych instancji może utrudnić kontrolę zasobów.
W praktyce sam koszt konstrukcji rzadko powinien decydować o wyborze. Najpierw patrzę na semantykę stanu i odpowiedzialność usługi, a dopiero później na mikrooptymalizacje. Jeżeli wydajność ma znaczenie, mierzę ją profilerem zamiast zgadywać.
Kiedy scoped jest lepszym wyborem
Scoped pasuje do usług, które powinny istnieć przez czas trwania jednej spójnej operacji. W aplikacji webowej najczęściej oznacza to jedną instancję na żądanie HTTP. Wszystkie komponenty obsługujące to żądanie mogą wtedy korzystać z tego samego kontekstu.
builder.Services.AddScoped();
builder.Services.AddScoped();
Najbardziej znanym przykładem jest Entity Framework Core DbContext. Kontekst śledzi encje, przechowuje zmiany i zwykle zapisuje je jako jedną jednostkę pracy. Utworzenie kilku niezależnych kontekstów w obrębie jednej operacji może sprawić, że komponenty nie zobaczą tych samych zmian albo wykonają kilka niepotrzebnych transakcji.
Co faktycznie daje jedna instancja
Załóżmy, że kontroler wywołuje serwis zamówień, a ten korzysta z repozytorium i kontekstu bazy danych. Jeśli wszystkie zależności są scoped, mogą używać tego samego kontekstu operacji. To upraszcza śledzenie zmian i pozwala zachować spójność procesu.
Scoped nie oznacza jednak automatycznie „bezpieczny współbieżnie”. Jeżeli w jednym żądaniu uruchomisz równoległe zadania korzystające z tej samej instancji DbContext, nadal możesz doprowadzić do błędów. Czas życia obiektu i jego bezpieczeństwo wielowątkowe to dwie różne właściwości.Najważniejsze różnice w realnej architekturze
Wybór cyklu życia staje się prostszy, gdy spojrzymy na sposób użycia usługi, a nie tylko na jej nazwę. Poniższe zestawienie pokazuje typowe decyzje architektoniczne.
| Przypadek | Lepszy wybór | Dlaczego |
|---|---|---|
| Walidacja pojedynczego modelu | Transient | Brak potrzeby współdzielenia stanu |
| Formatter odpowiedzi API | Transient | Usługa wykonuje krótką, niezależną operację |
| DbContext | Scoped | Wspólny kontekst i jednostka pracy |
| Kontekst zalogowanego żądania | Scoped | Wiele komponentów może odczytać te same dane |
| Serwis używany przez background worker | Scoped w ręcznie utworzonym zakresie | Worker nie ma automatycznie zakresu HTTP |
| Usługa z dużym stanem współdzielonym globalnie | Najczęściej singleton | Transient i scoped nie rozwiązują potrzeby globalnego współdzielenia |
Warto też pamiętać, że cykl życia zależności może ograniczać cykl życia usługi nadrzędnej. Usługa transient może korzystać ze scoped, jeśli sama jest tworzona wewnątrz aktywnego zakresu. Problem pojawia się wtedy, gdy krócej żyjąca zależność zostaje przechwycona przez obiekt żyjący znacznie dłużej.
Typowe błędy przy transient i scoped
Wstrzykiwanie scoped do singletona
To jeden z najczęstszych błędów. Singleton żyje przez cały czas działania aplikacji, więc nie może bezpośrednio przechowywać zależności scoped, która powinna kończyć życie po zakończeniu zakresu.
public class CacheRefreshService
{
private readonly AppDbContext _db;
public CacheRefreshService(AppDbContext db)
{
_db = db;
}
}
Jeśli CacheRefreshService jest singletonem, a AppDbContext ma cykl scoped, powstaje tak zwana captured dependency. W zależności od konfiguracji aplikacja zgłosi błąd podczas budowania kontenera albo przechowa zależność dłużej, niż zakładano.
Włączona walidacja zakresów pomaga wykrywać takie problemy wcześniej. Nie wyłączałbym jej tylko po to, aby aplikacja „przestała narzekać”. Komunikat kontenera często wskazuje realny błąd architektoniczny.
Pobieranie scoped w background service
Hosted service i worker nie obsługują żądania HTTP, dlatego nie mają automatycznego zakresu. Jeśli potrzebują DbContext albo innej usługi scoped, trzeba utworzyć zakres przez IServiceScopeFactory.
public sealed class ImportWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public ImportWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
using IServiceScope scope = _scopeFactory.CreateScope();
var importer = scope.ServiceProvider
.GetRequiredService();
await importer.RunAsync(stoppingToken);
}
}
Zakres powinien obejmować konkretną partię pracy, a nie cały czas działania workera. To zapewnia prawidłowe zwalnianie zasobów i ogranicza ryzyko pozostawienia starego stanu.
Przeczytaj również: Architektura monolityczna w .NET - kiedy ma sens?
Traktowanie scoped jak cache
Scoped współdzieli instancję tylko wewnątrz zakresu. Nie powinien zastępować cache aplikacyjnego ani mechanizmu komunikacji między żądaniami. Dane przechowywane w takiej usłudze znikną wraz z końcem zakresu, a w przypadku aplikacji internetowej zwykle po zakończeniu żądania.
Jak podejmuję decyzję przy projektowaniu usługi
Zaczynam od trzech prostych pytań. Czy usługa ma przechowywać stan? Czy kilka komponentów w ramach jednej operacji musi zobaczyć tę samą instancję? Czy tworzenie obiektu wiąże się z zasobem, który powinien być zwolniony na końcu zakresu?
- Jeżeli odpowiedzi brzmią „nie”, wybieram zwykle transient.
- Jeżeli wspólny stan ma obowiązywać w jednej operacji, wybieram scoped.
- Jeżeli usługa korzysta z DbContext, sprawdzam, czy cały graf zależności respektuje jego zakres.
- Jeżeli kod działa w workerze, tworzę zakres ręcznie i jasno określam jego granice.
- Jeżeli usługa ma być singletonem, upewniam się, że wszystkie jej zależności też mogą bezpiecznie żyć tak długo.
Pomaga mi także prześledzenie grafu zależności. Sama rejestracja AddTransient lub AddScoped mówi tylko, jak kontener tworzy konkretny typ. Nie mówi jeszcze, jak długo ten typ będzie faktycznie używany przez inne obiekty.
Granica zakresu zależy od hosta. W ASP.NET Core jest nią zazwyczaj żądanie HTTP, ale w innych modelach aplikacji może to być ręcznie utworzona operacja, obwód Blazor Server albo dłużej żyjący kontekst aplikacji. Dlatego nie przenoszę reguły „scoped oznacza request” bez sprawdzenia konkretnego środowiska.
Prosta reguła, która sprawdza się w większości projektów
Jeśli usługa wykonuje krótką, niezależną operację i nie przechowuje stanu, rejestruję ją jako transient. Jeśli ma reprezentować kontekst jednej operacji, współdzielić stan między komponentami albo pracować razem z DbContext, wybieram scoped.
Największe problemy nie wynikają zwykle z samego wyboru jednej z tych opcji, lecz z nieświadomego mieszania ich zakresów. Gdy jasno określam, gdzie zaczyna się i kończy operacja, a potem sprawdzam cały graf zależności, różnica między tymi cyklami życia staje się praktyczna, a nie tylko teoretyczna.
