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:
- Użytkownik wysyła żądanie HTTP.
- Routing wybiera kontroler i akcję.
- Model binding tworzy obiekt wejściowy.
- Walidacja sprawdza format i podstawowe reguły.
- Kontroler wywołuje model lub usługę aplikacyjną.
- 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ć.
