Una classe che salva un utente nel database, gli manda un’email di benvenuto e formatta una ricevuta è a una modifica di distanza dal rompere due funzionalità con cui non ha niente a che fare. Tocchi il template dell’email e rischi la scrittura sul database. Tocchi il layout della ricevuta e rischi l’email. SOLID sono cinque regole che attaccano questo tipo di groviglio da angolazioni diverse, e condividono un’idea sola: una classe deve cambiare per un solo motivo, e il resto del codice deve dipendere da cosa qualcosa promette di fare, non da come lo fa.

I principi vengono dal paper del 2000 di Robert C. Martin “Design Principles and Design Patterns”. Michael Feathers li ha poi messi nell’ordine che forma l’acronimo SOLID. Nessuno dei cinque riguarda la sintassi o un linguaggio specifico. Riguardano dove vanno le cuciture nel tuo codice, così che una modifica in un punto resti in quel punto.

Cosa significa SOLID, e perché sono cinque regole e non una

  • S, Single Responsibility: una classe ha un solo motivo per cambiare.
  • O, Open/Closed: aggiungi comportamento aggiungendo codice, non modificando codice che già funziona.
  • L, Liskov Substitution: una sottoclasse deve comportarsi come la sua classe madre ovunque la classe madre venga usata.
  • I, Interface Segregation: non costringere una classe a implementare metodi che non usa.
  • D, Dependency Inversion: dipendi da un’abstrazione, non da una classe concreta specifica.

Si sovrappongono di proposito. La Dependency Inversion è ciò che rende possibile l’Open/Closed nella pratica: puoi aggiungere una nuova implementazione senza toccare codice vecchio solo se quel codice è stato scritto contro un’interfaccia. L’Interface Segregation è il Single Responsibility applicato alle interfacce invece che alle classi. Trattali come cinque punti di vista su un unico obiettivo, non cinque caselle da spuntare.

Single Responsibility Principle: un solo motivo per cambiare

Ecco una classe con due motivi per cambiare nascosti in un solo corpo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def save(self, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (self.name, self.email),
        )

    def send_welcome_email(self, smtp_client) -> None:
        smtp_client.send(
            to=self.email,
            subject="Welcome",
            body=f"Hi {self.name}, thanks for signing up.",
        )

Un cambio di schema e un cambio di template email adesso toccano entrambi User. Testare save() tira dentro una connessione al database (o un mock), e testare send_welcome_email() tira dentro un client SMTP, anche se nessuno dei due test si occupa dell’altro metodo. È anche qui che inizia l’ingarbugliamento descritto in Cos’è lo Spaghetti Code e Come Rifattorizzarlo: una classe continua ad assorbire compiti non correlati perché era già lì.

Separa i compiti:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email


class UserRepository:
    def save(self, user: User, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email),
        )


class WelcomeEmailer:
    def send(self, user: User, smtp_client) -> None:
        smtp_client.send(
            to=user.email,
            subject="Welcome",
            body=f"Hi {user.name}, thanks for signing up.",
        )

Ora ogni classe cambia per un suo motivo, e ognuna si può testare con un fake che copre un compito piccolo. Nota che “un solo motivo per cambiare” non vuol dire “un solo metodo”. UserRepository potrebbe crescere con find_by_email e delete e avere comunque una sola responsabilità, perché ogni metodo serve lo stesso compito: salvare utenti.

Open/Closed Principle: aggiungere comportamento senza modificare codice che funziona

Un calcolatore che si dirama su un tag di tipo:

1
2
3
4
5
6
7
class AreaCalculator:
    def area(self, shape: dict) -> float:
        if shape["type"] == "circle":
            return 3.14159 * shape["radius"] ** 2
        elif shape["type"] == "rectangle":
            return shape["width"] * shape["height"]
        raise ValueError(f"unknown shape: {shape['type']}")

Aggiungere un triangolo significa modificare di nuovo area(). Peggio ancora, ogni altra funzione che si dirama su shape["type"] va trovata e modificata nello stesso modo. Ogni nuova forma è una modifica a codice già testato, con il rischio di rompere i casi del cerchio e del rettangolo mentre ci sei dentro.

Definisci il contratto una volta e aggiungi nuove forme come nuove classi:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


def total_area(shapes: list[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

Aggiungere Triangle è una nuova classe che implementa area(). total_area non cambia mai, e nemmeno Circle e Rectangle. Questo è tutto ciò che significa “aperto per l’estensione, chiuso per la modifica”: il comportamento nuovo si scrive come codice nuovo, e il codice già coperto da test resta intatto.

Liskov Substitution Principle: una sottoclasse deve mantenere le promesse della classe madre

Il fallimento classico è un Square costruito come sottoclasse di Rectangle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def set_width(self, width: float) -> None:
        self.width = width

    def set_height(self, height: float) -> None:
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Square(Rectangle):
    def set_width(self, width: float) -> None:
        self.width = width
        self.height = width

    def set_height(self, height: float) -> None:
        self.width = height
        self.height = height
1
2
3
4
def resize_and_check(rect: Rectangle) -> None:
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20

resize_and_check funziona per qualsiasi Rectangle. Passagli un Square e l’assert fallisce: impostare l’altezza ha cambiato di nascosto anche la larghezza, quindi l’area è 16, non 20. Square passa il type-check come Rectangle, quindi mypy non ha niente da segnalare, eppure rompe il comportamento che ogni chiamante di Rectangle era scritto per aspettarsi. Le violazioni di Liskov raramente riguardano le firme dei metodi, che gli strumenti già intercettano. Riguardano una sottoclasse che cambia di nascosto cosa i chiamanti hanno il diritto di assumere.

Non risolverlo con un caso speciale per i quadrati dentro resize_and_check. Risolvilo non facendo di Square una sottoclasse di Rectangle. Un quadrato non ha due dimensioni ridimensionabili in modo indipendente, quindi non può onorare il contratto di Rectangle. Entrambi possono implementare l’interfaccia Shape dell’esempio Open/Closed, senza nessuna eredità tra loro.

Interface Segregation Principle: non imporre metodi inutilizzati a una classe

Un’interfaccia che accorpa capacità non correlate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

    @abstractmethod
    def scan(self, document) -> None: ...

    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printer):
    def print(self, document) -> None:
        ...  # funziona davvero

    def scan(self, document) -> None:
        raise NotImplementedError("this printer cannot scan")

    def fax(self, document) -> None:
        raise NotImplementedError("this printer cannot fax")

Ogni chiamante che ha in mano un Printer deve ora sapere, fuori dal sistema dei tipi, quali metodi sono sicuri su quale istanza. L’interfaccia promette tre capacità e ne consegna una, più due mine.

Separa l’interfaccia per capacità invece che per dispositivo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Printable(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

class Scannable(ABC):
    @abstractmethod
    def scan(self, document) -> None: ...

class Faxable(ABC):
    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printable):
    def print(self, document) -> None: ...


class AllInOnePrinter(Printable, Scannable, Faxable):
    def print(self, document) -> None: ...
    def scan(self, document) -> None: ...
    def fax(self, document) -> None: ...

Una funzione che ha bisogno solo di stampare prende un Printable, e non c’è nessun .scan() da chiamare per errore su un dispositivo che non l’ha mai avuto. Il contratto viene da cosa l’oggetto implementa, non da un commento che ti avvisa quali metodi lanceranno un’eccezione.

Dependency Inversion Principle: dipendi da abstrazioni, non da classi concrete

Un servizio costruito direttamente su una libreria di database:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class SQLiteUserRepository:
    def save(self, user) -> None:
        ...  # codice specifico di sqlite3


class UserService:
    def __init__(self):
        self.repository = SQLiteUserRepository()

    def register(self, user) -> None:
        self.repository.save(user)

UserService è saldato a SQLite. Passare a Postgres, o passargli un repository finto in un test, significa modificare UserService stesso invece di sostituire una dipendenza.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from abc import ABC, abstractmethod

class UserRepository(ABC):
    @abstractmethod
    def save(self, user) -> None: ...


class SQLiteUserRepository(UserRepository):
    def save(self, user) -> None:
        ...  # codice specifico di sqlite3


class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def register(self, user) -> None:
        self.repository.save(user)

UserService adesso dipende dall’abstrazione UserRepository. Costruire un SQLiteUserRepository(), o un FakeUserRepository() per un test, e passarlo è un passo separato, e quel passo è la dependency injection. I due termini vengono usati in modo intercambiabile, ma sono cose diverse: la dependency inversion è la regola di design (dipendi da abstrazioni), e la dependency injection è la tecnica che fornisce l’oggetto concreto. Cos’è la Dependency Injection (con Esempi) copre il lato dell’injection, incluso cosa aggiunge un container DI quando una classe ha bisogno di più di una o due dipendenze collegate.

Quando SOLID diventa over-engineering

Spezzare uno script di venti righe in cinque interfacce non ti porta niente se esiste esattamente un’implementazione e nessun piano per una seconda. Interface Segregation e Dependency Inversion aggiungono entrambe un livello di indirezione. Quel livello si ripaga quando sostituisci davvero le implementazioni, come produzione contro test o SQLite contro Postgres, o quando devi isolare una classe dietro un test double come descritto in Test Unitari vs Test di Integrazione vs Test End-to-End. Se non sostituisci mai niente, l’abstrazione è puro overhead.

Più abstrazioni non vuol dire design migliore. Un costruttore che prende otto interfacce iniettate non è più SOLID di uno che ne prende tre. Vuol dire che la classe dietro ha ancora troppe responsabilità, il che ti rimanda alla prima lettera.

SOLID resta comunque un vocabolario nato per il paradigma a oggetti. Un bel po’ di codice buono non ha classi per niente. Le stesse abitudini restano valide: funzioni piccole con un solo compito, e dipendenze passate dall’esterno invece che prese da variabili globali. Contano anche in uno stile funzionale, anche quando nessuna delle cinque lettere si applica alla lettera.

Come iniziare ad applicare SOLID a un codebase esistente

Non riadattare un intero codebase contro una checklist. Applica prima Single Responsibility, e solo dove una classe fa già male a modificare: un file che causa conflitti di merge ogni sprint, o un test che ha bisogno di tre mock non correlati prima di poter partire. Metti una dipendenza dietro un’abstrazione solo quando una seconda implementazione o un fake per i test è un bisogno attuale, non ipotetico. La maggior parte dei problemi di Open/Closed e Liskov parte da lì: una sottoclasse fatta crescere per coprire una responsabilità che non le apparteneva. Risolvi quello prima di metterti a caccia di interfacce da segregare o dipendenze da invertire: nella maggior parte dei casi si sistemano da sole.

Articoli correlati