Dobra architektura zaczyna się od czystego kodu. Musimy wiedzieć, jak łączyć ze sobą klasy i komponenty oraz jak rozmieszczać odpowiedzialności, aby system od początku był otwarty na rozwój, a nie zamknięty na zmiany.
Do tego właśnie powstały reguły SOLID, które są niezależne od języków programowania, można je zastosować w każdym kodzie.
SOLID składa się z następujących reguł:
SRP – Single Responsibility Principle – reguła jednej odpowiedzialności
Sedno zasady:
Klasa lub moduł powinien mieć tylko jeden powód do zmiany. Innymi słowy, powinien odpowiadać przed jednym „aktorem” czyli jedną grupą odbiorców, np. zespołem lub obszarem biznesowym.
W praktyce:
Jeśli dział księgowości i marketingu potrzebują zmian w tym samym fragmencie kodu, to znaczy, że łamiesz SRP. Rozdziel odpowiedzialności tak, aby każda część kodu służyła tylko jednemu aktorowi.
❌ Zły przykład:
class SalesReport {
generateReport(data: Sale[]): string {
// calculation logic
const result = this.calculateStatistics(data);
// formatting for accounting - ❌ tutaj księgowość będzie chciała zmian
return this.formatForAccounting(result);
}
private calculateStatistics(data: Sale[]) { /* ... */ }
private formatForAccounting(result: any) { /* ... */ }
}
// Problem: Jak marketing też zechce raportu, będziesz musiał modyfikować tę klasęProblem: Gdy marketing będzie chciał inny format raportu, będziesz musiał modyfikować tę samą klasę, którą używa księgowość. Dwa działy (dwóch aktorów) = dwa powody do zmiany = złamanie SRP.
✅ Dobry przykład:
// Single responsibility: calculation - zmienia się tylko gdy zmieni się logika obliczeń
class SalesCalculator {
calculateStatistics(data: Sale[]) { /* ... */ }
}
// Single responsibility: formatting for accounting - zmienia się tylko gdy księgowość chce innego formatu
class AccountingFormatter {
format(statistics: any): string { /* ... */ }
}
// Single responsibility: formatting for marketing - zmienia się tylko gdy marketing chce innego formatu
class MarketingFormatter {
format(statistics: any): string { /* ... */ }
}
// Teraz każda klasa ma JEDEN powód do zmianyOCP – Open-Closed Principle – reguła otwarty-zamknięty
Sedno zasady:
Kod powinien być otwarty na rozbudowę (możesz dodawać nowe funkcje), ale zamknięty na modyfikację (nie zmieniasz istniejącego kodu).
W praktyce:
Gdy pojawia się nowa reguła biznesowa, dodajesz nową klasę/funkcję, ale NIE modyfikujesz starego działającego kodu. Dzięki temu nie ryzykujesz zepsucia tego co już działa.
❌ Zły przykład:
class DiscountSystem {
calculateDiscount(type: string, price: number): number {
if (type === "REGULAR_CUSTOMER") {
return price * 0.9;
} else if (type === "VIP") {
return price * 0.8;
} else if (type === "NEW") {
return price * 0.95;
}
// ❌ Nowy rabat? Modyfikujesz tę metodę dodając kolejny if
return price;
}
}✅ Dobry przykład:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularCustomerDiscount implements DiscountStrategy {
calculate(price: number) { return price * 0.9; }
}
class VIPDiscount implements DiscountStrategy {
calculate(price: number) { return price * 0.8; }
}
// ✓ Nowy rabat? Dodajesz nową klasę, NIE dotykasz DiscountSystem
class SeasonalDiscount implements DiscountStrategy {
calculate(price: number) { return price * 0.85; }
}
class DiscountSystem {
calculateDiscount(strategy: DiscountStrategy, price: number): number {
return strategy.calculate(price); // Ta metoda NIE zmienia się przy nowych rabatach
}
}LSP – Liskov Substitution Principle – reguła podstawiania
Sedno zasady:
Obiekt klasy pochodnej powinien dać się użyć wszędzie tam, gdzie używany jest obiekt klasy bazowej, i program nadal musi działać poprawnie, bez niespodzianek, błędów i zmiany zachowania.
W praktyce:
Jeśli jakaś klasa dziedziczy po innej, to musi dotrzymywać wszystkich obietnic klasy bazowej. Kod, który działa z typem bazowym, nie może się „zepsuć” po podstawieniu klasy pochodnej.
Jeśli Twoja funkcja oczekuje obiektu typu A, to musi on działać poprawnie z każdą jego odmianą (C, D itd.), bez żadnych „ale”. Klasa pochodna nie może być „wybrakowaną” wersją rodzica. Nie może nagle powiedzieć: „Tak, jestem Płatnością, ale ja akurat nie potrafię robić tego, co inne płatności”.
❌ Zły przykład:
class PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing payment: $${amount}`);
}
// Klasa bazowa obiecuje że każda płatność może być zwrócona
refund(amount: number): void {
console.log(`Refunding: $${amount}`);
}
}
class CreditCardPayment extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Charging credit card: $${amount}`);
}
// Karty kredytowe wspierają zwroty - OK
refund(amount: number): void {
console.log(`Refunding to credit card: $${amount}`);
}
}
class CashPayment extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Receiving cash: $${amount}`);
}
// ❌ Gotówka NIE wspiera zwrotów - łamie "umowę" klasy bazowej!
// Klasa obiecała że refund() działa, a tutaj rzuca błędem
refund(amount: number): void {
throw new Error("Cannot refund cash payments!");
}
}
// Test pokazujący problem
function handlePaymentAndRefund(processor: PaymentProcessor, amount: number) {
processor.processPayment(amount);
// Oczekujemy że refund() zadziała dla każdego PaymentProcessor
processor.refund(amount / 2);
}
handlePaymentAndRefund(new CreditCardPayment(), 100); // ✓ Działa
handlePaymentAndRefund(new CashPayment(), 100); // ✗ Błąd! CashPayment nie zachowuje się jak PaymentProcessorW przykładzie funkcja handlePaymentAndRefund oczekuje, że każda implementacja PaymentProcessor obsługuje zwroty. CashPayment łamie ten kontrakt, przez co nie może bezpiecznie zastąpić klasy bazowej, to naruszenie LSP.
✅ Dobry przykład:
// Bazowy interfejs - tylko to co wspólne dla WSZYSTKICH płatności
interface PaymentProcessor {
processPayment(amount: number): void;
}
// Rozszerzony interfejs - tylko dla płatności które WSPIERAJĄ zwroty
interface RefundablePayment extends PaymentProcessor {
refund(amount: number): void;
}
// Karta kredytowa wspiera zwroty - implementuje RefundablePayment
class CreditCardPayment implements RefundablePayment {
processPayment(amount: number): void {
console.log(`Charging credit card: $${amount}`);
}
// ✓ Obiecaliśmy refund() i go dostarczamy
refund(amount: number): void {
console.log(`Refunding to credit card: $${amount}`);
}
}
// Gotówka NIE wspiera zwrotów - implementuje tylko PaymentProcessor
class CashPayment implements PaymentProcessor {
processPayment(amount: number): void {
console.log(`Receiving cash: $${amount}`);
}
// ✓ Nie ma metody refund() - nie obiecujemy zwrotów dla gotówki
}
// Funkcja dla WSZYSTKICH płatności - tylko przetwarzanie
function processPayment(processor: PaymentProcessor, amount: number) {
processor.processPayment(amount); // Działa dla KAŻDEJ płatności
}
// Funkcja TYLKO dla płatności ze zwrotami
function processRefundablePayment(processor: RefundablePayment, amount: number) {
processor.processPayment(amount);
processor.refund(amount / 2); // Bezpieczne - wiemy że refund() istnieje
}
// Użycie:
processPayment(new CreditCardPayment(), 100); // ✓ Działa
processPayment(new CashPayment(), 100); // ✓ Działa
processRefundablePayment(new CreditCardPayment(), 100); // ✓ Działa
// processRefundablePayment(new CashPayment(), 100); // ✗ Błąd kompilacji - CashPayment nie jest RefundablePayment!Każda klasa spełnia „umowę” swojego interfejsu. CashPayment nie obiecuje zwrotów, więc nie ma metody refund(). Kod który potrzebuje zwrotów używa RefundablePayment i ma gwarancję że refund() zadziała.
ISP – Interface Segregation Principle – reguła podziału interfejsów
Sedno zasady:
Nie zmuszaj klas do zależności od metod, których nie używają. Lepiej mieć kilka małych, wyspecjalizowanych interfejsów niż jeden duży (tzw. „fat interface”).
W praktyce:
Zamiast jednego „grubego” interfejsu z 10 metodami, zrób 5 małych interfejsów z 2 metodami każdy. Klasa implementuje tylko te interfejsy, których rzeczywiście potrzebuje.
❌ Zły przykład:
interface Employee {
code(): void;
design(): void;
manage(): void;
}
class Developer implements Employee {
code() { /* implementation */ }
design() { throw new Error("I don't design!"); } // ❌
manage() { throw new Error("I don't manage!"); } // ❌
}W przykładzie: Developer potrzebuje tylko metody code(), ale interfejs Employee zmusza go do implementowania również design() i manage(), których nie używa.
✅ Dobry przykład:
interface Coder {
code(): void;
}
interface Designer {
design(): void;
}
interface Manager {
manage(): void;
}
class Developer implements Coder {
code() { /* implementation */ }
}
class TechLead implements Coder, Manager {
code() { /* implementation */ }
manage() { /* implementation */ }
}Zamiast jednego interfejsu Employee z trzema metodami, zrób trzy małe interfejsy: Coder, Designer, Manager. Wtedy Developer implementuje tylko Coder, TechLead implementuje Coder i Manager, a każdy dostaje tylko to czego potrzebuje.
DIP – Dependency Inversion Principle – reguła odwracania zależności
Sedno zasady:
Kod wysokiego poziomu (logika biznesowa) nie powinien zależeć od szczegółów technicznych. Zarówno logika biznesowa, jak i implementacje techniczne powinny zależeć od abstrakcji.
W praktyce:
Nie wołaj bezpośrednio konkretnej klasy bazy danych czy API. Zamiast tego użyj interfejsu. Dzięki temu możesz łatwo podmieniać implementacje (np. zmienić bazę z MySQL na Postgres) bez zmiany logiki biznesowej.
❌ Zły przykład:
class MySQLDatabase {
saveUser(data: any) { /* MySQL */ }
}
class UserService {
private database = new MySQLDatabase(); // ❌ bezpośrednia implementacja!
registerUser(data: any) {
this.database.saveUser(data);
}
}W przykładzie: UserService tworzy bezpośrednio instancję MySQLDatabase. Gdy będziesz chciał zmienić bazę na Postgres, musisz modyfikować kod UserService.
✅ Dobry przykład:
interface Database {
saveUser(data: any): void;
}
class MySQLDatabase implements Database {
saveUser(data: any) { /* MySQL */ }
}
class PostgresDatabase implements Database {
saveUser(data: any) { /* Postgres */ }
}
class UserService {
constructor(private database: Database) {} // ✓ zależność od interfejsu!
registerUser(data: any) {
this.database.saveUser(data);
}
}
// Użycie:
const service = new UserService(new MySQLDatabase());Podsumowanie
W czasach, gdy coraz więcej kodu powstaje przy wsparciu narzędzi AI, umiejętność jego oceny staje się równie ważna jak samo pisanie. Zasady SOLID dają solidny punkt odniesienia do takiego review, pozwalają szybko wychwycić nadmierne sprzężenia, zbyt wiele odpowiedzialności w jednym miejscu i rozwiązania, które nie przetrwają kolejnych zmian biznesowych.
