Zasady SOLID w C#

Zasady SOLID w C#: 5 reguł na prawdziwych przykładach z .NET

Znasz definicje SOLID na pamięć, ale wciąż nie wiesz, kiedy ich użyć? To najczęstszy problem na drodze junior → mid. Większość tutoriali tłumaczy zasady SOLID w C# na abstrakcyjnych kwadratach, prostokątach i niekończącym się Animal → Dog. Problem w tym, że w prawdziwym projekcie nie refaktoryzujesz zwierząt — refaktoryzujesz zamówienia, płatności i powiadomienia.

W tym przewodniku przejdziemy przez wszystkie 5 zasad SOLID na kodzie wyciągniętym z realnego systemu e-commerce. Dla każdej zasady zobaczysz ten sam schemat: zły kod → dobry kod → reguła do zapamiętania. Na końcu pokażę, dlaczego kod zgodny z SOLID testuje się bez bazy danych, bez SMTP i bez sieci i jak 36 testów xUnit może przejść na zielono w ułamek sekundy.

📥 Cały kod źródłowy (solution Visual Studio)
pobierzesz z repozytorium GitHub.

Czym jest SOLID i dlaczego rekruterzy o to pytają

SOLID to akronim pięciu zasad projektowania obiektowego spopularyzowanych przez Roberta C. Martina (Uncle Bob). Wszystkie sprowadzają się do jednego celu:

Pisz kod, który możesz zmienić bez strachu. Nie bez wysiłku, bez strachu, że coś nieoczekiwanie się zepsuje.

To dlatego SOLID jest stałym punktem rozmów rekrutacyjnych na .NET. Rekruter nie sprawdza, czy wyrecytujesz definicję, sprawdza, czy potrafisz wskazać złamanie zasady w kodzie i je naprawić. A to umiejętność praktyczna, nie teoretyczna.

Pięć zasad w skrócie:

  • S — Single Responsibility Principle (jedna odpowiedzialność)
  • O — Open/Closed Principle (otwarte/zamknięte)
  • L — Liskov Substitution Principle (podstawialność Liskov)
  • I — Interface Segregation Principle (segregacja interfejsów)
  • D — Dependency Inversion Principle (odwrócenie zależności)

💡
Jeśli dopiero zaczynasz z C#, zacznij od podstaw OOP, a tu wróć po opanowaniu klas, interfejsów i dziedziczenia.


S — Single Responsibility Principle

Klasa powinna mieć tylko jeden powód do zmiany. Lepsza, praktyczna wersja tej definicji: klasa odpowiada przed jednym aktorem, jedną grupą ludzi, która może chcieć ją zmienić.

S — Single Responsibility Principle
S — Single Responsibility Principle

Zły kod: klasa, która robi wszystko

// ❌ OrderServiceBad — pięć powodów do zmiany w jednej klasie
public class OrderServiceBad
{
    public void PlaceOrder(string email, decimal price, int qty)
    {
        // 1) walidacja — zmienia ją zespół od reguł biznesowych
        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
            throw new ArgumentException("Zły email");

        // 2) liczenie ceny — zmienia ją dział finansowy (VAT, rabaty)
        var total = price * qty * 1.23m;

        // 3) zapis do bazy — zmienia go zespół od bazy danych

        // 4) wysyłka maila — zmienia go zespół od komunikacji

        // 5) generowanie faktury — zmienia go księgowość

    }
}

Test na SRP jest banalny: zadaj pytanie „kto zmienia tę klasę?”. Jeśli odpowiedź to więcej niż jeden zespół (finanse, komunikacja, baza, księgowość…), zasada jest złamana.

Dobry kod: jedna klasa = jedna odpowiedzialność

// ✅ Każda odpowiedzialność w osobnej klasie, OrderService tylko orkiestruje
public class OrderService(
    OrderValidator validator,       // walidacja
    PriceCalculator calculator,     // ceny i VAT
    IOrderRepository repository,    // zapis
    IOrderNotifier notifier,        // powiadomienia
    InvoiceGenerator invoiceGenerator) // faktury
{
    public async Task<OrderResult> PlaceOrderAsync(PlaceOrderRequest request)
    {
        var validation = validator.Validate(request);
        if (!validation.IsValid)
            return OrderResult.Rejected(validation.Errors);

        var price = calculator.Calculate(request); // czysta logika, łatwa do testu
        int orderId = repository.Save(request.CustomerId, price.TotalGross, DateTime.UtcNow);
        await notifier.NotifyOrderConfirmedAsync(orderId, request.CustomerEmail);

        return OrderResult.Placed(orderId, price);
    }
}

Reguła do zapamiętania:
jeden powód do zmiany = jeden aktor. Im mniejsza i bardziej skupiona klasa, tym łatwiej ją zrozumieć, przetestować i podmienić.


O — Open/Closed Principle

Klasy powinny być otwarte na rozszerzenia, ale zamknięte na modyfikacje. Nową funkcjonalność dodajesz, pisząc nowy kod, a nie edytując i ryzykując zepsucie istniejącego.

O — Open/Closed Principle
O — Open/Closed Principle

Zły kod: rosnący switch

// ❌ Każda nowa metoda płatności = edycja tej metody = ryzyko regresji
public decimal Process(string method, decimal amount)
{
    switch (method)
    {
        case "card":  return amount * 1.02m;
        case "blik":  return amount;             // bez prowizji
        case "paypal": return amount * 1.034m;
        // dodajesz Apple Pay? Musisz DOTKNĄĆ tej klasy i ją przetestować od nowa
        default: throw new NotSupportedException(method);
    }
}

Dobry kod: wzorzec Strategy

// ✅ Wspólny kontrakt dla każdej metody płatności
public interface IPaymentProvider
{
    string Method { get; }
    bool Supports(decimal amount);
    PaymentResult Process(decimal amount);
}

// Nowa płatność = NOWA klasa. Zero zmian w PaymentProcessor.
public sealed class ApplePayProvider : IPaymentProvider
{
    public string Method => "applepay";
    public bool Supports(decimal amount) => amount is >= 0.5m and <= 10_000m;
    public PaymentResult Process(decimal amount) => PaymentResult.Success(amount);
}

public class PaymentProcessor(IEnumerable<IPaymentProvider> providers)
{
    public PaymentResult Process(string method, decimal amount)
    {
        var provider = providers.FirstOrDefault(p => p.Method == method)
            ?? throw new NotSupportedException(method);
        return provider.Process(amount);
    }
}

Dodanie Apple Pay nie wymaga ani jednej linii zmiany w PaymentProcessor. Rejestrujesz nową klasę w kontenerze DI i gotowe, kod jest zamknięty na modyfikację, otwarty na rozszerzenie.

🔗 Rrozwiń temat w artykule „Wzorzec Strategia (Strategy Pattern)”

Reguła do zapamiętania:
jeśli dodanie nowego przypadku zmusza Cię do edycji istniejącej klasy, prawdopodobnie łamiesz OCP. Pomyśl o abstrakcji.


L — Liskov Substitution Principle

Podtyp musisz móc podstawić w miejsce typu bazowego bez psucia programu. Podklasa nie może osłabiać kontraktu klasy bazowej, nie wolno jej np. rzucać wyjątku tam, gdzie baza działała poprawnie.

L — Liskov Substitution Principle
L — Liskov Substitution Principle

Zły kod: dwa klasyczne złamania

// ❌ 1) Square dziedziczy po Rectangle, ale łamie niezmiennik
public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = base.Height = value; } // ustawiając Width zmieniasz Height!
    }
}

// ❌ 2) Struś to ptak, ale nie lata — override rzuca wyjątkiem
public class Ostrich : Bird
{
    public override void Fly() => throw new NotSupportedException("Strusie nie latają");
}

Sygnał alarmowy LSP jest prosty: NotImplementedException lub NotSupportedException w override to prawie zawsze znak, że hierarchia dziedziczenia jest na siłę.

Dobry kod: segreguj zachowania zamiast wymuszać dziedziczenie

// ✅ Rozdziel możliwości na osobne interfejsy
public interface IFlyingBird { void Fly(); }
public interface IRunningBird { void Run(); }

public sealed class Eagle : IFlyingBird { public void Fly() { /* leci */ } }
public sealed class Ostrich : IRunningBird { public void Run() { /* biegnie */ } }

// Metoda przyjmuje IFlyingBird — KAŻDY ptak na liście naprawdę potrafi latać
public void MakeAllFly(IEnumerable<IFlyingBird> birds)
{
    foreach (var bird in birds) bird.Fly(); // nigdy nie wybuchnie
}

Reguła do zapamiętania:
jeśli musisz „wyłączyć” odziedziczoną metodę, to nie jest relacja jest-czymś (is-a). Sięgnij po kompozycję lub mniejsze interfejsy.


I — Interface Segregation Principle

Klient nie powinien być zmuszany do zależności od metod, których nie używa. Lepiej mieć kilka małych, wyspecjalizowanych interfejsów niż jeden „gruby”.

I — Interface Segregation Principle
I — Interface Segregation Principle

Zły kod: interfejs-potwór

// ❌ 13 metod w jednym interfejsie — klasa CSV musi „udawać", że umie wszystko
public interface IReportServiceBad
{
    string GenerateHtml();
    string GenerateCsv();
    byte[] GeneratePdf();
    Task SendByEmailAsync();
    Task SendByFaxAsync();      // serio, faks?
    Task UploadToFtpAsync();
    void SaveToDatabase();
    void PushToS3();
    // ...i jeszcze 5 innych
}

public class CsvReportBad : IReportServiceBad
{
    public string GenerateCsv() => "id,name\n1,Laptop";
    // 8 metod, których CSV nie potrzebuje:
    public byte[] GeneratePdf() => throw new NotImplementedException();
    public Task SendByFaxAsync() => throw new NotImplementedException();
    // ...martwy kod ×6
}

Osiem NotImplementedException to osiem pułapek czekających na produkcji.

Dobry kod: małe interfejsy = role

// ✅ Każda klasa implementuje TYLKO to, czego faktycznie używa
public interface ICsvExporter { string ToCsv(); }
public interface IPdfExporter { byte[] ToPdf(); }
public interface IReportArchiver { void Archive(byte[] data); }

public sealed class SalesReportGenerator : ICsvExporter
{
    public string ToCsv() => "id,name\n1,Laptop"; // i tyle — żadnych martwych metod
}

Reguła do zapamiętania:
jeśli implementacja interfejsu zmusza Cię do pisania throw new NotImplementedException(), interfejs jest za szeroki. Podziel go.


D — Dependency Inversion Principle

Moduły wysokopoziomowe nie powinny zależeć od niskopoziomowych, oba mają zależeć od abstrakcji. Szczegóły zależą od abstrakcji, nie odwrotnie.

Zły kod: new w ciele klasy

// ❌ Serwis sam tworzy konkretne zależności — nie da się go przetestować bez SMTP
public class OrderNotificationServiceBad
{
    private readonly SmtpEmailSenderBad _email = new();  // sztywne powiązanie
    private readonly SmsGatewayBad _sms = new();

    public void NotifyOrderConfirmed(int orderId, string contact)
    {
        _email.Send(contact, $"Zamówienie {orderId} potwierdzone");
        _sms.Send(contact, $"Zamówienie {orderId}");
    }
}

Dobry kod: zależność od abstrakcji + wstrzykiwanie

// ✅ Wspólna abstrakcja kanału
public interface INotificationChannel
{
    bool IsEnabled { get; }
    Task SendAsync(string message, CancellationToken ct = default);
}

// Serwis nie wie, czy to SMTP, SMS czy Slack — zna tylko interfejs
public class OrderNotificationService(IEnumerable<INotificationChannel> channels)
{
    public async Task NotifyOrderConfirmedAsync(int orderId, string recipient)
    {
        var message = $"Zamówienie {orderId} potwierdzone";
        var tasks = channels.Where(c => c.IsEnabled)
                            .Select(c => c.SendAsync(message));
        await Task.WhenAll(tasks);
    }
}

⚠️ Częsta pułapka rekrutacyjna:
DIP to zasada architektoniczna, a Dependency Injection to mechanizm, który ją realizuje. Można używać kontenera DI i wstrzykiwać konkrety — wtedy masz DI bez DIP. Inwersja zaczyna się od abstrakcji.

🔗 „Dependency Injection w .NET- Refaktoryzacja do Clean Architecture”

Reguła do zapamiętania:
widzisz new KonkretnaKlasa() w ciele serwisu?
To kandydat do wstrzyknięcia przez interfejs.


Dlaczego kod SOLID testuje się bez infrastruktury

kod SOLID testuje się bez infrastruktury
kod SOLID testuje się bez infrastruktury

To najmocniejszy, namacalny argument za stosowaniem SOLID i najlepszy moment na demonstrację „na żywo”. Cały przykładowy projekt ma 36 testów xUnit, które przechodzą na zielono bez ani jednej bazy danych, serwera SMTP czy połączenia sieciowego.

Jak to możliwe? Bo kod zależy od abstrakcji, więc w teście podstawiasz prostego fake’a:

// Fake kanału powiadomień — zamiast prawdziwego SMTP
public sealed class FakeNotificationChannel : INotificationChannel
{
    public List<string> Sent { get; } = new();
    public bool IsEnabled => true;
    public Task SendAsync(string message, CancellationToken ct = default)
    {
        Sent.Add(message);   // zamiast wysyłać, zapamiętujemy
        return Task.CompletedTask;
    }
}

[Fact]
public async Task NotifyOrderConfirmed_SendsToAllChannels()
{
    var fake = new FakeNotificationChannel();
    var service = new OrderNotificationService(new[] { fake });

    await service.NotifyOrderConfirmedAsync(orderId: 42, "jan@test.pl");

    Assert.Single(fake.Sent); // dowód, że wiadomość poszła — bez SMTP
}

Testowalność to nie cel SOLID to jego efekt uboczny. Jeśli klasa jest trudna do przetestowania, to zwykle sygnał, że łamie którąś z zasad.


Checklista: SOLID code review w 60 sekund

Zanim wrzucisz Pull Request, przejedź po tej liście:

  • [ ] S — Czy każda klasa ma jeden, jasny powód do zmiany? (test: „kto ją zmienia?”)
  • [ ] O — Czy dodanie nowego przypadku wymaga edycji istniejącej klasy?
  • [ ] L — Czy któryś override rzuca NotSupported/NotImplemented?
  • [ ] I — Czy implementacja interfejsu zostawia metody jako NotImplementedException?
  • [ ] D — Czy w ciele serwisu jest new KonkretnaKlasa() zamiast wstrzyknięcia?
  • [ ] Bonus — Czy potrafisz przetestować tę klasę bez bazy, sieci i SMTP?

Jeśli przy którymś punkcie odpowiadasz „tak” (poza S, gdzie chcesz „tak”), masz kandydata do refaktoryzacji.


Najczęstsze błędy juniorów przy SOLID

  • Over-engineering — interfejs dla klasy, która ma jedną implementację i nigdy nie będzie mieć drugiej. SOLID ma upraszczać zmianę, nie mnożyć abstrakcje na zapas.
  • Mylenie SRP z „jedną metodą” — SRP dotyczy powodów do zmiany, nie liczby metod. Klasa może mieć ich dziesięć i nadal mieć jedną odpowiedzialność.
  • DI ≠ DIP — wstrzykiwanie konkretnych typów to nadal brak inwersji.
  • Dziedziczenie zamiast kompozycji — większość złamań LSP bierze się z relacji „jest-czymś”, która tak naprawdę nią nie jest.

Podsumowanie

Zasady SOLID w C# to nie akademicka teoria do wykucia przed rozmową to praktyczne narzędzia, które codziennie decydują o tym, czy zmiana w kodzie zajmie Ci pięć minut, czy pięć godzin. Kluczowe wnioski:

  • S — jeden powód do zmiany na klasę.
  • O — rozszerzaj nowym kodem, nie edytuj istniejącego.
  • L — podtyp nie osłabia kontraktu bazy.
  • I — małe interfejsy zamiast jednego grubego.
  • D — zależ od abstrakcji, wstrzykuj implementacje.

A najlepszy test poprawności? Spróbuj napisać test jednostkowy bez infrastruktury. Jeśli się da — Twój kod prawdopodobnie szanuje SOLID.

🚀 Co dalej?

Chcesz zobaczyć wszystkie 5 zasad krok po kroku, z refaktoryzacją na żywo i 36 testami przechodzącymi w czasie rzeczywistym?

A teraz Ty: która z 5 zasad SOLID sprawia Ci w praktyce najwięcej kłopotu?
Napisz w komentarzu, czytam i odpowiadam na każdy. 👇

🔗 Przeczytaj również: „10 pytań rekrutacyjnych Junior .NET Developer (z odpowiedziami i kodem)” — SOLID to tylko jedno z nich.


Zobacz też

Zobacz także — powiązane artykuły

👉 MCP w .NET (C#) – jak zbudować serwer AI krok po kroku

👉 Tworzenie klas i obiektów w C# — kompletny przewodnik

👉 Pattern Matching w C# – switch expressions i type patterns

👉 sprawdź moje kursy .NET i C#.

Dodaj komentarz