Principi SOLID e design pattern in C#

Principi SOLID e design pattern in C#

Conoscere la sintassi di un linguaggio è solo il primo passo: scrivere software di qualità richiede di organizzare il codice in modo che sia comprensibile, manutenibile ed estensibile nel tempo. I principi SOLID e i design pattern offrono una guida collaudata per raggiungere questi obiettivi. In questo articolo presentiamo i cinque principi SOLID e alcuni pattern ricorrenti, illustrandoli con esempi in C#. Non si tratta di regole rigide, ma di strumenti di ragionamento da applicare con equilibrio.

I principi SOLID

SOLID è un acronimo che raccoglie cinque principi di progettazione orientata agli oggetti, formulati per ridurre l'accoppiamento e aumentare la coesione del codice. Vediamoli uno per uno.

Single Responsibility Principle

Il principio di singola responsabilità afferma che una classe dovrebbe avere un solo motivo per cambiare, ovvero occuparsi di una sola cosa. Una classe che mescola più responsabilità diventa difficile da modificare, perché un cambiamento in un ambito rischia di rompere gli altri.

// Violazione: questa classe fa troppe cose
class BadReport
{
    public string Generate() => "contenuto del report";
    public void SaveToFile(string content) { /* scrittura su disco */ }
    public void SendByEmail(string content) { /* invio email */ }
}

// Meglio: responsabilita separate in classi distinte
class ReportGenerator
{
    public string Generate() => "contenuto del report";
}

class FileSaver
{
    public void Save(string content) { /* scrittura su disco */ }
}

Open/Closed Principle

Il principio aperto/chiuso stabilisce che le entità software dovrebbero essere aperte all'estensione ma chiuse alla modifica. In pratica, dovremmo poter aggiungere nuovi comportamenti senza alterare il codice esistente, tipicamente sfruttando l'astrazione e il polimorfismo.

// L'astrazione permette di aggiungere forme senza modificare il calcolo
interface IShape
{
    double Area();
}

class Circle : IShape
{
    public double Radius { get; set; }
    public double Area() => Math.PI * Radius * Radius;
}

class Rectangle : IShape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public double Area() => Width * Height;
}

// Aggiungere un nuovo tipo di forma non richiede di toccare questo metodo
static double TotalArea(IEnumerable<IShape> shapes) => shapes.Sum(s => s.Area());

Liskov Substitution Principle

Il principio di sostituzione di Liskov richiede che un oggetto di una classe derivata possa sostituire un oggetto della classe base senza alterare la correttezza del programma. In altre parole, una sottoclasse non dovrebbe indebolire le garanzie o cambiare il comportamento atteso della base in modo sorprendente.

Interface Segregation Principle

Il principio di segregazione delle interfacce suggerisce di preferire interfacce piccole e specifiche a interfacce ampie e generiche. Un tipo non dovrebbe essere costretto a dipendere da metodi che non usa. Interfacce mirate riducono l'accoppiamento e rendono il codice più flessibile.

// Meglio interfacce piccole e focalizzate
interface IReadable
{
    string Read();
}

interface IWritable
{
    void Write(string content);
}

// Un tipo implementa solo cio di cui ha davvero bisogno
class ReadOnlyDocument : IReadable
{
    public string Read() => "contenuto";
}

Dependency Inversion Principle

Il principio di inversione delle dipendenze afferma che i moduli di alto livello non dovrebbero dipendere da quelli di basso livello, ma entrambi dovrebbero dipendere da astrazioni. Dipendere da un'interfaccia anziché da un'implementazione concreta rende il codice sostituibile e testabile. Questo principio è così centrale da meritare, nel prossimo articolo, un approfondimento dedicato alla dependency injection.

// Dipendenza da un'astrazione, non da una classe concreta
interface IMessageSender
{
    void Send(string message);
}

class NotificationService
{
    private readonly IMessageSender _sender;

    // La dipendenza viene fornita dall'esterno tramite l'interfaccia
    public NotificationService(IMessageSender sender)
    {
        _sender = sender;
    }

    public void Notify(string message) => _sender.Send(message);
}

I design pattern

I design pattern sono soluzioni riutilizzabili a problemi ricorrenti di progettazione. Non sono codice da copiare, ma schemi concettuali da adattare. Vediamone alcuni tra i più usati in C#.

Il pattern Strategy

Il pattern Strategy incapsula algoritmi intercambiabili dietro un'interfaccia comune, permettendo di selezionare il comportamento a runtime. È l'applicazione diretta del principio aperto/chiuso: nuove strategie si aggiungono senza modificare il codice che le usa.

interface IDiscountStrategy
{
    decimal Apply(decimal price);
}

class NoDiscount : IDiscountStrategy
{
    public decimal Apply(decimal price) => price;
}

class PercentageDiscount : IDiscountStrategy
{
    private readonly decimal _percentage;
    public PercentageDiscount(decimal percentage) => _percentage = percentage;
    public decimal Apply(decimal price) => price * (1 - _percentage / 100);
}

class Checkout
{
    // La strategia e scelta dall'esterno e puo cambiare a runtime
    public decimal Total(decimal price, IDiscountStrategy strategy)
        => strategy.Apply(price);
}

Il pattern Factory

Il pattern Factory centralizza la creazione degli oggetti in un metodo o una classe dedicata, nascondendo la logica di istanziazione a chi ne usufruisce. È utile quando la scelta del tipo concreto dipende da condizioni o configurazioni.

static class ShapeFactory
{
    // Crea l'implementazione corretta in base a un identificatore
    public static IShape Create(string type) => type switch
    {
        "circle" => new Circle { Radius = 1 },
        "rectangle" => new Rectangle { Width = 1, Height = 1 },
        _ => throw new ArgumentException("Tipo sconosciuto", nameof(type))
    };
}

Il pattern Repository

Il pattern Repository astrae l'accesso ai dati dietro un'interfaccia, separando la logica applicativa dai dettagli della persistenza. Il codice che usa il repository non sa se i dati provengano da un database, da un file o dalla memoria, il che facilita i test e le sostituzioni.

interface IProductRepository
{
    Product? GetById(int id);
    void Add(Product product);
    IEnumerable<Product> GetAll();
}

Applicare i principi con equilibrio

Principi e pattern sono strumenti, non dogmi. Applicarli meccanicamente a ogni situazione porta a un'astrazione eccessiva, che complica il codice invece di semplificarlo. Il buon senso resta la guida migliore: si introducono un'astrazione o un pattern quando risolvono un problema reale di flessibilità o manutenibilità, non per principio.

Conclusione

Abbiamo presentato i cinque principi SOLID, che guidano verso codice a basso accoppiamento e alta coesione, e alcuni design pattern ricorrenti come Strategy, Factory e Repository. Abbiamo sottolineato che il principio di inversione delle dipendenze è così importante da meritare un approfondimento specifico. Proprio la dependency injection, che ne è l'applicazione pratica più diffusa in .NET, è l'argomento del prossimo articolo.