Illustrazione dell'articolo SOLID e buone pratiche: i principi e soprattutto i loro limiti

SOLID e buone pratiche: i principi e soprattutto i loro limiti

SOLID torna in ogni colloquio e in ogni revisione del codice. Molti sanno recitare le cinque lettere. Molti meno sanno riconoscere il momento in cui un principio si applica davvero, e ancora meno il momento in cui applicarlo sarebbe un errore.

Questo articolo descrive i cinque principi con esempi brevi. Si allarga poi alle altre buone pratiche che servono ogni giorno e si chiude con ciò che SOLID non dice.

Da dove viene

L'acronimo è stato proposto da Michael Feathers all'inizio degli anni 2000. Raggruppa principi raccolti da Robert C. Martin. Alcuni sono più antichi: il principio aperto/chiuso viene da Bertrand Meyer nel 1988 e la sostituzione di Liskov da Barbara Liskov nel 1987. Tutti rispondono alla stessa domanda: come scrivere codice che si potrà modificare tra sei mesi senza rompere tutto?

Una parola importante prima di cominciare. Sono principi, non regole. Danno una direzione, non fissano una soglia. Un codice che rispetta SOLID alla lettera ma richiede quaranta classi per inviare un'email non è un buon codice.

S - Single Responsibility Principle (principio di responsabilità singola)

Una classe deve avere un solo motivo per cambiare.

La formulazione esatta di Robert C. Martin è più sottile di quella che si ripete. Una classe deve rispondere a un solo attore: un solo gruppo di persone in grado di chiedere una modifica.

Il problema

class InvoiceService
{
    public function calculateTotal(Invoice $invoice): float
    {
        // Regola di business decisa dalla contabilità
    }

    public function saveToDatabase(Invoice $invoice): void
    {
        // Persistenza decisa dal team tecnico
    }

    public function renderPdf(Invoice $invoice): string
    {
        // Impaginazione decisa dal marketing
    }
}

Tre motivi per cambiare. Tre attori diversi. Un semplice cambio di identità grafica obbliga a toccare una classe che contiene anche il calcolo dell'IVA. Il rischio di rottura è diretto.

La correzione

class InvoiceCalculator
{
    public function calculateTotal(Invoice $invoice): float { }
}

class InvoiceRepository
{
    public function save(Invoice $invoice): void { }
}

class InvoicePdfRenderer
{
    public function render(Invoice $invoice): string { }
}

La trappola

È il principio applicato peggio dei cinque. Portato all'estremo, produce classi con un solo metodo di tre righe e un'architettura in cui seguire un'elaborazione richiede di aprire quindici file.

Il buon indicatore non è il numero di metodi. Chiediti piuttosto: due persone diverse potrebbero chiedere di modificare questa classe? Se sì, dividila. Altrimenti, lasciala stare.

O - Open/Closed Principle (principio aperto/chiuso)

Aperto all'estensione, chiuso alla modifica.

In parole semplici: aggiungere un comportamento non dovrebbe obbligare a toccare il codice già scritto.

Il problema

public class ShippingCalculator {

    public BigDecimal calculate(String method) {
        if ("standard".equals(method)) {
            return BigDecimal.valueOf(5.90);
        }
        if ("express".equals(method)) {
            return BigDecimal.valueOf(12.50);
        }
        throw new IllegalArgumentException("Modalità sconosciuta");
    }
}

Ogni nuova modalità di spedizione obbliga a riaprire questo metodo. Cresce, e ogni modifica mette a rischio le modalità che funzionavano già.

La correzione

public interface ShippingMethod {

    String code();

    BigDecimal cost();
}
@Component
public class ExpressShipping implements ShippingMethod {

    @Override
    public String code() {
        return "express";
    }

    @Override
    public BigDecimal cost() {
        return BigDecimal.valueOf(12.50);
    }
}

Spring inietta poi tutte le implementazioni disponibili nel calcolatore.

@Service
public class ShippingCalculator {

    private final Map<String, ShippingMethod> methods;

    public ShippingCalculator(List<ShippingMethod> methods) {
        this.methods = methods.stream()
            .collect(Collectors.toMap(ShippingMethod::code, Function.identity()));
    }

    public BigDecimal calculate(String code) {
        ShippingMethod method = methods.get(code);

        if (method == null) {
            throw new IllegalArgumentException("Modalità sconosciuta: " + code);
        }

        return method.cost();
    }
}

Aggiungere una modalità significa creare una classe. Nessun file esistente viene toccato.

Quando non applicarlo

Tre condizioni che non cambieranno mai non giustificano un'interfaccia e tre classi. Il principio diventa conveniente quando ti accorgi che torni continuamente a modificare la stessa condizione, la seconda o la terza volta, non prima.

L - Liskov Substitution Principle (principio di sostituzione di Liskov)

Una sottoclasse deve poter sostituire la sua classe madre senza rompere nulla.

Il principio viene da Barbara Liskov nel 1987. È il più astratto dei cinque e il più spesso frainteso.

L'esempio classico

public class Rectangle {

    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int area() {
        return width * height;
    }
}
public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

In matematica, un quadrato è un rettangolo. Nella programmazione a oggetti, no.

Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);

// Ci aspettiamo 20. Otteniamo 16.
System.out.println(r.area());

Il quadrato mantiene i lati uguali, come deve. L'ultima chiamata a setHeight(4) cambia quindi anche la larghezza. Il codice chiamante rispetta il contratto di Rectangle e ottiene un risultato sbagliato. La sottoclasse ha infranto la promessa della classe madre.

Come riconoscerlo

Tre segnali che non ingannano.

Un metodo ridefinito che solleva UnsupportedOperationException.

Una sottoclasse che rifiuta valori che la classe madre accettava.

Codice chiamante che controlla il tipo reale con instanceof prima di agire. Questo è il più eloquente. Se devi sapere quale sottoclasse stai usando, il polimorfismo non funziona.

La correzione

Spesso l'ereditarietà era semplicemente lo strumento sbagliato. Due classi distinte dietro un'interfaccia comune risolvono il problema.

public interface Shape {
    int area();
}
public record Rectangle(int width, int height) implements Shape {

    @Override
    public int area() {
        return width * height;
    }
}
public record Square(int side) implements Shape {

    @Override
    public int area() {
        return side * side;
    }
}

Gli oggetti diventano immutabili. La questione non si pone più.

I - Interface Segregation Principle (principio di segregazione delle interfacce)

Nessuno deve dipendere da metodi che non usa.

Il problema

interface Employee
{
    public function work(): void;
    public function submitTimesheet(): void;
    public function attendMeeting(): void;
}

Un consulente esterno non ha un foglio ore interno da compilare. Dovrà comunque implementare il metodo con un corpo vuoto o un'eccezione, il che viola anche Liskov. I principi spesso si sovrappongono.

La correzione

interface Worker
{
    public function work(): void;
}

interface TimeTracked
{
    public function submitTimesheet(): void;
}
class Contractor implements Worker
{
    public function work(): void { }
}

class Employee implements Worker, TimeTracked
{
    public function work(): void { }

    public function submitTimesheet(): void { }
}

Un'interfaccia con un solo metodo non ha nulla di strano. Anzi, è spesso il segno di un'astrazione ben mirata.

D - Dependency Inversion Principle (principio di inversione delle dipendenze)

Il codice di business non deve dipendere dal codice tecnico. Entrambi dipendono da astrazioni.

È il principio più strutturante e quello che spiega perché Spring e Laravel funzionano come funzionano.

Il problema

class OrderService
{
    private MySqlOrderRepository $repository;

    public function __construct()
    {
        $this->repository = new MySqlOrderRepository();
    }
}

La tua regola di business dipende da MySQL. Non puoi testarla senza database. Non puoi cambiare sistema di archiviazione senza modificarla.

La correzione

interface OrderRepository
{
    public function findById(int $id): ?Order;

    public function save(Order $order): void;
}
class OrderService
{
    public function __construct(
        private readonly OrderRepository $repository
    ) {
    }
}

C'è un dettaglio che molti articoli trascurano. Il verso della dipendenza conta. L'interfaccia OrderRepository appartiene allo strato di business, non a quello tecnico. È il business a dire di cosa ha bisogno e l'infrastruttura si adegua. Altrimenti hai solo aggiunto un'interfaccia senza invertire nulla.

In Laravel, il collegamento avviene in un service provider.

public function register(): void
{
    $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}

In Spring, viene risolto da solo appena esiste una sola implementazione.

Gli altri principi che contano

SOLID non copre tutto. Questi quattro servono almeno altrettanto ogni giorno.

DRY (Don't Repeat Yourself)

Non ripeterti. Una stessa conoscenza deve esistere in un solo punto.

Attenzione al fraintendimento. DRY parla di conoscenza, non di codice che si assomiglia. Due funzioni quasi identiche oggi ma che rispondono a due bisogni diversi finiranno per divergere. Unirle crea un accoppiamento artificiale, e lo pagherai più avanti con una funzione piena di condizioni.

La regola pratica: aspetta la terza occorrenza. Due volte può essere un caso. Tre volte è uno schema.

KISS (Keep It Simple, Stupid)

Fai semplice. Tra due soluzioni che funzionano, vince la più leggibile, anche se è meno brillante.

// Brillante, ma bisogna rileggerlo due volte
$total = array_reduce($items, fn($c, $i) => $c + ($i->qty * $i->price), 0);
// Semplice e immediato
$total = 0;

foreach ($items as $item) {
    $total += $item->qty * $item->price;
}

Il secondo non è peggiore. È solo più lungo, e più veloce da capire per chi rileggerà il tuo codice.

YAGNI (You Ain't Gonna Need It)

Non ti servirà. Non scrivere codice per un bisogno immaginario.

È il contrappeso del principio aperto/chiuso. Un'interfaccia con una sola implementazione, creata per ogni evenienza, è peso morto. Sarà anche progettata male, visto che l'hai pensata senza conoscere il secondo caso d'uso.

La legge di Demetra

Parla solo con i tuoi vicini diretti.

// Tre livelli di conoscenza su oggetti che non ti riguardano
$city = $order->getCustomer()->getAddress()->getCity();
// Il contratto è chiaro
$city = $order->getShippingCity();

Ogni punto in più è una dipendenza in più. Se Address cambia, la prima riga si rompe senza che nulla lo lasciasse prevedere.

I segni di un codice che si degrada

Alcuni sintomi concreti da tenere d'occhio in revisione.

La classe che non smette di crescere. Oltre le trecento righe, fatti la domanda. Non è un limite assoluto. È una soglia di attenzione.

Il metodo con più di tre parametri. Spesso il segno che manca un oggetto.

// Difficile da chiamare senza sbagliare l'ordine
public void createUser(String name, String email, String street, String city) { }
// L'intenzione è chiara
public void createUser(UserIdentity identity, Address address) { }

Il parametro booleano. save($user, true) non dice nulla a chi legge. Due metodi ben nominati valgono più di un flag.

Le condizioni annidate. Tre livelli si sostituiscono quasi sempre con delle clausole di guardia.

// Prima
public function process(Order $order): void
{
    if ($order->isValid()) {
        if ($order->hasStock()) {
            if ($order->isPaid()) {
                $this->ship($order);
            }
        }
    }
}
// Dopo
public function process(Order $order): void
{
    if (!$order->isValid()) {
        return;
    }

    if (!$order->hasStock()) {
        return;
    }

    if (!$order->isPaid()) {
        return;
    }

    $this->ship($order);
}

Il commento che ripete il codice. Un commento deve dire perché, non come. Se il tuo codice ha bisogno di un commento per essere capito, comincia rinominando le variabili.

// Inutile, lo dice già il codice
// Incrementa il contatore
$count++;
// Utile, il codice non può dirlo
// Il fornitore a volte invia due volte la stessa riga,
// quindi eliminiamo i duplicati prima di fatturare
$lines = collect($lines)->unique('reference');

Il nome che non dice nulla. data, info, manager, helper, process. Un buon nome permette di capire senza aprire il metodo.

Ciò che SOLID non dice

Questi principi sono discussi e le critiche sono giuste.

Risalgono a un'epoca in cui l'ereditarietà era ovunque e i linguaggi erano più rigidi. Alcuni si applicano male alla programmazione funzionale, dove le funzioni pure rendono inutili molte di queste domande.

Descrivono il risultato voluto senza dire quando agire. La responsabilità singola non definisce nemmeno cosa sia una responsabilità. Eppure è tutta lì la difficoltà.

Il vero rischio resta l'over-engineering. Una piccola applicazione sommersa da interfacce, factory e strati di astrazione è più difficile da mantenere di un'applicazione diretta. La complessità non è sparita, ha solo cambiato posto.

L'atteggiamento giusto sta in una frase. Scrivi il codice più diretto che risolve il problema di oggi. Quando una modifica diventa faticosa, chiediti quale di questi principi avrebbe evitato il dolore. Rifattorizza in quel momento. Un refactoring guidato da un fastidio reale vale sempre più di un'astrazione preventiva.

Per approfondire

Un esercizio per finire. Prendi una classe del tuo progetto che supera le trecento righe, elenca i suoi metodi e scrivi accanto a ciascuno chi potrebbe chiederne la modifica. Se trovi almeno due attori diversi, hai il tuo primo taglio.

Commenti (0)

Lascia un commento

Non hai effettuato l'accesso

Puoi commentare indicando il tuo nome e la tua email. Il messaggio sarà pubblicato dopo la moderazione. Con un account appare subito e resta modificabile.

La tua email non sarà pubblicata.

Ancora nessun commento

Sii il primo a commentare questo articolo!