Najwięcej problemów z kodem zaczyna się nie wtedy, gdy aplikacja jest mała, lecz wtedy, gdy każda zmiana wymaga dotykania kilku przypadkowych klas. Dobre projektowanie obiektowe pomaga rozdzielić odpowiedzialności, chronić reguły biznesowe i budować architekturę, którą da się rozwijać bez ciągłego gaszenia pożarów. Pokażę, jak stosować te zasady w C# i .NET, gdzie naprawdę pomagają wzorce oraz jakie decyzje najczęściej prowadzą do zbyt skomplikowanego kodu.
Dobre obiekty chronią reguły i ograniczają koszt zmian
- Odpowiedzialność jest ważniejsza niż liczba klas i interfejsów.
- Hermetyzacja powinna chronić stan oraz reguły, a nie tylko ukrywać pola.
- SOLID służy do podejmowania decyzji, nie do mechanicznego tworzenia abstrakcji.
- Kompozycja zwykle daje większą elastyczność niż rozbudowane dziedziczenie.
- Dependency injection oddziela logikę aplikacji od konkretnych narzędzi i ułatwia testowanie.
Dobre obiekty zaczynają się od odpowiedzialności
Obiekt nie powinien być tylko pojemnikiem na dane z zestawem getterów i setterów. Jego zadaniem jest pilnowanie określonego fragmentu wiedzy oraz wykonywanie operacji, które naturalnie do niego należą. Klasa BankAccount może na przykład kontrolować wypłatę środków, zamiast pozwalać dowolnemu kodowi zmieniać saldo.
W praktyce zaczynam od pytania, co może się zmieniać niezależnie. Jeżeli sposób naliczania rabatu zmienia się częściej niż zapis zamówienia, nie powinny one być odpowiedzialnością tej samej klasy. Taki podział nie jest akademickim ćwiczeniem. Dzięki niemu zmiana reguły biznesowej nie wymusza przebudowy całego procesu.
Cztery filary to punkt wyjścia, nie gotowy projekt
- Abstrakcja pokazuje tylko te cechy obiektu, które są potrzebne w danym problemie.
- Hermetyzacja ukrywa stan i pozwala zmieniać go przez kontrolowane operacje.
- Dziedziczenie umożliwia budowanie typu na bazie innego typu, ale powinno odzwierciedlać prawdziwą relację „jest rodzajem”.
- Polimorfizm pozwala korzystać z różnych implementacji przez wspólny kontrakt.
Najczęstszy błąd polega na traktowaniu tych filarów jak celu samego w sobie. Można stworzyć dziesiątki klas, interfejsów i klas bazowych, a mimo to otrzymać system trudny w utrzymaniu. Liczy się przede wszystkim spójność odpowiedzialności oraz jasna granica między tym, co obiekt wie, a tym, co robi jego otoczenie.
public sealed class Order
{
private readonly List _lines = new();
public IReadOnlyCollection Lines => _lines;
public void AddProduct(Product product, int quantity)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
_lines.Add(new OrderLine(product, quantity));
}
} W tym przykładzie lista nie jest publicznie modyfikowalna, a zamówienie samo pilnuje podstawowej reguły ilości. To niewielki fragment kodu, ale pokazuje ważną praktykę: stan powinien być zmieniany tam, gdzie znajdują się reguły jego zmiany.
SOLID pomaga podejmować małe decyzje
Zasady SOLIDnie są checklistą, którą trzeba odhaczyć przed wysłaniem pull requesta. Traktuję je raczej jako zestaw pytań pomagających ocenić, czy klasa ma właściwy rozmiar, czy zależności są rozsądne i czy zmiana jednego wymagania nie uruchomi lawiny modyfikacji.
| Zasada | Praktyczne pytanie | Typowy sygnał ostrzegawczy |
|---|---|---|
| Single Responsibility | Czy klasa ma jeden powód do zmiany? | Jedna klasa zapisuje dane, wysyła e-maile i liczy ceny. |
| Open/Closed | Czy nowy wariant wymaga dopisywania warunków w wielu miejscach? | Rozrastający się łańcuch if lub switch. |
| Liskov Substitution | Czy typ pochodny naprawdę może zastąpić bazowy? | Metody dziedziczone rzucają wyjątek „nieobsługiwane”. |
| Interface Segregation | Czy implementacja potrzebuje wszystkich metod interfejsu? | Klasy implementujące puste lub sztuczne metody. |
| Dependency Inversion | Czy logika zależy od kontraktu, a nie od szczegółu technicznego? | Bezpośrednie tworzenie klienta HTTP lub repozytorium w serwisie. |
Nie każda klasa musi mieć interfejs, a nie każdy warunek oznacza złamanie zasady Open/Closed. Nadmiar abstrakcji także ma koszt: zwiększa liczbę plików, utrudnia nawigację i może ukryć prostą logikę za kilkoma poziomami pośredników.
Dobrym przykładem jest obsługa metod płatności. Zamiast umieszczać wszystkie warianty w jednym serwisie, można zdefiniować kontrakt i osobne strategie:
public interface IPaymentMethod
{
Task PayAsync(Money amount, CancellationToken cancellationToken);
}
public sealed class CardPayment : IPaymentMethod
{
public Task PayAsync(
Money amount,
CancellationToken cancellationToken)
{
// Obsługa płatności kartą
return Task.CompletedTask;
}
}Nowa metoda płatności nie musi przebudowywać kodu zamówienia. To właśnie praktyczna wartość polimorfizmu. Trzeba jednak uważać, by interfejs nie był tworzony wyłącznie „na wszelki wypadek”. Najlepszy moment na jego wydzielenie pojawia się wtedy, gdy istnieje realna zmienność albo potrzeba niezależnego testowania.
Wzorce mają sens dopiero wtedy, gdy rozwiązują problem
Wzorzec projektowy nie jest gotowym kawałkiem kodu do wklejenia. To sprawdzony sposób organizowania współpracy między obiektami. W .NET często wystarczy prosty wariant wzorca, bez budowania całej hierarchii klas znanej z podręcznikowych przykładów.
| Wzorzec | Kiedy pomaga | Na co uważać |
|---|---|---|
| Strategy | Gdy algorytm ma kilka wymiennych wariantów, na przykład naliczanie rabatu. | Nie twórz osobnych strategii dla jednego prostego warunku. |
| Factory | Gdy tworzenie obiektu wymaga decyzji lub kilku kroków. | Prosta fabryka nie powinna ukrywać banalnego konstruktora. |
| Decorator | Gdy chcesz dodać logowanie, cache albo pomiar czasu bez zmiany głównej klasy. | Zbyt wiele dekoratorów utrudnia śledzenie przepływu. |
| Adapter | Gdy zewnętrzne API ma kontrakt niepasujący do modelu aplikacji. | Nie przenoś typów dostawcy przez całą domenę. |
| Repository | Gdy chcesz oddzielić model domenowy od sposobu przechowywania danych. | Nie twórz repozytorium jako pustej warstwy nad każdym zapytaniem ORM. |
Najbardziej użyteczna zasada brzmi prosto: najpierw nazwij problem, dopiero potem wzorzec. Jeżeli klasa ma za dużo powodów do zmiany, potrzebujesz rozdzielenia odpowiedzialności. Jeżeli warianty algorytmu rosną, przyda się strategia. Jeżeli kod zależy od biblioteki zewnętrznej, adapter może ochronić resztę systemu.
Kompozycja zwykle wygrywa z dziedziczeniem
Dziedziczenie jest naturalne w modelowaniu stabilnych relacji, ale szybko tworzy sztywną hierarchię. Klasa bazowa zaczyna gromadzić zachowania wspólne dla pozornie podobnych typów, a każda zmiana dotyka wielu klas pochodnych.
| Dziedziczenie | Kompozycja |
|---|---|
| Opisuje relację typu „jest rodzajem”. | Opisuje relację typu „ma” albo „korzysta z”. |
| Wspólne zachowanie znajduje się w klasie bazowej. | Zachowanie jest dostarczane przez współpracujące obiekty. |
| Dobrze działa przy stabilnej hierarchii. | Łatwiej podmienić element bez zmiany całego typu. |
W kodzie biznesowym częściej wybieram kompozycję. Zamówienie może korzystać z kalkulatora dostawy, polityki rabatowej i modułu płatności, zamiast dziedziczyć po rozbudowanej klasie bazowej. Taki układ daje mniejszą zależność między zmianami, choć wymaga rozsądnego nazwania kontraktów.
Granice obiektów wyznaczają architekturę .NET
Na poziomie pojedynczych klas można stosować hermetyzację i SOLID, ale prawdziwa wartość pojawia się dopiero wtedy, gdy podobne granice widać w całej aplikacji. W typowym rozwiązaniu .NET logika domenowa nie powinna wiedzieć, czy dane są przechowywane w SQL Serverze, Cosmos DB czy pliku.
Warstwa domeny chroni reguły
Domena zawiera pojęcia i zasady biznesowe, które powinny działać niezależnie od interfejsu użytkownika oraz infrastruktury. Mogą to być encje, obiekty wartości i usługi domenowe. Obiekt wartości, taki jak adres lub kwota, nie ma własnej tożsamości, ale pilnuje poprawności swoich danych.
Unikam modelu anemicznego, w którym encje mają wyłącznie publiczne właściwości, a cała logika znajduje się w serwisach. Taki kod na początku wydaje się prosty, lecz z czasem reguły rozlewają się po kontrolerach, handlerach i zadaniach w tle.
Warstwa aplikacji koordynuje przypadki użycia
Warstwa aplikacjiodpowiada za przebieg operacji, na przykład utworzenie zamówienia albo anulowanie subskrypcji. Nie powinna znać szczegółów połączenia z bazą ani formatowania odpowiedzi HTTP. Jej rolą jest orkiestrowanie współpracy między domeną a portami dostępu do świata zewnętrznego.
W praktyce dobrze sprawdzają się krótkie handlery lub serwisy przypadków użycia. Jeśli jeden z nich zaczyna walidować formularz, liczyć reguły biznesowe, wykonywać kilka zapytań i publikować zdarzenia, to znak, że granice zostały źle ustawione.
Infrastruktura realizuje szczegóły
Repozytoria, klienci zewnętrznych usług, implementacje logowania i mechanizmy kolejkowania należą do infrastruktury. Kod aplikacyjny korzysta z kontraktów, a konkretne implementacje są podawane przez wstrzykiwanie zależności.
builder.Services.AddScoped();
builder.Services.AddScoped(); Wbudowany kontener DI w .NET dobrze wystarcza dla większości aplikacji. Bezpośrednie tworzenie zależności wewnątrz klasy wiąże ją z konkretną implementacją i utrudnia testy. Z drugiej strony nie warto wstrzykiwać wszystkiego. Zależność powinna być abstrakcją wtedy, gdy jej podmiana ma znaczenie, a nie tylko dlatego, że interfejs wygląda architektonicznie.
Ten podział jest bliski architekturze heksagonalnej i Clean Architecture, ale nazwy są mniej istotne niż kierunek zależności. Najważniejsze, by szczegóły techniczne wskazywały do środka, a reguły biznesowe nie były zakładnikiem frameworka.
Jak przejść od wymagań do kodu bez nadmiarowej komplikacji
Projektowanie zaczynam od scenariusza, a nie od tabeli w bazie danych. Pytam, kto wykonuje operację, jakie reguły muszą zostać spełnione i co powinno się wydarzyć po jej zakończeniu. Dopiero później dobieram klasy, interfejsy i sposób przechowywania danych.
- Spisz przypadek użycia jednym zdaniem, na przykład „klient anuluje opłaconą rezerwację”.
- Wypisz reguły, które nie mogą zostać złamane.
- Znajdź właściciela reguły, czyli obiekt, który ma dane potrzebne do jej sprawdzenia.
- Oddziel zmienność, taką jak sposób płatności, dostawy albo naliczania rabatu.
- Ustal granice zewnętrzne dla bazy, API, kolejki i zegara systemowego.
- Sprawdź kod testem, zanim dodasz kolejną abstrakcję.
Test jest tu świetnym narzędziem projektowym. Jeżeli test wymaga uruchomienia bazy danych, kilku kontenerów i dużego obiektu konfiguracyjnego, prawdopodobnie badana klasa wie za dużo. Z kolei test domeny, który tworzy obiekt i sprawdza jedną regułę, często pokazuje, że granica jest dobrze ustawiona.
Przeczytaj również: Data fabric w praktyce - warstwy, wzorce i wdrożenie w Azure
Typowe pułapki w projektach obiektowych
- Klasa Bóg robi wszystko, bo przez długi czas dopisywano do niej kolejne funkcje.
- Interfejs na każdą klasę zwiększa rozmiar rozwiązania bez realnej korzyści.
- Publiczne settery pozwalają utworzyć stan, który nigdy nie powinien istnieć.
- Dziedziczenie dla ponownego użycia kodu tworzy zależności, których nie ma w domenie.
- Repozytorium nad ORM powtarza API biblioteki i nie daje dodatkowej ochrony.
- Wzorzec użyty z przyzwyczajenia może być trudniejszy w utrzymaniu niż prosty warunek.
Nie próbuję też rozdzielać systemu na mikrousługi tylko dlatego, że aplikacja ma wiele klas. Dobrze zaprojektowany monolit z wyraźnymi modułami bywa łatwiejszy do rozwijania, wdrażania i monitorowania. Architektura powinna wynikać z granic biznesowych oraz potrzeb operacyjnych, a nie z mody na konkretny diagram.
Najlepszy pierwszy krok to jedna dobrze chroniona reguła
Nie trzeba przebudowywać całej aplikacji, aby poprawić jej projekt. Wybierz jeden często zmieniany fragment, na przykład naliczanie ceny albo obsługę płatności, i sprawdź, gdzie naprawdę znajduje się jego reguła. Następnie zamknij stan, oddziel szczegół techniczny od kontraktu i napisz test opisujący oczekiwane zachowanie.
Takie małe usprawnienia szybko pokazują, czy obrana granica ma sens. Gdy kod staje się łatwiejszy do nazwania, testowania i zmiany, architektura zaczyna pracować na swoją wartość. Dojrzałe podejście obiektowe nie polega na mnożeniu elementów, lecz na tym, by każda ważna reguła miała jedno czytelne miejsce i rozsądnych właścicieli.
