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 ricorre in quasi tutti i colloqui e nelle code review. Molte persone sanno recitare le cinque lettere. Molti meno sanno individuare quando un principio si applica davvero e ancora meno sanno riconoscere quando applicarlo sarebbe un errore.

Questo articolo illustra i cinque principi con esempi brevi. Si estende poi alle altre buone pratiche utili nel lavoro quotidiano e si conclude con ciò che SOLID non dice.

Da dove viene

L'acronimo è stato proposto da Michael Feathers all'inizio degli anni 2000. Riprende principi formulati da Robert C. Martin, tutti legati alla stessa domanda: come scrivere codice che potremo modificare tra sei mesi senza rompere tutto?

Una precisazione importante prima di iniziare. Sono principi, non regole. Danno una direzione, non fissano soglie. Un codice che segue SOLID alla lettera ma richiede quaranta classi per inviare un'email non è buon codice.

S - Single Responsibility Principle

Una classe dovrebbe avere una sola ragione per cambiare.

La formulazione esatta di Robert C. Martin è più precisa di quella che ripetiamo di solito. Una classe dovrebbe rispondere a un solo attore. Un solo gruppo di persone in grado di richiedere una modifica.

Il problema

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

    public function saveToDatabase(Invoice $invoice): void
    {
        // Persistenza definita dalla parte tecnica
    }

    public function renderPdf(Invoice $invoice): string
    {
        // Formattazione definita dal marketing
    }
}

Tre ragioni per cambiare. Tre attori diversi. Un semplice cambiamento dello stile grafico obbliga a modificare una classe che contiene anche il calcolo dell'IVA. Il rischio di rompere qualcosa è immediato.

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'operazione richiede di aprire quindici file.

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

O - Open/Closed Principle

Aperto all'estensione, chiuso alla modifica.

Traduzione semplice. Aggiungere un comportamento non dovrebbe richiedere di modificare 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("Mode inconnu");
    }
}

Ogni nuovo metodo di spedizione richiede di riaprire questo metodo. Cresce e ogni modifica mette a rischio i metodi che già funzionavano.

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. Aggiungere un metodo significa creare una classe. Nessun file esistente viene modificato.

Quando non applicarlo

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

L - Liskov Substitution Principle

Una sottoclasse deve poter sostituire la classe padre senza rompere nulla.

Il principio deriva da Barbara Liskov nel 1987. È il più astratto dei cinque e quello 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;
    }
}

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 codice chiamante rispetta il contratto di Rectangle e ottiene un risultato errato. La sottoclasse ha infranto la promessa della classe padre.

Come riconoscerlo

Tre segnali inequivocabili.

Un metodo sovrascritto che lancia UnsupportedOperationException.

Una sottoclasse che rifiuta valori accettati dalla classe padre.

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

La correzione

La correzione

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;
    }
}

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

Gli oggetti diventano immutabili. Il problema non si presenta più.

I - Interface Segregation Principle

Il problema

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

Nessuno dovrebbe dipendere da metodi che non utilizza.

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 collaboratore esterno non ha un foglio presenze interno da compilare. Dovrebbe comunque implementare il metodo con un corpo vuoto o un'eccezione, violando anche Liskov. I principi spesso si sovrappongono.

Un'interfaccia con un solo metodo non ha nulla di sbagliato. Spesso è proprio il segno di un'astrazione ben mirata.

D - Dependency Inversion Principle

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

Il problema

class OrderService
{
    private MySqlOrderRepository $repository;

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

È il principio più strutturante e quello che spiega perché Spring e Laravel funzionano nel modo in cui funzionano.

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
    ) {
    }
}

La tua regola di business dipende da MySQL. Non puoi testarla senza un database. Non puoi cambiare lo storage senza modificarla.

C'è un dettaglio che molti articoli trascurano. La direzione della dipendenza conta. L'interfaccia OrderRepository appartiene al livello di business, non a quello tecnico. È il business a definire ciò di cui ha bisogno. L'infrastruttura si adegua. Senza questo, hai semplicemente aggiunto un'interfaccia senza invertire davvero nulla.

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

In Laravel, il binding viene effettuato in un service provider.

In Spring, viene dedotto automaticamente non appena esiste una sola implementazione.

Gli altri principi che contano

SOLID non copre tutto. Questi quattro sono almeno altrettanto utili nel lavoro quotidiano.

SOLID non copre tutto. Questi quattro sono almeno altrettanto utili nel lavoro quotidiano.

Non ripeterti. La stessa conoscenza dovrebbe esistere in un solo posto.

Attenzione al fraintendimento. DRY riguarda la conoscenza, non il codice che si assomiglia. Due funzioni quasi identiche oggi ma che rispondono a esigenze diverse finiranno per divergere. Fonderle crea un accoppiamento artificiale e più avanti lo pagherai con una funzione piena di condizioni.

Regola pratica: aspetta la terza occorrenza. Due volte può essere una coincidenza. Tre volte è un modello.

Regola pratica: aspetta la terza occorrenza. Due volte può essere una coincidenza. Tre volte è un modello.

// Intelligente, ma bisogna rileggerla 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;
}

Le second n'est pas moins bon. Il est juste plus long et plus rapide à comprendre pour celui qui te relira.

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

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

Non ne avrai bisogno. Non scrivere codice per un'esigenza immaginaria.

È il contrappeso del principio Open/Closed. Un'interfaccia con una sola implementazione, creata nel caso servisse, è peso morto. Inoltre sarà probabilmente progettata male perché l'hai pensata senza conoscere il secondo caso d'uso.

La legge di Demeter

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

Parla solo con i tuoi vicini diretti.

Ogni punto in più è una dipendenza in più. Se Address cambia, la prima riga si rompe anche se nulla lo lasciava intuire.

I segnali di un codice che si sta degradando

Alcuni sintomi concreti da tenere d'occhio durante una code review.

La classe che continua a crescere. Oltre le trecento righe, poniti la domanda. Non è un limite assoluto. È una soglia di attenzione.

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

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

Il parametro booleano. save($user, true) non dice nulla al lettore. Due metodi ben nominati sono meglio di un flag.

// 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);
}

Le condizioni annidate. Tre livelli possono quasi sempre essere sostituiti da guard clauses.

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

Il commento che ripete il codice. Un commento dovrebbe dire perché, non come. Se il codice ha bisogno di un commento per essere compreso, inizia rinominando le variabili.

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 fondate.

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 diverse di queste domande.

Descrivono il risultato desiderato senza dire quando agire. La Single Responsibility non definisce nemmeno che cosa sia una responsabilità. Eppure è proprio questa la difficoltà.

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

Il giusto approccio può essere riassunto in una frase. Scrivi il codice più diretto che risolve il problema di oggi. Quando una modifica diventa difficile, chiediti quale di questi principi avrebbe evitato il problema. Fai il refactoring in quel momento. Un refactoring guidato da una difficoltà reale è sempre migliore di un'astrazione preventiva.

Per approfondire

  • Clean Code di Robert C. Martin. Da leggere con senso critico: alcuni consigli sono invecchiati. I capitoli sui nomi e sulle funzioni restano eccellenti

  • Refactoring di Martin Fowler. Il catalogo delle trasformazioni e del sintomo che attiva ciascuna

  • Il catalogo online di Fowler

  • Le PSR per gli standard PHP

  • Effective Java di Joshua Bloch per la parte Java

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!