Vai al contenuto

Design pattern creazionali

I pattern creazionali spiegano come creare oggetti senza legare il codice alle classi concrete. Il codice che usa un oggetto conosce solo un'interfaccia; chi decide quale classe istanziare sta in un punto solo, facile da cambiare e da testare.

Pattern Problema che risolve Usalo quando
Factory Method Il codice fa new su classi concrete e non si estende Devi aggiungere nuove varianti senza toccare chi le usa
Abstract Factory Servono famiglie di oggetti coerenti fra loro Hai più prodotti che devono "andare insieme"
Builder Costruttori con troppi parametri, molti opzionali Un oggetto si costruisce a passi, in varianti diverse
Prototype Creare copie di oggetti complessi senza conoscerne la classe Clonare costa meno che costruire da zero
Singleton Una sola istanza condivisa e un accesso globale Risorsa davvero unica, e sai accettare i rischi

Cosa sono

Un pattern creazionale separa due decisioni: quale oggetto creare e come usarlo. Il codice cliente lavora con un'interfaccia (Protocol o ABC in Python) e delega la creazione a una fabbrica, a un costruttore a passi o a un clone. Così puoi aggiungere una nuova classe senza modificare chi la usa.

Approfondimento con analogie e codice in più linguaggi: refactoring.guru/design-patterns/creational-patterns.

flowchart LR
    cliente(["🧑 Codice cliente"])
    subgraph uno["🔨 Un oggetto alla volta"]
        fm["🏭 Factory Method"]
        pr["🧬 Prototype"]
    end
    subgraph molti["📦 Oggetti complessi o in famiglia"]
        af["🏬 Abstract Factory"]
        bu["🧱 Builder"]
    end
    subgraph unico["🔒 Istanza unica"]
        sg["🔑 Singleton"]
    end
    cliente -->|"nuova variante"| fm
    cliente -->|"copia di uno esistente"| pr
    cliente -->|"famiglia coerente"| af
    cliente -->|"tanti parametri"| bu
    cliente -->|"risorsa condivisa"| sg

Factory Method

In una frase: Un metodo che crea un oggetto, con sottoclassi che decidono quale classe concreta istanziare.

Problema che risolve: Il servizio Pagamenti nasce con Stripe e fa StripeProvider() ovunque. Arriva PayPal: devi toccare ogni punto che crea il provider e riempire il codice di if. Ogni nuovo gateway rompe di nuovo tutto.

Come funziona:

Illustrazione: la pizzeria con due sedi, stessa ricetta, forno diverso

classDiagram
    class PaymentProvider {
        <<interface>>
        +charge(amount) str
    }
    class StripeProvider {
        +charge(amount) str
    }
    class PayPalProvider {
        +charge(amount) str
    }
    class Checkout {
        <<abstract>>
        +create_provider() PaymentProvider
        +pay(amount) str
    }
    class StripeCheckout {
        +create_provider() PaymentProvider
    }
    class PayPalCheckout {
        +create_provider() PaymentProvider
    }
    PaymentProvider <|.. StripeProvider
    PaymentProvider <|.. PayPalProvider
    Checkout <|-- StripeCheckout
    Checkout <|-- PayPalCheckout
    Checkout ..> PaymentProvider : crea
  1. Checkout definisce la logica comune (pay) e dichiara un metodo astratto create_provider.
  2. pay chiama create_provider senza sapere cosa torna, solo che rispetta PaymentProvider.
  3. Ogni sottoclasse (StripeCheckout, PayPalCheckout) decide quale provider concreto istanziare.
  4. Per aggiungere un gateway crei una sottoclasse nuova: il codice esistente non cambia.

Analogia: una pizzeria ha una ricetta unica per "preparare l'ordine", ma ogni sede sceglie il forno che usa.

Quando usarlo: - Non sai in anticipo quali classi concrete servono, e ne arriveranno altre. - Vuoi che la logica comune resti in un posto e cambi solo il "pezzo" creato. - Vuoi testare la logica con un provider finto, senza toccare la rete.

Quando NON usarlo / rischi: - Hai una sola classe concreta e nessun piano di aggiungerne: è solo indirezione. - Una gerarchia di sottoclassi per ogni variante può crescere troppo; in Python spesso basta passare una funzione o una classe come parametro.

Esempio pratico:

from typing import Protocol
from abc import ABC, abstractmethod

class PaymentProvider(Protocol):
    def charge(self, amount: int) -> str: ...

class StripeProvider:
    def charge(self, amount: int) -> str: return f"stripe:{amount}"

class PayPalProvider:
    def charge(self, amount: int) -> str: return f"paypal:{amount}"

class Checkout(ABC):
    @abstractmethod
    def create_provider(self) -> PaymentProvider: ...
    def pay(self, amount: int) -> str:
        return self.create_provider().charge(amount)

class StripeCheckout(Checkout):
    def create_provider(self) -> PaymentProvider: return StripeProvider()

Pattern correlati: Abstract Factory (una famiglia di Factory Method), Template Method (stessa struttura: il passo variabile è la creazione), Prototype (alternativa senza sottoclassi).

Abstract Factory

In una frase: Un'interfaccia per creare famiglie di oggetti correlati senza indicare le classi concrete.

Problema che risolve: Il servizio Notifiche deve inviare una conferma d'ordine e un promemoria di spedizione. Via email serve un template HTML, via SMS un testo corto. Se mischi un template email con un canale SMS il messaggio esce rotto. Vuoi garantire che tutti gli oggetti di un invio siano dello stesso canale.

Come funziona:

flowchart LR
    notif["✉️ Servizio Notifiche"]
    subgraph email["📧 EmailFactory"]
        ec["📄 Conferma HTML"]
        er["📄 Promemoria HTML"]
    end
    subgraph sms["📱 SmsFactory"]
        sc["💬 Conferma 160 car."]
        sr["💬 Promemoria 160 car."]
    end
    cliente(["🧑 Cliente"])
    notif -->|"canale email"| email
    notif -->|"canale sms"| sms
    email -->|"famiglia coerente"| cliente
    sms -->|"famiglia coerente"| cliente
classDiagram
    class NotificationFactory {
        <<interface>>
        +create_confirmation() Message
        +create_reminder() Message
    }
    class EmailFactory {
        +create_confirmation() Message
        +create_reminder() Message
    }
    class SmsFactory {
        +create_confirmation() Message
        +create_reminder() Message
    }
    class Message {
        <<interface>>
        +render(order_id) str
    }
    class EmailConfirmation
    class EmailReminder
    class SmsConfirmation
    class SmsReminder
    NotificationFactory <|.. EmailFactory
    NotificationFactory <|.. SmsFactory
    Message <|.. EmailConfirmation
    Message <|.. EmailReminder
    Message <|.. SmsConfirmation
    Message <|.. SmsReminder
    EmailFactory ..> EmailConfirmation : crea
    SmsFactory ..> SmsConfirmation : crea
  1. NotificationFactory dichiara un metodo per ogni prodotto della famiglia (conferma, promemoria).
  2. EmailFactory e SmsFactory implementano tutti i metodi e restituiscono solo oggetti del proprio canale.
  3. Il cliente riceve una factory e chiama i metodi: non sa mai se sta lavorando con email o SMS.
  4. Il canale si sceglie una volta sola, quando scegli la factory. Gli oggetti sono coerenti per costruzione.

Analogia: un negozio di mobili vende sedie, tavoli e divani in stile moderno o classico; scegli lo stile e ricevi tutti i pezzi abbinati.

Quando usarlo: - Il codice deve lavorare con più famiglie di prodotti e i prodotti di una famiglia devono stare insieme. - Vuoi cambiare famiglia (canale, tema, fornitore) in un punto solo, per esempio in configurazione.

Quando NON usarlo / rischi: - Hai un solo prodotto per famiglia: basta un Factory Method. - Aggiungere un nuovo tipo di prodotto obbliga a modificare l'interfaccia e tutte le factory concrete.

Esempio pratico:

from typing import Protocol

class Message(Protocol):
    def render(self, order_id: str) -> str: ...

class EmailConfirmation:
    def render(self, order_id: str) -> str: return f"<h1>Ordine {order_id}</h1>"

class SmsConfirmation:
    def render(self, order_id: str) -> str: return f"Ordine {order_id} ok"

class NotificationFactory(Protocol):
    def create_confirmation(self) -> Message: ...

class EmailFactory:
    def create_confirmation(self) -> Message: return EmailConfirmation()

class SmsFactory:
    def create_confirmation(self) -> Message: return SmsConfirmation()

def notify(factory: NotificationFactory, order_id: str) -> str:
    return factory.create_confirmation().render(order_id)

Pattern correlati: Factory Method (spesso usato dentro ogni metodo della factory), Builder (costruisce un oggetto complesso a passi, invece di una famiglia), Singleton (la factory concreta è spesso una sola), Bridge.

Builder

In una frase: Costruisce un oggetto complesso passo per passo, separando la costruzione dalla rappresentazione.

Problema che risolve: Un Order ha cliente, righe, indirizzo di spedizione, codice sconto, note, regalo, fattura. Il costruttore ha dieci parametri, quasi tutti opzionali, e le chiamate diventano Order(c, items, None, None, True, None, ...): illeggibili e facili da sbagliare.

Come funziona:

flowchart LR
    cliente(["🧑 Cliente"])
    subgraph builder["🧱 OrderBuilder"]
        s1["🛒 add_item"] --> s2["🎟️ with_coupon"] --> s3["🎁 as_gift"]
    end
    ordine["📄 Order immutabile"]
    mag["🏭 Magazzino"]
    cliente -->|"compone a passi"| builder
    s3 -->|"build()"| ordine
    ordine -->|"pronto"| mag
classDiagram
    class OrderBuilder {
        -customer str
        -items list
        -coupon str
        -gift bool
        +add_item(sku, qty) OrderBuilder
        +with_coupon(code) OrderBuilder
        +as_gift() OrderBuilder
        +build() Order
    }
    class Order {
        +customer str
        +items tuple
        +coupon str
        +gift bool
    }
    class CheckoutService {
        +create_order() Order
    }
    OrderBuilder ..> Order : build
    CheckoutService ..> OrderBuilder : usa
  1. Il builder tiene lo stato parziale dell'ordine mentre lo componi.
  2. Ogni metodo (add_item, with_coupon, as_gift) imposta una parte e restituisce il builder, così le chiamate si concatenano.
  3. build() valida (almeno una riga, cliente presente) e restituisce un Order immutabile.
  4. Il cliente legge una sequenza di passi con nomi chiari invece di una lista di posizionali.

Analogia: ordini un panino al banco: scegli pane, farcitura, salse, una cosa alla volta; alla fine ti consegnano il panino completo.

Quando usarlo: - L'oggetto ha molti parametri opzionali o va costruito in più passi con validazione finale. - Vuoi produrre varianti diverse dello stesso oggetto con lo stesso processo (ordine normale, ordine regalo, ordine aziendale).

Quando NON usarlo / rischi: - L'oggetto ha tre o quattro campi: in Python bastano dataclass e argomenti keyword-only. - Il builder mutabile può essere riusato per errore fra due ordini: fai in modo che build() restituisca sempre un oggetto nuovo e immutabile.

Esempio pratico:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Order:
    customer: str
    items: tuple[tuple[str, int], ...]
    coupon: str | None = None
    gift: bool = False

class OrderBuilder:
    def __init__(self, customer: str) -> None:
        self._customer, self._items = customer, []
        self._coupon: str | None = None
        self._gift = False
    def add_item(self, sku: str, qty: int) -> "OrderBuilder":
        self._items.append((sku, qty)); return self
    def with_coupon(self, code: str) -> "OrderBuilder":
        self._coupon = code; return self
    def as_gift(self) -> "OrderBuilder":
        self._gift = True; return self
    def build(self) -> Order:
        if not self._items: raise ValueError("ordine vuoto")
        return Order(self._customer, tuple(self._items), self._coupon, self._gift)

Uso: OrderBuilder("anna").add_item("SKU-1", 2).with_coupon("WELCOME").build().

Pattern correlati: Abstract Factory (restituisce subito il prodotto, il Builder lo costruisce a passi), Composite (il Builder spesso costruisce alberi), Prototype.

Prototype

In una frase: Crea nuovi oggetti copiando un'istanza esistente, senza dipendere dalla sua classe.

Problema che risolve: Il Catalogo ha prodotti configurati con decine di attributi, varianti e prezzi per paese. Per creare la taglia "L" a partire dalla "M" dovresti leggere tutti i campi uno per uno, incluso quelli privati, e conoscere la classe esatta (prodotto semplice, bundle, abbonamento).

Come funziona:

flowchart LR
    db[("🗄️ DB Prodotti")]
    cat["🛍️ Servizio Catalogo"]
    base["👕 T-shirt M, prezzo, attributi"]
    copia["👕 T-shirt L (copia)"]
    db -->|"1. carica il modello"| cat
    cat -->|"2. clone()"| base
    base -->|"3. copia completa"| copia
    copia -->|"4. cambia solo la taglia"| cat
classDiagram
    class Prototype {
        <<interface>>
        +clone() Prototype
    }
    class Product {
        +sku str
        +name str
        +price int
        +attributes dict
        +clone() Product
    }
    class BundleProduct {
        +children list
        +clone() BundleProduct
    }
    class CatalogService {
        +create_variant(base, sku) Product
    }
    Prototype <|.. Product
    Product <|-- BundleProduct
    CatalogService ..> Prototype : clone
  1. L'interfaccia Prototype espone un solo metodo: clone().
  2. Ogni classe sa copiare sé stessa, campi privati compresi, e decide se la copia è profonda.
  3. CatalogService prende un prodotto "modello", lo clona e cambia solo i campi che differiscono.
  4. Il servizio non conosce la classe concreta: un bundle si clona come un prodotto semplice.

Analogia: la divisione cellulare: la cellula si copia da sola, con tutto il suo contenuto, senza che qualcuno la ricostruisca dall'esterno.

Quando usarlo: - Costruire l'oggetto da zero costa (query, calcoli) e hai già un'istanza simile. - Vuoi ridurre le sottoclassi che esistono solo per preimpostare valori: tieni un registro di prototipi pronti. - Il codice non deve dipendere dalla classe concreta dell'oggetto da copiare.

Quando NON usarlo / rischi: - Oggetti con riferimenti circolari o risorse esterne (connessioni, file): la copia profonda è fragile. - Copia superficiale per errore: due prodotti condividono lo stesso dict di attributi e si modificano a vicenda.

Esempio pratico:

import copy
from dataclasses import dataclass, field, replace
from typing import Protocol, Self

class Prototype(Protocol):
    def clone(self) -> Self: ...

@dataclass
class Product:
    sku: str
    name: str
    price: int
    attributes: dict[str, str] = field(default_factory=dict)

    def clone(self) -> Self:
        return copy.deepcopy(self)

base = Product("TSHIRT-M", "T-shirt", 1990, {"size": "M", "color": "blue"})
large = base.clone()
large.sku, large.attributes["size"] = "TSHIRT-L", "L"
# con dataclass immutabili: replace(base, sku="TSHIRT-L")

Pattern correlati: Abstract Factory (può usare prototipi al posto di sottoclassi), Memento (anche lui salva copie di stato), Composite e Decorator (strutture complesse che conviene clonare).

Singleton

In una frase: Garantisce che una classe abbia una sola istanza e offre un punto di accesso globale.

Problema che risolve: Il servizio Ordini apre un pool di connessioni al database. Se ogni modulo ne crea uno, il database si satura. Serve un'istanza condivisa e un modo per raggiungerla da ovunque.

Come funziona:

flowchart LR
    ordini["📦 OrderRepository"]
    pag["💳 PaymentRepository"]
    pool{{"🔒 ConnectionPool, una sola istanza"}}
    db[("🗄️ Database")]
    test(["🧪 Test"])
    ordini -->|"get_instance()"| pool
    pag -->|"get_instance()"| pool
    pool --> db
    test -.->|"difficile da sostituire"| pool
classDiagram
    class ConnectionPool {
        -instance ConnectionPool$
        -ConnectionPool()
        +get_instance() ConnectionPool$
        +acquire() Connection
    }
    class OrderRepository {
        +save(order)
    }
    class PaymentRepository {
        +save(payment)
    }
    OrderRepository ..> ConnectionPool : get_instance
    PaymentRepository ..> ConnectionPool : get_instance
  1. Il costruttore è nascosto: nessuno può fare ConnectionPool() dall'esterno.
  2. Il metodo statico get_instance() crea l'istanza la prima volta e poi restituisce sempre la stessa.
  3. Tutti i repository chiamano get_instance() e condividono lo stesso pool.
  4. In Python il "singleton" naturale è un oggetto a livello di modulo: il modulo viene importato una sola volta.

Analogia: un paese ha un solo governo: chiunque dica "il governo" si riferisce alla stessa entità.

Quando usarlo: - La risorsa è davvero unica per processo (pool di connessioni, cache in memoria, configurazione caricata). - Vuoi controllare l'inizializzazione pigra di una risorsa costosa.

Quando NON usarlo / rischi: - È stato globale travestito: chi lo usa nasconde una dipendenza e i test non possono sostituirla senza trucchi (monkeypatch, reset manuali fra un test e l'altro). - Viola la responsabilità singola: la classe gestisce sia il proprio compito sia il proprio ciclo di vita. - Con thread e processi multipli l'istanza "unica" non lo è più: serve un lock o una strategia per processo. - Alternativa preferibile: crea l'istanza una volta sola all'avvio (composition root) e passala come dipendenza. Ottieni l'unicità senza l'accesso globale, e nei test passi un finto.

Esempio pratico:

from typing import Protocol

class ConnectionPool:
    _instance: "ConnectionPool | None" = None

    @classmethod
    def get_instance(cls) -> "ConnectionPool":
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance

    def acquire(self) -> str: return "conn"

# Alternativa con dependency injection: unica istanza, nessun accesso globale.
class Pool(Protocol):
    def acquire(self) -> str: ...

class OrderRepository:
    def __init__(self, pool: Pool) -> None: self._pool = pool
    def save(self, order_id: str) -> str: return f"{self._pool.acquire()}:{order_id}"

repo = OrderRepository(ConnectionPool())  # creato una volta sola all'avvio

Pattern correlati: Abstract Factory, Builder e Prototype (le loro istanze sono spesso singleton), Facade (spesso una sola istanza), Flyweight (molte istanze condivise, non una).

Factory Method vs Abstract Factory vs Builder

Pattern Cosa crea Come decide Quando preferirlo
Factory Method Un oggetto Una sottoclasse sovrascrive un metodo Una sola gerarchia di prodotti, varianti che crescono
Abstract Factory Una famiglia di oggetti coerenti Scegli la factory concreta una volta Più prodotti che devono stare insieme (canale, tema)
Builder Un oggetto complesso, a passi Il cliente chiama i passi che vuole Molti parametri opzionali, validazione finale

Come scegliere

flowchart TD
    q1{"Serve una sola istanza condivisa?"}
    q2{"Oggetto con molti parametri opzionali?"}
    q3{"Hai gia' un'istanza simile da copiare?"}
    q4{"Devi creare piu' oggetti che vanno insieme?"}
    di["Crea all'avvio e passa come dipendenza"]
    sg["Singleton, solo se la DI non basta"]
    bu["Builder"]
    pr["Prototype"]
    af["Abstract Factory"]
    fm["Factory Method"]
    q1 -- si --> di
    di --> sg
    q1 -- no --> q2
    q2 -- si --> bu
    q2 -- no --> q3
    q3 -- si --> pr
    q3 -- no --> q4
    q4 -- si --> af
    q4 -- no --> fm