Dependency Injection w ASP.NET Core od zera

Jak działa Dependency Injection w ASP.NET Core MVC? Serwis, interfejs, rejestracja w Startup i constructor injection — wszystko na jednym prostym przykładzie.

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

To piąty odcinek kursu ASP .NET Core dla początkujących. Wcześniej klepaliśmy kontrolery, widoki i model Product. Teraz czas na rzecz, która w .NET jest chlebem powszednim — Dependency Injection. Zrobimy serwis, wyciągniemy logikę z kontrolera i pokażę ci, dlaczego zamiana jednej implementacji na drugą nie powinna być wycieczką po całym repo.

Czemu nie trzymać logiki w kontrolerach

Wyobraź sobie, że w metodzie Save w WarehouseController zaczynasz pisać kod, który łączy się z bazą, zapisuje produkt, robi walidację, woła zewnętrzne API i loguje zdarzenia. Dziesięć linijek. Potem dwadzieścia. A to dopiero jedna akcja. Za chwilę takich akcji w kontrolerze masz kilkanaście.

Kontroler spuchnie tak, że zmiana czegokolwiek stanie się piekłem. A najgorsze — tej logiki nie ruszysz z tego miejsca, bo jest wbita w kontroler na amen.

Dlatego w ASP.NET Core (i szerzej w .NET) od lat obowiązuje prosta zasada: kontroler jest cienki. Jego zadaniem jest odebrać request, zawołać coś, co wie co robić, i zwrócić wynik. Za „wie co robić" odpowiadają serwisy — warstwa pośrednicząca, do której przenosimy całą logikę biznesową.

W profesjonalnych aplikacjach idzie się dalej i stosuje np. wzorzec CQRS, ale to już wyższa szkoła jazdy. Na tym etapie kursu wystarczą zwykłe serwisy — i tak je spotkasz w 90% projektów, które będziesz otwierał na co dzień.

Serwisy i interfejsy

W projekcie zakładam dwa foldery: Services (na implementacje) i Interfaces (na kontrakty). Rozdzielenie ich porządkuje rozwiązanie — od razu widać, co jest definicją, a co realizacją.

Zaczynam od interfejsu. Nazwy interfejsów w .NET poprzedzamy literą I:

public interface IWarehouseService
{
    int Save(Product product);
}

Jedna metoda: Save, która przyjmuje Product (nasz model z poprzednich lekcji) i zwraca int — identyfikator zapisanego rekordu. Nic więcej na tym etapie nie jest potrzebne.

Teraz implementacja:

public class WarehouseService : IWarehouseService
{
    public int Save(Product product)
    {
        // TODO: tu trafi logika zapisu do bazy — odcinek o EF Core
        return 1;
    }
}

Na razie metoda wraca sztywną jedynkę — prawdziwy zapis do bazy zrobimy w kolejnej lekcji, kiedy dotkniemy Entity Framework Core i migracji. Ważne, że typ zwracany i sygnatura są już finalne — kontroler nie musi wiedzieć, że pod spodem siedzi stub.

Dlaczego nie new WarehouseService() w kontrolerze

Pokusa jest oczywista. Stoisz w kontrolerze, potrzebujesz serwisu, więc:

var service = new WarehouseService();
var id = service.Save(product);

Zadziała. Ale jest to słaby wzorzec. Załóżmy, że za pół roku okazuje się, że WarehouseService nie spełnia już nowych wymagań i chcesz użyć NewWarehouseService, który implementuje ten sam IWarehouseService. W modelu „ręcznego new" musisz znaleźć każde miejsce w aplikacji, gdzie powołujesz do życia starą klasę, i podmienić je na nową.

Gdyby nie DI, musiałbyś znajdować tę zmianę w tysiącu miejsc. Z DI zmieniasz jedną linijkę w Startup i cały system korzysta z nowej logiki.

Dlatego w ASP.NET Core masz wbudowany kontener DI. On trzyma mapę „interfejs → aktualna implementacja" i sam wstrzykuje właściwy obiekt tam, gdzie go poprosisz.

Rejestracja w kontenerze DI

Rejestracją zajmujemy się w klasie Startup, w metodzie ConfigureServices. To miejsce, w którym mówisz kontenerowi: „gdy ktoś poprosi o IWarehouseService, daj mu WarehouseService".

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();
    services.AddScoped<IWarehouseService, WarehouseService>();
}

AddScoped to jeden z trzech cykli życia obiektów w kontenerze:

  • AddTransient — nowa instancja przy każdym pobraniu z kontenera.
  • AddScoped — jedna instancja na request HTTP (czyli przez cały jeden „strzał" od przeglądarki).
  • AddSingleton — jedna instancja na całą aplikację.

Dla serwisów aplikacyjnych, które dotykają bazy danych, Scoped jest domyślnym wyborem — idzie ręka w rękę z DbContext z Entity Frameworka, który też jest rejestrowany jako scoped.

Od tej chwili cały system wie, że IWarehouseService = WarehouseService. Chcesz przełączyć na nowszą wersję? Zmieniasz jedną linijkę:

services.AddScoped<IWarehouseService, NewWarehouseService>();

I tyle. Tysiąc miejsc używających tego serwisu łapie nową implementację bez żadnej zmiany w ich kodzie. To jest właśnie ten moment, w którym DI zaczyna robić robotę.

Wstrzykiwanie przez konstruktor

Skoro kontener wie, co komu dać, to kontroler musi jeszcze poprosić o zależność. Robimy to przez constructor injection — czyli deklarujemy parametr w konstruktorze i przepisujemy go do prywatnego pola readonly.

public class WarehouseController : Controller
{
    private readonly IWarehouseService _warehouseService;

    public WarehouseController(IWarehouseService warehouseService)
    {
        _warehouseService = warehouseService;
    }

    [HttpPost]
    public IActionResult Save(Product product)
    {
        var id = _warehouseService.Save(product);

        TempData["ProductId"] = id;
        return RedirectToAction("Index");
    }
}

Zwróć uwagę na kilka rzeczy:

  1. Parametr konstruktora to interfejs, nie konkretna klasa. Kontroler nie ma pojęcia, co naprawdę siedzi pod spodem — i to jest zaleta, nie wada.
  2. Pole jest readonly — dzięki temu nikt przypadkiem nie podmieni referencji w środku akcji.
  3. Zero new. Nigdzie w kontrolerze nie wołasz konstruktora serwisu. To zadanie kontenera.

Kontener sam rozpozna, że WarehouseController potrzebuje IWarehouseService, wyciągnie z rejestracji odpowiednią implementację i wstrzyknie ją przy każdym requeście.

TempData, bo ViewBag gubi wartość po redirect

Mała dygresja z lekcji — po Save robię RedirectToAction, żeby uniknąć klasycznego problemu z odświeżeniem strony i ponownym POSTem (pattern PRG — Post/Redirect/Get). Problem polega na tym, że ViewBag i ViewData żyją tylko w obrębie jednego requestu — po redirect wartość znika.

Rozwiązaniem jest TempData, które trzyma dane w zakresie co najmniej dwóch requestów (do pierwszego odczytu). Dlatego id zapisanego produktu pakuję do TempData["ProductId"], a w akcji Index po prostu go odczytuję:

public IActionResult Index()
{
    ViewBag.ProductId = TempData["ProductId"];
    return View();
}

Bez TempData po redirect dostałbyś pustkę — i to jest częsta pułapka, o którą potykają się osoby zaczynające z MVC.

Podmiana implementacji w praktyce

Dla pokazania, po co to wszystko, w lekcji tworzymy drugi serwis — NewWarehouseService — który dziedziczy po IWarehouseService i zwraca np. 3 zamiast 1:

public class NewWarehouseService : IWarehouseService
{
    public int Save(Product product) => 3;
}

Rejestracja w Startup zmienia się z jednej linijki na drugą:

// services.AddScoped<IWarehouseService, WarehouseService>();
services.AddScoped<IWarehouseService, NewWarehouseService>();

Uruchamiam aplikację, wysyłam formularz — i na widoku pojawia się 3. Nie zmieniłem ani jednej linijki w kontrolerze. Nie zmieniłem niczego w widokach. Cały system, bez względu na to, w ilu miejscach używa IWarehouseService, automatycznie łapie nową logikę.

To jest moment, w którym widać różnicę między kodem, który „działa", a kodem, który da się utrzymać przez lata.

Kod z lekcji

Cały projekt z tej lekcji — z serwisem, interfejsem, rejestracją i kontrolerem — wrzuciłem na brancha Lekcja-5 na GitHubie: github.com/jarmatys/asp-net-core-podstawy/tree/Lekcja-5. Ściągnij, porównaj z własną wersją, sprawdź czy niczego nie zgubiłeś.

Co dalej?

W kolejnym odcinku zabieramy się za Entity Framework Core — czyli dokładamy realną bazę danych, tworzymy kontekst, robimy pierwszą migrację i wreszcie zapisujemy produkt naprawdę, zamiast zwracać sztywną jedynkę z serwisu. Wtedy DI, które dziś wygląda może trochę teoretycznie, pokaże pełnię swojej mocy — bo DbContext też wstrzykujemy przez konstruktor, dokładnie tak samo jak IWarehouseService.

Na razie zapamiętaj trzy rzeczy:

  1. Logika nie mieszka w kontrolerze — od tego są serwisy.
  2. Kontroler zależy od interfejsu, nie od klasy — dzięki temu da się ją podmienić w jednym miejscu.
  3. Rejestracja w ConfigureServices + konstruktor — to jest cały DI w ASP.NET Core. Nic więcej nie musisz umieć, żeby zacząć pisać produkcyjny kod.

Jak zawsze — jeśli coś nie działa, pytanie w komentarzu albo mail. Do zobaczenia w lekcji szóstej.

Tagi:#course-aspnet#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.