SOLID — Zasady Projektowania Obiektowego

Pięć zasad dobrego projektowania kodu obiektowego, sformułowanych przez Roberta C. Martina ("Uncle Boba"). Każda zasada wytłumaczona najprościej jak się da, z analogią oraz przykładem złego i dobrego podejścia w PHP i JavaScript.

Szybki przegląd

LiteraZasadaW skrócie
SSingle Responsibility PrincipleJedna klasa = jedna odpowiedzialność, jeden powód do zmiany.
OOpen/Closed PrincipleOtwarte na rozszerzenie, zamknięte na modyfikację.
LLiskov Substitution PrinciplePodklasa musi dać się podstawić zamiast klasy bazowej bez niespodzianek.
IInterface Segregation PrincipleLepiej kilka małych interfejsów niż jeden wielki "kombajn".
DDependency Inversion PrincipleZależność od abstrakcji (interfejsu), nie od konkretnej implementacji.

SSingle Responsibility Principle (Zasada Pojedynczej Odpowiedzialności)

Klasa powinna mieć tylko JEDEN powód, żeby się zmienić. Jeśli opisując ją używasz słowa "i" ("zapisuje użytkownika I wysyła maila I loguje zdarzenie") — robi za dużo.

Wyobraź sobie pracownika restauracji, który jednocześnie gotuje, obsługuje stolik i prowadzi księgowość. Gdy zmieni się przepis podatkowy, ten sam człowiek musi przerwać gotowanie, żeby to ogarnąć — a klienci czekają głodni. Lepiej mieć osobnych specjalistów: kucharza, kelnera, księgowego. Tak samo klasa w kodzie powinna mieć jedno, wąskie zadanie.

PHP

❌ Źle — klasa User zna się na bazie danych i na wysyłce maili
class User
{
    public function __construct(public string $name, public string $email) {}

    public function saveToDatabase(): void
    {
        // User nie powinien wiedzieć NIC o bazie danych!
        $pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
        $stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
        $stmt->execute([$this->name, $this->email]);
    }

    public function sendWelcomeEmail(): void
    {
        // User nie powinien wiedzieć NIC o wysyłce maili!
        mail($this->email, 'Witaj!', 'Dzięki za rejestrację, ' . $this->name);
    }
}
✅ Dobrze — każda klasa robi jedną rzecz
class User
{
    public function __construct(
        public readonly string $name,
        public readonly string $email
    ) {}
}

class UserRepository
{
    public function __construct(private PDO $pdo) {}

    public function save(User $user): void
    {
        $stmt = $this->pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
        $stmt->execute([$user->name, $user->email]);
    }
}

class WelcomeMailer
{
    public function send(User $user): void
    {
        mail($user->email, 'Witaj!', 'Dzięki za rejestrację, ' . $user->name);
    }
}

JavaScript

❌ Źle — Invoice umie się liczyć, drukować i zapisywać do pliku
class Invoice {
  constructor(items) {
    this.items = items;
  }

  calculateTotal() {
    return this.items.reduce((sum, i) => sum + i.price, 0);
  }

  // Invoice nie powinien umieć się "drukować" ani zapisywać do pliku!
  printInvoice() {
    console.log(`Faktura na kwotę: ${this.calculateTotal()} zł`);
  }

  saveToFile() {
    const fs = require('fs');
    fs.writeFileSync('invoice.txt', this.calculateTotal().toString());
  }
}
✅ Dobrze — rozdzielone odpowiedzialności
class Invoice {
  constructor(items) {
    this.items = items;
  }

  calculateTotal() {
    return this.items.reduce((sum, i) => sum + i.price, 0);
  }
}

class InvoicePrinter {
  print(invoice) {
    console.log(`Faktura na kwotę: ${invoice.calculateTotal()} zł`);
  }
}

class InvoiceStorage {
  saveToFile(invoice, fs) {
    fs.writeFileSync('invoice.txt', invoice.calculateTotal().toString());
  }
}

OOpen/Closed Principle (Zasada Otwarte-Zamknięte)

Kod powinien dać się ROZSZERZAĆ o nowe funkcje, bez grzebania w starym, już działającym i przetestowanym kodzie.

To jak z ładowarką USB — producent telefonu nie przerabia portu za każdym razem, gdy powstaje nowy model kabla, bo port jest "otwarty" na wszystko, co pasuje do standardu. W kodzie: zamiast doklejać kolejny "if" za każdym nowym przypadkiem, projektujesz tak, żeby nowy przypadek = nowa klasa/nowy wpis, a stary kod zostaje nietknięty.

PHP

❌ Źle — nowy kształt = kolejny "if" w środku
class AreaCalculator
{
    public function calculate(array $shapes): float
    {
        $total = 0;
        foreach ($shapes as $shape) {
            // każdy nowy kształt = kolejny "if" tutaj, musisz WRACAĆ do tego kodu
            if ($shape instanceof Circle) {
                $total += pi() * $shape->radius ** 2;
            } elseif ($shape instanceof Square) {
                $total += $shape->side ** 2;
            }
            // ...i tak w nieskończoność dla każdego nowego kształtu
        }
        return $total;
    }
}
✅ Dobrze — nowy kształt = nowa klasa, zero zmian gdzie indziej
interface Shape
{
    public function area(): float;
}

class Circle implements Shape
{
    public function __construct(private float $radius) {}
    public function area(): float { return pi() * $this->radius ** 2; }
}

class Square implements Shape
{
    public function __construct(private float $side) {}
    public function area(): float { return $this->side ** 2; }
}

class AreaCalculator
{
    public function calculate(array $shapes): float
    {
        $total = 0;
        foreach ($shapes as $shape) {
            $total += $shape->area(); // nowy kształt? zero zmian w tej klasie!
        }
        return $total;
    }
}

JavaScript

❌ Źle — nowy typ rabatu = kolejny "else if"
function calculateDiscount(type, price) {
  if (type === 'student') {
    return price * 0.9;
  } else if (type === 'senior') {
    return price * 0.85;
  } else if (type === 'employee') {
    return price * 0.8;
  }
  return price;
}
✅ Dobrze — nowy rabat to nowy wpis, funkcja się nie zmienia
const discountStrategies = {
  student: price => price * 0.9,
  senior: price => price * 0.85,
  employee: price => price * 0.8,
};

function calculateDiscount(type, price) {
  const strategy = discountStrategies[type] ?? (price => price);
  return strategy(price);
}

// Nowy rabat? Dopisujesz linijkę do obiektu, nie ruszasz funkcji.
discountStrategies.vip = price => price * 0.7;

LLiskov Substitution Principle (Zasada Podstawienia Liskov)

Jeśli klasa B dziedziczy po klasie A, to wszędzie tam, gdzie kod używa A, musisz móc bez obaw podstawić B — program ma dalej działać poprawnie.

Kwadrat matematycznie jest prostokątem, ale w kodzie: jeśli "Kwadrat" dziedziczy po "Prostokąt" i przy zmianie jednego boku zmienia oba naraz, to każdy kod pisany z myślą o Prostokącie nagle dostaje inny wynik. Podobnie ptak, który "musi" latać, i pingwin, który dziedziczy po Ptaku, a w metodzie latania rzuca wyjątkiem — to ten sam problem.

PHP

❌ Źle — Square "poprawia" zachowanie rodzica i psuje kod, który go używa
class Rectangle
{
    protected float $width;
    protected float $height;

    public function setWidth(float $w): void { $this->width = $w; }
    public function setHeight(float $h): void { $this->height = $h; }
    public function area(): float { return $this->width * $this->height; }
}

class Square extends Rectangle
{
    public function setWidth(float $w): void { $this->width = $this->height = $w; }
    public function setHeight(float $h): void { $this->width = $this->height = $h; }
}

function test(Rectangle $r): void
{
    $r->setWidth(5);
    $r->setHeight(4);
    // Dla Rectangle: 20. Dla Square: 16. Ten sam kod - różny wynik!
    echo $r->area();
}
✅ Dobrze — Square to osobny, uczciwy typ, nie udaje Rectangle
interface HasArea
{
    public function area(): float;
}

class Rectangle implements HasArea
{
    public function __construct(protected float $width, protected float $height) {}
    public function area(): float { return $this->width * $this->height; }
}

class Square implements HasArea
{
    public function __construct(private float $side) {}
    public function area(): float { return $this->side ** 2; }
}

JavaScript

❌ Źle — Penguin "jest" ptakiem, ale wywali się na fly()
class Bird {
  fly() {
    return 'Lecę wysoko!';
  }
}

class Penguin extends Bird {
  fly() {
    // Pingwin "jest" ptakiem, ale nie lata - kod, który wywoła fly(), się wywali
    throw new Error('Pingwiny nie latają!');
  }
}

function makeItFly(bird) {
  console.log(bird.fly()); // działa dla Bird, wybucha dla Penguin
}
✅ Dobrze — Penguin nigdy nie obiecuje umiejętności, której nie ma
class Bird {}

class FlyingBird extends Bird {
  fly() {
    return 'Lecę wysoko!';
  }
}

class Penguin extends Bird {
  swim() {
    return 'Płynę szybko!';
  }
}

IInterface Segregation Principle (Zasada Segregacji Interfejsów)

Nie zmuszaj klasy do implementowania metod, których nigdy nie użyje. Lepiej kilka małych, wyspecjalizowanych interfejsów niż jeden wielki "kombajn".

To jak pilot do telewizora z 200 przyciskami, z których używasz 5 — reszta to szum i ryzyko pomyłki. Albo restauracja, w której chcesz zamówić tylko frytki, a jesteś zmuszony "zamówić" całe 50-stronicowe menu degustacyjne.

PHP

❌ Źle — RobotWorker musi mieć eat(), choć nigdy nie je
interface Worker
{
    public function work(): void;
    public function eat(): void;
}

class Human implements Worker
{
    public function work(): void { echo 'Pracuję'; }
    public function eat(): void { echo 'Jem obiad'; }
}

class RobotWorker implements Worker
{
    public function work(): void { echo 'Pracuję 24/7'; }
    // Robot MUSI mieć eat(), mimo że nigdy nie je - klasyczny zgrzyt
    public function eat(): void { throw new Exception('Roboty nie jedzą!'); }
}
✅ Dobrze — wąskie interfejsy, klasa bierze tylko to, czego potrzebuje
interface Workable
{
    public function work(): void;
}

interface Eatable
{
    public function eat(): void;
}

class Human implements Workable, Eatable
{
    public function work(): void { echo 'Pracuję'; }
    public function eat(): void { echo 'Jem obiad'; }
}

class RobotWorker implements Workable
{
    public function work(): void { echo 'Pracuję 24/7'; }
    // Nie ma eat() - i nie musi, bo interfejs jest wąski i konkretny
}

JavaScript

❌ Źle — "gruby" interfejs wymusza metody, których część klientów nie potrzebuje
class MultiFunctionPrinter {
  print(doc) { console.log('Drukuję', doc); }
  scan(doc) { console.log('Skanuję', doc); }
  fax(doc) { console.log('Faksuję', doc); }
}

class OldPrinter extends MultiFunctionPrinter {
  scan() {
    throw new Error('Ta drukarka nie skanuje!');
  }
  fax() {
    throw new Error('Ta drukarka nie faksuje!');
  }
}
✅ Dobrze — OldPrinter bierze tylko to, czego faktycznie potrzebuje
class OldPrinter {
  print(doc) {
    console.log('Drukuję', doc);
  }
}

class ScannerMixin {
  scan(doc) {
    console.log('Skanuję', doc);
  }
}

class FaxMixin {
  fax(doc) {
    console.log('Faksuję', doc);
  }
}

DDependency Inversion Principle (Zasada Odwrócenia Zależności)

Logika biznesowa nie powinna zależeć bezpośrednio od konkretnej implementacji (konkretnej bazy, konkretnego mailera). Obie strony mają zależeć od wspólnej abstrakcji (interfejsu).

Lampka biurkowa nie jest przylutowana na stałe do elektrowni — wpina się do standardowego gniazdka. Gniazdko to "abstrakcja". Elektrownia (albo generator, albo panel słoneczny) może się zmieniać, a lampka o tym nawet nie wie.

PHP

❌ Źle — OrderService jest "przyklejony" na sztywno do MySQL
class MySQLDatabase
{
    public function save(string $data): void { /* zapis do MySQL */ }
}

class OrderService
{
    private MySQLDatabase $db;

    public function __construct()
    {
        // OrderService jest "przyklejony" na sztywno do MySQL
        $this->db = new MySQLDatabase();
    }

    public function placeOrder(string $orderData): void
    {
        $this->db->save($orderData);
    }
}
// Chcesz przejść na PostgreSQL albo podstawić mocka w testach? Musisz ruszać OrderService.
✅ Dobrze — zależność wstrzyknięta z zewnątrz, przez interfejs
interface Database
{
    public function save(string $data): void;
}

class MySQLDatabase implements Database
{
    public function save(string $data): void { /* zapis do MySQL */ }
}

class PostgresDatabase implements Database
{
    public function save(string $data): void { /* zapis do Postgres */ }
}

class OrderService
{
    public function __construct(private Database $db) {}

    public function placeOrder(string $orderData): void
    {
        $this->db->save($orderData);
    }
}

// $service = new OrderService(new MySQLDatabase()); // albo new PostgresDatabase()

JavaScript

❌ Źle — NotificationService sam sobie tworzy konkretnego nadawcę
class EmailSender {
  send(message) {
    console.log('Email:', message);
  }
}

class NotificationService {
  constructor() {
    // Sztywno "wklejony" konkretny nadawca - trudno to podmienić czy przetestować
    this.sender = new EmailSender();
  }

  notify(message) {
    this.sender.send(message);
  }
}
✅ Dobrze — dowolny "sender" pasuje, wstrzyknięty z zewnątrz
class EmailSender {
  send(message) {
    console.log('Email:', message);
  }
}

class SmsSender {
  send(message) {
    console.log('SMS:', message);
  }
}

class NotificationService {
  constructor(sender) {
    this.sender = sender;
  }

  notify(message) {
    this.sender.send(message);
  }
}

// const service = new NotificationService(new SmsSender());

SŁOWNICZEK POJĘĆ