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? |

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ściprzez 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.
- Znajdź punkt bólu. Sprawdź, gdzie jedna zmiana powoduje poprawki w wielu klasach albo gdzie testy są wyjątkowo trudne.
- Wypisz odpowiedzialności. Oddziel logikę biznesową od zapisu danych, komunikacji, serializacji i obsługi infrastruktury.
- Zdefiniuj mały kontrakt. Interfejs powinien zawierać tylko operacje potrzebne konkretnemu klientowi.
- Wydziel zmienny wariant. Jeśli rośnie liczba warunków, rozważ strategię, fabrykę albo osobny handler.
- 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ć.
