SOLID w C# i .NET - zasady, przykłady i refaktoryzacja

Bruno Krawczyk 9 czerwca 2026
Młody mężczyzna w okularach i bluzie z kapturem pracuje przy laptopie. Za nim widać cyfrowy świat, gdzie zasady SOLID są kluczem do sukcesu.

Spis treści

Duża klasa, która waliduje dane, zapisuje rekordy do bazy, wysyła e-maile i jeszcze zna szczegóły płatności, prędzej czy później zaczyna przeszkadzać całemu zespołowi. Zasady SOLID pomagają projektować kod C# tak, aby łatwiej go testować, rozwijać i zmieniać bez lawiny poprawek w niepowiązanych miejscach. Pokażę, na czym polega każda z pięciu reguł, jak rozpoznać jej złamanie oraz jak stosować ją rozsądnie w aplikacjach .NET.

Pięć reguł, które porządkują kod obiektowy

  • SOLID to zestaw pięciu zasad projektowania obiektowego, a nie gotowy framework ani sztywny standard.
  • SRP ogranicza klasę do jednego powodu do zmiany, natomiast OCP ułatwia rozszerzanie zachowania bez modyfikowania stabilnego kodu.
  • LSP i ISP chronią przed niebezpiecznym dziedziczeniem oraz zbyt dużymi interfejsami.
  • DIP oddziela logikę biznesową od szczegółów technicznych, takich jak SQL, HTTP czy system plików.
  • Najlepszy efekt daje stopniowe stosowanie zasad tam, gdzie kod rzeczywiście często się zmienia.

Po co w ogóle stosować SOLID

Pięć zasad SOLID opisuje sposób budowania zależności między klasami. Ich celem nie jest tworzenie jak największej liczby interfejsów, tylko ograniczanie kosztu zmian. Jeżeli modyfikacja jednej funkcji wymaga poprawiania kontrolera, serwisu, kilku modeli i testów niezwiązanych z problemem, projekt prawdopodobnie ma zbyt silne sprzężenie.

W praktyce liczy się kilka efektów. Kod zgodny z tymi regułami jest zwykle łatwiejszy do testowania, ponieważ jego elementy mają mniej odpowiedzialności i zależności. Prościej też wymienić bazę danych, dostawcę płatności albo sposób wysyłania powiadomień bez przepisywania logiki domenowej.

Nie traktuję jednak SOLID jak religii. Mała aplikacja, jednorazowy skrypt czy prosty endpoint nie potrzebują pięciu warstw abstrakcji. Największą wartość zasady dają tam, gdzie kod żyje długo, rozwija go kilka osób, a wymagania regularnie się zmieniają.

Skrót Pełna nazwa Główne pytanie
S Single Responsibility Principle Czy klasa ma jeden powód do zmiany?
O Open/Closed Principle Czy mogę dodać zachowanie bez psucia stabilnego kodu?
L Liskov Substitution Principle Czy typ pochodny można bezpiecznie zastąpić bazowym?
I Interface Segregation Principle Czy klienci zależą tylko od potrzebnych metod?
D Dependency Inversion Principle Czy logika biznesowa zależy od abstrakcji, a nie szczegółów?

Zasady SOLID: S jak Single Responsibility, O jak Open/Closed, L jak Liskov Substitution, I jak Interface Segregation, D jak Dependency Inversion.

Dwie pierwsze zasady porządkują odpowiedzialność i rozszerzanie

Single Responsibility Principle

SRP często tłumaczy się jako zasadę jednej odpowiedzialności. Precyzyjniejsze jest inne sformułowanie: klasa powinna mieć jeden powód do zmiany. Nie chodzi więc o to, aby miała tylko jedną metodę. Klasa może zawierać kilka operacji, jeśli wszystkie dotyczą tego samego obszaru.

Przykładowy kod jest problematyczny, gdy jedna klasa obsługuje zamówienie, generuje dokument PDF i zapisuje dane:

public class OrderService
{
    public void CreateOrder(Order order)
    {
        Validate(order);
        SaveToDatabase(order);
        GeneratePdf(order);
        SendEmail(order);
    }
}

Każda z tych czynności może zmienić się niezależnie. Reguły walidacji wynikają z biznesu, sposób zapisu z infrastruktury, format PDF z wymagań dokumentu, a treść wiadomości z komunikacji z klientem. Rozdzielenie odpowiedzialności może wyglądać tak:

public class OrderService
{
    private readonly IOrderRepository repository;
    private readonly IInvoiceGenerator invoiceGenerator;
    private readonly INotificationSender notificationSender;

    public async Task CreateOrderAsync(Order order)
    {
        OrderValidator.Validate(order);
        await repository.SaveAsync(order);

        var invoice = invoiceGenerator.Generate(order);
        await notificationSender.SendAsync(invoice);
    }
}

Sam podział nie wystarczy, jeśli tylko przeniesiemy bałagan do trzech nowych klas. Zadaję sobie pytanie, kto powinien zmienić ten kod. Jeżeli odpowiedzią są trzy różne zespoły albo trzy różne typy wymagań, to dobry sygnał, że odpowiedzialności zostały połączone zbyt mocno.

Open/Closed Principle

Zasada otwarte-zamknięte mówi, że moduł powinien być otwarty na rozszerzanie i zamknięty na modyfikację. Nie oznacza to zakazu poprawiania istniejącego kodu. Chodzi o to, aby stabilna logika nie wymagała ciągłych zmian tylko dlatego, że pojawia się kolejny wariant funkcji.

Typowym przykładem jest długa instrukcja warunkowa:

public decimal CalculateDiscount(string customerType, decimal price)
{
    if (customerType == "Regular")
        return price * 0.05m;

    if (customerType == "Premium")
        return price * 0.15m;

    return 0;
}

Przy każdym nowym typie klienta trzeba modyfikować tę metodę i dodawać kolejne warunki. Lepszym rozwiązaniem może być strategia, czyli osobny obiekt reprezentujący sposób obliczania rabatu:

public interface IDiscountPolicy
{
    decimal Calculate(decimal price);
}

public sealed class PremiumDiscount : IDiscountPolicy
{
    public decimal Calculate(decimal price) => price * 0.15m;
}

public sealed class NoDiscount : IDiscountPolicy
{
    public decimal Calculate(decimal price) => 0;
}

OCP nie wymaga automatycznego tworzenia strategii dla każdego if-a. Jeśli istnieją tylko dwa warianty, prosty warunek może być czytelniejszy. Abstrakcja ma sens wtedy, gdy wariantów przybywa, różnią się regułami i prawdopodobnie będą rozwijane niezależnie.

Liskov i interfejsy pokazują, gdzie abstrakcja jest źle zaprojektowana

Substitution Principle

Zasada podstawienia Liskov mówi, że obiekt typu pochodnego powinien móc zastąpić obiekt typu bazowego bez zmieniania poprawności programu. Najczęstszy problem pojawia się wtedy, gdy dziedziczenie wynika z podobieństwa nazw, a nie z rzeczywiście wspólnego kontraktu.

Klasyczny przykład to prostokąt i kwadrat. Jeśli Square dziedziczy po Rectangle, kod oczekujący niezależnej zmiany szerokości i wysokości może przestać działać, bo kwadrat musi utrzymać równe boki. Typ pochodny nie spełnia wtedy założeń klasy bazowej.

W C# podobny błąd może wyglądać tak:

public abstract class FileStorage
{
    public abstract Task SaveAsync(Stream content);
    public abstract Task DeleteAsync(string name);
}

public sealed class ReadOnlyFileStorage : FileStorage
{
    public override Task SaveAsync(Stream content)
        => throw new NotSupportedException();

    public override Task DeleteAsync(string name)
        => throw new NotSupportedException();
}

Jeżeli użytkownik klasy bazowej ma prawo oczekiwać zapisu i usuwania, implementacja tylko do odczytu łamie kontrakt. Lepszym rozwiązaniem jest rozdzielenie możliwości na mniejsze interfejsy albo zbudowanie osobnych abstrakcji dla odczytu i zapisu. Wyjątek NotSupportedException w implementacji metody często sygnalizuje, że dziedziczenie zostało użyte w niewłaściwym miejscu.

Interface Segregation Principle

Klient nie powinien zależeć od metod, których nie używa. To sedno zasady ISP. Duży interfejs wygląda początkowo wygodnie, ale szybko zmusza implementacje do dodawania pustych metod, wyjątków albo sztucznych zachowań.

public interface IEmployee
{
    void Work();
    void Eat();
    void AttendMeeting();
}

public sealed class RobotEmployee : IEmployee
{
    public void Work() { }
    public void Eat() => throw new NotSupportedException();
    public void AttendMeeting() { }
}

Robot nie powinien implementować kontraktu pracownika, który zakłada jedzenie. Można rozbić interfejs na mniejsze role:

public interface IWorker
{
    void Work();
}

public interface IMeetingParticipant
{
    void AttendMeeting();
}

Małe interfejsy pomagają także w testach. Mock lub atrapę zależności przygotowuje się wtedy dla kilku potrzebnych metod, a nie dla całego, przypadkowo rozrośniętego kontraktu. Trzeba jednak zachować umiar. Dzielenie interfejsu na jedną metodę tylko dlatego, że jest to formalnie zgodne z ISP, zwykle pogarsza czytelność.

Dependency Inversion oddziela biznes od infrastruktury

DIP jest szczególnie ważny w aplikacjach ASP.NET Core. Zasada mówi, że moduły wysokiego poziomu, czyli logika biznesowa, nie powinny zależeć bezpośrednio od modułów niskiego poziomu, takich jak SQL Server, klient HTTP czy system plików. Obie warstwy powinny zależeć od abstrakcji.

Wadliwy przykład tworzy konkretne zależności wewnątrz serwisu:

public class UserService
{
    public async Task RegisterAsync(User user)
    {
        var connection = new SqlConnection("...");
        var emailSender = new SmtpEmailSender();

        // zapis do bazy i wysłanie wiadomości
    }
}

Taki kod trudno testować, ponieważ test rejestracji może wymagać prawdziwej bazy oraz serwera pocztowego. Serwis zna szczegóły techniczne, chociaż jego zadaniem jest realizacja procesu biznesowego.

public interface IUserRepository
{
    Task AddAsync(User user);
}

public interface IEmailSender
{
    Task SendAsync(string address, string message);
}

public class UserService
{
    private readonly IUserRepository repository;
    private readonly IEmailSender emailSender;

    public UserService(
        IUserRepository repository,
        IEmailSender emailSender)
    {
        this.repository = repository;
        this.emailSender = emailSender;
    }

    public async Task RegisterAsync(User user)
    {
        await repository.AddAsync(user);
        await emailSender.SendAsync(user.Email, "Witaj!");
    }
}
Wstrzykiwanie zależności

przez konstruktor nie jest całym DIP, ale w .NET stanowi jego praktyczną realizację. Rejestracja może wyglądać tak:

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

Dzięki temu test może podać prostą implementację pamięciową albo mock. Wymiana SMTP na usługę Azure Communication Services nie wymaga zmiany reguł rejestracji użytkownika, tylko konfiguracji kompozycji aplikacji.

Trzeba uważać na jedną pułapkę. Interfejs dodany wyłącznie po to, aby „był SOLID”, nie daje automatycznie korzyści. Dobra abstrakcja opisuje potrzebę biznesową, na przykład IPaymentGateway albo IOrderRepository, a nie przypadkową klasę techniczną z metodami skopiowanymi jeden do jednego.

Jak stosować te zasady podczas refaktoryzacji

Najlepiej nie przebudowywać całej aplikacji od zera. Zaczynam od miejsca, które często się zmienia albo generuje błędy. Może to być obsługa płatności, import plików, wycena zamówienia lub integracja z zewnętrznym API.

  1. Znajdź punkt bólu. Sprawdź, gdzie jedna zmiana powoduje poprawki w wielu klasach albo gdzie testy są wyjątkowo trudne.
  2. Wypisz odpowiedzialności. Oddziel logikę biznesową od zapisu danych, komunikacji, serializacji i obsługi infrastruktury.
  3. Zdefiniuj mały kontrakt. Interfejs powinien zawierać tylko operacje potrzebne konkretnemu klientowi.
  4. Wydziel zmienny wariant. Jeśli rośnie liczba warunków, rozważ strategię, fabrykę albo osobny handler.
  5. Dodaj testy przed większą zmianą. Testy zabezpieczają zachowanie, które refaktoryzacja ma zachować.

Przy przeglądzie kodu używam kilku prostych pytań. Czy klasa ma jeden dominujący powód do zmiany? Czy implementacja interfejsu korzysta ze wszystkich metod? Czy kod wywołujący zna konkretne biblioteki i dostawców? Czy dodanie nowej reguły wymaga edycji długiego łańcucha warunków?

Jeśli odpowiedź brzmi „tak”, nie oznacza to jeszcze konieczności natychmiastowej przebudowy. Najpierw oceniam częstotliwość zmian i koszt ryzyka. Stabilny kod, którego nikt nie rozwija, może pozostać prosty. Abstrakcje najlepiej wprowadzać tam, gdzie faktycznie chronią przed kolejnymi zmianami.

Najczęstsze błędy przy wdrażaniu SOLID

Zbyt wiele warstw

Jednym z częstszych nieporozumień jest utożsamianie dobrego projektu z dużą liczbą projektów, folderów i interfejsów. Klasa CustomerService, interfejs ICustomerService, fabryka serwisów i adapter do fabryki nie poprawią kodu, jeśli rozwiązują problem, który nie istnieje.

Interfejsy odzwierciedlające klasy

Interfejs IProductService zawierający dokładnie te same metody co ProductService bywa tylko dodatkową warstwą. Lepszy kontrakt opisuje rolę używaną przez klienta, na przykład IProductCatalog dla odczytu produktów. Dzięki temu nazwa i zakres abstrakcji mają sens poza konkretną implementacją.

Dziedziczenie zamiast kompozycji

Dziedziczenie powinno wyrażać prawdziwą relację „jest rodzajem”. Gdy chcemy tylko ponownie wykorzystać kilka metod, często lepsza będzie kompozycja, czyli przekazanie obiektu jako zależności. To ogranicza ryzyko złamania LSP i pozwala zmieniać elementy niezależnie.

Przeczytaj również: Zasada otwarte-zamknięte w C# i .NET - praktyczne OCP

Ślepe stosowanie OCP

Nie każda instrukcja switch jest problemem. Czasem warunek jest krótszy i łatwiejszy do zrozumienia niż pięć klas strategii. OCP zaczyna realnie pomagać wtedy, gdy warianty mają różne reguły, pojawiają się regularnie albo są rozwijane przez różne części systemu.

Jak ocenić, czy architektura naprawdę zyskała

Po refaktoryzacji nie pytam tylko, czy kod wygląda bardziej „obiektowo”. Sprawdzam, czy nowa wersja pozwala taniej wprowadzać zmiany. Dodaję nową metodę płatności, zmieniam sposób wysyłki powiadomień albo uruchamiam test bez infrastruktury i obserwuję, ile klas trzeba dotknąć.

Dobrze zaprojektowany kod nie eliminuje wszystkich zależności ani nie gwarantuje braku błędów. Powinien jednak ograniczać ich zasięg. Jeżeli zmiana dostawcy bazy danych wymusza modyfikację reguł naliczania rabatu, granice między domeną i infrastrukturą są prawdopodobnie zbyt słabe.

W aplikacjach .NET szczególnie dobrze widać wartość tych reguł przy testach jednostkowych. Serwis, który otrzymuje repozytorium i klienta płatności przez konstruktor, można uruchomić z prostymi atrapami. Krótki test bez sieci i bazy często mówi więcej o jakości projektu niż sama liczba interfejsów.

SOLID nie zastępuje znajomości domeny, testów ani rozsądnego podziału modułów. Jest raczej zestawem pytań, które pomagają zauważyć zbyt silne sprzężenie, niejasną odpowiedzialność i abstrakcje niedopasowane do potrzeb kodu.

Największą wartość daje właściwy moment na zmianę

Nie zaczynałbym od przepisywania całego projektu pod pięć liter. Wybrałbym jeden fragment, który boli zespół, zabezpieczył jego zachowanie testami i dopiero wtedy rozdzielił odpowiedzialności. Taka mała, celowa refaktoryzacja zwykle daje więcej niż architektura zbudowana z wyprzedzeniem dla problemów, które mogą nigdy się nie pojawić.

Najkrótsza praktyczna zasada brzmi więc tak: stosuj SOLID tam, gdzie pomaga ograniczyć koszt kolejnej zmiany. Jeśli kod staje się prostszy do zrozumienia, testowania i rozwijania, abstrakcja spełniła swoje zadanie. Jeśli tylko zwiększa liczbę plików, prawdopodobnie nadszedł moment, aby ją uprościć.

FAQ - Najczęstsze pytania

Największą wartość SOLID daje w kodzie, który często się zmienia, rozwija go kilka osób lub trudno go testować. W małym skrypcie, prostym endpointzie albo jednorazowej aplikacji rozbudowane abstrakcje mogą tylko zwiększyć złożoność.

Klasa prawdopodobnie łamie SRP, gdy łączy niezależne zadania, takie jak walidacja, zapis do bazy, generowanie PDF i wysyłanie e-maili. Dobrym pytaniem jest to, kto powinien zmienić dany fragment kodu. Jeśli odpowiedzialność dotyczy kilku różnych typów wymagań lub zespołów, warto ją rozdzielić.

Strategia ma sens, gdy liczba wariantów rośnie, różnią się one regułami i prawdopodobnie będą rozwijane niezależnie. Przy dwóch prostych wariantach zwykły warunek może być czytelniejszy, więc OCP nie wymaga tworzenia osobnej klasy dla każdego if-a.

Jeśli klasa pochodna rzuca NotSupportedException dla metod klasy bazowej, prawdopodobnie nie spełnia kontraktu oczekiwanego przez kod korzystający z typu bazowego. Przykładem jest magazyn tylko do odczytu dziedziczący po abstrakcji wymagającej zapisu i usuwania. Lepszym rozwiązaniem może być rozdzielenie możliwości na mniejsze interfejsy lub osobne abstrakcje.

Serwis powinien otrzymywać przez konstruktor abstrakcje, takie jak IUserRepository i IEmailSender, zamiast tworzyć bezpośrednio SqlConnection lub SmtpEmailSender. Dzięki temu test może użyć implementacji pamięciowej albo mocka, bez prawdziwej bazy i serwera pocztowego. Wymiana dostawcy infrastruktury wymaga wtedy zmiany konfiguracji kompozycji, a nie logiki biznesowej.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

solid
refaktoryzacja
wstrzykiwanie zależności
interfejsy
dziedziczenie
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