SOLID w C# bez przesady - zasady, przykłady i refaktoryzacja

Bruno Krawczyk 3 sierpnia 2026
Młody programista z niebieskimi włosami żongluje symbolami kryptowalut, otoczony ekranami z kodem. To dowód na solidne programowanie.

Spis treści

Gdy jedna klasa zapisuje dane, wysyła e-maile, liczy rabaty i jeszcze zna szczegóły bazy danych, każda zmiana zaczyna boleć. W tym artykule pokazuję, jak zasady SOLID pomagają porządkować kod, projektować architekturę aplikacji .NET i podejmować rozsądne decyzje bez tworzenia zbędnej abstrakcji. Znajdziesz tu przykłady w C#, praktyczne zastosowanie w ASP.NET Core oraz sytuacje, w których lepiej nie stosować SOLID na siłę.

Pięć prostych zasad może wyraźnie zmniejszyć koszt zmian w kodzie

  • SRP ogranicza klasę do jednej odpowiedzialności i jednego powodu do zmiany.
  • OCP pozwala dodawać zachowania bez ciągłego modyfikowania stabilnego kodu.
  • LSP i ISP pomagają projektować kontrakty, które są przewidywalne i wygodne w użyciu.
  • DIP oddziela logikę biznesową od infrastruktury, na przykład bazy danych lub zewnętrznego API.
  • Najważniejszy jest umiar, bo nadmiar interfejsów i warstw może zwiększyć złożoność zamiast ją ograniczyć.

Zasady SOLID: SRP, OCP, LSP, ISP, DIP. Kluczowe dla solidnego programowania, zapewniające czytelność i łatwość utrzymania kodu.

Czym naprawdę są zasady SOLID i po co stosować je w praktyce

SOLID to zestaw pięciu zasad projektowania obiektowego. Nie jest frameworkiem, biblioteką ani sztywnym standardem, który trzeba wdrożyć w każdej klasie. Traktuję te reguły raczej jako zestaw pytań pomagających ocenić, czy kod będzie łatwy do zmiany, testowania i rozwijania.

W praktyce solid programowanie nie polega na tworzeniu jak największej liczby interfejsów. Chodzi o takie ułożenie odpowiedzialności, aby zmiana sposobu wysyłki wiadomości nie wymagała edycji modułu płatności, kontrolera i kilku niezwiązanych klas. Największą wartość widać wtedy, gdy aplikacja rośnie, a nie podczas pisania pierwszych kilkuset linii.

Litera Zasada Praktyczne pytanie
S Single Responsibility Principle Czy klasa ma jeden wyraźny powód do zmiany?
O Open/Closed Principle Czy nowe zachowanie można dodać bez ruszania stabilnego kodu?
L Liskov Substitution Principle Czy implementację można bezpiecznie podmienić inną?
I Interface Segregation Principle Czy klient zależy tylko od metod, których naprawdę potrzebuje?
D Dependency Inversion Principle Czy logika biznesowa nie zależy bezpośrednio od szczegółów technicznych?

Same definicje niewiele zmieniają. Dopiero gdy potrafię rozpoznać konkretny zapach w kodzie, na przykład klasę robiącą pięć różnych rzeczy albo interfejs z dwudziestoma metodami, zasady zaczynają być użyteczne.

SRP i OCP porządkują odpowiedzialności oraz zmiany

Jedna odpowiedzialność nie oznacza jednej metody

Najczęstsze nieporozumienie dotyczące SRP polega na założeniu, że klasa powinna mieć tylko jedną metodę. Chodzi o coś innego. Klasa powinna mieć jeden powód do zmiany, czyli odpowiadać za jeden spójny obszar decyzji.

Przykładowy serwis zamówień może dziś walidować koszyk, obliczać rabat, zapisywać dane i wysyłać e-mail. Taki kod działa, ale każda zmiana reguł rabatowych może naruszyć obsługę bazy danych. Rozdzielenie odpowiedzialności daje prostszy układ:

public sealed class OrderValidator
{
    public bool IsValid(Order order) =>
        order.Items.Count > 0 && order.CustomerId != Guid.Empty;
}

public sealed class DiscountCalculator
{
    public decimal Calculate(Order order)
    {
        return order.Total > 500 ? order.Total * 0.1m : 0m;
    }
}

public sealed class OrderRepository
{
    public Task SaveAsync(Order order)
    {
        // Zapis do bazy danych
        return Task.CompletedTask;
    }
}

Nie rozbijam klas mechanicznie tylko dlatego, że plik jest długi. Patrzę na źródła zmian. Jeśli dział sprzedaży zmienia rabaty, a zespół infrastruktury zmienia sposób zapisu, są to dwa różne powody, by modyfikować kod.

OCP chroni stabilne fragmenty systemu

Zasada otwarte-zamknięte mówi, że moduł powinien być otwarty na rozszerzanie i zamknięty na modyfikację. Nie oznacza to zakazu zmieniania kodu. Chodzi o to, aby nowe warianty można było dodawać przez osobne implementacje, zamiast rozbudowywać jeden wielki warunek.

Dobrym przykładem jest naliczanie kosztu dostawy. Zamiast dopisywać kolejne instrukcje if, można zdefiniować strategię:

public interface IShippingCostCalculator
{
    decimal Calculate(Order order);
}

public sealed class CourierShipping : IShippingCostCalculator
{
    public decimal Calculate(Order order) => 14.99m;
}

public sealed class ParcelLockerShipping : IShippingCostCalculator
{
    public decimal Calculate(Order order) => 10.99m;
}

Dodanie dostawy ekspresowej nie wymaga wtedy zmiany istniejących kalkulatorów. Trzeba jednak zachować rozsądek. Jeśli mamy tylko dwa warianty, a trzeci nigdy nie powstanie, rozbudowany mechanizm strategii może być droższy w utrzymaniu niż prosty warunek.

LSP i ISP pomagają budować dobre kontrakty

Podtyp powinien zachowywać się przewidywalnie

Zasada podstawień Liskov oznacza, że obiekt klasy pochodnej powinien dać się wykorzystać tam, gdzie oczekiwany jest obiekt bazowy, bez łamania założeń programu. Problem pojawia się wtedy, gdy dziedziczenie opisuje podobieństwo nazw, ale nie podobieństwo zachowania.

Klasyczny przykład to prostokąt i kwadrat. Jeśli kod prostokąta pozwala niezależnie ustawiać szerokość i wysokość, kwadrat może złamać te założenia, bo zmiana jednego wymiaru musi zmienić drugi. W aplikacji biznesowej podobny błąd pojawia się wtedy, gdy typ oznaczony jako „czytelnik” dziedziczy po „użytkowniku z możliwością zapisu”, ale metoda zapisu zawsze rzuca wyjątek.

W praktyce częściej wybieram kompozycję zamiast dziedziczenia. Kompozycja oznacza składanie obiektu z mniejszych zależności, co zwykle daje mniej ukrytych reguł i łatwiejsze testy.

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

Małe interfejsy są wygodniejsze dla klientów

Interface Segregation Principle ostrzega przed dużymi interfejsami, które zmuszają klasy do implementowania metod, których nie używają. Interfejs urządzenia wielofunkcyjnego z metodami drukowania, skanowania i faksowania będzie problemem dla prostej drukarki.

Lepszy podział wygląda tak:

public interface IPrinter
{
    void Print(Document document);
}

public interface IScanner
{
    Document Scan();
}

public sealed class BasicPrinter : IPrinter
{
    public void Print(Document document)
    {
        // Drukowanie dokumentu
    }
}

Mały kontrakt ułatwia podmianę implementacji i pisanie testów jednostkowych. Nie przesadzam jednak z dzieleniem interfejsów. Interfejs z jedną metodą ma sens, gdy reprezentuje realną granicę odpowiedzialności, a nie tylko spełnia checklistę.

DIP łączy zasady SOLID z architekturą aplikacji .NET

Odwrócenie zależności oznacza, że logika biznesowa nie powinna znać szczegółów infrastruktury. Serwis zamówień nie musi wiedzieć, czy dane trafiają do SQL Servera, Cosmos DB, pliku czy zewnętrznego API. Powinien znać abstrakcję opisującą potrzebną operację.

public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

public sealed class OrderService
{
    private readonly IOrderRepository repository;

    public OrderService(IOrderRepository repository)
    {
        this.repository = repository;
    }

    public Task PlaceAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        // Reguły biznesowe pozostają niezależne od bazy danych.
        return repository.SaveAsync(order, cancellationToken);
    }
}

W ASP.NET Core zależność można zarejestrować w kontenerze wstrzykiwania zależności:

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

Dzięki temu test może użyć prostego repozytorium pamięciowego albo mocka. Największa korzyść nie polega jednak na samym testowaniu. Zyskujemy możliwość zmiany infrastruktury bez przepisywania logiki domenowej, co ma znaczenie przy migracji bazy, integracji z Azure albo rozdzielaniu modułów.

W tym miejscu SOLID spotyka się z architekturą warstwową, heksagonalną i podejściem clean architecture. Nie trzeba wdrażać całego wzorca, aby skorzystać z tej idei. Czasem wystarczy jedna granica między domeną a adapterem infrastruktury.

Jak stosować te zasady podczas refaktoryzacji

Nie zaczynam od przepisania całej aplikacji. Najpierw szukam miejsca, w którym zmiana jest droga albo ryzykowna. To zwykle lepszy sygnał niż sama długość klasy. Ból przy zmianie pokazuje, gdzie architektura naprawdę potrzebuje poprawy.

  1. Wybierz konkretny scenariusz. Na przykład dodanie nowego operatora płatności.
  2. Wskaż zależności. Sprawdź, czy logika biznesowa zna bazę, HTTP, system plików albo framework.
  3. Oddziel odpowiedzialności. Wydziel tylko te elementy, które mają własny powód do zmiany.
  4. Zdefiniuj mały kontrakt. Interfejs powinien opisywać potrzebę klienta, a nie wszystkie możliwości implementacji.
  5. Dodaj test przed większą zmianą. Test zabezpiecza zachowanie, które właśnie porządkujesz.
  6. Sprawdź prostszą wersję. Jeśli kilka klas i interfejsów nie daje wyraźnej korzyści, zatrzymaj refaktoryzację.

Przydatnym sygnałem jest częstotliwość zmian. Jeżeli do dodania nowej bramki płatniczej edytuję kontroler, serwis, konfigurację i kilka klas warunkowych, system ma zbyt mocno splecione odpowiedzialności. Po refaktoryzacji nowa implementacja powinna być możliwa do dodania w jednym module i jednym miejscu rejestracji.

Nie traktuję też testów jako celu samego w sobie. Jeśli kod jest trudny do przetestowania, często wskazuje to na zbyt dużą odpowiedzialność klasy albo ukrytą zależność. Test staje się wtedy narzędziem do znalezienia problemu projektowego, a nie tylko kontrolą jakości.

Kiedy SOLID szkodzi i jak uniknąć przesady

Największym błędem jest zamiana zasad w religię. Mała aplikacja, jednorazowy skrypt migracyjny albo prosty endpoint może działać lepiej bez pięciu warstw, sześciu interfejsów i fabryki do tworzenia obiektu, który ma jeden konstruktor.

Przed dodaniem abstrakcji zadaję sobie trzy pytania:

  • Czy istnieje realna zmienność, którą chcę ukryć?
  • Czy ta granica ułatwi testowanie albo ograniczy wpływ przyszłych zmian?
  • Czy zespół szybko zrozumie, gdzie znajduje się właściwa logika?

Jeśli odpowiedź brzmi „nie”, zostawiam prostszy kod. SOLID ma zmniejszać koszt zmian, a nie zwiększać liczbę plików. Dobrze zaprojektowana klasa może mieć więcej niż jedną metodę, a czasem nawet zależeć od konkretnego typu, jeśli ten typ jest stabilny i nie ma sensownej alternatywy.

Uważam też, że nie każda aplikacja potrzebuje pełnej architektury heksagonalnej. W projekcie rozwijanym przez kilka lat granice modułów mogą uratować sytuację. W małym narzędziu używanym przez tydzień będą prawdopodobnie kosztem bez odpowiedniego zwrotu.

Najlepszy test zasad SOLID pojawia się przy kolejnej zmianie

Nie oceniam jakości projektu po liczbie interfejsów ani po tym, czy katalogi idealnie przypominają popularny diagram architektury. Patrzę na to, jak łatwo zespół potrafi odpowiedzieć na nowe wymaganie. Jeśli zmiana jest lokalna, testowalna i nie wymaga rozumienia całej aplikacji, kierunek jest dobry.

Na początek wybrałbym jedną klasę, która robi zbyt wiele, oraz jeden fragment zawierający rozrastające się warunki. Rozdzielenie odpowiedzialności, mały kontrakt i zależność od abstrakcji często dają więcej niż przebudowa całego rozwiązania według gotowego wzorca.

Najważniejsza lekcja jest prosta. SOLID nie ma produkować eleganckiego diagramu, tylko kod, który można bezpiecznie rozwijać. Gdy zasady pomagają szybciej reagować na zmiany, warto ich używać. Gdy tworzą dodatkową ceremonię bez konkretnej korzyści, rozsądniej uprościć projekt.

FAQ - Najczęstsze pytania

Klasa narusza SRP, gdy ma kilka niezależnych powodów do zmiany, na przykład jednocześnie waliduje zamówienie, oblicza rabat, zapisuje dane i wysyła e-mail. W takiej sytuacji warto rozdzielić odpowiedzialności według źródeł zmian, a nie mechanicznie według liczby metod.

OCP sprawdza się, gdy system ma dodawać kolejne warianty zachowania, na przykład metody dostawy. Osobne implementacje strategii pozwalają dodać nowy wariant bez modyfikowania istniejących kalkulatorów, ale przy dwóch stabilnych opcjach prosty warunek może być tańszy w utrzymaniu.

Logika biznesowa powinna zależeć od abstrakcji, takiej jak IOrderRepository, a nie bezpośrednio od SQL Servera, Cosmos DB czy pliku. W ASP.NET Core implementację można zarejestrować w kontenerze zależności, dzięki czemu test użyje repozytorium pamięciowego lub mocka, a migracja infrastruktury nie wymaga przepisywania domeny.

Nie warto dodawać wielu warstw i interfejsów do małego narzędzia, jednorazowego skryptu migracyjnego lub prostego endpointu bez realnej zmienności. Przed wprowadzeniem abstrakcji trzeba sprawdzić, czy ułatwi testowanie, ograniczy wpływ przyszłych zmian i pozostanie zrozumiała dla zespołu.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

solid
c#
asp.net core
refaktoryzacja
wstrzykiwanie zależności
Autor Bruno Krawczyk
Bruno Krawczyk
Mam na imię Bruno i od 8 lat zgłębiam tajniki programowania w ekosystemie .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z technologią zaczęła się od ciekawości, jak złożone systemy mogą ułatwiać codzienne życie i rozwiązywać realne problemy. Dziś moją misją jest dzielenie się tą wiedzą, starając się przybliżyć nawet najbardziej skomplikowane zagadnienia w sposób zrozumiały i przystępny dla każdego. W moich artykułach na kursdotnet.pl skupiam się na praktycznych aspektach, analizuję najnowsze trendy i weryfikuję informacje, aby dostarczyć Wam treści, które są nie tylko dokładne i aktualne, ale przede wszystkim użyteczne w Waszej własnej ścieżce rozwoju technologicznego.

Udostępnij artykuł

Napisz komentarz