Vai al contenuto

Design pattern strutturali

I pattern strutturali spiegano come comporre oggetti e classi in strutture più grandi, mantenendo queste strutture flessibili ed efficienti. Il tema comune è l'interposizione: un oggetto si mette "in mezzo" fra chi chiama e chi risponde per adattare, estendere, semplificare o controllare l'accesso.

flowchart LR
    cliente(["🧑 Chi chiama"])
    subgraph mezzo["🧩 Un oggetto in mezzo"]
        adapter["🔌 Adapter: traduce"]
        decorator["🧥 Decorator: aggiunge"]
        proxy["🛂 Proxy: controlla"]
        facade["☎️ Facade: semplifica"]
    end
    subgraph forma["🌳 Una forma per molti oggetti"]
        composite["🌲 Composite: albero"]
        bridge["🌉 Bridge: due dimensioni"]
        flyweight["🪶 Flyweight: stato condiviso"]
    end
    reale["⚙️ Oggetto reale"]
    cliente --> mezzo --> reale
Pattern Problema che risolve Usalo quando
Adapter Due interfacce incompatibili devono collaborare Integri una libreria o un'API esterna che non controlli
Bridge Due dimensioni di variazione fanno esplodere le sottoclassi Astrazione e implementazione devono evolvere in modo indipendente
Composite Oggetti singoli e gruppi vanno trattati allo stesso modo Hai una gerarchia ad albero di parti e contenitori
Decorator Aggiungere comportamenti senza toccare la classe originale Vuoi combinare funzionalità opzionali a runtime
Facade Un sottosistema complesso espone troppe classi Serve un punto di ingresso semplice per i casi comuni
Flyweight Troppi oggetti simili consumano troppa memoria Milioni di istanze condividono gran parte dello stato
Proxy Controllare o posticipare l'accesso a un oggetto Serve cache, lazy loading, controllo accessi o logging

Cosa sono

Un pattern strutturale descrive come mettere insieme classi e oggetti in strutture più grandi senza irrigidirle. Invece di ereditare, questi pattern preferiscono comporre: un oggetto ne avvolge un altro e aggiunge, traduce o filtra il comportamento. Il risultato è codice che cambia aggiungendo pezzi, non modificando quelli esistenti.

Per approfondire con analogie e codice in altri linguaggi: refactoring.guru/design-patterns/structural-patterns.

Adapter

In una frase: Un traduttore che fa collaborare due interfacce incompatibili.

Problema che risolve: Il servizio Notifiche spedisce con un corriere che espone crea_spedizione(indirizzo, peso_grammi). Arriva un secondo corriere con una libreria che vuole ship(dict) e il peso in chilogrammi. Non puoi modificare la libreria e non vuoi riempire il codice di if corriere == ....

Come funziona:

flowchart LR
    notifiche["✉️ Servizio Notifiche"]
    adapter["🔌 AdapterExpress"]
    lib["📦 LibreriaExpress"]
    corriere>"🚚 Corriere Express"]
    notifiche -->|"crea_spedizione(indirizzo, grammi)"| adapter
    adapter -->|"ship(payload) in kg"| lib
    lib --> corriere
classDiagram
    class Corriere {
        <<interface>>
        +crea_spedizione(indirizzo, peso_grammi) str
    }
    class CorriereNazionale {
        +crea_spedizione(indirizzo, peso_grammi) str
    }
    class LibreriaExpress {
        +ship(payload) dict
    }
    class AdapterExpress {
        -express LibreriaExpress
        +crea_spedizione(indirizzo, peso_grammi) str
    }
    Corriere <|.. CorriereNazionale
    Corriere <|.. AdapterExpress
    AdapterExpress o-- LibreriaExpress
    class ServizioSpedizioni {
        -corriere Corriere
    }
    ServizioSpedizioni --> Corriere
  1. Corriere è l'interfaccia che il tuo codice si aspetta.
  2. LibreriaExpress è la classe esterna con un'interfaccia diversa.
  3. AdapterExpress implementa Corriere e al suo interno tiene un'istanza di LibreriaExpress.
  4. Ogni chiamata a crea_spedizione viene tradotta in una chiamata a ship, convertendo i dati.
  5. ServizioSpedizioni lavora solo con Corriere e non sa che esiste un adapter.

Analogia: l'adattatore di corrente per la presa estera. La spina non cambia, la rete elettrica non cambia, l'adattatore fa da ponte.

Quando usarlo: - Devi usare una classe esistente, ma la sua interfaccia non coincide con il resto del codice. - Vuoi riutilizzare più classi di terze parti dietro un'unica interfaccia. - Vuoi isolare il codice da una libreria che potrebbe cambiare.

Quando NON usarlo / rischi: - Se controlli entrambe le classi, cambia direttamente l'interfaccia: l'adapter è un livello in più. - Troppi adapter annidati rendono difficile capire dove finiscono i dati. - Non usarlo per nascondere differenze semantiche profonde (esempio: una API sincrona e una asincrona).

Esempio pratico:

from typing import Protocol

class Corriere(Protocol):
    def crea_spedizione(self, indirizzo: str, peso_grammi: int) -> str: ...

class LibreriaExpress:  # classe esterna, non modificabile
    def ship(self, payload: dict) -> dict:
        return {"tracking": f"EXP-{payload['kg']}"}

class AdapterExpress:
    def __init__(self, express: LibreriaExpress) -> None:
        self._express = express

    def crea_spedizione(self, indirizzo: str, peso_grammi: int) -> str:
        risposta = self._express.ship({"to": indirizzo, "kg": peso_grammi / 1000})
        return risposta["tracking"]

def spedisci(corriere: Corriere) -> str:
    return corriere.crea_spedizione("Via Roma 1, Milano", 1500)

print(spedisci(AdapterExpress(LibreriaExpress())))

Pattern correlati: Bridge (si progetta prima, l'Adapter si aggiunge dopo), Decorator (stessa interfaccia, aggiunge comportamento), Proxy (stessa interfaccia, controlla l'accesso), Facade (semplifica un intero sottosistema), Anti-Corruption Layer.

Bridge

In una frase: Separa l'astrazione dall'implementazione così che le due possano evolvere da sole.

Problema che risolve: Il servizio Notifiche deve inviare messaggi di tipo diverso (conferma ordine, spedizione partita, reso approvato) su canali diversi (email, SMS, push). Con l'ereditarietà finisci con ConfermaOrdineEmail, ConfermaOrdineSms, SpedizionePush... Ogni nuovo tipo o canale moltiplica le classi.

Come funziona:

flowchart LR
    subgraph cosa["💬 Cosa dire"]
        conferma["📄 Conferma ordine"]
        spedita["📄 Spedizione partita"]
    end
    ponte{{"🌉 Ponte"}}
    subgraph come["📡 Come inviare"]
        email["✉️ Email"]
        sms["📱 SMS"]
        push["🔔 Push"]
    end
    cliente(["🧑 Cliente"])
    cosa --> ponte --> come --> cliente
classDiagram
    class Notifica {
        -canale Canale
        +invia(destinatario)
    }
    class ConfermaOrdine {
        +invia(destinatario)
    }
    class SpedizionePartita {
        +invia(destinatario)
    }
    class Canale {
        <<interface>>
        +consegna(destinatario, testo)
    }
    class CanaleEmail {
        +consegna(destinatario, testo)
    }
    class CanaleSms {
        +consegna(destinatario, testo)
    }
    Notifica <|-- ConfermaOrdine
    Notifica <|-- SpedizionePartita
    Notifica o-- Canale
    Canale <|.. CanaleEmail
    Canale <|.. CanaleSms
  1. Notifica è l'astrazione: sa cosa dire, non come trasmetterlo.
  2. Canale è l'implementazione: sa trasmettere, non cosa dire.
  3. L'astrazione tiene un riferimento all'implementazione (il "ponte").
  4. Aggiungere un tipo di notifica crea una sottoclasse di Notifica; aggiungere un canale crea una sottoclasse di Canale.
  5. Le combinazioni nascono a runtime: ConfermaOrdine(CanaleSms()).

Analogia: telecomando e televisore. Un telecomando (astrazione) funziona con qualunque marca di TV (implementazione), e puoi migliorare l'uno senza toccare l'altro.

Quando usarlo: - Una classe varia lungo due o più dimensioni indipendenti. - Vuoi cambiare l'implementazione a runtime. - Vuoi che team diversi lavorino su astrazione e implementazione senza pestarsi i piedi.

Quando NON usarlo / rischi: - Se c'è una sola dimensione di variazione, basta una semplice interfaccia. - Introduce indirezione: con poche classi il codice diventa più difficile da seguire, non più semplice. - Decidere "cosa è astrazione e cosa implementazione" richiede una progettazione iniziale.

Esempio pratico:

from abc import ABC, abstractmethod
from typing import Protocol

class Canale(Protocol):
    def consegna(self, destinatario: str, testo: str) -> None: ...

class CanaleEmail:
    def consegna(self, destinatario: str, testo: str) -> None:
        print(f"EMAIL a {destinatario}: {testo}")

class Notifica(ABC):
    def __init__(self, canale: Canale) -> None:
        self._canale = canale

    @abstractmethod
    def invia(self, destinatario: str) -> None: ...

class ConfermaOrdine(Notifica):
    def __init__(self, canale: Canale, ordine_id: str) -> None:
        super().__init__(canale)
        self._ordine_id = ordine_id

    def invia(self, destinatario: str) -> None:
        self._canale.consegna(destinatario, f"Ordine {self._ordine_id} confermato")

ConfermaOrdine(CanaleEmail(), "A-42").invia("anna@example.com")

Pattern correlati: Adapter (ripara interfacce esistenti, il Bridge le progetta), Abstract Factory (può creare le implementazioni), Strategy (struttura simile, ma lo Strategy cambia un algoritmo).

Composite

In una frase: Tratta un oggetto singolo e un gruppo di oggetti con la stessa interfaccia.

Problema che risolve: Il Catalogo vende prodotti singoli e bundle ("kit scrivania": monitor, tastiera, supporto). Un bundle può contenere altri bundle. Calcolare il prezzo o il peso del carrello richiede di distinguere continuamente "è un prodotto o un bundle?" e di gestire la ricorsione a mano.

Come funziona:

flowchart TD
    carrello(["🛒 Carrello"])
    kit["🎁 Bundle Kit scrivania"]
    monitor["🖥️ Monitor 199"]
    tastiera["⌨️ Tastiera 49"]
    acc["🎁 Bundle Accessori"]
    mouse["🖱️ Mouse"]
    tapp["🟫 Tappetino"]
    carrello -->|"prezzo()"| kit
    kit --> monitor
    kit --> tastiera
    kit --> acc
    acc --> mouse
    acc --> tapp
classDiagram
    class Articolo {
        <<interface>>
        +prezzo() Decimal
    }
    class Prodotto {
        -nome str
        -costo Decimal
        +prezzo() Decimal
    }
    class Bundle {
        -figli List~Articolo~
        -sconto Decimal
        +aggiungi(articolo)
        +prezzo() Decimal
    }
    Articolo <|.. Prodotto
    Articolo <|.. Bundle
    Bundle o-- Articolo : contiene
  1. Articolo è l'interfaccia comune: espone prezzo().
  2. Prodotto è la foglia: restituisce il proprio costo.
  3. Bundle è il contenitore: tiene una lista di Articolo e calcola il prezzo sommando i figli.
  4. Siccome un Bundle è anche un Articolo, può contenere altri bundle: l'albero cresce senza codice in più.
  5. Il carrello chiama prezzo() sulla radice e la ricorsione avviene da sola.

Analogia: un esercito. Un ordine dato al generale scende a divisioni, brigate, plotoni, fino al singolo soldato: ogni livello obbedisce alla stessa interfaccia "esegui ordine".

Quando usarlo: - Il dominio ha una struttura ad albero (bundle, categorie, menu, componenti UI). - Il client deve trattare foglie e contenitori allo stesso modo.

Quando NON usarlo / rischi: - Se la struttura è piatta, una semplice lista basta. - L'interfaccia comune rischia di essere troppo generica: aggiungi() su una foglia non ha senso. - Operazioni che dipendono dal tipo (sconti solo sui bundle) complicano il codice.

Esempio pratico:

from decimal import Decimal
from typing import Protocol

class Articolo(Protocol):
    def prezzo(self) -> Decimal: ...

class Prodotto:
    def __init__(self, nome: str, costo: Decimal) -> None:
        self.nome, self._costo = nome, costo

    def prezzo(self) -> Decimal:
        return self._costo

class Bundle:
    def __init__(self, sconto: Decimal = Decimal("0")) -> None:
        self._figli: list[Articolo] = []
        self._sconto = sconto

    def aggiungi(self, articolo: Articolo) -> None:
        self._figli.append(articolo)

    def prezzo(self) -> Decimal:
        totale = sum((a.prezzo() for a in self._figli), Decimal("0"))
        return totale - self._sconto

kit = Bundle(sconto=Decimal("10"))
kit.aggiungi(Prodotto("Monitor", Decimal("199")))
kit.aggiungi(Prodotto("Tastiera", Decimal("49")))
print(kit.prezzo())  # 238

Pattern correlati: Decorator (struttura simile, ma con un solo figlio), Iterator (per attraversare l'albero), Visitor (per operazioni sull'intero albero), Builder (per costruire alberi complessi).

Decorator

In una frase: Avvolge un oggetto in un altro con la stessa interfaccia per aggiungergli comportamento.

Problema che risolve: Il client HTTP di Pagamenti deve fare logging, poi riprovare in caso di errore, poi misurare i tempi. Metterli nella classe la gonfia e ogni combinazione ("solo retry", "retry e logging") richiede una sottoclasse. Serve un modo per aggiungere i comportamenti uno alla volta, nell'ordine che vuoi.

Come funziona:

Illustrazione: la matrioska, ogni strato aggiunge una cosa e inoltra all'interno

classDiagram
    class ClientPagamenti {
        <<interface>>
        +addebita(importo) str
    }
    class ClientHttp {
        +addebita(importo) str
    }
    class DecoratorBase {
        -interno ClientPagamenti
        +addebita(importo) str
    }
    class ConLogging {
        +addebita(importo) str
    }
    class ConRetry {
        -tentativi int
        +addebita(importo) str
    }
    ClientPagamenti <|.. ClientHttp
    ClientPagamenti <|.. DecoratorBase
    DecoratorBase o-- ClientPagamenti : avvolge
    DecoratorBase <|-- ConLogging
    DecoratorBase <|-- ConRetry
  1. ClientPagamenti è l'interfaccia che tutti rispettano, compresi i decoratori.
  2. ClientHttp è l'oggetto concreto che fa il lavoro vero.
  3. Ogni decoratore tiene un riferimento a un altro ClientPagamenti (l'"interno").
  4. Il decoratore fa qualcosa prima o dopo, poi delega all'interno.
  5. Impilando i decoratori ottieni una catena: ConLogging(ConRetry(ClientHttp())). Il chiamante vede sempre un ClientPagamenti.

Analogia: vestirsi a strati. Maglia, felpa, giacca: ogni strato aggiunge qualcosa e li puoi togliere o riordinare, ma sotto resti sempre tu.

Nei microservizi questo pattern è ovunque: un Retry e un Circuit Breaker sono decoratori di un client. Avvolgono la chiamata remota, aggiungono la logica di resilienza e lasciano l'interfaccia intatta. Un service mesh fa lo stesso, ma fuori dal processo.

Quando usarlo: - Vuoi aggiungere responsabilità a singoli oggetti a runtime, senza toccarne la classe. - Le funzionalità opzionali si combinano in molti modi (logging, retry, cache, metriche). - L'ereditarietà è bloccata (classe final) o produrrebbe troppe sottoclassi.

Quando NON usarlo / rischi: - Una catena lunga è difficile da ispezionare: "quale strato ha fallito?". - L'ordine conta: Retry(CircuitBreaker(x)) e CircuitBreaker(Retry(x)) si comportano in modo diverso. - Se il comportamento è sempre lo stesso per tutti, mettilo nella classe e basta.

Esempio pratico:

from typing import Protocol

class ClientPagamenti(Protocol):
    def addebita(self, importo: int) -> str: ...

class ClientHttp:
    def addebita(self, importo: int) -> str:
        return f"ok:{importo}"

class ConRetry:
    def __init__(self, interno: ClientPagamenti, tentativi: int = 3) -> None:
        self._interno, self._tentativi = interno, tentativi

    def addebita(self, importo: int) -> str:
        for n in range(1, self._tentativi + 1):
            try:
                return self._interno.addebita(importo)
            except ConnectionError:
                if n == self._tentativi:
                    raise
        raise RuntimeError("non raggiungibile")

class ConLogging:
    def __init__(self, interno: ClientPagamenti) -> None:
        self._interno = interno

    def addebita(self, importo: int) -> str:
        print(f"addebito {importo}")
        return self._interno.addebita(importo)

client: ClientPagamenti = ConLogging(ConRetry(ClientHttp()))
print(client.addebita(1999))

Pattern correlati: Proxy (stessa forma, ma controlla l'accesso invece di aggiungere), Adapter (cambia l'interfaccia, il Decorator la mantiene), Composite (un Decorator è un Composite con un solo figlio), Chain of Responsibility, Retry, Circuit Breaker.

Facade

In una frase: Un'unica interfaccia semplice davanti a un sottosistema complesso.

Problema che risolve: Per completare un checkout il frontend deve: riservare la scorta in Magazzino, addebitare in Pagamenti, creare l'ordine in Ordini, inviare la conferma da Notifiche. Nell'ordine giusto, con i rollback giusti. Ogni client che ripete questa sequenza duplica logica e sbaglia prima o poi.

Come funziona:

Illustrazione: il concierge d'hotel risponde a una sola richiesta e parla lui con i reparti

classDiagram
    class CheckoutFacade {
        +completa(carrello, cliente) str
    }
    class Magazzino {
        +riserva(articoli)
    }
    class Pagamenti {
        +addebita(cliente, importo)
    }
    class Ordini {
        +crea(carrello) str
    }
    class Notifiche {
        +conferma(cliente, ordine_id)
    }
    class Frontend
    Frontend --> CheckoutFacade
    CheckoutFacade --> Magazzino
    CheckoutFacade --> Pagamenti
    CheckoutFacade --> Ordini
    CheckoutFacade --> Notifiche
  1. CheckoutFacade espone un solo metodo, completa().
  2. Dentro, chiama i quattro servizi nell'ordine corretto e gestisce gli errori.
  3. Il Frontend conosce solo la facade: non sa quanti servizi ci sono dietro.
  4. I servizi restano accessibili direttamente per i casi avanzati: la facade non li nasconde, li semplifica.

Analogia: l'operatore del call center. Tu chiami un numero, lui parla con fatturazione, tecnici e logistica per te.

Quando usarlo: - Vuoi un punto di ingresso semplice per il caso d'uso più comune di un sottosistema. - Vuoi disaccoppiare i client dalle classi interne, così puoi cambiarle senza rompere nulla. - Vuoi organizzare un sistema a livelli: una facade per ogni livello.

Quando NON usarlo / rischi: - La facade può diventare un "god object" che sa tutto e fa tutto. - Se nasconde troppo, i client non riescono a fare i casi particolari e la aggirano. - Non sostituisce una buona progettazione del sottosistema: se è un disastro dentro, resta un disastro.

Esempio pratico:

from dataclasses import dataclass

@dataclass
class Carrello:
    articoli: list[str]
    totale: int

class CheckoutFacade:
    def __init__(self, magazzino, pagamenti, ordini, notifiche) -> None:
        self._magazzino, self._pagamenti = magazzino, pagamenti
        self._ordini, self._notifiche = ordini, notifiche

    def completa(self, carrello: Carrello, cliente: str) -> str:
        self._magazzino.riserva(carrello.articoli)
        try:
            self._pagamenti.addebita(cliente, carrello.totale)
        except Exception:
            self._magazzino.rilascia(carrello.articoli)
            raise
        ordine_id = self._ordini.crea(carrello)
        self._notifiche.conferma(cliente, ordine_id)
        return ordine_id

Pattern correlati: Adapter (adatta una classe, la Facade semplifica molte), Mediator (coordina componenti che si conoscono a vicenda), Singleton (spesso la facade è una sola), API Gateway (la stessa idea a livello di rete), Saga (quando il checkout è distribuito).

Flyweight

In una frase: Condivide la parte comune dello stato fra molti oggetti per risparmiare memoria.

Problema che risolve: Il Catalogo tiene in memoria due milioni di varianti di prodotto. Ogni variante porta con sé descrizione, immagini e scheda tecnica del modello base, identiche per migliaia di varianti. La memoria esplode mentre l'unica cosa che cambia davvero è taglia, colore e giacenza.

Come funziona:

flowchart LR
    fabbrica["🏭 FabbricaModelli"]
    modello["👕 Modello T-shirt: descrizione, immagini"]
    v1["🏷️ S nero, giacenza 12"]
    v2["🏷️ M bianco, giacenza 3"]
    v3["🏷️ L nero, giacenza 0"]
    v4["🏷️ ... x 2 milioni"]
    fabbrica -->|"crea una volta"| modello
    v1 --> modello
    v2 --> modello
    v3 --> modello
    v4 --> modello
classDiagram
    class ModelloProdotto {
        -nome str
        -descrizione str
        -immagini List~str~
    }
    class Variante {
        -modello ModelloProdotto
        -taglia str
        -colore str
        -giacenza int
    }
    class FabbricaModelli {
        -cache dict
        +ottieni(nome) ModelloProdotto
    }
    Variante --> ModelloProdotto : stato condiviso
    FabbricaModelli --> ModelloProdotto : crea o riusa
    Variante ..> FabbricaModelli
  1. ModelloProdotto è il flyweight: contiene lo stato intrinseco, uguale per tutti e immutabile.
  2. Variante contiene lo stato estrinseco: taglia, colore, giacenza, diverso per ogni istanza.
  3. FabbricaModelli garantisce che ogni modello esista una volta sola: se c'è in cache lo restituisce, altrimenti lo crea.
  4. Due milioni di varianti puntano a poche migliaia di modelli: la memoria scende di ordini di grandezza.

Analogia: in un videogioco con una foresta di un milione di alberi, la texture e il modello 3D sono caricati una volta; ogni albero tiene solo posizione e dimensione.

Quando usarlo: - Hai un numero enorme di oggetti e la memoria è un problema reale e misurato. - Gran parte dello stato può essere estratto, condiviso e reso immutabile.

Quando NON usarlo / rischi: - Ottimizzazione prematura: se la memoria non è un problema, il codice diventa solo più complicato. - Lo stato condiviso deve essere immutabile, altrimenti una modifica si propaga a tutti. - Calcolare lo stato estrinseco a ogni chiamata può costare CPU.

Esempio pratico:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class ModelloProdotto:  # stato intrinseco, condiviso e immutabile
    nome: str
    descrizione: str
    immagini: tuple[str, ...]

class FabbricaModelli:
    def __init__(self) -> None:
        self._cache: dict[str, ModelloProdotto] = {}

    def ottieni(self, nome: str, descrizione: str, immagini: tuple[str, ...]) -> ModelloProdotto:
        if nome not in self._cache:
            self._cache[nome] = ModelloProdotto(nome, descrizione, immagini)
        return self._cache[nome]

@dataclass
class Variante:  # stato estrinseco
    modello: ModelloProdotto
    taglia: str
    colore: str
    giacenza: int = 0

fabbrica = FabbricaModelli()
base = fabbrica.ottieni("T-shirt", "Cotone 100%", ("front.jpg", "back.jpg"))
varianti = [Variante(base, t, c) for t in "SML" for c in ("nero", "bianco")]

Pattern correlati: Composite (le foglie condivise di un albero possono essere flyweight), Singleton (un flyweight è "un singleton per chiave"), Factory Method (la fabbrica che gestisce la cache), Proxy (per cache di oggetti remoti, non di memoria).

Proxy

In una frase: Un sostituto con la stessa interfaccia che controlla l'accesso all'oggetto reale.

Problema che risolve: Ordini interroga il Catalogo via rete per ogni riga di ogni carrello. Le schede prodotto cambiano una volta al giorno, ma la chiamata parte sempre. Vuoi una cache, ma senza che Ordini debba sapere che esiste e senza modificare il client del catalogo.

Come funziona:

flowchart LR
    ordini["📦 Servizio Ordini"]
    proxy["🛂 CatalogoConCache"]
    cache[("🗄️ Cache locale, TTL 1 ora")]
    reale["📚 CatalogoRemoto"]
    ordini -->|"scheda(MON-27)"| proxy
    proxy -->|"se valido, restituisci"| cache
    proxy -.->|"altrimenti chiama e salva"| reale
classDiagram
    class Catalogo {
        <<interface>>
        +scheda(sku) dict
    }
    class CatalogoRemoto {
        +scheda(sku) dict
    }
    class CatalogoConCache {
        -reale Catalogo
        -cache dict
        -ttl int
        +scheda(sku) dict
    }
    class ServizioOrdini {
        -catalogo Catalogo
    }
    Catalogo <|.. CatalogoRemoto
    Catalogo <|.. CatalogoConCache
    CatalogoConCache o-- Catalogo : delega se serve
    ServizioOrdini --> Catalogo
  1. Catalogo è l'interfaccia comune a oggetto reale e proxy.
  2. CatalogoRemoto fa la chiamata HTTP vera.
  3. CatalogoConCache riceve la richiesta prima: se ha il dato valido lo restituisce, altrimenti delega a CatalogoRemoto e salva.
  4. ServizioOrdini tiene un Catalogo e non distingue fra proxy e reale.
  5. Lo stesso schema vale per altri tipi di proxy: lazy loading (crea l'oggetto pesante alla prima chiamata), protezione (verifica i permessi), logging (registra le chiamate).

Analogia: la carta di credito è un proxy del contante. Il negozio la accetta come fosse denaro, ma la banca controlla ogni transazione.

Quando usarlo: - Cache di risultati costosi o remoti. - Inizializzazione pigra di oggetti pesanti. - Controllo accessi, logging o conteggio dei riferimenti senza toccare l'oggetto reale.

Quando NON usarlo / rischi: - Una cache nel proxy introduce il problema dell'invalidazione: dati vecchi silenziosi. - Aggiunge latenza e un punto di fallimento in più se il proxy è remoto. - Nasconde al chiamante cosa succede davvero: il debug diventa più difficile.

Esempio pratico:

import time
from typing import Protocol

class Catalogo(Protocol):
    def scheda(self, sku: str) -> dict: ...

class CatalogoRemoto:
    def scheda(self, sku: str) -> dict:
        return {"sku": sku, "nome": "Monitor 27"}  # qui ci sarebbe una chiamata HTTP

class CatalogoConCache:
    def __init__(self, reale: Catalogo, ttl_secondi: int = 3600) -> None:
        self._reale, self._ttl = reale, ttl_secondi
        self._cache: dict[str, tuple[float, dict]] = {}

    def scheda(self, sku: str) -> dict:
        salvato = self._cache.get(sku)
        if salvato and time.monotonic() - salvato[0] < self._ttl:
            return salvato[1]
        dato = self._reale.scheda(sku)
        self._cache[sku] = (time.monotonic(), dato)
        return dato

catalogo: Catalogo = CatalogoConCache(CatalogoRemoto())
print(catalogo.scheda("MON-27"))

Pattern correlati: Decorator (stessa forma, ma il Decorator aggiunge comportamento e il Proxy controlla l'accesso), Adapter (cambia l'interfaccia, il Proxy la mantiene), Facade (semplifica, non sostituisce), Circuit Breaker (un proxy di protezione verso un servizio remoto), Sidecar (un proxy fuori dal processo).

Adapter vs Decorator vs Proxy vs Facade

Hanno tutti la stessa forma, un oggetto che ne avvolge un altro, ma intento diverso.

Pattern Interfaccia esposta Intento Frase chiave
Adapter Diversa da quella avvolta Far collaborare interfacce incompatibili "Traduce"
Decorator Uguale a quella avvolta Aggiungere comportamento, impilabile "Aggiunge"
Proxy Uguale a quella avvolta Controllare l'accesso all'oggetto reale "Controlla"
Facade Nuova e più semplice Semplificare un intero sottosistema "Semplifica"

Come scegliere

flowchart TD
    q1{"L'interfaccia che hai è diversa da quella che ti serve?"}
    q1 -- sì --> q1b{"Avvolgi una classe o un intero sottosistema?"}
    q1b -- una classe --> adapter["Adapter"]
    q1b -- un sottosistema --> facade["Facade"]
    q1 -- no --> q2{"Vuoi aggiungere comportamento senza cambiare l'interfaccia?"}
    q2 -- sì --> q2b{"Aggiungi funzionalità o controlli l'accesso?"}
    q2b -- aggiungi --> decorator["Decorator"]
    q2b -- controllo o cache --> proxy["Proxy"]
    q2 -- no --> q3{"Hai una struttura ad albero di parti e contenitori?"}
    q3 -- sì --> composite["Composite"]
    q3 -- no --> q4{"Una classe varia lungo due dimensioni indipendenti?"}
    q4 -- sì --> bridge["Bridge"]
    q4 -- no --> q5{"Troppi oggetti simili consumano memoria?"}
    q5 -- sì --> flyweight["Flyweight"]
    q5 -- no --> nessuno["Probabilmente non serve un pattern strutturale"]