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.
| Litera | Zasada | W skrócie |
|---|---|---|
| S | Single Responsibility Principle | Jedna klasa = jedna odpowiedzialność, jeden powód do zmiany. |
| O | Open/Closed Principle | Otwarte na rozszerzenie, zamknięte na modyfikację. |
| L | Liskov Substitution Principle | Podklasa musi dać się podstawić zamiast klasy bazowej bez niespodzianek. |
| I | Interface Segregation Principle | Lepiej kilka małych interfejsów niż jeden wielki "kombajn". |
| D | Dependency Inversion Principle | Zależność od abstrakcji (interfejsu), nie od konkretnej implementacji. |
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.
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);
}
}
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());
}
}
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.
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;
}
}
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;
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.
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; }
}
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!';
}
}
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.
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
}
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);
}
}
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.
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()
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());