S.O.L.I.D.

SOLID: principi per codice che invecchia bene 🧱

Perché SOLID? 🤔

Quando un progetto cresce, il codice tende a diventare un condominio con fili elettrici volanti, tubi rumorosi e vicini che litigano sulle dipendenze. I principi SOLID sono le regole del buon vicinato: niente magia, solo buone pratiche che rendono il software più semplice da estendere, testare e mantenere.

In breve: meno accoppiamento, più coesione, dipendenze più sane. Risultato? Refactoring meno dolorosi e feature che entrano senza demolire mezzo edificio.


SRP: responsabilità singola 🧹

Il principio di responsabilità singola dice che un modulo dovrebbe avere una sola ragione per cambiare. Tradotto: evita le classi “coltellino svizzero”.

  • In pratica: separa orchestrazione, dominio e I/O. Una classe che valida dati non dovrebbe anche inviare email e scrivere su database.
  • Segnali d’allarme: metodi con nomi eterogenei, dipendenze a cascata, test che devono istanziare “mezza città”.
  • Refactoring utile: estrai servizi dedicati, usa composizione, introduci adapter per I/O e mantieni il core di dominio puro.

Benefici: test più piccoli, cambi locali e meno regressioni.

Esempio TypeScript 🟦

 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type User = { email: string };

class UserValidator {
 isValid(u: User): boolean {
  // regex email semplificata per esempio didattico
  return /\S+@\S+\.\S+/.test(u.email);
 }
}

interface UserRepo {
 save(u: User): Promise<void>;
}

class PostgresUserRepo implements UserRepo {
 async save(u: User) {
  // INSERT INTO users ...
 }
}

interface Mailer {
 send(to: string, subject: string, body: string): Promise<void>;
}

class SmtpMailer implements Mailer {
 async send(to: string, subject: string, body: string) {
  // invio email...
 }
}

class UserService {
 constructor(
  private validator: UserValidator,
  private repo: UserRepo,
  private mailer: Mailer,
 ) {}

 async register(u: User) {
  if (!this.validator.isValid(u)) throw new Error('Email non valida');
  await this.repo.save(u);
  await this.mailer.send(u.email, 'Benvenuto', 'Grazie per la registrazione!');
 }
}

Esempio Go 🟩

 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
27
28
29
30
31
32
package user

import (
 "context"
 "errors"
 "regexp"
)

type User struct{ Email string }

type Validator interface{ IsValid(User) bool }
type Repo interface{ Save(ctx context.Context, u User) error }
type Mailer interface{ Send(ctx context.Context, to, subject, body string) error }

type EmailValidator struct{ rx *regexp.Regexp }
func NewEmailValidator() *EmailValidator { 
 // regex email semplificata per esempio didattico
 return &EmailValidator{rx: regexp.MustCompile(`\S+@\S+\.\S+`)} 
}
func (v *EmailValidator) IsValid(u User) bool { return v.rx.MatchString(u.Email) }

type Service struct {
 v Validator
 r Repo
 m Mailer
}

func (s Service) Register(ctx context.Context, u User) error {
 if !s.v.IsValid(u) { return errors.New("email non valida") }
 if err := s.r.Save(ctx, u); err != nil { return err }
 return s.m.Send(ctx, u.Email, "Benvenuto", "Grazie per la registrazione!")
}

OCP: aperto/chiuso 🚪

“Open for extension, closed for modification”: il comportamento si estende senza modificare il codice esistente.

  • In pratica: programma su astrazioni; usa strategie, plugin, eventi, feature flag. Aggiungi nuovi comportamenti creando nuove implementazioni, non toccando quelle vecchie.
  • Segnali d’allarme: switch/if su tipi sparsi ovunque, classi che cambiano ogni volta che aggiungi un caso.
  • Refactoring utile: Strategy/Factory/Template Method, polimorfismo, registri di handler, configurazioni dichiarative.

Benefici: minor rischio di rompere il passato, rilascio di estensioni indipendenti.

Esempio TypeScript 🟦

 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
27
28
29
interface DiscountStrategy {
 apply(total: number): number;
}

class NoDiscount implements DiscountStrategy {
 apply(t: number) { return t; }
}

class BlackFriday implements DiscountStrategy {
 apply(t: number) { return t * 0.7; }
}

class DiscountEngine {
 constructor(
  private strategies = new Map<string, DiscountStrategy>([
   ['default', new NoDiscount()],
  ])
 ) {}
 register(name: string, s: DiscountStrategy) { this.strategies.set(name, s); }
 apply(name: string, total: number) {
  // Evitiamo di creare una nuova istanza ogni volta: fallback alla strategia 'default'
  return (this.strategies.get(name) ?? this.strategies.get('default')!).apply(total);
 }
}

// Estensione: aggiungo una nuova strategia senza toccare il motore
class VipDiscount implements DiscountStrategy {
 apply(t: number) { return t * 0.85; }
}

Esempio Go 🟩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package discount

type Strategy interface { Apply(total float64) float64 }

type NoDiscount struct{}
func (NoDiscount) Apply(t float64) float64 { return t }

type BlackFriday struct{}
func (BlackFriday) Apply(t float64) float64 { return t * 0.7 }

type Engine struct { strategies map[string]Strategy }

func NewEngine() *Engine { return &Engine{strategies: map[string]Strategy{"default": NoDiscount{}}} }
func (e *Engine) Register(name string, s Strategy) { e.strategies[name] = s }
func (e *Engine) Apply(name string, total float64) float64 {
 s, ok := e.strategies[name]
 if !ok { s = e.strategies["default"] }
 return s.Apply(total)
}

// Estensione: aggiungo una nuova strategia
type VipDiscount struct{}
func (VipDiscount) Apply(t float64) float64 { return t * 0.85 }

LSP: sostituzione di Liskov 🔁

I sottotipi devono poter sostituire i loro base type senza sorprendere nessuno. Se un figlio rompe le assunzioni del padre, c’è un problema di modellazione.

  • In pratica: rispetta pre/post-condizioni e invarianti; non “specializzare” rompendo il contratto.
  • Segnali d’allarme: override che lancia eccezioni “non supportato”, metodi che cambiano semantica o requisiti.
  • Refactoring utile: rivedi la gerarchia; considera composizione > ereditarietà; spezza responsabilità in interfacce più piccole.

Benefici: polimorfismo affidabile, test più semplici e meno rami condizionali.

Esempio TypeScript 🟦

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
interface Shape { area(): number }

class Rectangle implements Shape {
 constructor(public w: number, public h: number) {}
 area() { return this.w * this.h }
}

class Square implements Shape {
 constructor(public side: number) {}
 area() { return this.side * this.side }
}

function totalArea(shapes: Shape[]): number {
 return shapes.reduce((sum, s) => sum + s.area(), 0)
}

// Sostituibilità: Square e Rectangle rispettano lo stesso contratto di Shape
// Nota: Square NON eredita da Rectangle così evitiamo la classica violazione LSP (mutazioni di width/height)

Esempio Go 🟩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package geometry

type Shape interface { Area() float64 }

type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64 { return r.W * r.H }

type Square struct{ Side float64 }
func (s Square) Area() float64 { return s.Side * s.Side }

func TotalArea(shapes []Shape) (sum float64) {
 for _, s := range shapes { sum += s.Area() }
 return
}

ISP: segregazione delle interfacce ✂️

I client non devono dipendere da metodi che non usano. Le interfacce “gonfie” costringono le implementazioni a comportamenti innaturali.

  • In pratica: preferisci interfacce piccole e coerenti; separa comandi da query; evita “manager” onniscienti.
  • Segnali d’allarme: metodi no-op, eccezioni “non implementato”, mock complicatissimi.
  • Refactoring utile: estrai interfacce per capability (es. Reader/Writer), adotta CQRS dove ha senso, usa adapter per scenari legacy.

Benefici: accoppiamento ridotto e test più dichiarativi.

Esempio TypeScript 🟦

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Interfacce piccole e mirate
interface Reader { read(): string }
interface Writer { write(data: string): void }

class FileReader implements Reader {
 read() { return 'data'; }
}

class FileWriter implements Writer {
 write(data: string) { /* scrittura su file */ }
}

function copy(r: Reader, w: Writer) {
 w.write(r.read())
}

Esempio Go 🟩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package ioexample

// Interfacce piccole (ispirate alla stdlib)
type Reader interface { Read() string }
type Writer interface { Write(data string) }

type MemReader struct{ data string }
func (m MemReader) Read() string { return m.data }

type MemWriter struct{ buf []string }
func (m *MemWriter) Write(data string) { m.buf = append(m.buf, data) }

func Copy(r Reader, w Writer) { w.Write(r.Read()) }

Nota: per semplicità didattica qui Reader/Writer usano string; nella stdlib Go io.Reader / io.Writer gestiscono stream di byte e errori.


DIP: inversione delle dipendenze 🔌

I moduli di alto livello non dovrebbero dipendere dai dettagli di basso livello: entrambi dipendono da astrazioni. E le astrazioni non dovrebbero dipendere dai dettagli.

  • In pratica: definisci contratti nel dominio; implementazioni concrete vivono ai bordi (I/O, framework, DB). Usa dependency injection (anche manuale!), container solo dove serve.
  • Segnali d’allarme: new sparsi ovunque, funzioni che conoscono la tecnologia di storage, logica di business che importa SDK esterni.
  • Refactoring utile: introduci porte/adapter, costruttori che accettano interfacce, factory per risorse costose, inversion of control per orchestrazione.

Benefici: testabilità, sostituibilità dei dettagli, migrazioni tecnologiche meno traumatiche.

Esempio TypeScript 🟦

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
interface PaymentGateway {
 charge(amount: number): Promise<void>;
}

class StripeGateway implements PaymentGateway {
 async charge(amount: number) { /* chiamata API Stripe */ }
}

class OrderService {
 constructor(private gateway: PaymentGateway) {}
 checkout(amount: number) { return this.gateway.charge(amount) }
}

// In test: passa un fake che implementa PaymentGateway
// Esempio rapido:
// class FakeGateway implements PaymentGateway {
//   calls = 0;
//   async charge(amount: number) { this.calls++; }
// }

Esempio Go 🟩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package order

import "context"

type Gateway interface { Charge(ctx context.Context, amount int64) error }

type Stripe struct{}
func (Stripe) Charge(ctx context.Context, amount int64) error { /* call */ return nil }

type Service struct{ gw Gateway }
func NewService(gw Gateway) Service { return Service{gw: gw} }
func (s Service) Checkout(ctx context.Context, amount int64) error { return s.gw.Charge(ctx, amount) }

// In test: fornisci un double (mock/stub) che soddisfa Gateway

Metterli insieme nel mondo reale 🧩

Un flusso sano spesso segue questo schema:

  1. Modella il dominio con entità e servizi coesi (SRP).
  2. Espandi i comportamenti tramite strategie/handler configurabili (OCP).
  3. Evita gerarchie profonde, preferisci composizione e contratti chiari (LSP/ISP).
  4. Tieni i dettagli alle periferie e dipendi da interfacce (DIP).

Suggerimento: struttura a strati (domain → application → infrastructure) o esagonale. Il core non importa framework; gli adapter sì.


Checklist veloce ✅

  • Una classe ha una sola ragione di cambiare? (SRP)
  • Posso aggiungere un comportamento senza toccare codice esistente? (OCP)
  • I sottotipi rispettano il contratto del tipo base? (LSP)
  • Le interfacce sono piccole e coerenti con i client reali? (ISP)
  • Il dominio dipende da astrazioni, non da dettagli o framework? (DIP)

Se hai risposto “sì” cinque volte, probabilmente dormirai meglio stanotte.

Ultimo aggiornamento il