• Aplikacje webowe
  • Model MVC w ASP.NET Core - encje, DTO i dobra architektura

Model MVC w ASP.NET Core - encje, DTO i dobra architektura

Radosław Krajewski 9 sierpnia 2026
Schemat mvc model w ASP.NET Core: zielony kontroler, żółty widok, czerwony model.

Spis treści

Gdy aplikacja webowa zaczyna obsługiwać logowanie, zamówienia albo płatności, szybko okazuje się, że samo rozdzielenie kodu na kontrolery i widoki nie wystarcza. Najwięcej decyzji trafia do modelu, czyli części odpowiedzialnej za dane, reguły biznesowe i bezpieczną współpracę z bazą. Pokażę, czym model w MVC różni się od encji i DTO, jak wygląda przepływ żądania w ASP.NET Core oraz jakie błędy najczęściej prowadzą do trudnego w utrzymaniu kodu.

Model MVC porządkuje dane i reguły, ale nie powinien przejmować całej aplikacji

  • Model opisuje dane oraz reguły, według których aplikacja może je odczytywać i zmieniać.
  • Kontroler koordynuje żądanie, ale nie powinien zawierać całej logiki biznesowej.
  • DTO i ViewModel chronią model domenowy przed przypadkowymi danymi z formularza lub API.
  • Walidacja musi działać po stronie serwera, nawet gdy formularz sprawdzany jest także w przeglądarce.
  • Dobry model można testować niezależnie od bazy danych, widoku i transportu HTTP.

Model w MVC to coś więcej niż klasa odwzorowująca tabelę

Wzorzec Model-View-Controller dzieli aplikację na trzy odpowiedzialności. Model przechowuje stan i reguły biznesowe, widok prezentuje dane użytkownikowi, a kontroler przyjmuje żądanie i wybiera sposób obsługi. Taki podział ma sens tylko wtedy, gdy każda część faktycznie wykonuje swoją pracę.

Najczęstsze uproszczenie brzmi: model to klasa odpowiadająca tabeli w bazie. Encja, na przykład Order albo Customer, może być częścią modelu, ale sam model jest pojęciem szerszym. Obejmuje również operacje, walidację, obiekty wartości, usługi domenowe i mechanizmy dostępu do danych, zależnie od skali projektu.

W praktyce rozróżniam trzy warstwy, które początkujący często wrzucają do jednego worka:

Element Za co odpowiada Czego nie powinien robić
Model domenowy Reguły biznesowe i spójność danych Znać szczegółów HTML, HTTP lub konkretnego formularza
Encja Obiekt mający tożsamość i zwykle zapisywany w bazie Przyjmować bezpośrednio całego nieufnego wejścia użytkownika
ViewModel lub DTO Dane dopasowane do widoku albo kontraktu API Przejmować reguł, które powinny należeć do domeny

Ta różnica robi się istotna już przy prostym formularzu produktu. Formularz może przyjmować nazwę, cenę i kategorię, ale encja może dodatkowo zawierać identyfikator właściciela, status publikacji oraz datę utworzenia. Nie chcę, aby użytkownik mógł ustawić te pola samym POST-em.

Co powinno znaleźć się w modelu aplikacji webowej

Model powinien pilnować tego, co jest prawdziwe z punktu widzenia biznesu. Jeżeli zamówienie nie może zostać opłacone dwa razy, ta reguła nie powinna zależeć od tego, czy wywołał ją kontroler MVC, zadanie w tle czy endpoint API.

Dobrym przykładem jest metoda zmieniająca status zamówienia:

public class Order
{
    public OrderStatus Status { get; private set; }

    public void MarkAsPaid()
    {
        if (Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("Anulowane zamówienie nie może zostać opłacone.");

        if (Status == OrderStatus.Paid)
            return;

        Status = OrderStatus.Paid;
    }
}

Reguła znajduje się przy danych, których dotyczy. Kontroler nie musi wiedzieć, jakie przejścia między statusami są dozwolone, a test tej zasady nie wymaga uruchamiania serwera ani tworzenia widoku.

Do modelu mogą należeć także obiekty wartości. Przykładowo adres e-mail, kwota albo numer NIP nie zawsze powinny być zwykłym napisem. Obiekt wartości grupuje dane i pilnuje ich poprawności, dzięki czemu nie trzeba powtarzać tej samej logiki w kilku kontrolerach.

Nie przesadzam jednak z rozbudową. Dla prostego panelu administracyjnego klasa z właściwościami, walidacją i usługą aplikacyjną może być wystarczająca. Model nie musi być skomplikowany, żeby był dobrze zaprojektowany. Ważne, aby najistotniejsze reguły nie były ukryte w widoku albo przypadkowym fragmencie kontrolera.

Jak dane przechodzą przez MVC od żądania do odpowiedzi

W aplikacji ASP.NET Core żądanie trafia do kontrolera dzięki routingowi. Potem mechanizm model binding odczytuje wartości z adresu URL, parametrów zapytania, formularza lub treści żądania i próbuje utworzyć obiekt przekazywany do metody akcji.

Przepływ można uprościć do kilku kroków:

  1. Użytkownik wysyła żądanie HTTP.
  2. Routing wybiera kontroler i akcję.
  3. Model binding tworzy obiekt wejściowy.
  4. Walidacja sprawdza format i podstawowe reguły.
  5. Kontroler wywołuje model lub usługę aplikacyjną.
  6. Wynik trafia do widoku, przekierowania albo odpowiedzi JSON.

Przykładowy model wejściowy może wyglądać tak:

public class CreateProductInput
{
    [Required]
    [StringLength(120)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 100000)]
    public decimal Price { get; set; }
}

Kontroler korzysta wtedy z gotowego obiektu i sprawdza stan modelu:

[HttpPost]
public IActionResult Create(CreateProductInput input)
{
    if (!ModelState.IsValid)
        return View(input);

    _productService.Create(input.Name, input.Price);

    return RedirectToAction(nameof(Index));
}

Model binding nie oznacza walidacji biznesowej. Sprawdzenie, czy cena jest większa od zera, może należeć do walidacji wejścia. Decyzja, czy produkt o danej cenie może zostać opublikowany w konkretnej kategorii, powinna już trafić do logiki aplikacji lub domeny.

To rozróżnienie ma praktyczne znaczenie. Błąd konwersji, na przykład tekst „abc” w polu liczbowym, pojawia się podczas bindowania. Naruszenie reguły, takiej jak zakres ceny albo brak wymaganej nazwy, jest efektem walidacji. W obu przypadkach aplikacja powinna zareagować po stronie serwera.

Encja, DTO i ViewModel nie są tym samym obiektem

W małym przykładzie można użyć jednej klasy do formularza, bazy danych i widoku. Na początku wygląda to wygodnie, ale z czasem każda zmiana zaczyna wpływać na kilka miejsc. Najbezpieczniej rozdzielić obiekt zapisu od obiektu wejściowego, gdy aplikacja ma więcej niż jeden scenariusz użycia.

Encja bazy danych

Encja opisuje obiekt przechowywany w bazie i zwykle ma własną tożsamość. Może zawierać pola techniczne, relacje oraz informacje, których użytkownik nie powinien zmieniać.

DTO dla API

DTO, czyli Data Transfer Object, opisuje dane przesyłane między procesami lub warstwami. W API warto mieć osobne typy dla żądania i odpowiedzi, ponieważ klient nie powinien dostawać automatycznie wszystkich kolumn z bazy.

ViewModel dla widoku

ViewModel jest skrojony pod konkretny ekran. Może łączyć dane produktu, listę kategorii i komunikat dla użytkownika. Nie musi odwzorowywać żadnej tabeli, bo jego celem jest wygodne i bezpieczne renderowanie widoku.

Najgroźniejszy błąd w tym miejscu to tak zwany overposting. Jeżeli akcja przyjmuje encję z polami IsAdmin, OwnerId albo IsPublished, klient może spróbować przesłać własne wartości tych właściwości. Osobny model wejściowy ogranicza zakres danych, które w ogóle mogą trafić do operacji.

Gdzie umieścić dostęp do bazy i logikę biznesową

Kontroler powinien być krótki, ale krótkość sama w sobie nie jest celem. Chodzi o to, aby kontroler koordynował scenariusz, a nie implementował zasady biznesowe, zapytania SQL i formatowanie odpowiedzi w jednej metodzie.

W prostym projekcie kontroler może korzystać bezpośrednio z DbContext. Przy większej liczbie przypadków użycia wygodniej wprowadzić usługę aplikacyjną, która pobiera dane, wywołuje reguły domeny i zapisuje wynik. Nie tworzę automatycznie repozytorium do każdej tabeli, bo dodatkowa warstwa bez realnej odpowiedzialności często tylko zaciemnia kod.

Przykładowy podział może wyglądać tak:

  • Kontroler przyjmuje żądanie i wybiera odpowiedź.
  • Usługa aplikacyjna realizuje konkretny przypadek użycia.
  • Model domenowy pilnuje reguł i spójności.
  • Warstwa dostępu do danych odczytuje i zapisuje informacje.

Taki układ nie jest obowiązkowym dogmatem. Dla kilku ekranów CRUD kontroler, który korzysta z kontekstu bazy, może być całkowicie wystarczający. Gdy jednak pojawiają się płatności, uprawnienia, integracje i zadania asynchroniczne, rozdzielenie odpowiedzialności szybko zaczyna oszczędzać czas.

Błędy, które osłabiają model MVC

Kontroler jako centrum całej aplikacji

Jeżeli metoda akcji ma kilkadziesiąt linii, buduje zapytania, sprawdza statusy, liczy rabaty i wysyła e-mail, model został prawdopodobnie pominięty. Przeniesienie kodu do przypadkowej klasy pomocniczej niewiele zmienia. Trzeba nazwać odpowiedzialność, na przykład CheckoutService albo OrderPaymentPolicy.

Logika biznesowa w widoku

Warunek wpływający wyłącznie na prezentację, taki jak wybór klasy CSS, może zostać w Razorze. Reguła mówiąca, czy użytkownik może anulować zamówienie, nie powinna być zaszyta w instrukcji if w HTML. Widok ma pokazywać wynik decyzji, a nie podejmować tę decyzję.

Jedna klasa do wszystkiego

Wspólna klasa dla encji, formularza i odpowiedzi API szybko puchnie od atrybutów oraz wyjątków. Zmiana nazwy pola w bazie zaczyna wtedy wpływać na kontrakt publiczny i interfejs użytkownika. Rozdzielenie typów jest szczególnie ważne przy publicznych API, które muszą zachowywać kompatybilność.

Walidacja tylko w przeglądarce

Walidacja JavaScript poprawia wygodę, ale nie jest zabezpieczeniem. Żądanie HTTP można wysłać ręcznie, ominąć formularz albo zmienić jego pola. Serwer musi powtórzyć wszystkie istotne kontrole i nie może ufać danym tylko dlatego, że pochodziły z własnego interfejsu.

Przeczytaj również: var w JavaScripcie - zakres, hoisting i pułapki starego kodu

Brak testów reguł

Najcenniejsze testy modelu nie sprawdzają, czy kontroler zwrócił konkretny widok. Sprawdzają, czy przypadki biznesowe działają poprawnie: anulowanie opłaconego zamówienia jest odrzucone, rabat nie schodzi poniżej ustalonego minimum, a nieaktywny produkt nie pojawia się w sprzedaży.

Jak zaprojektować model, który nie będzie przeszkadzał w rozwoju

Zaczynam od przypadków użycia, a nie od folderów. Pytam, jakie decyzje aplikacja musi podejmować i które dane są do nich potrzebne. Dopiero potem wybieram encje, DTO, usługi i sposób zapisu.

Pomaga mi kilka prostych zasad:

  • Na wejściu przyjmuj tylko pola, które użytkownik rzeczywiście może zmienić.
  • Reguły biznesowe umieszczaj blisko danych, których dotyczą.
  • Nie uzależniaj modelu domenowego od Razor, kontrolera ani konkretnego mechanizmu HTTP.
  • Oddziel walidację formatu od decyzji biznesowych.
  • Testuj najważniejsze reguły bez uruchamiania bazy i serwera.
  • Dodawaj kolejne warstwy dopiero wtedy, gdy rozwiązują realny problem.

Dobry sygnał ostrzegawczy pojawia się wtedy, gdy zmiana jednego procesu wymaga edycji kontrolera, widoku, kilku modeli i przypadkowego kodu w JavaScript. Zwykle oznacza to, że odpowiedzialności zostały wymieszane albo jeden obiekt reprezentuje zbyt wiele różnych rzeczy.

Model MVC nie ma za zadanie stworzyć większej liczby plików. Ma sprawić, aby aplikacja była przewidywalna: dane mają swoje miejsce, reguły można znaleźć, a widok nie zna szczegółów zapisu. To szczególnie dobrze działa w aplikacjach .NET, gdzie model binding, walidacja i wstrzykiwanie zależności wspierają taki podział od strony frameworka.

Najlepszy model to ten, który jasno chroni reguły aplikacji

Model w MVC jest warstwą odpowiedzialną za dane i zachowanie aplikacji, a nie tylko nazwą folderu obok kontrolerów i widoków. Gdy oddzielisz encję od danych wejściowych, przeniesiesz reguły biznesowe poza kontroler i zostawisz widokowi prezentację, kod staje się łatwiejszy do testowania oraz rozwijania.

Nie warto kopiować rozbudowanej architektury do małego projektu. Warto natomiast od początku pilnować jednej granicy: użytkownik może przesłać dane, ale tylko model powinien rozstrzygać, co aplikacja może z nimi zrobić.

FAQ - Najczęstsze pytania

Model domenowy zawiera reguły biznesowe i dba o spójność danych. Encja ma tożsamość i zwykle jest zapisywana w bazie, DTO przenosi dane między warstwami lub przez API, a ViewModel jest dopasowany do konkretnego widoku. Rozdzielenie tych typów ogranicza ryzyko przypadkowej zmiany pól technicznych przez użytkownika.

Żądanie przechodzi przez routing, który wybiera kontroler i akcję. Model binding tworzy obiekt wejściowy na podstawie adresu URL, parametrów, formularza lub treści żądania, a walidacja sprawdza jego poprawność. Następnie kontroler wywołuje model lub usługę aplikacyjną, a wynik trafia do widoku, przekierowania albo odpowiedzi JSON.

Nie należy przyjmować bezpośrednio encji zawierającej pola takie jak IsAdmin, OwnerId czy IsPublished. Lepiej użyć osobnego modelu wejściowego, który zawiera wyłącznie właściwości możliwe do zmiany w danym scenariuszu, a następnie jawnie przekazać dane do operacji domenowej lub usługi.

Kontroler powinien koordynować żądanie i wybierać odpowiedź, natomiast reguły biznesowe powinny znajdować się w modelu domenowym lub odpowiedniej usłudze. W prostym projekcie kontroler może korzystać bezpośrednio z DbContext. Przy płatnościach, uprawnieniach, integracjach i zadaniach asynchronicznych przydatna jest usługa aplikacyjna, ale repozytorium nie powinno być tworzone automatycznie dla każdej tabeli.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

mvc
asp.net core
dto
model binding
overposting
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