I Principi SOLID nel Software Design Moderno
Una guida completa e pratica ai 5 principi SOLID con esempi in TypeScript. Scopri come scrivere codice robusto, manutenibile e facile da estendere.
Nel panorama dello sviluppo software moderno, scrivere codice che "funziona" è solo il primo passo. La vera sfida consiste nello scrivere codice che possa sopravvivere al cambiamento. I requisiti di business evolvono, i bug emergono e nuove funzionalità devono essere integrate continuamente.
Se il codice non è strutturato correttamente, ogni modifica rischia di introdurre regressioni involontarie, creando quello che in gergo chiamiamo "codice spaghetti" o debito tecnico.
Per risolvere questo problema, all'inizio degli anni 2000, Robert C. Martin (noto come Uncle Bob) ha introdotto e promosso i principi SOLID: cinque regole fondamentali di progettazione orientata agli oggetti (OOD) che, se applicate correttamente, rendono il software incredibilmente flessibile, testabile e manutenibile.
In questo articolo esploreremo ciascun principio nel dettaglio, vedendo come violarlo e, soprattutto, come risolverlo usando TypeScript.
Cosa significa SOLID?
SOLID è un acronimo in cui ogni lettera rappresenta un principio di design specifico:
| Lettera | Principio | Concetto Chiave |
|---|---|---|
| S | Single Responsibility Principle (SRP) | Una classe dovrebbe avere una, e una sola, ragione per cambiare. |
| O | Open-Closed Principle (OCP) | Le entità software dovrebbero essere aperte all'estensione, ma chiuse alla modifica. |
| L | Liskov Substitution Principle (LSP) | I sottotipi devono poter essere sostituiti dai loro tipi di base senza alterare la correttezza del programma. |
| I | Interface Segregation Principle (ISP) | Molte interfacce specifiche sono meglio di una singola interfaccia generica. |
| D | Dependency Inversion Principle (DIP) | Dipendi dalle astrazioni, non dalle implementazioni concrete. |
1. Single Responsibility Principle (SRP)
"Una classe dovrebbe avere una sola ragione per cambiare."
Questo principio non significa che una classe debba contenere un solo metodo, ma piuttosto che debba essere responsabile di un unico attore o funzionalità del sistema. Se una classe si occupa di troppe cose (ha troppe responsabilità), diventa fragile: una modifica a una parte della classe rischia di rompere funzionalità non correlate.
🔴 La Violazione (Prima)
Immaginiamo una classe Book che gestisce sia le informazioni del libro sia la logica per salvare il libro su file o formattarlo in HTML.
class Book {
constructor(
public title: string,
public author: string,
public content: string
) {}
// Responsabilità 1: Gestione dei dati del libro
getTitle() { return this.title; }
getAuthor() { return this.author; }
// Responsabilità 2: Presentazione (HTML)
toHtml() {
return `<h1>${this.title}</h1><p>di ${this.author}</p>`;
}
// Responsabilità 3: Persistenza (Salvataggio)
saveToFile() {
const fs = require('fs');
fs.writeFileSync(`${this.title}.txt`, this.content);
}
}
Perché è un problema?
Se decidiamo di cambiare il formato di salvataggio (es. salvare su database invece che su file) o di modificare la formattazione HTML, dobbiamo modificare la classe Book. La classe ha ben tre motivi diversi per cambiare.
🟢 La Soluzione (Dopo)
Suddividiamo le responsabilità creando classi distinte per la persistenza e per la formattazione.
// Classe focalizzata esclusivamente sui dati del libro
class Book {
constructor(
public title: string,
public author: string,
public content: string
) {}
}
// Classe responsabile della persistenza
class BookRepository {
saveToFile(book: Book) {
const fs = require('fs');
fs.writeFileSync(`${book.title}.txt`, book.content);
}
}
// Classe responsabile della visualizzazione
class BookPresenter {
toHtml(book: Book): string {
return `<h1>${book.title}</h1><p>di ${book.author}</p>`;
}
}
Ora, se cambia il database o la struttura HTML, la classe Book non viene minimamente toccata. Ciascuna classe ha un solo motivo per cambiare.
2. Open-Closed Principle (OCP)
"Le entità software dovrebbero essere aperte all'estensione, ma chiuse alla modifica."
Questo principio stabilisce che dovresti essere in grado di aggiungere nuove funzionalità a un sistema senza dover modificare il codice esistente. La modifica del codice esistente comporta sempre il rischio di introdurre nuovi bug in parti di codice già testate e funzionanti. La soluzione risiede nell'uso delle astrazioni (classi astratte o interfacce).
🔴 La Violazione (Prima)
Immaginiamo un sistema che calcola l'area di diverse forme geometriche.
class Rettangolo {
constructor(public larghezza: number, public altezza: number) {}
}
class Cerchio {
constructor(public raggio: number) {}
}
class CalcolatoreArea {
// Calcola l'area scorrendo un array di forme
calcola(forme: any[]): number {
return forme.reduce((totale, forma) => {
if (forma instanceof Rettangolo) {
return totale + (forma.larghezza * forma.altezza);
} else if (forma instanceof Cerchio) {
return totale + (Math.PI * Math.pow(forma.raggio, 2));
}
return totale;
}, 0);
}
}
Perché è un problema?
Se domani volessimo aggiungere una nuova forma (es. un Triangolo), saremmo costretti a modificare il metodo calcola della classe CalcolatoreArea per aggiungere un nuovo blocco else if. Questa classe non è "chiusa alla modifica".
🟢 La Soluzione (Dopo)
Definiamo un'interfaccia Forma che espone il metodo calcolaArea(). In questo modo, ogni nuova forma implementerà la propria logica e il calcolatore non dovrà più essere modificato.
interface Forma {
calcolaArea(): number;
}
class Rettangolo implements Forma {
constructor(private larghezza: number, private altezza: number) {}
calcolaArea(): number {
return this.larghezza * this.altezza;
}
}
class Cerchio implements Forma {
constructor(private raggio: number) {}
calcolaArea(): number {
return Math.PI * Math.pow(this.raggio, 2);
}
}
// Classe aperta all'estensione, ma chiusa alla modifica
class CalcolatoreArea {
calcola(forme: Forma[]): number {
return forme.reduce((totale, forma) => totale + forma.calcolaArea(), 0);
}
}
Se ora vogliamo aggiungere un Triangolo, ci basterà creare una nuova classe che implementa Forma. La classe CalcolatoreArea continuerà a funzionare senza alcuna modifica.
3. Liskov Substitution Principle (LSP)
"Se
q(x)è una proprietà dimostrabile degli oggettixdi tipoT, alloraq(y)deve essere valida per gli oggettiydi tipoS, doveSè un sottotipo diT."
In parole semplici: le classi derivate devono poter essere utilizzate al posto delle loro classi base senza alterare il comportamento corretto dell'applicazione. Se una sottoclasse sovrascrive un metodo della classe base in modo tale da violarne il contratto originale (es. lanciando un'eccezione inaspettata o cambiando il tipo di ritorno), sta violando il principio LSP.
🔴 La Violazione (Prima)
Il classico esempio del Rettangolo e del Quadrato. Geometricamente un quadrato è un rettangolo, ma nel software questa ereditarietà può causare seri problemi.
class Rettangolo {
constructor(protected larghezza: number, protected altezza: number) {}
setLarghezza(w: number) { this.larghezza = w; }
setAltezza(h: number) { this.altezza = h; }
getArea() { return this.larghezza * this.altezza; }
}
class Quadrato extends Rettangolo {
// Per un quadrato, larghezza e altezza devono essere sempre uguali
setLarghezza(w: number) {
this.larghezza = w;
this.altezza = w;
}
setAltezza(h: number) {
this.larghezza = h;
this.altezza = h;
}
}
function renderizza(rettangolo: Rettangolo) {
rettangolo.setLarghezza(5);
rettangolo.setAltezza(4);
// Per un rettangolo standard l'area dovrebbe essere 5 * 4 = 20.
// Ma se passiamo un Quadrato, l'area sarà 4 * 4 = 16!
console.log(`Area attesa: 20, Area reale: ${rettangolo.getArea()}`);
}
Perché è un problema?
La funzione renderizza assume che modificando l'altezza non si modifichi la larghezza. Il Quadrato viola questa assunzione (il contratto implicito della classe base), rompendo il comportamento del programma quando viene sostituito a Rettangolo.
🟢 La Soluzione (Dopo)
Se due classi condividono una firma ma hanno comportamenti incompatibili nelle mutazioni, è preferibile evitare l'ereditarietà diretta e fare riferimento a un'astrazione comune più semplice (sola lettura dell'area) oppure tenere le classi separate.
interface FormaGeometrica {
getArea(): number;
}
class Rettangolo implements FormaGeometrica {
constructor(private larghezza: number, private altezza: number) {}
getArea(): number {
return this.larghezza * this.altezza;
}
}
class Quadrato implements FormaGeometrica {
constructor(private lato: number) {}
getArea(): number {
return Math.pow(this.lato, 2);
}
}
Ora non c'è più il rischio di sostituire erroneamente un oggetto mutabile modificandone lo stato in modo imprevisto. La gerarchia rispetta fedelmente il comportamento promesso.
4. Interface Segregation Principle (ISP)
"I client non dovrebbero essere forzati a dipendere da interfacce che non usano."
Questo principio suggerisce che sia molto meglio creare molte interfacce piccole e altamente specializzate piuttosto che un'unica interfaccia mastodontica e generica. Quando un'interfaccia è troppo grande, le classi che la implementano sono costrette a definire metodi di cui non hanno alcun bisogno, spesso lasciandoli vuoti o lanciando eccezioni del tipo "Metodo non implementato".
🔴 La Violazione (Prima)
Immaginiamo un'interfaccia unica per tutti i tipi di lavoratori in un ufficio.
interface Lavoratore {
lavora(): void;
mangia(): void;
dorme(): void;
}
class Impiegato implements Lavoratore {
lavora() { console.log("Sto programmando..."); }
mangia() { console.log("Pausa pranzo in mensa."); }
dorme() { console.log("Dormo a casa di notte."); }
}
class RobotLavoratore implements Lavoratore {
lavora() { console.log("Sto assemblando componenti..."); }
// Un robot non mangia né dorme!
mangia() {
throw new Error("I robot non mangiano!");
}
dorme() {
throw new Error("I robot non dormono!");
}
}
Perché è un problema?
La classe RobotLavoratore è costretta a implementare metodi (mangia, dorme) che non hanno alcun senso per essa. Se un client provasse a chiamare mangia() su un'istanza di Lavoratore senza sapere che è un robot, l'applicazione andrebbe in crash.
🟢 La Soluzione (Dopo)
Suddividiamo l'interfaccia Lavoratore in interfacce più piccole e mirate. Le classi implementeranno solo le interfacce di cui hanno effettivamente bisogno.
interface Lavorabile {
lavora(): void;
}
interface Alimentabile {
mangia(): void;
}
interface Riposabile {
dorme(): void;
}
// L'impiegato umano ha bisogno di tutte le funzionalità
class Impiegato implements Lavorabile, Alimentabile, Riposabile {
lavora() { console.log("Sto programmando..."); }
mangia() { console.log("Pausa pranzo."); }
dorme() { console.log("Dormo."); }
}
// Il robot implementa esclusivamente l'interfaccia Lavorabile
class RobotLavoratore implements Lavorabile {
lavora() { console.log("Sto assemblando componenti..."); }
}
Grazie a questa segregazione, il codice è pulito, privo di eccezioni fittizie e perfettamente aderente alla realtà del modello.
5. Dependency Inversion Principle (DIP)
"1. I moduli di alto livello non dovrebbero dipendere dai moduli di basso livello. Entrambi dovrebbero dipendere dalle astrazioni. 2. Le astrazioni non dovrebbero dipendere dai dettagli. I dettagli dovrebbero dipendere dalle astrazioni."
Questo principio è alla base della moderna architettura del software (inclusa la Clean Architecture). Invece di far dipendere una classe complessa di alto livello (la logica di business) da una classe di basso livello (es. un database o un client HTTP), entrambe devono dipendere da un'astrazione comune (un'interfaccia). Questo disaccoppia completamente le componenti del sistema.
🔴 La Violazione (Prima)
Immaginiamo una classe Notificatore che invia messaggi d'errore via SMS. La classe di alto livello dipende direttamente da una classe specifica di basso livello.
// Modulo di basso livello (dettaglio)
class ClientSms {
inviaSms(numero: string, messaggio: string) {
console.log(`SMS inviato a ${numero}: ${messaggio}`);
}
}
// Modulo di alto livello (logica di business)
class NotificatoreErrore {
private clientSms: ClientSms;
constructor() {
// Accoppiamento forte! Creiamo l'istanza concreta internamente
this.clientSms = new ClientSms();
}
segnalaProblema(messaggio: string) {
this.clientSms.inviaSms("[PHONE_NUMBER]", `ERRORE: ${messaggio}`);
}
}
Perché è un problema?
Se decidessimo di passare dagli SMS alle email o a una notifica Slack, dovremmo modificare la classe NotificatoreErrore. Inoltre, testare la classe NotificatoreErrore in isolamento (unit testing) diventa difficilissimo perché effettuerà sempre chiamate reali al client SMS.
🟢 La Soluzione (Dopo)
Definiamo un'interfaccia ServizioNotifica (astrazione). La classe NotificatoreErrore dipenderà da questa interfaccia e riceverà l'istanza concreta dall'esterno tramite il costruttore (tecnica nota come Dependency Injection).
// Astrazione condivisa
interface ServizioNotifica {
invia(messaggio: string): void;
}
// Implementazione concreta 1 (Basso livello)
class ServizioSms implements ServizioNotifica {
constructor(private numero: string) {}
invia(messaggio: string): void {
console.log(`SMS inviato a ${this.numero}: ${messaggio}`);
}
}
// Implementazione concreta 2 (Basso livello)
class ServizioEmail implements ServizioNotifica {
constructor(private email: string) {}
invia(messaggio: string): void {
console.log(`Email inviata a ${this.email}: ${messaggio}`);
}
}
// Modulo di alto livello che dipende esclusivamente dall'astrazione
class NotificatoreErrore {
constructor(private servizioNotifica: ServizioNotifica) {}
segnalaProblema(messaggio: string) {
this.servizioNotifica.invia(`ERRORE: ${messaggio}`);
}
}
// Utilizzo pratico
const notificatoreSms = new NotificatoreErrore(new ServizioSms("[PHONE_NUMBER]"));
notificatoreSms.segnalaProblema("Database offline");
const notificatoreEmail = new NotificatoreErrore(new ServizioEmail("[EMAIL_ADDRESS]"));
notificatoreEmail.segnalaProblema("Memoria esaurita");
Ora il sistema è totalmente disaccoppiato. Possiamo cambiare il canale di notifica in qualsiasi momento o creare un finto servizio (MockNotifica) per effettuare test unitari rapidi e sicuri senza inviare notifiche reali.
Conclusione
Applicare i principi SOLID richiede inizialmente uno sforzo maggiore in fase di progettazione e la scrittura di qualche riga di codice in più (interfacce, classi separate). Tuttavia, questo investimento iniziale viene ripagato ampiamente nel tempo:
- Manutenibilità: Trovare e correggere bug è più facile perché il codice è isolato.
- Estensibilità: Aggiungere nuove feature richiede solo la scrittura di nuove classi, senza toccare il vecchio codice.
- Testabilità: È possibile testare ogni componente in isolamento mockando le dipendenze.
Nello sviluppo moderno con framework complessi, la comprensione approfondita di questi concetti è ciò che distingue uno sviluppatore junior da un software architect. Inizia ad applicarli un passo alla volta nei tuoi progetti quotidiani e noterai subito la differenza nella qualità e nella fluidità del tuo flusso di lavoro.