Dependency injection w C# bez pułapek - cykle życia i testy

Radosław Krajewski 13 września 2026
Dependency Injection w C#: z kontenerem czy bez? Iskra tworzenia kodu.

Spis treści

Klasa, która sama tworzy logger, klienta HTTP i repozytorium, szybko staje się trudna do testowania oraz zmiany. Temat c# dependency injection pomaga uporządkować ten problem: pokazuję, jak działa wstrzykiwanie zależności w C#, jak skonfigurować wbudowany kontener .NET, dobrać cykl życia usług i uniknąć pułapek, które wychodzą dopiero na produkcji.

Najważniejsze zasady DI w C# w jednym miejscu

  • Wstrzykuj zależności przez konstruktor, zamiast tworzyć je operatorem new wewnątrz klasy.
  • Rejestruj usługi w Program.cs przy użyciu AddTransient, AddScoped lub AddSingleton.
  • Scoped pasuje zwykle do usług powiązanych z jednym żądaniem HTTP, a Singleton wymaga bezpiecznej obsługi współbieżności.
  • Interfejs ułatwia testowanie, ale nie każda klasa potrzebuje osobnego abstrakcyjnego interfejsu.
  • Service locator i zbyt wiele zależności często oznaczają problem z projektem klasy, a nie brak kolejnej rejestracji.

Na czym polega wstrzykiwanie zależności w C#

Zależność to obiekt, którego inna klasa potrzebuje do wykonania swojej pracy. Serwis zamawiający płatność może korzystać z klienta API, repozytorium i loggera. W podejściu bez DI tworzy je sam, przez co zostaje mocno związany z konkretnymi implementacjami.

public class OrderService
{
    private readonly PaymentClient _paymentClient;

    public OrderService()
    {
        _paymentClient = new PaymentClient();
    }
}

Ten kod działa, ale test jednostkowy musi korzystać z prawdziwego klienta albo z trudnych obejść. Gdy przestawiamy się na odwrócenie zależności, klasa otrzymuje gotowy obiekt z zewnątrz i nie musi wiedzieć, jak go utworzyć.

public interface IPaymentClient
{
    Task ChargeAsync(decimal amount, CancellationToken cancellationToken);
}

public class OrderService(IPaymentClient paymentClient)
{
    public Task PayAsync(
        decimal amount,
        CancellationToken cancellationToken)
    {
        return paymentClient.ChargeAsync(amount, cancellationToken);
    }
}

Najczęściej używam tu constructor injection, czyli wstrzykiwania przez konstruktor. Zależności są wtedy widoczne od razu, obiekt nie może powstać w niepełnym stanie, a test otrzymuje prostą możliwość podstawienia atrap lub mocków.

Sam kontener DI nie jest architekturą aplikacji. To mechanizm, który buduje obiekty i dostarcza ich zależności. Dobra architektura zaczyna się wcześniej, od jasnego podziału odpowiedzialności i sensownych granic między modułami.

Jak skonfigurować DI w praktycznym przykładzie

W aplikacji ASP.NET Core usługi rejestruje się zwykle w pliku Program.cs. Wbudowany kontener korzysta z interfejsu IServiceCollection, a później rozwiązuje pełny graf obiektów, czyli wszystkie zależności potrzebne do utworzenia żądanej klasy.

Załóżmy, że aplikacja pobiera dane kursów z repozytorium. Najpierw definiuję kontrakt i implementację.

public interface ICourseRepository
{
    Task GetAsync(
        int id,
        CancellationToken cancellationToken);
}

public sealed class SqlCourseRepository(
    AppDbContext dbContext) : ICourseRepository
{
    public Task GetAsync(
        int id,
        CancellationToken cancellationToken)
    {
        return dbContext.Courses
            .SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
    }
}
Serwis aplikacyjny zależy już tylko od interfejsu. Dzięki temu nie zna Entity Framework Core ani szczegółów bazy danych.
public sealed class CourseService(
    ICourseRepository repository,
    ILogger logger)
{
    public async Task GetAsync(
        int id,
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Pobieram kurs {CourseId}", id);

        return await repository.GetAsync(id, cancellationToken);
    }
}

Rejestracja może wyglądać tak:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext();
builder.Services.AddScoped();
builder.Services.AddScoped();

var app = builder.Build();

app.MapGet("/courses/{id:int}",
    async (
        int id,
        CourseService courseService,
        CancellationToken cancellationToken) =>
    {
        var course = await courseService.GetAsync(id, cancellationToken);

        return course is null
            ? Results.NotFound()
            : Results.Ok(course);
    });

app.Run();

W tym przykładzie framework tworzy CourseService, wykrywa jego dwa parametry konstruktora, a potem dostarcza repozytorium i logger. Jeśli którejś usługi zabraknie, błąd pojawi się przy uruchomieniu lub podczas rozwiązywania zależności, zamiast zostać ukryty w przypadkowym miejscu kodu.

Trzy sposoby rejestracji usług

Najczęściej wystarcza rejestracja typu interfejs plus implementacja. Kontener obsługuje jednak także fabrykę oraz bezpośrednio utworzoną instancję.

Rejestracja Kiedy jej użyć
AddScoped() Gdy implementacja ma być tworzona przez kontener na podstawie konstruktora.
AddSingleton(sp => ...) Gdy utworzenie obiektu wymaga konfiguracji albo zależności pobranej z kontenera.
AddSingleton(cache) Gdy masz już gotową instancję i świadomie chcesz udostępnić ją przez cały czas działania aplikacji.

Fabryka jest użyteczna, gdy trzeba przekazać wartość z konfiguracji, ale nie powinna stać się miejscem ręcznego budowania całej aplikacji. Jeśli w lambdzie pojawia się kilkanaście instrukcji i wiele wywołań GetRequiredService, zwykle lepiej wydzielić osobną fabrykę albo zmienić projekt zależności.

Jak dobrać cykl życia usługi

Najwięcej błędów w DI nie wynika z samej rejestracji, tylko z niewłaściwego czasu życia obiektu. W .NET masz trzy podstawowe opcje: Transient, Scoped i Singleton.

Cykl życia Co oznacza Typowe zastosowanie
Transient Nowa instancja powstaje przy każdym pobraniu usługi. Lekkie, bezstanowe serwisy i formatery.
Scoped Jedna instancja działa w obrębie konkretnego zakresu. Repozytoria i kontekst bazy danych w aplikacji webowej.
Singleton Jedna instancja żyje tak długo, jak proces aplikacji. Bezstanowa konfiguracja, cache lub współdzielony klient, jeśli jest bezpieczny współbieżnie.

Transient sprawdza się przy prostych usługach

Serwis zarejestrowany jako transient nie przechowuje stanu między wywołaniami. To dobry wybór dla walidatora, mappera albo kalkulatora, szczególnie gdy utworzenie obiektu jest tanie i nie ma powodu, aby współdzielić instancję.

builder.Services.AddTransient();

Nie oznacza to jednak, że transient jest zawsze bezpiecznym ustawieniem domyślnym. Jeśli usługa otwiera kosztowne zasoby albo ma stan, tworzenie jej przy każdym pobraniu może zwiększyć zużycie pamięci i obciążenie aplikacji.

Scoped pasuje do żądania HTTP

W aplikacji webowej zakres scoped jest zwykle związany z jednym żądaniem. Dlatego DbContext i repozytoria często rejestruje się właśnie w ten sposób. Wszystkie komponenty obsługujące dane żądanie mogą wtedy pracować na tej samej instancji kontekstu.

Trzeba pamiętać o różnicy między aplikacją webową a zadaniem działającym w tle. Worker nie ma automatycznie zakresu żądania, więc przed użyciem usługi scoped powinien utworzyć własny zakres przez IServiceScopeFactory.

Singleton wymaga ostrożności

Singleton żyje przez cały proces, dlatego nie powinien zależeć bezpośrednio od usługi scoped. Taki błąd nazywa się captive dependency. Singleton zatrzymuje wtedy zależność o krótszym życiu na zbyt długo, co może prowadzić do nieaktualnych danych, problemów z wątkami albo wyjątku przy walidacji zakresów.

Singleton musi także poprawnie obsługiwać współbieżność. Kontener gwarantuje bezpieczne tworzenie i rozwiązywanie usług, ale nie sprawia, że własne pola i kolekcje singletonu stają się automatycznie bezpieczne dla wielu wątków.

Jak obsługiwać wiele implementacji i konfigurację

W większej aplikacji jedna abstrakcja może mieć kilka implementacji. Przykładowo system wysyła powiadomienia e-mailem i SMS-em. Można zarejestrować obie klasy pod tym samym interfejsem, a potem wstrzyknąć kolekcję.

builder.Services.AddTransient();
builder.Services.AddTransient();

public sealed class NotificationService(
    IEnumerable senders)
{
    public async Task SendAsync(
        Notification notification,
        CancellationToken cancellationToken)
    {
        foreach (var sender in senders)
        {
            await sender.SendAsync(notification, cancellationToken);
        }
    }
}

To rozwiązanie pasuje do wzorca strategii, gdy wszystkie implementacje mają wykonać podobny etap pracy. Jeżeli trzeba wybrać dokładnie jedną implementację na podstawie kanału, można użyć własnej fabryki lub, w nowszych wersjach .NET, usług kluczowanych.

builder.Services.AddKeyedTransient("stripe");

builder.Services.AddKeyedTransient("paypal");

public sealed class CheckoutService(
    [FromKeyedServices("stripe")] IPaymentProvider provider)
{
    public Task PayAsync(
        Payment payment,
        CancellationToken cancellationToken)
    {
        return provider.PayAsync(payment, cancellationToken);
    }
}

Usługi kluczowane są wygodne, gdy wybór implementacji jest stabilny i czytelny. Przy bardziej dynamicznych regułach wolę jawną fabrykę, ponieważ logika decyzji pozostaje wtedy w jednym miejscu, a konstruktor nie zaczyna pełnić roli konfiguracji biznesowej.

Konfigurację też wstrzykuj przez opcje

Zamiast przekazywać do serwisu całe IConfiguration, lepiej związać konkretną sekcję z klasą opcji. Dzięki temu serwis zależy od typowanej konfiguracji, a nie od nazw tekstowych rozsianych po kodzie.

public sealed class PaymentOptions
{
    public required string BaseUrl { get; init; }
    public int TimeoutSeconds { get; init; } = 10;
}

builder.Services
    .AddOptions()
    .Bind(builder.Configuration.GetSection("Payment"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

public sealed class PaymentClient(
    IOptions options,
    HttpClient httpClient)
{
    private readonly PaymentOptions _options = options.Value;
}

Walidacja przy starcie jest szczególnie cenna w środowisku produkcyjnym. Aplikacja kończy uruchamianie od razu z jasnym błędem, zamiast przyjąć pierwsze żądanie i dopiero wtedy odkryć brakujący adres endpointu.

Najczęstsze błędy przy używaniu DI

Wstrzykiwanie IServiceProvider do każdej klasy

Kod poniżej wygląda elastycznie, ale ukrywa prawdziwe zależności:

public class ReportService(IServiceProvider provider)
{
    public void Generate()
    {
        var exporter = provider.GetRequiredService();
        exporter.Export();
    }
}

To odmiana wzorca service locator. Klasa nie pokazuje w konstruktorze, czego naprawdę potrzebuje, a błąd pojawia się dopiero podczas wykonania konkretnej ścieżki. Zwykle lepiej wstrzyknąć IReportExporter bezpośrednio.

Zbyt wiele zależności w jednym konstruktorze

Konstruktor z ośmioma lub dziesięcioma parametrami nie jest automatycznie błędem, ale często sygnalizuje, że klasa ma zbyt wiele odpowiedzialności. Sam spotykam się z tym najczęściej w kontrolerach, które zaczynają pełnić rolę warstwy aplikacyjnej, walidatora i koordynatora integracji jednocześnie.

Rozwiązaniem nie powinno być mechaniczne pogrupowanie parametrów w jeden obiekt typu Dependencies. To tylko chowa problem. Lepiej rozdzielić scenariusze biznesowe albo wydzielić mniejsze serwisy o jednej wyraźnej odpowiedzialności.

Rejestrowanie wszystkiego jako singleton

Singleton ogranicza liczbę alokacji, ale nie jest uniwersalną optymalizacją. Usługa przechowująca dane użytkownika, wynik zapytania albo zmienny kontekst żądania może wtedy przypadkowo udostępnić stan innej osobie.

Najpierw określam, czy obiekt ma stan, kto jest jego właścicielem i jak długo ten stan powinien istnieć. Dopiero później wybieram cykl życia. Wydajność nie powinna zastępować poprawnego modelu stanu.

Przeczytaj również: YAGNI w .NET - kiedy upraszcza architekturę, a kiedy szkodzi?

Brak testu grafu zależności

Warto uruchamiać walidację zakresów w środowisku deweloperskim i testowym:

var provider = services.BuildServiceProvider(
    new ServiceProviderOptions
    {
        ValidateScopes = true,
        ValidateOnBuild = true
    });

Opcja ValidateOnBuild pomaga wykryć brakujące rejestracje, a ValidateScopes ujawnia część problemów z mieszaniem singletonów i scoped. Nie zastępuje testów integracyjnych, ale skraca drogę od pomyłki w konfiguracji do czytelnego komunikatu.

Jak wdrożyć DI bez nadmiarowej abstrakcji

Nie każda klasa musi mieć interfejs. Dla prostego serwisu używanego w jednym miejscu bezstanowa klasa konkretna może być czytelniejsza niż para IFoo i Foo. Interfejs daje największą wartość wtedy, gdy istnieje realna granica, kilka implementacji albo potrzeba izolacji testu.

W praktyce stosuję prosty proces:

  1. Wypisuję odpowiedzialność klasy i sprawdzam, czy sama tworzy obiekty, których nie powinna znać.
  2. Wydzielam kontrakt tylko dla zależności, którą chcę wymieniać, testować lub ukryć za granicą modułu.
  3. Rejestruję implementację w kompozycji aplikacji, najczęściej w Program.cs albo w metodzie rozszerzającej.
  4. Dobieram cykl życia na podstawie stanu i zasobów, a nie przyzwyczajenia.
  5. Dodaję test jednostkowy, w którym zależność zastępuję prostą atrapą lub mockiem.

Rejestracje związane z jednym modułem dobrze zamknąć w metodzie rozszerzającej:

public static class CourseModule
{
    public static IServiceCollection AddCourseModule(
        this IServiceCollection services)
    {
        services.AddScoped();
        services.AddScoped();

        return services;
    }
}

Wtedy Program.cs zachowuje rolę miejsca składania aplikacji, a szczegóły modułu nie rozlewają się po całym pliku. Nie przesadzałbym jednak z liczbą takich metod. Dla małego projektu kilka jawnych rejestracji jest często bardziej zrozumiałe niż rozbudowana automatyczna rejestracja przez skanowanie assembly.

Co sprawdzić przed wdrożeniem aplikacji

  • Czy każda klasa ma tylko te zależności, których naprawdę używa?
  • Czy usługi scoped nie są pobierane bezpośrednio przez singletony?
  • Czy singletony nie przechowują niezabezpieczonego współdzielonego stanu?
  • Czy zadania w tle tworzą zakres dla usług scoped?
  • Czy konfiguracja jest typowana i walidowana przy starcie?
  • Czy testy sprawdzają rejestrację kluczowych komponentów oraz ich rzeczywisty graf zależności?

Jeżeli odpowiedzi są pozytywne, wbudowany kontener .NET zwykle w zupełności wystarcza. Zewnętrzny kontener może dodać dekoratory, bardziej zaawansowane konwencje lub specjalne scenariusze, ale zwiększa też liczbę pojęć i miejsc, w których można popełnić błąd.

DI, które pomaga zamiast komplikować kod

Najlepsze wdrożenie dependency injection nie polega na tym, aby każdą klasę obudować interfejsem i zarejestrować jako singleton. Chodzi o jawne zależności, kontrolowany cykl życia i łatwe testowanie. Gdy klasa mówi w konstruktorze, czego potrzebuje, a kompozycja aplikacji decyduje, jakie implementacje otrzyma, kod staje się prostszy do rozwijania.

Na początek wystarczy constructor injection, trzy świadomie dobrane cykle życia i rejestracje w Program.cs. Dopiero gdy projekt rzeczywiście tego wymaga, warto dodawać fabryki, usługi kluczowane czy zewnętrzny kontener. Taka kolejność pozwala korzystać z zalet DI bez zamieniania architektury w dodatkową warstwę ceremonii.

FAQ - Najczęstsze pytania

Transient pasuje do lekkich, bezstanowych usług, takich jak walidatory i kalkulatory. Scoped sprawdza się przy repozytoriach oraz DbContext w obrębie jednego żądania HTTP. Singleton jest właściwy dla usług żyjących przez cały proces, ale nie może bezpośrednio zależeć od usługi scoped i musi bezpiecznie obsługiwać współbieżność.

Nie wstrzykuj bezpośrednio usługi scoped do singletonu, ponieważ singleton może przechować zależność o krótszym czasie życia. Włącz ValidateScopes oraz ValidateOnBuild, aby wykrywać część takich błędów i brakujących rejestracji podczas budowania kontenera.

Zarejestruj każdą implementację pod tym samym interfejsem, a następnie wstrzyknij kolekcję IEnumerable. To rozwiązanie pasuje na przykład do wielu nadawców powiadomień. Gdy trzeba wybrać jedną implementację według stabilnego klucza, można użyć usług kluczowanych, a przy bardziej dynamicznych regułach jawnej fabryki.

Klasa opcji, na przykład PaymentOptions, ogranicza zależność serwisu do konkretnej sekcji konfiguracji zamiast rozpraszać tekstowe nazwy po kodzie. Metody Bind, ValidateDataAnnotations i ValidateOnStart pozwalają wykryć brakujące lub niepoprawne wartości już podczas uruchamiania aplikacji.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

wstrzykiwanie zależności
testy jednostkowe
kontenery di
singleton
konfiguracja typowana
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz