Zrozumienie zasad SOLID poprzez praktyczne przykłady kodu
Ten przewodnik wyjaśnia wszystkie pięć zasad SOLID za pomocą konkretnych przykładów kodu, pokazując, jak są one stosowane w rzeczywistych projektach oraz aplikacjach React.
Gdy tworzysz oprogramowanie, poprawne uruchomienie kodu to zaledwie połowa pracy.
Gdy aplikacja zaczyna się rozwijać, jej baza kodu często staje się trudniejsza do odczytania, trudniejsza do modyfikacji i trudniejsza w utrzymaniu poprawnego funkcjonowania. Niewielka zmiana w jednej części systemu może potajemnie uszkodzić coś zupełnie innego.
To właśnie taki rodzaj problemu ma rozwiązać zasada SOLID.
SOLID to zbiór pięciu zasad projektowania z programowania obiektowego, które pomagają tworzyć kod, który jest:
- Prostszy w utrzymaniu
- Prostszy w testowaniu
- Prostszy w rozszerzaniu
- Mniej powiązany
- Lepszy do zrozumienia przez zespół
Skrót ten oznacza:
S — Zasada pojedynczej odpowiedzialności
O — Zasada otwartości/zamknięcia
L — Zasada podstawienia Liskova
I — Zasada segregacji interfejsów
D — Zasada odwrócenia zależności
Rozważmy każdą z nich na prostych przykładach.
1. S — Zasada pojedynczej odpowiedzialności
„Klasa powinna mieć tylko jeden powód do zmiany.”
Mówiąc prościej, każda klasa lub moduł powinien być zbudowany wokół jednego zadania.
Rozważmy klasę User, która jest odpowiedzialna za:
- Zachowywanie danych użytkownika
- Komunikację z bazą danych
- Wysyłanie e-maili
- Tworzenie raportów
To zbyt wiele dla jednej klasy.
class User {
createUser() {
// create user
}
saveToDatabase() {
// save user
}
sendEmail() {
// send email
}
generateReport() {
// generate report
}
}
Jeśli logika wysyłania e-maili musi ulec zmianie, edytujesz klasę User.
Jeśli zmienia się sposób obsługi bazy danych, znów musisz edytować tę samą klasę.
Czystsze podejście polega na rozdzieleniu tych zadań.
class User {
createUser() {
// create user
}
}
class UserRepository {
saveToDatabase() {
// database logic
}
}
class EmailService {
sendEmail() {
// email logic
}
}
class ReportService {
generateReport() {
// report logic
}
}
Dzięki takiemu podziałowi każda klasa obejmuje dokładnie jedną funkcję.
Dlaczego to jest przydatne?
Gdy tylko zmieni się jakieś wymaganie, od razu wiesz, którą część kodu należy edytować.
Jedna funkcja na klasę oznacza jeden powód, dla którego ta klasa może ulec zmianie.
2. O — Zasada otwartości/zamknięcia
„Entytety oprogramowania powinny być otwarte na rozbudowę, ale zamknięte na modyfikację.”
Sformułowanie to brzmi abstrakcyjnie, ale koncepcja stojąca za nim nie jest.
Celem jest wprowadzanie nowych funkcjonalności bez ciągłego przepisywania już działającego kodu.
Weźmy przykład przetwarzania płatności:
function processPayment(type, amount) {
if (type === "card") {
// card payment
} else if (type === "upi") {
// UPI payment
} else if (type === "paypal") {
// PayPal payment
}
}
Załóżmy teraz, że musisz obsłużyć:
- Stripe
- Razorpay
- Apple Pay
- Google Pay
Każda nowa opcja sprawia, że ta funkcja staje się większa i bardziej złożona.
Lepszą strategią jest przypisanie każdej metodzie płatności własnej klasy.
class CardPayment {
pay(amount) {
console.log(`Card payment: ${amount}`);
}
}
class UpiPayment {
pay(amount) {
console.log(`UPI payment: ${amount}`);
}
}
class PaypalPayment {
pay(amount) {
console.log(`PayPal payment: ${amount}`);
}
}
Gdy taka struktura już istnieje, dodanie kolejnej opcji płatności nie wymaga modyfikowania już napisanych klas.
class StripePayment {
pay(amount) {
console.log(`Stripe payment: ${amount}`);
}
}
Pierwotne implementacje pozostają dokładnie takie, jakie były.
Idea
Rozwijaj system poprzez dodawanie nowego kodu, a nie poprzez ciągłe edytowanie już stabilnego kodu.
3. L — Zasada podstawienia Liskova
„Podtypy powinny być zastępowalne przez swoje typy bazowe.”
W istocie zasada ta stanowi:
Jeśli
Bjest podtypemA, powinno być możliwe zastąpienieBwszędzie tam, gdzie używany jestA, a aplikacja powinna nadal prawidłowo funkcjonować.
Klasycznym przykładem są ptaki.
Załóżmy, że definiujemy:
class Bird {
fly() {
console.log("Flying");
}
}
Następnie to rozszerzasz:
class Sparrow extends Bird {
fly() {
console.log("Sparrow is flying");
}
}
Na razie wszystko w porządku.
Ale co się stanie z pingwinem?
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
To ujawnia wadę w projekcie.
Jeśli inne części kodu zakładają, że każdy obiekt typu Bird potrafi latać, przekazanie mu instancji Penguin złamie to założenie.
Bardziej rozsądny projekt wyodrębnia zachowanie lotu do osobnego modułu.
class Bird {
eat() {
console.log("Eating");
}
}
class FlyingBird extends Bird {
fly() {
console.log("Flying");
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}
W ten sposób pingwiny nie są już zmuszane do obsługi zachowań, które do nich nie odnoszą się.
Lekcja
Unikaj tworzenia hierarchii dziedziczenia, które nie są logicznie spójne.
Podklasa musi działać poprawnie wszędzie tam, gdzie oczekuje się, że będzie działać jej klasa nadrzędna.
4. I — Zasada segregacji interfejsów
„Klienci nie powinni być zmuszani do polegania na metodach, których nie używają.”
Wyobraź sobie interfejs zbudowany w ten sposób:
print()
scan()
fax()
copy()
Wyobraźmy teraz sobie prosty drukarkę, która potrafi tylko drukować.
Dlaczego ta drukarka miałaby musieć implementować również funkcje scan(), fax() i copy()?
Nie ma ku temu żadnego sensownego powodu.
Lepszym podejściem jest podział interfejsu według tego, co każda z tych funkcji faktycznie robi.
Naprzykład:
class Printer {
print() {
console.log("Printing...");
}
}
class Scanner {
scan() {
console.log("Scanning...");
}
}
class FaxMachine {
fax() {
console.log("Faxing...");
}
}
Prosta drukarka musi implementować tylko te funkcje, które rzeczywiście obsługuje.
We współczesnym JavaScript
JavaScript nie posiada formalnych interfejsów w takim stopniu jak Java czy C#, ale podstawowa idea pozostaje ta sama.
Można to zrealizować poprzez:
- Małe moduły
- Małe API
- Kompozycję
- Odrębne usługi
- Skupione komponenty React
Zamiast łączyć wszystko w jedną ogromną usługę:
userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();
Rozdziel odpowiedzialności:
userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();
Lekcja
Nigdy nie zmuszaj komponentu, klasy ani modułu do korzystania z funkcjonalności, której nie potrzebuje.
5. D — Zasada odwrócenia zależności
„Moduły wysokiego poziomu nie powinny polegać bezpośrednio na modułach niskiego poziomu. Obie powinny polegać na abstrakcjach.”
Zasada ta ma na celu zmniejszenie ścisłego powiązania.
Weźmy ten przykład:
class MongoDB {
save(data) {
console.log("Saving to MongoDB");
}
}
class UserService {
constructor() {
this.database = new MongoDB();
}
saveUser(user) {
this.database.save(user);
}
}
Problem polega na tym, że UserService jest bezpośrednio połączony z MongoDB.
Przejście później na PostgreSQL oznaczałoby konieczność ponownej modyfikacji samego UserService.
Lepszym podejściem jest wstrzykiwanie zależności zamiast tego.
class UserService {
constructor(database) {
this.database = database;
}
saveUser(user) {
this.database.save(user);
}
}
Teraz można swobodnie przekazywać różne implementacje bazy danych.
const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);
A później wymiana ich jest prosta:
const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);
UserService nigdy nie musi wiedzieć, jaka baza danych znajduje się za nim.
Dlaczego to jest przydatne?
Dzięki temu otrzymujemy kod, który jest:
- Latwiejszy do testowania
- Latwiejszy do zastąpienia
- Mniej ściśle powiązany
- Latwiejszy do utrzymania z biegiem czasu
SOLID w rzeczywistym projekcie
Przestrzeganie zasad SOLID nie oznacza tworzenia osobnej klasy dla każdej rzeczy.
To rozróżnienie ma duże znaczenie.
SOLID dotyczy podejmowania rozsądnych decyzji projektowych, a nie gromadzenia abstrakcji tylko dla samej przyjemności.
W aplikacji React na przykład te zasady naturalnie się objawiają, gdy oddzielimy:
Components
↓
Hooks
↓
Services
↓
API Layer
↓
Database
Głównym zadaniem komponentu jest obsługa interfejsu użytkownika.
Własny hook może zawierać logikę stanu, którą można wykorzystywać wielokrotnie.
Służba API może zajmować się komunikacją HTTP.
Tło aplikacji obsługuje logikę biznesową.
Szyna bazy danych zajmuje się trwałością danych.
Taki podział utrzymuje aplikację w zrozumiałej formie w miarę jej rozwoju.
SOLID i React
Mimo że SOLID wywodzi się z projektowania orientowanego na obiekty, kilka jego koncepcji dobrze sprawdza się w pracy z React.
Jedna odpowiedzialność
Zamiast budować jeden ogromny komponent:
Dashboard.jsx
który próbuje robić wszystko, rozdziel go na:
Dashboard
UserProfile
Statistics
RecentOrders
Notifications
Każda z tych części ma wtedy znacznie jaśniejszy zakres obowiązków.
Otwarty/zamknięty
Projektuj komponenty wielokrotnego użycia, które uzyskują nowe zachowanie poprzez props, zamiast przepisywać ich wewnętrzną strukturę za każdym razem, gdy potrzebna jest nowa wersja.
<Button variant="primary">
Save
</Button>
<Button variant="danger">
Delete
</Button>
Inwersja zależności
Zamiast łączyć komponent bezpośrednio z konkretnym sposobem pobierania danych, przechowuj logikę API w usłudze lub hooku.
const users = await userService.getUsers();
Sam komponent nie musi wiedzieć, w jaki sposób ta prośba jest realizowana pod spodem.
Dlaczego SOLID ma znaczenie
Rzeczywiste korzyści z SOLID niewiele mają wspólnego z nadaniem kodowi eleganckiego wyglądu.
Chodzi o to, by przyszłe zmiany były mniej uciążliwe.
Wyobraź sobie projekt obejmujący:
10 programistów → 100 funkcji → tysiące plików → ciągłe zmiany
Bez starannego projektowania jeden mały wymóg może wywołać łańcuch niepowiązanych błędów.
Dzięki jasnej separacji i luźnemu powiązaniu zmiany stają się znacznie bardziej przewidywalne.
SOLID może ci pomóc:
- Zredukować duplikację kodu
- Zmniejszyć ścisłe powiązania
- Poprawić możliwości testowania
- Ułatwić rozbudowę funkcji
- Uproszczyć debugowanie
- Poprawić współpracę w zespole
- Zapewnić utrzymaność dużych aplikacji
SOLID nie oznacza nadprojektowania
To może być najważniejszy wniosek z całego tekstu.
Nie stosuj zasad SOLID jako sztywnej listy kontrolnej.
Weź prostą funkcję na przykład:
function add(a, b) {
return a + b;
}
Ona nie wymaga pięciu klas, trzech interfejsów ani kontenera iniekcji zależności.
Celem nigdy nie było uczynienie kodu bardziej skomplikowanym.
Celem jest ułatwienie pracy z prawdziwie złożonym kodem.
Zastosuj zasady SOLID dopiero wtedy, gdy złożoność systemu faktycznie uzasadnia dodatkową strukturę.
Krótka podsumowanie
S — Jedna odpowiedzialność: klasa lub moduł powinien mieć jedną główną funkcję.
O — Otwarty/zamknięty: rozwijaj funkcjonalność, zamiast wielokrotnie edytować już działający kod.
L — Zasada podstawienia Liskova: podklasy powinny działać poprawnie wszędzie tam, gdzie oczekuje się klasę nadrzędną.
I — Zasada segregacji interfejsów: nie zmuszaj klientów do korzystania z funkcjonalności, których nie potrzebują.
D — Zasada odwrócenia zależności: polegaj na abstrakcjach, a nie na bezpośrednim używaniu konkretnych implementacji.
Ostateczne uwagi
SOLID nie został stworzony po to, by zapamiętywać pięć definicji na rozmowę kwalifikacyjną.
To sposób myślenia o projektowaniu oprogramowania.
Podczas pisania kodu warto zatrzymać się i zadać sobie pytania:
Czy ten moduł próbuje robić zbyt wiele rzeczy naraz?
Czy dodanie nowej funkcji zmusi do przepisania istniejącego kodu?
Czy wprowadzane są tu niepotrzebne zależności?
Czy ten kod można przetestować bez trudności?
Czy coś jest zmuszane do obsługi zachowań, których tak naprawdę nie potrzebuje?
Rozważanie tych pytań ma zazwyczaj większe znaczenie niż umiejętność wymienienia, co oznaczają poszczególne litery SOLID.
Dobry oprogramowanie to nie tylko takie, które teraz poprawnie funkcjonuje.
Dobry oprogramowanie to takie, które stopniowo się zmienia w sposób uporządkowany, zamiast przerodzić się w koszmar.
Literatura pokrewna
- Kiedy AI pisze Twój React App, ale pomija zasady czystego kodu — Poznaj siedem nawyków pisania czystego kodu – DRY, zasada jednej odpowiedzialności, klauzule ochronne i inne – które często są łamane w kodzie React generowanym przez AI oraz sposoby ich naprawy.