I principi SOLID rappresentano cinque linee guida fondamentali per lo sviluppo di software orientato agli oggetti, introdotti da Robert C. Martin.
Questi principi aiutano a creare sistemi più manutenibili, flessibili e scalabili. In questo articolo, esploreremo ogni principio con esempi pratici utilizzando php con framework Laravel.
Diamo insieme un’occhiata dal punto di vista pratico esplorando l’applicazione dei 5 principi:
- Single Responsibility Principle (SRP);
- Open/Closed Principle (OCP);
- Liskov Substitution Principle (LSP);
- Interface Segregation Principle (ISP);
- Dependency Inversion Principle (DIP).
Single Responsibility Principle (SRP)
Il principio della responsabilità singola afferma che una classe dovrebbe avere una sola ragione per cambiare.
In altre parole, una classe dovrebbe avere una sola responsabilità.
❌ Implementazione Scorretta:
class Order {
private $items;
private $mailer;
private $pdfGenerator;
public function calculateTotalPrice()
{
return $this->items->sum(function($item) {
return $item->price * $item->quantity;
});
}
public function generateInvoicePdf()
{
$data = [
'items' => $this->items,
'total' => $this->calculateTotalPrice()
];
return $this->pdfGenerator->generate($data);
}
public function sendOrderConfirmation()
{
$this->mailer->send(
'order.confirmation',
['order' => $this]
);
}
public function saveToDatabase()
{
// Logica per salvare l'ordine nel database
}
}
Questa implementazione viola SRP perché la classe Order ha troppe responsabilità:
- Gestione dei calcoli dell’ordine
- Generazione di PDF
- Invio di email
- Persistenza dei dati
✅ Implementazione Corretta:
class Order {
private $items;
public function calculateTotalPrice(): float
{
return $this->items->sum(function($item) {
return $item->price * $item->quantity;
});
}
}
class OrderPdfGenerator {
private $pdfGenerator;
public function generateInvoice(Order $order): string
{
$data = [
'order' => $order,
'total' => $order->calculateTotalPrice()
];
return $this->pdfGenerator->generate($data);
}
}
class OrderMailer {
private $mailer;
public function sendConfirmation(Order $order): void
{
$this->mailer->send(
'order.confirmation',
['order' => $order]
);
}
}
class OrderRepository {
public function save(Order $order): void
{
// Logica per salvare l'ordine nel database
}
}
Nell’implementazione scorretta abbiamo questa situazione:
class Order {
private $items;
private $mailer;
private $pdfGenerator;
public function calculateTotalPrice() { ... } // Responsabilità 1: Calcoli
public function generateInvoicePdf() { ... } // Responsabilità 2: Generazione PDF
public function sendOrderConfirmation() { ... } // Responsabilità 3: Invio email
public function saveToDatabase() { ... } // Responsabilità 4: Persistenza dati
}
I problemi di questa implementazione sono:
- La classe deve conoscere troppe cose:
- Come calcolare i prezzi
- Come generare PDF
- Come inviare email
- Come salvare nel database
- Se qualcosa cambia in una di queste aree, dobbiamo modificare la classe:
- Se cambia il sistema di invio email
- Se cambia il formato del PDF
- Se cambia il sistema di storage
- È difficile da testare perché:
- Dobbiamo mockare molte dipendenze
- I test diventano complessi
- È difficile isolare i comportamenti
Nell’implementazione corretta abbiamo diviso tutto in classi separate:
// Classe 1: Si occupa solo dei calcoli dell'ordine
class Order {
public function calculateTotalPrice(): float {}
}
// Classe 2: Si occupa solo della generazione PDF
class OrderPdfGenerator {
private $pdfGenerator;
public function generateInvoice(Order $order): string {}
}
// Classe 3: Si occupa solo dell'invio email
class OrderMailer {
private $mailer;
public function sendConfirmation(Order $order): void {}
}
// Classe 4: Si occupa solo della persistenza
class OrderRepository {
public function save(Order $order): void {}
}
Open/Closed Principle (OCP)
Il principio Open/Closed stabilisce che le entità software dovrebbero essere aperte all’estensione ma chiuse alla modifica.
“Aperto all’estensione” significa che dovremmo poter aggiungere nuove funzionalità o comportamenti al nostro software senza dover toccare il codice esistente. È come se stessimo aggiungendo nuovi pezzi a un puzzle, senza dover modificare i pezzi che sono già al loro posto.
“Chiuso alla modifica” significa che il codice esistente, una volta testato e verificato, non dovrebbe essere modificato per aggiungere nuove funzionalità. È come se quel codice fosse “sigillato” – funziona, è testato, ed è affidabile.
Un esempio pratico nella vita reale potrebbe essere un telecomando universale:
- È “chiuso alla modifica” perché il suo codice base che gestisce i segnali infrarossi non deve essere modificato
- È “aperto all’estensione” perché puoi aggiungere il supporto per nuovi dispositivi semplicemente programmando nuovi codici, senza dover modificare come il telecomando gestisce i segnali
In sostanza, questo principio ci suggerisce di scrivere il nostro codice in modo che quando vogliamo aggiungere nuove funzionalità, dovremmo poterlo fare creando nuovo codice invece di modificare quello esistente. Questo riduce il rischio di introdurre bug in funzionalità che già funzionavano correttamente.
È un po’ come costruire un edificio pensando già alle future espansioni: invece di dover abbattere muri per aggiungere nuove stanze, l’edificio è progettato per permettere l’aggiunta di nuovi moduli senza toccare la struttura esistente.
❌ Implementazione Scorretta:
class PaymentProcessor
{
public function processPayment(string $type, float $amount)
{
switch ($type) {
case 'credit_card':
// Logica per pagamento con carta di credito
break;
case 'paypal':
// Logica per pagamento PayPal
break;
case 'bank_transfer':
// Logica per bonifico bancario
break;
}
}
}
Questa implementazione viola OCP perché:
- Per aggiungere un nuovo metodo di pagamento, dobbiamo modificare la classe esistente
- Il codice diventa sempre più complesso con l’aggiunta di nuovi metodi
- È difficile da testare e mantenere
✅ Implementazione Corretta:
interface PaymentMethod
{
public function processPayment(float $amount): bool;
}
class CreditCardPayment implements PaymentMethod
{
public function processPayment(float $amount): bool
{
// Logica per pagamento con carta di credito
return true;
}
}
class PayPalPayment implements PaymentMethod
{
public function processPayment(float $amount): bool
{
// Logica per pagamento PayPal
return true;
}
}
class BankTransferPayment implements PaymentMethod
{
public function processPayment(float $amount): bool
{
// Logica per bonifico bancario
return true;
}
}
class PaymentProcessor
{
public function process(PaymentMethod $paymentMethod, float $amount): bool
{
return $paymentMethod->processPayment($amount);
}
}
Nell’implementazione scorretta abbiamo vari problemi:
- Violazione del principio Open/Closed:
- Per aggiungere un nuovo metodo di pagamento (es: Apple Pay), dobbiamo modificare la classe esistente
- Dobbiamo aggiungere un nuovo case allo switch
- Rischiamo di introdurre bug nel codice esistente
- Manutenibilità Problematica:
- La classe cresce continuamente con ogni nuovo metodo di pagamento
- Lo switch diventa sempre più complesso
- Aumenta il rischio di errori
- Testing Complesso:
- Dobbiamo testare tutti i casi ogni volta che aggiungiamo un nuovo metodo
- È difficile isolare i test per un singolo tipo di pagamento
- I test diventano fragili e complessi
Nell’implementazione corretta abbiamo utilizzato l’interfaccia poiché è un elemento fondamentale per rispettare l’Open/Closed Principle.
L’interfaccia agisce come un “contratto” che definisce cosa deve fare un metodo di pagamento:
interface PaymentMethod {
public function processPayment(float $amount): bool;
}
Questo “contratto” ci dice che qualsiasi classe che vuole essere un metodo di pagamento DEVE implementare il metodo che:
- Accetta un importo (
)
- Restituisce un booleano (
) per indicare se il pagamento è andato a buon fine.
Liskov Substitution Principle (LSP)
Il principio di sostituzione di Liskov stabilisce che gli oggetti di una classe derivata devono poter sostituire gli oggetti della classe base senza alterare la correttezza del programma.
❌ Implementazione Scorretta:
abstract class Storage {
abstract public function save(string $data): bool;
abstract public function read(string $id): string;
}
class FileStorage extends Storage {
public function save(string $data): bool
{
// Salva i dati in un file
return true;
}
public function read(string $id): string
{
// Legge i dati da un file
return "data";
}
}
class ReadOnlyStorage extends Storage {
public function save(string $data): bool
{
throw new Exception("Cannot save to read-only storage!");
}
public function read(string $id): string
{
// Legge i dati
return "data";
}
}
Questa implementazione viola LSP perché:
lancia un’eccezione per un metodo che dovrebbe essere supportato
- Non mantiene il contratto della classe base
- Il codice client non può usare le classi in modo intercambiabile
✅ Implementazione Corretta:
interface Readable {
public function read(string $id): string;
}
interface Writable {
public function save(string $data): bool;
}
class FileStorage implements Readable, Writable {
public function save(string $data): bool
{
// Salva i dati in un file
return true;
}
public function read(string $id): string
{
// Legge i dati da un file
return "data";
}
}
class ReadOnlyStorage implements Readable {
public function read(string $id): string
{
// Legge i dati
return "data";
}
}
Avevamo uno che forzava tutte le implementazioni ad avere sia metodi di lettura che di scrittura, anche quando questo non aveva senso (come nel caso di uno storage di sola lettura).
La soluzione si articola in questi passaggi:
- Separazione delle Responsabilità
- Invece di avere una singola classe astratta
che forza entrambe le operazioni
- Abbiamo creato due interfacce separate:
per la lettura e
per la scrittura
- Ogni interfaccia definisce solo i metodi relativi alla sua responsabilità
- Invece di avere una singola classe astratta
- Implementazione Flessibile
può fare tutto, quindi implementa entrambe le interfacce
può solo leggere, quindi implementa solo
- Nessuna classe è forzata a implementare funzionalità che non può supportare
- Uso nel Service Container di Laravel
- Registriamo le nostre interfacce nel service container
- Per
possiamo usare sia
che
- Per
possiamo usare solo
- Laravel inietterà l’implementazione corretta in base all’interfaccia richiesta
- Uso nel Codice Client
- Se un controller ha bisogno solo di leggere, chiede un
- Se ha bisogno di scrivere, chiede un
- Il codice è più sicuro perché non può accidentalmente chiamare metodi non supportati
- Se un controller ha bisogno solo di leggere, chiede un
In pratica, è come se avessimo:
- Prima: Un unico “contratto di lavoro” che obbligava tutti a fare tutto
- Dopo: Contratti separati per mansioni diverse, dove ognuno firma solo per quello che può fare
Questo approccio:
- Rende il codice più sicuro
- Evita errori a runtime
- Permette più flessibilità nelle implementazioni
- Rispetta il principio di Liskov perché ogni classe può sostituire completamente l’interfaccia che implementa
È un po’ come in un ristorante:
-
- Non tutti i dipendenti devono saper fare tutto
- Alcuni sono specializzati in cucina, altri nel servizio ai tavoli
- Ognuno implementa solo le competenze che realmente possiede
- Il sistema funziona meglio perché ognuno fa ciò in cui è competente
Interface Segregation Principle (ISP)
Il principio di segregazione delle interfacce afferma che i client non dovrebbero essere forzati a dipendere da interfacce che non usano.
❌ Implementazione Scorretta:
interface MediaHandler {
public function upload(UploadedFile $file): string;
public function resize(string $path, int $width, int $height): void;
public function compress(string $path): void;
public function generateThumbnail(string $path): string;
public function extractMetadata(string $path): array;
}
class ImageHandler implements MediaHandler {
// Implementa tutti i metodi
}
class DocumentHandler implements MediaHandler {
public function upload(UploadedFile $file): string
{
// OK
return "";
}
public function resize(string $path, int $width, int $height): void
{
throw new Exception("Cannot resize documents!");
}
public function compress(string $path): void
{
// OK
}
public function generateThumbnail(string $path): string
{
throw new Exception("Cannot generate thumbnails for documents!");
}
public function extractMetadata(string $path): array
{
// OK
return [];
}
}
✅ Implementazione Corretta:
interface Uploadable {
public function upload(UploadedFile $file): string;
}
interface Resizable {
public function resize(string $path, int $width, int $height): void;
}
interface Compressible {
public function compress(string $path): void;
}
interface ThumbnailGenerator {
public function generateThumbnail(string $path): string;
}
interface MetadataExtractor {
public function extractMetadata(string $path): array;
}
class ImageHandler implements
Uploadable,
Resizable,
Compressible,
ThumbnailGenerator,
MetadataExtractor
{
// Implementa solo i metodi necessari
}
class DocumentHandler implements
Uploadable,
Compressible,
MetadataExtractor
{
// Implementa solo i metodi necessari
}
Nell’implementazione scorretta avevamo un’implementazione che presenta diversi problemi:
- Interfaccia “grassa”:
- Una singola interfaccia contiene troppi metodi
- Non tutti i tipi di media hanno bisogno di tutte queste operazioni
- Le classi sono forzate a implementare metodi che non useranno
- Implementazioni Problematiche:
class DocumentHandler implements MediaHandler {
public function upload(UploadedFile $file): string {
// OK - Ha senso per i documenti
return "";
}
public function resize(string $path, int $width, int $height): void {
// NON HA SENSO - I documenti non si ridimensionano!
throw new Exception("Cannot resize documents!");
}
public function compress(string $path): void {
// OK - Ha senso per i documenti
}
public function generateThumbnail(string $path): string {
// NON HA SENSO - I documenti non hanno thumbnail!
throw new Exception("Cannot generate thumbnails for documents!");
}
public function extractMetadata(string $path): array {
// OK - Ha senso per i documenti
return [];
}
}
La soluzione corretta:
// ✅ Implementazione Corretta
// 1. Interfacce separate per ogni funzionalità
interface Uploadable {
public function upload(UploadedFile $file): string;
}
interface Resizable {
public function resize(string $path, int $width, int $height): void;
}
interface Compressible {
public function compress(string $path): void;
}
interface ThumbnailGenerator {
public function generateThumbnail(string $path): string;
}
interface MetadataExtractor {
public function extractMetadata(string $path): array;
}
// 2. Le classi implementano solo ciò di cui hanno bisogno
class ImageHandler implements
Uploadable,
Resizable,
Compressible,
ThumbnailGenerator,
MetadataExtractor
{
// Implementa tutti i metodi perché le immagini supportano tutte le operazioni
}
class DocumentHandler implements
Uploadable,
Compressible,
MetadataExtractor
{
// Implementa solo i metodi che hanno senso per i documenti
}
vantaggi di questa soluzione:
- Flessibilità nelle Implementazioni:
// Esempio di uso in un controller
class UploadController {
public function handleImage(
Uploadable $uploader,
Resizable $resizer,
ThumbnailGenerator $thumbnailer
) {
$path = $uploader->upload($file);
$resizer->resize($path, 800, 600);
$thumb = $thumbnailer->generateThumbnail($path);
}
public function handleDocument(
Uploadable $uploader,
Compressible $compressor
) {
$path = $uploader->upload($file);
$compressor->compress($path);
// Non c'è resize o thumbnail perché non serve!
}
}
2. Dependency Injection più Precisa:
class ImageProcessor {
public function __construct(
private Resizable $resizer,
private ThumbnailGenerator $thumbnailer
) {}
public function process(string $path) {
// Usa solo le funzionalità necessarie
}
}
3. Testing Semplificato:
class ImageProcessorTest extends TestCase {
public function test_can_resize_image() {
// Mock solo dell'interfaccia necessaria
$resizer = $this->createMock(Resizable::class);
$thumbnailer = $this->createMock(ThumbnailGenerator::class);
$processor = new ImageProcessor($resizer, $thumbnailer);
// Test specifico per il resize
}
}
I benefici principali:
- Chiarezza:
- Ogni interfaccia ha una responsabilità chiara
- Le classi dichiarano esplicitamente le loro capacità
- Il codice è più autodocumentante
- Manutenibilità:
- Possiamo modificare una funzionalità senza impattare le altre
- Aggiungere nuove funzionalità è più semplice
- Il codice è più modulare
- Riusabilità:
- Possiamo riutilizzare le interfacce in modi diversi
- Possiamo comporre le funzionalità come necessitiamo
- È più facile creare nuove implementazioni
In pratica, invece di avere un’unica interfaccia “tuttofare”, abbiamo creato interfacce piccole e specifiche.
È come avere un set di attrezzi specializzati invece di un unico attrezzo multiuso che fa tutto male. Ogni classe può scegliere esattamente quali “attrezzi” implementare, senza essere forzata a implementare funzionalità che non hanno senso per il suo caso d’uso.
Dependency Inversion Principle (DIP)
Il principio di inversione delle dipendenze stabilisce che i moduli di alto livello non dovrebbero dipendere da moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni.
❌ Implementazione Scorretta:
class MySQLDatabase {
public function query(string $sql): array
{
// Esegue query SQL
return [];
}
}
class UserRepository {
private $database;
public function __construct()
{
$this->database = new MySQLDatabase();
}
public function findById(int $id): User
{
$result = $this->database->query("SELECT * FROM users WHERE id = " . $id);
return new User($result[0]);
}
}
class UserService {
private $repository;
public function __construct()
{
$this->repository = new UserRepository();
}
}
Questa implementazione viola DIP perché:
- Le classi di alto livello dipendono direttamente dalle implementazioni
- Non c’è possibilità di cambiare l’implementazione del database
- Il codice è difficile da testare
✅ Implementazione Corretta:
interface DatabaseInterface {
public function query(string $sql): array;
}
interface UserRepositoryInterface {
public function findById(int $id): User;
}
class MySQLDatabase implements DatabaseInterface {
public function query(string $sql): array
{
// Esegue query SQL
return [];
}
}
class UserRepository implements UserRepositoryInterface {
private $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function findById(int $id): User
{
$result = $this->database->query("SELECT * FROM users WHERE id = " . $id);
return new User($result[0]);
}
}
class UserService {
private $repository;
public function __construct(UserRepositoryInterface $repository)
{
$this->repository = $repository;
}
}
// Registrazione nel container Laravel
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(DatabaseInterface::class, MySQLDatabase::class);
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
}
}
I benefici principali:
- Disaccoppiamento:
- Le classi dipendono da astrazioni, non da implementazioni concrete
- Possiamo cambiare l’implementazione senza modificare il codice client
- Il codice è più modulare e flessibile
- Testabilità:
- Facile mockare le dipendenze per i test
- Possiamo testare le classi in isolamento
- I test sono più affidabili
- Manutenibilità:
- Le modifiche sono localizzate
- Possiamo aggiungere nuove implementazioni senza toccare il codice esistente
- Il codice è più facile da capire e modificare
In pratica, invece di avere classi che creano direttamente le loro dipendenze (accoppiamento stretto), abbiamo classi che ricevono le loro dipendenze attraverso le interfacce (inversione delle dipendenze). È come costruire con i LEGO: i pezzi si incastrano tra loro grazie a un’interfaccia standard, non importa di che colore o forma siano i singoli pezzi.
Conclusione
L’applicazione dei principi SOLID porta numerosi benefici:
- Codice più manutenibile e facile da testare;
- Maggiore flessibilità e adattabilità ai cambiamenti;
- Migliore riutilizzo dei componenti;
- Struttura più robusta e scalabile.
Gli esempi mostrati evidenziano come:
- La violazione dei principi SOLID porta a codice rigido e difficile da mantenere;
- L’applicazione corretta dei principi migliora la qualità e la manutenibilità del codice
- Framework come Laravel forniscono gli strumenti necessari per implementare questi principi efficacemente
Sebbene l’implementazione di questi principi richieda una maggiore pianificazione iniziale, i benefici a lungo termine sono significativi, specialmente in progetti di grandi dimensioni o in continua evoluzione.