Praca w IT

SOLID – 5 zasad dobrego kodu obiektowego

SOLID

Każdy programista był kiedyś w takim momencie: otwierasz plik napisany przez kogoś (albo przez siebie, pół roku temu), i już po dziesięciu minutach wiesz, że zmiana jednej linijki zepsuje pięć innych miejsc w aplikacji. SOLID to pięć zasad programowania obiektowego, które mają temu zapobiegać. Nie są magicznym zaklęciem ani gwarancją idealnego kodu, ale konsekwentnie stosowane realnie zmniejszają liczbę miejsc, w których pojedyncza zmiana wymaga przepisania połowy systemu. 

W tym artykule rozkładamy SOLID na czynniki pierwsze – z konkretnymi przykładami kodu, typowymi błędami i odpowiedzią na pytanie, kiedy te zasady faktycznie się przydają, a kiedy są zbędnym przerostem formy nad treścią.

Co to jest SOLID i skąd się wzięło

SOLID to akronim pięciu zasad projektowania obiektowego, które spopularyzował Robert C. Martin (znany w branży jako “Uncle Bob”) na przełomie lat 90. i początku XXI wieku. Same zasady wcześniej istniały jako osobne koncepcje, ale właśnie połączenie ich w jeden, łatwy do zapamiętania zestaw sprawiło, że stały się jednym z fundamentów nowoczesnego programowania obiektowego.

Każda litera akronimu odpowiada jednej zasadzie:

LiteraZasada
SSingle Responsibility Principle – zasada jednej odpowiedzialności
OOpen/Closed Principle – zasada otwarte-zamknięte
LLiskov Substitution Principle – zasada podstawienia Liskov
IInterface Segregation Principle – zasada segregacji interfejsów
DDependency Inversion Principle – zasada odwrócenia zależności

Co ważne: SOLID to nie konkretna technologia czy framework, a zestaw heurystyk. Działają niezależnie od języka – z powodzeniem stosuje się je w Javie, C#, PHP, Pythonie, TypeScripcie czy Kotlinie. Wymagają jednak, by język wspierał klasy, interfejsy i polimorfizm w sposób typowy dla programowania obiektowego.

Czy SOLID ma jeszcze sens w erze AI i szybkiego dostarczania kodu

Skoro coraz więcej kodu piszą generatory AI, a presja na szybkość wdrożeń stale rośnie, część zespołów pyta, czy formalne zasady projektowe wciąż mają znaczenie. Odpowiedź jest prosta: tak, i to coraz bardziej. Modele językowe potrafią w kilka sekund wygenerować funkcję, która działa, ale jest silnie sprzężona, słabo abstrahowana i trudna do bezpiecznej modyfikacji. Inżynier, który rozumie zasady projektowania, jest w stanie szybko ocenić kod wygenerowany przez AI i wychwycić problemy strukturalne, zanim się nawarstwią.

SOLID nie jest więc reliktem epoki przed AI – to wciąż jeden z najpraktyczniejszych sposobów odróżnienia kodu, który “działa”, od kodu, który da się bezpiecznie rozwijać przez lata.

1. Single Responsibility Principle (SRP) – zasada jednej odpowiedzialności

Klasyczna definicja Roberta C. Martina mówi, że klasa powinna mieć tylko jeden powód do zmiany. W praktyce oznacza to, że każda klasa (lub funkcja, moduł) powinna odpowiadać za jedną, ściśle określoną rzecz.

Zobaczmy przykład w Pythonie. Klasa, która łamie SRP, robi zbyt wiele naraz:

class Zamowienie:
    def __init__(self, produkty):
        self.produkty = produkty
 
    def oblicz_sume(self):
        return sum(p.cena for p in self.produkty)
 
    def zapisz_do_bazy(self):
        # logika SQL bezpośrednio w klasie Zamowienie
        ...
 
    def wyslij_email_potwierdzajacy(self):
        # logika wysyłki maila w tej samej klasie
        ...

Klasa Zamowienie odpowiada tu za logikę biznesową, zapis do bazy i wysyłkę e-maili. Zmiana dostawcy poczty albo bazy danych wymusza modyfikację tej samej klasy, co zwiększa ryzyko regresji. Rozwiązanie zgodne z SRP rozdziela te odpowiedzialności na osobne klasy:

class Zamowienie:
    def __init__(self, produkty):
        self.produkty = produkty
 
    def oblicz_sume(self):
        return sum(p.cena for p in self.produkty)
 
 
class ZamowienieRepozytorium:
    def zapisz(self, zamowienie: Zamowienie):
        ...
 
 
class PowiadomieniaEmail:
    def wyslij_potwierdzenie(self, zamowienie: Zamowienie):
        ...

Teraz zmiana sposobu wysyłki e-maili dotyka tylko PowiadomieniaEmail, a zmiana bazy danych – tylko ZamowienieRepozytorium. Klasa Zamowienie pozostaje czysto biznesowa.

Częsty błąd: traktowanie SRP jako “jedna metoda na klasę”. To nieporozumienie – chodzi o jeden powód do zmiany, nie o liczbę metod. Klasa może mieć dziesięć metod, jeśli wszystkie służą tej samej, dobrze zdefiniowanej odpowiedzialności.

2. Open/Closed Principle (OCP) – zasada otwarte-zamknięte

Zasada otwarte-zamknięte mówi, że klasy powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację. Innymi słowy: gdy pojawia się nowa funkcjonalność, najlepiej dodać nowy kod, a nie grzebać w istniejącym, działającym już mechanizmie.

Przykład łamiący OCP – obliczanie rabatu na podstawie typu klienta:

class Rabat:
    def oblicz(self, typ_klienta, cena):
        if typ_klienta == "standard":
            return cena
        elif typ_klienta == "premium":
            return cena * 0.9
        elif typ_klienta == "vip":
            return cena * 0.8
        # każdy nowy typ klienta = kolejny elif tutaj

Każdy nowy typ klienta wymaga edycji tej samej metody. To prosta droga do błędów – łatwo zapomnieć o jednym przypadku albo niechcący zepsuć inny. Wersja zgodna z OCP wykorzystuje polimorfizm:

from abc import ABC, abstractmethod
 
class StrategiaRabatu(ABC):
    @abstractmethod
    def oblicz(self, cena: float) -> float:
        ...
 
class RabatStandard(StrategiaRabatu):
    def oblicz(self, cena):
        return cena
 
class RabatPremium(StrategiaRabatu):
    def oblicz(self, cena):
        return cena * 0.9
 
class RabatVip(StrategiaRabatu):
    def oblicz(self, cena):
        return cena * 0.8
 
# Nowy typ klienta = nowa klasa, zero zmian w istniejącym kodzie
class RabatStudent(StrategiaRabatu):
    def oblicz(self, cena):
        return cena * 0.85

Dodanie rabatu studenckiego nie wymaga już dotykania kodu obsługującego standard, premium czy VIP. To właśnie sedno OCP – nowa funkcjonalność jako nowy kod, nie jako modyfikacja sprawdzonego mechanizmu.

3. Liskov Substitution Principle (LSP) – zasada podstawienia Liskov

Zasada sformułowana przez Barbarę Liskov mówi, że obiekty klasy bazowej powinny być wymienne na obiekty klas pochodnych bez zmiany zachowania programu. Jeśli klasa B dziedziczy po klasie A, to wszędzie, gdzie używamy A, powinniśmy móc bezpiecznie wstawić B.

Klasyczny przykład łamania LSP – kwadrat jako podklasa prostokąta:

class Prostokat:
    def __init__(self, szerokosc, wysokosc):
        self.szerokosc = szerokosc
        self.wysokosc = wysokosc
 
    def ustaw_szerokosc(self, w):
        self.szerokosc = w
 
    def ustaw_wysokosc(self, h):
        self.wysokosc = h
 
    def pole(self):
        return self.szerokosc * self.wysokosc
 
 
class Kwadrat(Prostokat):
    def ustaw_szerokosc(self, w):
        self.szerokosc = w
        self.wysokosc = w  # wymusza ten sam wymiar
 
    def ustaw_wysokosc(self, h):
        self.szerokosc = h
        self.wysokosc = h

Matematycznie kwadrat to prostokąt, więc dziedziczenie wydaje się naturalne. Problem pojawia się w praktyce: funkcja, która oczekuje obiektu Prostokat i niezależnie zmienia szerokość i wysokość, dla Kwadrat da nieoczekiwany wynik – zmiana jednego wymiaru niejawnie zmienia drugi. Kwadrat nie jest więc bezpiecznym podstawieniem Prostokat, co łamie LSP.

Lepszym rozwiązaniem jest osobna hierarchia bez wymuszania relacji “jest-a” tam, gdzie zachowanie się rozjeżdża – na przykład wspólny interfejs Figura z metodą pole(), implementowany niezależnie przez Prostokat i Kwadrat, bez dziedziczenia jednego po drugim.

LSP to często najbardziej niedoceniana zasada SOLID – łamie się ją niemal niezauważalnie, dziedzicząc “bo to logicznie pasuje”, a nie sprawdzając, czy podklasa faktycznie zachowuje się tak, jak oczekuje tego kod korzystający z klasy bazowej.

4. Interface Segregation Principle (ISP) – zasada segregacji interfejsów

ISP mówi, że klienci nie powinni być zmuszani do zależenia od interfejsów, których nie używają. Lepiej mieć kilka małych, wyspecjalizowanych interfejsów niż jeden duży “worek” z metodami dla wszystkich możliwych przypadków.

Przykład łamiący ISP w TypeScripcie – jeden przeładowany interfejs pracownika:

interface Pracownik {
  obliczWynagrodzenie(): number;
  generujRaportSprzedazy(): string;
  zarzadzajZespolem(): void;
}
 
class Programista implements Pracownik {
  obliczWynagrodzenie(): number { return 12000; }
  generujRaportSprzedazy(): string {
    throw new Error("Programista nie generuje raportów sprzedaży");
  }
  zarzadzajZespolem(): void {
    throw new Error("Programista nie zarządza zespołem");
  }
}

Klasa Programista musi implementować metody, które dla niej nie mają sensu, i “udawać” zgodność z interfejsem przez wyrzucanie wyjątków. To sygnał, że interfejs jest zbyt szeroki. Rozwiązanie zgodne z ISP rozbija go na mniejsze, dedykowane fragmenty:

interface MaWynagrodzenie {
  obliczWynagrodzenie(): number;
}
 
interface GenerujeRaportySprzedazy {
  generujRaportSprzedazy(): string;
}
 
interface ZarzadzaZespolem {
  zarzadzajZespolem(): void;
}
 
class Programista implements MaWynagrodzenie {
  obliczWynagrodzenie(): number { return 12000; }
}
 
class KierownikSprzedazy implements MaWynagrodzenie, GenerujeRaportySprzedazy, ZarzadzaZespolem {
  obliczWynagrodzenie(): number { return 15000; }
  generujRaportSprzedazy(): string { return "..."; }
  zarzadzajZespolem(): void { /* ... */ }
}

Teraz każda klasa implementuje tylko te interfejsy, które faktycznie odpowiadają jej roli. Brak sztucznych, niewykorzystywanych metod oznacza mniej miejsc, w których kod może się “wysypać” w czasie działania.

5. Dependency Inversion Principle (DIP) – zasada odwrócenia zależności

DIP to być może najczęściej cytowana, a rzadko kiedy w pełni rozumiana zasada SOLID. Mówi ona, że moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych – obie strony powinny zależeć od abstrakcji.

Przykład łamiący DIP – logika biznesowa zależna od konkretnej implementacji bazy danych:

class MySQLBazaDanych:
    def zapisz_uzytkownika(self, uzytkownik):
        print(f"Zapis {uzytkownik} do MySQL")
 
 
class RejestracjaUzytkownika:
    def __init__(self):
        self.baza = MySQLBazaDanych()  # twarda zależność od konkretnej klasy
 
    def zarejestruj(self, uzytkownik):
        self.baza.zapisz_uzytkownika(uzytkownik)

Klasa RejestracjaUzytkownika jest tu sztywno powiązana z MySQLBazaDanych. Zmiana bazy na PostgreSQL albo dodanie testów jednostkowych z mockiem wymaga edycji logiki biznesowej. DIP odwraca tę zależność – obie klasy zależą od wspólnej abstrakcji:

from abc import ABC, abstractmethod
 
class BazaDanych(ABC):
    @abstractmethod
    def zapisz_uzytkownika(self, uzytkownik):
        ...
 
class MySQLBazaDanych(BazaDanych):
    def zapisz_uzytkownika(self, uzytkownik):
        print(f"Zapis {uzytkownik} do MySQL")
 
class PostgresBazaDanych(BazaDanych):
    def zapisz_uzytkownika(self, uzytkownik):
        print(f"Zapis {uzytkownik} do PostgreSQL")
 
class RejestracjaUzytkownika:
    def __init__(self, baza: BazaDanych):
        self.baza = baza  # zależność wstrzykiwana z zewnątrz
 
    def zarejestruj(self, uzytkownik):
        self.baza.zapisz_uzytkownika(uzytkownik)

Teraz RejestracjaUzytkownika nie wie nic o MySQL ani Postgresie – zna jedynie abstrakcję BazaDanych. Konkretną implementację “wstrzykujemy” z zewnątrz, co ułatwia testowanie (można podać atrapę bazy) i zmianę technologii bez ingerencji w logikę biznesową. To mechanizm znany jako dependency injection – jedna z najbardziej praktycznych konsekwencji DIP.

SOLID w pigułce – tabela podsumowująca

LiteraZasadaNajkrócej
SSingle ResponsibilityJedna klasa = jeden powód do zmiany.
OOpen/ClosedRozszerzaj przez nowy kod, nie modyfikuj istniejącego.
LLiskov SubstitutionPodklasa musi działać tam, gdzie działa klasa bazowa.
IInterface SegregationMałe, wyspecjalizowane interfejsy zamiast jednego “worka”.
DDependency InversionZależność od abstrakcji, nie od konkretnych implementacji.

Kiedy SOLID pomaga, a kiedy szkodzi – clean code SOLID w praktyce

SOLID nie jest dogmatem, który trzeba spełnić w stu procentach w każdym projekcie. To zestaw narzędzi do podejmowania decyzji projektowych, a nie checklista do mechanicznego odhaczania.

Kiedy SOLID się sprawdza

  • Duże, długo żyjące systemy, które będą rozwijane przez lata przez wiele osób.
  • Projekty zespołowe, gdzie kod czytają i modyfikują inni ludzie niż jego autor.
  • Aplikacje, w których oczekujemy częstych zmian wymagań biznesowych.
  • Kod wymagający solidnego pokrycia testami jednostkowymi.

Kiedy SOLID może być przesadą

  • Szybkie prototypy i skrypty jednorazowe, gdzie liczy się czas dostarczenia, nie elegancja architektury.
  • Bardzo małe projekty, w których wprowadzenie pięciu warstw abstrakcji generuje więcej kodu niż realnie rozwiązuje problemy.
  • Sytuacje, w których wymagania są jeszcze niejasne – zbyt wczesna abstrakcja “na wszelki wypadek” bywa kosztowna do zmiany, gdy okazuje się błędna.

Doświadczeni programiści często traktują Single Responsibility i Dependency Inversion jako te zasady, od których warto zacząć, jeśli nie da się zastosować wszystkich pięciu naraz – adresują one najczęstsze realne problemy: rozrośnięte klasy i sztywne, trudne do testowania zależności.

Podsumowanie

SOLID to pięć zasad – Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation i Dependency Inversion – które pomagają pisać kod obiektowy łatwiejszy do utrzymania, testowania i rozszerzania. Nie są one specyfiką jednego języka czy frameworka – to ponadczasowe heurystyki projektowe, które sprawdzają się od ponad dwudziestu lat i, jak pokazuje praktyka zespołów pracujących z kodem generowanym przez AI, wciąż zyskują na znaczeniu, zamiast je tracąc.

Najważniejsza rada na koniec: nie ucz się SOLID, żeby “mieć ładny kod na papierze”. Ucz się SOLID, żeby za pół roku, gdy wrócisz do własnego projektu, nie musieć go przepisywać od zera.

Redaktorka, dziennikarka i copywriterka, autorka wywiadów, tekstów eksperckich, newsów poświęconych branży IT (i nie tylko).

Podobne artykuły