Salta al contenuto principale

Spring Boot Enterprise Patterns: 12 Pattern Production-Grade per Sistemi ad Alta Affidabilità

Sei pattern Spring per produzione, ognuno eseguito due volte con uscita identica: webhook idempotente, circuit breaker, rate limit, migrazioni, tenant.

Cosa ne pensi di questo articolo?

Spring Boot Enterprise Patterns: 12 Pattern Production-Grade per Sistemi ad Alta Affidabilità
Spring Boot EnterpriseFoto:AS PhotographyPexels

In Breve

Sei pattern Spring per produzione, ognuno eseguito due volte con uscita identica: webhook idempotente, circuit breaker, rate limit, migrazioni, tenant.

Un pattern è «da produzione» quando sai che cosa fa nel momento in cui qualcosa va storto: il mittente ti consegna due volte lo stesso pagamento, il servizio a cui chiedi un dato smette di rispondere, un cliente martella l'API, una migrazione si ferma a metà, due clienti condividono per sbaglio le stesse righe. Qui trovi sei pattern Spring che reggono a quei casi, ciascuno come un programma che ho compilato ed eseguito due volte con la stessa uscita, e per ciascuno il punto in cui smette di bastare. Ciò che richiede un server, Redis o PostgreSQL vero è dichiarato «schema da adattare, non eseguito».

Le versioni sono quelle correnti al 2026-11-13. L'elenco delle release di Spring Boot segna come corrente la 4.1.2; la uso solo come BOM (l'elenco delle versioni compatibili), da cui Maven ricava Spring Framework 7.0.10, Hibernate 7.4.13.Final, H2 2.4.240 e Flyway 12.4.0. Resilience4j 2.4.0 e Bucket4j 8.22.0 stanno fuori dal BOM: sono le ultime pubblicate su Maven Central lo stesso giorno. I programmi usano il JDK 21 e solo Spring Framework (contesto, JDBC, transazioni): nessun server si avvia, nessuna porta si apre, e per questo non si vede nessuna auto-configurazione di Spring Boot.

Il backend di questo sito (Spring Boot 3.4.1, 1.521 file Java, 371+ test, 47 Flyway migration, 98+ endpoint REST, integrazione Stripe SDK live) è il contesto in cui questi problemi sono nati; gli esempi sotto sono invece programmi di prova costruiti per l'articolo, con tabelle e nomi inventati. Per chi parte da zero sulla struttura a livelli, ti conviene prima leggere la regola della dipendenza; per un test che protegga la struttura nel tempo, l'articolo 01 di questa serie sulle regole ArchUnit congelate.

Come ho eseguito i programmi

Ogni pattern è un solo file Java. Il pom.xml di prova serve soltanto a scaricare le librerie e a stampare il classpath: compili e lanci con javac e java, senza plugin. Il passaggio da ricordare è il flag -parameters, che Spring usa per leggere i nomi dei parametri, e il fatto che la cartella migrations/ del pattern 4 deve essere raggiungibile dalla directory da cui lanci il programma.

export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64

# pom.xml di prova: importa il BOM spring-boot-dependencies 4.1.2 e dichiara
# spring-context, spring-jdbc, h2, flyway-core, hibernate-core (versioni dal BOM),
# resilience4j-circuitbreaker e -retry 2.4.0, bucket4j_jdk17-core 8.22.0
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt

mkdir -p out
$JAVA_HOME/bin/javac -parameters -cp "$(cat cp.txt)" -d out src/*.java
$JAVA_HOME/bin/java -cp "out:$(cat cp.txt)" WebhookIdempotente   # e gli altri cinque, uno alla volta

Le stringhe che i programmi stampano sono la sola parte che il lettore vede; i messaggi dei logger (SLF4J, Hibernate) vanno su stderr e sono tolti dalle uscite riportate. Per i tempi non riporto nessuna misura: i programmi sono deterministici perché l'orologio è finto dove serve, e dove c'è una sleep (il circuit breaker) è lunga abbastanza da non far variare l'uscita nelle due esecuzioni.

Pattern 1: webhook idempotente, con firma e transazione unica

Un webhook è una chiamata HTTP che un fornitore (di pagamenti, di spedizioni) ti fa quando succede qualcosa. Il fornitore non sa se hai ricevuto la chiamata: se non risponde 2xx la ripete, e può ripeterla anche se l'hai elaborata ma la risposta si è persa. Per i pagamenti il caso concreto è questo: un cliente paga 19,90 euro, la tua risposta si perde, il fornitore consegna di nuovo lo stesso evento e tu registri due pagamenti. La documentazione di Stripe lo dice senza giri: ti chiede di tenere traccia degli ID evento per riconoscere le consegne duplicate, di verificare la firma sul corpo grezzo e di rispondere 2xx rapidamente. Dice anche che in modalità live ritenta fino a tre giorni con attesa esponenziale.

Il programma sotto costruisce il pattern in tre pezzi. Firma calcola un HMAC-SHA256 su «timestamp.corpo» e rifiuta i timestamp più vecchi di cinque minuti, la tolleranza che la pagina di Stripe indica per le sue librerie; il confronto usa MessageDigest.isEqual perché è a tempo costante. Gestore.elabora è il cuore: la riga in evento_elaborato e l'effetto (la riga in pagamento) stanno nella stessa transazione, quindi o entrambe o nessuna. La chiave primaria su event_id è ciò che rende il duplicato impossibile, non un if che legge prima di scrivere: due richieste in parallelo passerebbero entrambe il controllo.

Attenzione a un punto: lo schema di firma è mio e serve a mostrare la forma, non è quello di Stripe. Con Stripe usa la verifica della libreria ufficiale e passale il corpo grezzo, come dice la pagina; la mia versione illustra soltanto dove si mettono firma e finestra di tempo.

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.sql.DataSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;

public class WebhookIdempotente {

    @Configuration
    @EnableTransactionManagement
    static class Config {
        @Bean DataSource dataSource() {
            return new DriverManagerDataSource("jdbc:h2:mem:wh;DB_CLOSE_DELAY=-1", "sa", "");
        }
        @Bean JdbcTemplate jdbc(DataSource ds) { return new JdbcTemplate(ds); }
        @Bean PlatformTransactionManager tx(DataSource ds) { return new DataSourceTransactionManager(ds); }
        @Bean Clock clock() { return Clock.fixed(Instant.parse("2026-11-13T10:00:00Z"), ZoneOffset.UTC); }
        @Bean Firma firma(Clock clock) { return new Firma("segreto-di-prova".getBytes(StandardCharsets.UTF_8), clock); }
        @Bean Gestore gestore(JdbcTemplate jdbc) { return new Gestore(jdbc); }
        @Bean Endpoint endpoint(Firma firma, Gestore gestore) { return new Endpoint(firma, gestore); }
    }

    /** Firma HMAC-SHA256 su "timestamp.corpo" e finestra di tolleranza contro il replay. */
    static class Firma {
        private final byte[] segreto;
        private final Clock clock;
        Firma(byte[] segreto, Clock clock) { this.segreto = segreto; this.clock = clock; }

        String calcola(long ts, String corpo) throws Exception {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(segreto, "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal((ts + "." + corpo).getBytes(StandardCharsets.UTF_8)));
        }

        boolean valida(long ts, String firmaRicevuta, String corpo) throws Exception {
            if (Math.abs(clock.instant().getEpochSecond() - ts) > 300) return false;   // replay
            byte[] atteso = calcola(ts, corpo).getBytes(StandardCharsets.UTF_8);
            return MessageDigest.isEqual(atteso, firmaRicevuta.getBytes(StandardCharsets.UTF_8)); // tempo costante
        }
    }

    @Service
    static class Gestore {
        private final JdbcTemplate jdbc;
        Gestore(JdbcTemplate jdbc) { this.jdbc = jdbc; }

        /** Registra l'evento E applica l'effetto nella stessa transazione. */
        @Transactional
        public String elabora(String eventId, int importoCent, boolean fallisci) {
            try {
                jdbc.update("INSERT INTO evento_elaborato(event_id) VALUES (?)", eventId);
            } catch (DuplicateKeyException e) {
                return "duplicato";
            }
            jdbc.update("INSERT INTO pagamento(event_id, importo_cent) VALUES (?, ?)", eventId, importoCent);
            if (fallisci) throw new IllegalStateException("errore dopo gli insert");
            return "elaborato";
        }
    }

    static class Endpoint {
        private final Firma firma; private final Gestore gestore;
        Endpoint(Firma firma, Gestore gestore) { this.firma = firma; this.gestore = gestore; }

        int riceve(long ts, String firmaRicevuta, String eventId, int importo, boolean fallisci, String corpo) throws Exception {
            if (!firma.valida(ts, firmaRicevuta, corpo)) return 400;
            try {
                gestore.elabora(eventId, importo, fallisci);
                return 200;                       // anche per il duplicato: il mittente smette di riprovare
            } catch (RuntimeException e) {
                return 500;                       // il mittente riprova
            }
        }
    }

    public static void main(String[] args) throws Exception {
        try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
            JdbcTemplate jdbc = ctx.getBean(JdbcTemplate.class);
            jdbc.execute("CREATE TABLE evento_elaborato(event_id VARCHAR(64) PRIMARY KEY)");
            jdbc.execute("CREATE TABLE pagamento(event_id VARCHAR(64), importo_cent INT)");
            Firma firma = ctx.getBean(Firma.class);
            Endpoint ep = ctx.getBean(Endpoint.class);
            long ora = ctx.getBean(Clock.class).instant().getEpochSecond();

            String corpo = "{\"id\":\"evt_1\",\"importo\":1990}";
            String f = firma.calcola(ora, corpo);
            System.out.println("firma corretta, 1a consegna : " + ep.riceve(ora, f, "evt_1", 1990, false, corpo));
            System.out.println("stessa consegna, 2a volta   : " + ep.riceve(ora, f, "evt_1", 1990, false, corpo));
            System.out.println("stessa consegna, 3a volta   : " + ep.riceve(ora, f, "evt_1", 1990, false, corpo));
            System.out.println("corpo alterato              : " + ep.riceve(ora, f, "evt_1", 99999, false, corpo.replace("1990", "9999")));
            long vecchio = ora - 301;
            System.out.println("firma valida ma vecchia 301s: " + ep.riceve(vecchio, firma.calcola(vecchio, corpo), "evt_1", 1990, false, corpo));

            String corpo2 = "{\"id\":\"evt_2\",\"importo\":500}";
            String f2 = firma.calcola(ora, corpo2);
            System.out.println("evt_2 con errore interno    : " + ep.riceve(ora, f2, "evt_2", 500, true, corpo2));
            System.out.println("righe dopo il rollback      : " + jdbc.queryForObject(
                "SELECT COUNT(*) FROM evento_elaborato WHERE event_id = 'evt_2'", Integer.class));
            System.out.println("evt_2 riprovato senza errore: " + ep.riceve(ora, f2, "evt_2", 500, false, corpo2));

            System.out.println("pagamenti totali            : " + jdbc.queryForObject("SELECT COUNT(*) FROM pagamento", Integer.class));
            System.out.println("somma importi (centesimi)   : " + jdbc.queryForObject("SELECT SUM(importo_cent) FROM pagamento", Integer.class));
        }
    }
}

// Output:
// firma corretta, 1a consegna : 200
// stessa consegna, 2a volta   : 200
// stessa consegna, 3a volta   : 200
// corpo alterato              : 400
// firma valida ma vecchia 301s: 400
// evt_2 con errore interno    : 500
// righe dopo il rollback      : 0
// evt_2 riprovato senza errore: 200
// pagamenti totali            : 2
// somma importi (centesimi)   : 2490

L'uscita si legge in tre gruppi. Le prime tre righe sono la stessa consegna ricevuta tre volte: tutte rispondono 200 (il mittente smette di ritentare) ma il conteggio finale dice due pagamenti in totale, uno per evt_1 e uno per evt_2. Il corpo alterato e la firma vecchia di 301 secondi danno 400. Il caso evt_2 è il più istruttivo: l'errore avviene dopo gli insert, la transazione torna indietro, la riga «elaborato» sparisce (zero righe) e il ritentativo del mittente riesce. Se avessi salvato l'evento fuori dalla transazione, il ritentativo sarebbe stato scambiato per un duplicato e il pagamento perso.

Dove smette di bastare: l'effetto sul database è protetto, quello su un servizio esterno no (una mail inviata prima dell'errore parte comunque, quindi a ogni ritentativo può ripartire). Inoltre ho eseguito tutto su H2: su PostgreSQL non l'ho provato e il comportamento di una transazione dopo un errore di vincolo cambia da motore a motore, quindi verifica come il tuo database tratta il secondo insert dentro la stessa transazione prima di copiare la struttura. Su Stripe, per vedere cosa succede quando i pagamenti sono davvero ricorrenti, c'è l'articolo sui pagamenti e gli abbonamenti.

Pattern 2: circuit breaker e retry verso un servizio instabile

Quando un servizio da cui dipendi è lento o giù, le tue richieste si accumulano in attesa del timeout e trascinano giù anche te. Un circuit breaker conta gli esiti delle ultime chiamate e, superata una soglia di fallimenti, smette di chiamare: le richieste successive falliscono subito, senza toccare il servizio. Dopo un'attesa lascia passare poche chiamate di prova e, se riescono, richiude. Gli stati sono tre: CLOSED (si chiama), OPEN (non si chiama), HALF_OPEN (si prova).

La trappola è nei valori predefiniti, che ho letto nella documentazione di Resilience4j: slidingWindowSize e minimumNumberOfCalls valgono 100, failureRateThreshold 50 (in percentuale), waitDurationInOpenState 60 secondi. Con i predefiniti il circuito non può aprirsi prima di cento chiamate registrate: un servizio che fallisce sempre ti costa cento timeout prima della protezione. Qui ho ridotto la finestra a quattro chiamate per rendere visibile il meccanismo; in produzione la dimensione della finestra dipende dal tuo traffico, e non ho una cifra da consigliare senza misurarlo sul tuo sistema.

import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public class Resilienza {

    /** Servizio finto: fallisce finché "giu" è true. Conta le chiamate che arrivano davvero. */
    static class ServizioFinto {
        volatile boolean giu = true;
        final AtomicInteger chiamate = new AtomicInteger();
        String chiama() throws IOException {
            chiamate.incrementAndGet();
            if (giu) throw new IOException("servizio non raggiungibile");
            return "risposta";
        }
    }

    static String chiamaConFallback(CircuitBreaker cb, ServizioFinto s) {
        Supplier<String> decorata = CircuitBreaker.decorateSupplier(cb, () -> {
            try { return s.chiama(); } catch (IOException e) { throw new RuntimeException(e); }
        });
        try {
            return decorata.get();
        } catch (CallNotPermittedException e) {
            return "FALLBACK (circuito aperto, chiamata non inviata)";
        } catch (RuntimeException e) {
            return "FALLBACK (errore: " + e.getCause().getMessage() + ")";
        }
    }

    public static void main(String[] args) throws Exception {
        CircuitBreakerConfig cfg = CircuitBreakerConfig.custom()
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(4)
            .minimumNumberOfCalls(4)
            .failureRateThreshold(50)                       // percentuale
            .waitDurationInOpenState(Duration.ofMillis(300))
            .permittedNumberOfCallsInHalfOpenState(2)
            .build();
        CircuitBreaker cb = CircuitBreaker.of("servizio-esterno", cfg);
        ServizioFinto s = new ServizioFinto();

        for (int i = 1; i <= 6; i++) {
            System.out.printf("chiamata %d -> %s | stato=%s | arrivate al servizio=%d%n",
                i, chiamaConFallback(cb, s), cb.getState(), s.chiamate.get());
        }

        Thread.sleep(400);                                   // supera waitDurationInOpenState
        s.giu = false;                                       // il servizio si è ripreso
        for (int i = 7; i <= 9; i++) {
            System.out.printf("chiamata %d -> %s | stato=%s | arrivate al servizio=%d%n",
                i, chiamaConFallback(cb, s), cb.getState(), s.chiamate.get());
        }

        // Retry con backoff esponenziale: 3 tentativi, 50 ms poi 100 ms
        ServizioFinto s2 = new ServizioFinto();
        AtomicInteger tentativo = new AtomicInteger();
        RetryConfig rc = RetryConfig.custom()
            .maxAttempts(3)
            .intervalFunction(io.github.resilience4j.core.IntervalFunction.ofExponentialBackoff(50, 2.0))
            .retryExceptions(IOException.class)
            .build();
        Retry retry = Retry.of("r", rc);
        Retry.EventPublisher ev = retry.getEventPublisher();
        ev.onRetry(e -> System.out.println("  retry #" + e.getNumberOfRetryAttempts() + " dopo " + e.getWaitInterval().toMillis() + " ms"));
        try {
            Retry.decorateCheckedSupplier(retry, () -> {
                if (tentativo.incrementAndGet() < 3) throw new IOException("transitorio");
                return "ok al tentativo " + tentativo.get();
            }).get();
            System.out.println("retry: ok al tentativo " + tentativo.get());
        } catch (Throwable t) {
            System.out.println("retry fallito: " + t);
        }
    }
}

// Output:
// chiamata 1 -> FALLBACK (errore: servizio non raggiungibile) | stato=CLOSED | arrivate al servizio=1
// chiamata 2 -> FALLBACK (errore: servizio non raggiungibile) | stato=CLOSED | arrivate al servizio=2
// chiamata 3 -> FALLBACK (errore: servizio non raggiungibile) | stato=CLOSED | arrivate al servizio=3
// chiamata 4 -> FALLBACK (errore: servizio non raggiungibile) | stato=OPEN | arrivate al servizio=4
// chiamata 5 -> FALLBACK (circuito aperto, chiamata non inviata) | stato=OPEN | arrivate al servizio=4
// chiamata 6 -> FALLBACK (circuito aperto, chiamata non inviata) | stato=OPEN | arrivate al servizio=4
// chiamata 7 -> risposta | stato=HALF_OPEN | arrivate al servizio=5
// chiamata 8 -> risposta | stato=CLOSED | arrivate al servizio=6
// chiamata 9 -> risposta | stato=CLOSED | arrivate al servizio=7
//   retry #1 dopo 50 ms
//   retry #2 dopo 100 ms
// retry: ok al tentativo 3

Nell'uscita le prime quattro chiamate arrivano tutte al servizio (la colonna «arrivate» sale fino a 4) e alla quarta lo stato passa a OPEN, perché sono falliti 4 su 4, cioè il 100% contro una soglia del 50%. Le chiamate 5 e 6 non raggiungono il servizio: il contatore resta a 4 e il fallback risponde subito. Dopo 400 ms (più dei 300 configurati) la chiamata 7 passa, lo stato è HALF_OPEN; con permittedNumberOfCallsInHalfOpenState a 2, la chiamata 8 chiude il circuito. Nota che la transizione da OPEN a HALF_OPEN avviene al primo tentativo dopo l'attesa, non da sola: è il comportamento predefinito, descritto dalla stessa pagina.

Il retry, nell'ultima parte, ripete una chiamata fallita con attese di 50 e poi 100 millisecondi (backoff esponenziale con moltiplicatore 2). Ripete solo le eccezioni elencate in retryExceptions: se elencassi tutte le eccezioni, ritenteresti anche un errore di validazione che fallirà sempre. E ritenta soltanto operazioni idempotenti: ripetere un pagamento non lo è, a meno che tu non abbia il pattern 1 dall'altra parte. Non usare il circuit breaker dove un errore è un dato (un 404 che significa «non esiste»): lo configureresti per contarlo come fallimento e apriresti il circuito per una condizione normale.

Pattern 3: rate limiting per fascia con un token bucket

Un token bucket funziona come un secchio di gettoni: ogni richiesta ne consuma uno, e il secchio si riempie con un ritmo costante fino a una capacità massima. Se il secchio è vuoto la richiesta viene rifiutata. L'immagine utile è un cinema con cinque biglietti al minuto per i clienti «free»: puoi spenderli tutti insieme (burst di cinque) ma poi aspetti un gettone ogni dodici secondi. Il vantaggio rispetto a «cinque richieste per minuto di calendario» è che non c'è il picco al cambio di minuto.

Il programma usa Bucket4j 8.22.0 con un secchio per cliente e capacità diversa per fascia. La riga che conta è refillGreedy: aggiunge i gettoni a piccole dosi continue (uno ogni 12 secondi per cinque al minuto), mentre refillIntervally aspetta la fine del periodo e rimette tutto insieme. L'orologio è un TimeMeter finto, che si può passare a withCustomTimePrecision: è il meccanismo che la documentazione indica per sostituire il tempo della JVM, e qui rende deterministico il tempo di attesa.

import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.TimeMeter;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class LimitePerFascia {

    /** Orologio manuale: rende deterministico il tempo di attesa. */
    static class OrologioFinto implements TimeMeter {
        long nanos = 0;
        @Override public long currentTimeNanos() { return nanos; }
        @Override public boolean isWallClockBased() { return false; }
        void avanzaSecondi(long s) { nanos += Duration.ofSeconds(s).toNanos(); }
    }

    enum Fascia {
        FREE(5), PRO(20);
        final int perMinuto;
        Fascia(int perMinuto) { this.perMinuto = perMinuto; }
    }

    static class Limitatore {
        private final Map<String, Bucket> secchi = new ConcurrentHashMap<>();
        private final OrologioFinto orologio;
        Limitatore(OrologioFinto o) { this.orologio = o; }

        ConsumptionProbe prova(String cliente, Fascia fascia) {
            Bucket b = secchi.computeIfAbsent(cliente, k -> Bucket.builder()
                .addLimit(limite -> limite.capacity(fascia.perMinuto)
                                          .refillGreedy(fascia.perMinuto, Duration.ofMinutes(1)))
                .withCustomTimePrecision(orologio)
                .build());
            return b.tryConsumeAndReturnRemaining(1);
        }
    }

    public static void main(String[] args) {
        OrologioFinto t = new OrologioFinto();
        Limitatore lim = new Limitatore(t);

        for (int i = 1; i <= 7; i++) {
            ConsumptionProbe p = lim.prova("cliente-free", Fascia.FREE);
            if (p.isConsumed()) {
                System.out.printf("FREE richiesta %d: ok, restano %d%n", i, p.getRemainingTokens());
            } else {
                long attesaSec = (long) Math.ceil(p.getNanosToWaitForRefill() / 1e9);
                System.out.printf("FREE richiesta %d: 429, Retry-After=%d s%n", i, attesaSec);
            }
        }
        t.avanzaSecondi(12);   // 5 gettoni/minuto = 1 ogni 12 s
        ConsumptionProbe dopo = lim.prova("cliente-free", Fascia.FREE);
        System.out.println("FREE dopo 12 s: consumata=" + dopo.isConsumed() + ", restano " + dopo.getRemainingTokens());
        System.out.println("PRO  richiesta 1: " + lim.prova("cliente-pro", Fascia.PRO).getRemainingTokens() + " gettoni rimasti");
    }
}

// Output:
// FREE richiesta 1: ok, restano 4
// FREE richiesta 2: ok, restano 3
// FREE richiesta 3: ok, restano 2
// FREE richiesta 4: ok, restano 1
// FREE richiesta 5: ok, restano 0
// FREE richiesta 6: 429, Retry-After=12 s
// FREE richiesta 7: 429, Retry-After=12 s
// FREE dopo 12 s: consumata=true, restano 0
// PRO  richiesta 1: 19 gettoni rimasti

Le richieste da 1 a 5 consumano i gettoni (restano 4, 3, 2, 1, 0); la sesta e la settima ricevono 429 con un tempo di attesa di 12 secondi, che è esattamente getNanosToWaitForRefill arrotondato per eccesso e che puoi mettere nell'intestazione Retry-After. Dopo aver avanzato l'orologio di 12 secondi, una richiesta passa di nuovo e il secchio torna a zero. Il cliente «pro» ha il suo secchio da venti: dopo la prima richiesta ne restano 19.

Limiti da conoscere. Questo secchio vive in memoria di un solo processo: con più istanze ogni istanza concede il limite intero. Bucket4j offre moduli per condividere lo stato su Redis (Lettuce, Jedis, Redisson) e su griglie compatibili con JCache, elencati nel README del progetto, ma non li ho eseguiti perché richiedono un server Redis: quello che segue è uno schema da adattare, non eseguito. Nel mio esempio la fascia è fissata alla prima richiesta (computeIfAbsent): se un cliente passa da free a pro, il suo secchio resta quello vecchio finché non lo rimuovi. E la mappa dei secchi cresce con i clienti: serve una scadenza, altrimenti è una perdita di memoria lenta. Per un caso reale in cui un solo numero per tutta l'API era l'errore, leggi il postmortem sul rate limiting multi-bucket.

Pattern 4: migrazione di schema senza fermare l'applicazione (expand e contract)

Rinominare una colonna mentre l'applicazione gira è pericoloso perché per qualche minuto convivono due versioni del codice: quella vecchia cerca la colonna con il nome vecchio, quella nuova con il nuovo. Il pattern expand-contract spezza la modifica in tre migrazioni che non rompono mai nessuna delle due versioni. Nel mio esempio la colonna email deve diventare una versione normalizzata in minuscolo.

  1. Expand: aggiungi la colonna nuova, vuota e nullable, senza toccare quella vecchia (V2).
  2. Dual write: rilasci la versione dell'applicazione che scrive in entrambe le colonne.
  3. Backfill: copi nella colonna nuova i dati già presenti (V3).
  4. Contract: dopo che nessun codice legge più la colonna vecchia, la elimini e rinomini la nuova (V4), e rilasci la versione che scrive in una sola colonna.

L'ordine qui è il contenuto: se inverti i passi 2 e 3 perdi gli aggiornamenti fatti tra backfill e rilascio, e se esegui il contract prima che tutte le istanze usino il solo codice nuovo, le istanze vecchie vanno in errore. La V4 è l'unico passo che non si può annullare senza un backup, quindi si fa per ultima e dopo un tempo di osservazione che decidi tu.

-- V1__utenti.sql
CREATE TABLE utente (
    id    INT PRIMARY KEY,
    email VARCHAR(255) NOT NULL
);
INSERT INTO utente(id, email) VALUES (1, 'anna@example.org'), (2, 'bruno@example.org');

-- V2__expand_email_normalizzata.sql
-- Fase 1 (expand): colonna nuova, nullable, nessun dato da riscrivere
ALTER TABLE utente ADD COLUMN email_norm VARCHAR(255);

-- V3__backfill_email_normalizzata.sql
-- Fase 2: copia dei dati vecchi nella colonna nuova
UPDATE utente SET email_norm = LOWER(email) WHERE email_norm IS NULL;

-- V4__contract_email_normalizzata.sql
-- Fase 3 (contract): solo quando nessun codice legge o scrive piu' la colonna vecchia
ALTER TABLE utente ALTER COLUMN email_norm SET NOT NULL;
ALTER TABLE utente DROP COLUMN email;
ALTER TABLE utente ALTER COLUMN email_norm RENAME TO email;

Le quattro migrazioni qui sopra sono i file che Flyway ha applicato. Il programma sotto le esegue una alla volta con target(...), che ferma Flyway a una versione, e dopo ogni passo stampa le colonne e le righe: così vedi la colonna nuova vuota dopo l'expand, popolata dal solo codice dual-write per la riga 1, completata dal backfill per la riga 2, e infine sola dopo il contract.

import java.sql.*;
import org.flywaydb.core.Flyway;

public class ExpandContract {
    static final String URL = "jdbc:h2:mem:ec;DB_CLOSE_DELAY=-1";

    static void migraFinoA(String versione) {
        Flyway.configure()
            .dataSource(URL, "sa", "")
            .locations("filesystem:migrations")
            .target(versione)
            .load()
            .migrate();
    }

    static void mostra(Connection c, String titolo) throws SQLException {
        StringBuilder colonne = new StringBuilder();
        try (ResultSet rs = c.getMetaData().getColumns(null, null, "UTENTE", null)) {
            while (rs.next()) colonne.append(rs.getString("COLUMN_NAME").toLowerCase()).append(' ');
        }
        System.out.println("== " + titolo + " | colonne: " + colonne.toString().trim());
        try (Statement s = c.createStatement(); ResultSet rs = s.executeQuery("SELECT * FROM utente ORDER BY id")) {
            ResultSetMetaData md = rs.getMetaData();
            while (rs.next()) {
                StringBuilder riga = new StringBuilder("   ");
                for (int i = 1; i <= md.getColumnCount(); i++)
                    riga.append(md.getColumnLabel(i).toLowerCase()).append('=').append(rs.getString(i)).append(' ');
                System.out.println(riga.toString().stripTrailing());
            }
        }
    }

    /** Versione N+1 dell'applicazione: scrive in ENTRAMBE le colonne. */
    static void aggiornaEmailDualWrite(Connection c, int id, String email) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement("UPDATE utente SET email = ?, email_norm = ? WHERE id = ?")) {
            ps.setString(1, email); ps.setString(2, email.toLowerCase()); ps.setInt(3, id);
            ps.executeUpdate();
        }
    }

    /** Versione N+2 dell'applicazione: dopo il contract esiste una sola colonna. */
    static void aggiornaEmailSingleWrite(Connection c, int id, String email) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement("UPDATE utente SET email = ? WHERE id = ?")) {
            ps.setString(1, email.toLowerCase()); ps.setInt(2, id);
            ps.executeUpdate();
        }
    }

    public static void main(String[] args) throws Exception {
        migraFinoA("1");
        try (Connection c = DriverManager.getConnection(URL, "sa", "")) {
            mostra(c, "dopo V1");
            migraFinoA("2");
            mostra(c, "dopo V2 (expand): la colonna nuova e' vuota");
            aggiornaEmailDualWrite(c, 1, "Anna.Rossi@Example.org");
            mostra(c, "dopo un update col codice dual-write");
            migraFinoA("3");
            mostra(c, "dopo V3 (backfill)");
            migraFinoA("4");
            mostra(c, "dopo V4 (contract)");
            aggiornaEmailSingleWrite(c, 2, "Bruno.Verdi@Example.org");
            mostra(c, "dopo un update col codice single-write");
            try (Statement s = c.createStatement()) {
                s.executeUpdate("UPDATE utente SET email_norm = 'x' WHERE id = 1");
            } catch (SQLException e) {
                System.out.println("codice dual-write dopo il contract: " + e.getMessage().split("\n")[0]);
            }
        }
    }
}

// Output:
// == dopo V1 | colonne: id email
//    id=1 email=anna@example.org
//    id=2 email=bruno@example.org
// == dopo V2 (expand): la colonna nuova e' vuota | colonne: id email email_norm
//    id=1 email=anna@example.org email_norm=null
//    id=2 email=bruno@example.org email_norm=null
// == dopo un update col codice dual-write | colonne: id email email_norm
//    id=1 email=Anna.Rossi@Example.org email_norm=anna.rossi@example.org
//    id=2 email=bruno@example.org email_norm=null
// == dopo V3 (backfill) | colonne: id email email_norm
//    id=1 email=Anna.Rossi@Example.org email_norm=anna.rossi@example.org
//    id=2 email=bruno@example.org email_norm=bruno@example.org
// == dopo V4 (contract) | colonne: id email
//    id=1 email=anna.rossi@example.org
//    id=2 email=bruno@example.org
// == dopo un update col codice single-write | colonne: id email
//    id=1 email=anna.rossi@example.org
//    id=2 email=bruno.verdi@example.org
// codice dual-write dopo il contract: Column "EMAIL_NORM" not found; SQL statement:

L'ultima riga dell'uscita è la prova del perché il contract va per ultimo: il codice dual-write, se girasse ancora dopo la V4, fallirebbe con «Column EMAIL_NORM not found». Attenzione al limite di questa dimostrazione: H2 in memoria prova la logica delle migrazioni e dei dati, non i blocchi di tabella, che dipendono dal database. Dalla documentazione di PostgreSQL su ALTER TABLE risulta che, salvo indicazione contraria, ogni forma richiede un lock ACCESS EXCLUSIVE, e che aggiungere una colonna con valore predefinito non volatile è molto veloce perché il valore sta nei metadati. La pagina di CREATE INDEX dice che una costruzione standard blocca le scritture finché non finisce, mentre CONCURRENTLY no (con alcune avvertenze). Lo schema sotto è quindi da adattare e non l'ho eseguito.

-- PostgreSQL: schema da adattare, NON eseguito
-- su una tabella grande, l'indice sulla colonna nuova si costruisce senza bloccare le scritture
CREATE INDEX CONCURRENTLY idx_utente_email_norm ON utente (email_norm);

Il comando va in una migrazione a sé: CONCURRENTLY non può stare dentro un blocco di transazione e Flyway, in genere, esegue ogni migrazione in una transazione; controlla nella documentazione di Flyway come disattivarla per quella migrazione. Per il versionamento dello schema dentro una Clean Architecture c'è l'articolo su JPA e Flyway, e per le trappole reali di Flyway in produzione i tre postmortem.

Pattern 5: un tenant, uno schema

Multi-tenancy significa servire più clienti con lo stesso codice tenendo separati i loro dati. La guida di Hibernate 7.4 distingue tre approcci: un database per tenant, uno schema per tenant, o righe nello stesso schema separate da una colonna discriminante (l'annotazione @TenantId, che Hibernate valorizza da solo). Per i primi due serve un MultiTenantConnectionProvider che restituisca la connessione giusta, e un CurrentTenantIdentifierResolver che dica quale tenant è corrente.

Il programma crea tre schemi in H2 e fa scrivere la stessa chiave (id = 1) in due tenant diversi. Tre righe contano. TenantContext usa un ThreadLocal e va pulito con remove() in un finally: dimenticarlo in un server a thread riusati significa servire la richiesta dopo con il tenant della precedente. TENANT_NOTI è una lista chiusa, perché l'identificatore finisce in un comando SET SCHEMA e uno statement DDL non accetta parametri: concatenare un valore che arriva da fuori è un'iniezione SQL. E releaseConnection riporta la connessione allo schema pubblico prima di restituirla, perché con un pool lo schema impostato resterebbe «appiccicato» alla connessione.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.Set;
import org.hibernate.SessionFactory;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;

public class MultiTenant {
    static final String URL = "jdbc:h2:mem:mt;DB_CLOSE_DELAY=-1";

    @Entity(name = "Fattura")
    public static class Fattura {
        @Id public Integer id;
        public String cliente;
        public Fattura() {}
        public Fattura(Integer id, String cliente) { this.id = id; this.cliente = cliente; }
    }

    /** Il tenant corrente vive in un ThreadLocal: va ripulito a fine richiesta. */
    static final class TenantContext {
        private static final ThreadLocal<String> CORRENTE = new ThreadLocal<>();
        static void imposta(String t) { CORRENTE.set(t); }
        static String leggi() { return CORRENTE.get(); }
        static void pulisci() { CORRENTE.remove(); }
    }

    static class Risolutore implements CurrentTenantIdentifierResolver<String> {
        @Override public String resolveCurrentTenantIdentifier() {
            String t = TenantContext.leggi();
            return t != null ? t : "PUBLIC";
        }
        @Override public boolean validateExistingCurrentSessions() { return true; }
    }

    static class ProviderPerSchema implements MultiTenantConnectionProvider<String> {
        // Lista chiusa: l'identificatore finisce in uno statement DDL, non puo' essere un parametro
        private static final Set<String> TENANT_NOTI = Set.of("PUBLIC", "ACME", "GLOBEX");

        @Override public Connection getAnyConnection() throws SQLException {
            return DriverManager.getConnection(URL, "sa", "");
        }
        @Override public void releaseAnyConnection(Connection c) throws SQLException { c.close(); }

        @Override public Connection getConnection(String tenant) throws SQLException {
            if (!TENANT_NOTI.contains(tenant)) throw new SQLException("tenant sconosciuto: " + tenant);
            Connection c = getAnyConnection();
            try (Statement s = c.createStatement()) { s.execute("SET SCHEMA " + tenant); }
            return c;
        }
        @Override public void releaseConnection(String tenant, Connection c) throws SQLException {
            try (Statement s = c.createStatement()) { s.execute("SET SCHEMA PUBLIC"); } // niente schema "appiccicato" in un pool
            c.close();
        }
        @Override public boolean supportsAggressiveRelease() { return false; }
        @Override public boolean isUnwrappableAs(Class<?> t) { return false; }
        @Override public <T> T unwrap(Class<T> t) { throw new UnsupportedOperationException(); }
    }

    public static void main(String[] args) throws Exception {
        try (Connection c = DriverManager.getConnection(URL, "sa", ""); Statement s = c.createStatement()) {
            for (String t : new String[] {"ACME", "GLOBEX"}) {
                s.execute("CREATE SCHEMA " + t);
                s.execute("CREATE TABLE " + t + ".Fattura(id INT PRIMARY KEY, cliente VARCHAR(100))");
            }
            s.execute("CREATE TABLE PUBLIC.Fattura(id INT PRIMARY KEY, cliente VARCHAR(100))");
        }
        var registry = new StandardServiceRegistryBuilder()
            .applySettings(Map.<String, Object>of(
                AvailableSettings.JAKARTA_JDBC_URL, URL,
                AvailableSettings.JAKARTA_JDBC_USER, "sa",
                AvailableSettings.JAKARTA_JDBC_PASSWORD, "",
                AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, new ProviderPerSchema(),
                AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, new Risolutore()))
            .build();
        try (SessionFactory sf = new MetadataSources(registry).addAnnotatedClass(Fattura.class)
                .buildMetadata().buildSessionFactory()) {

            TenantContext.imposta("ACME");
            try (var s = sf.openSession()) {
                s.beginTransaction(); s.persist(new Fattura(1, "cliente di ACME")); s.getTransaction().commit();
            } finally { TenantContext.pulisci(); }

            TenantContext.imposta("GLOBEX");
            try (var s = sf.openSession()) {
                s.beginTransaction(); s.persist(new Fattura(1, "cliente di GLOBEX")); s.getTransaction().commit();
            } finally { TenantContext.pulisci(); }

            for (String t : new String[] {"ACME", "GLOBEX"}) {
                TenantContext.imposta(t);
                try (var s = sf.openSession()) {
                    System.out.println(t + " vede: " + s.find(Fattura.class, 1).cliente);
                } finally { TenantContext.pulisci(); }
            }

            TenantContext.imposta("ACME; DROP TABLE PUBLIC.Fattura");
            try (var s = sf.openSession()) {
                s.find(Fattura.class, 1);
            } catch (Exception e) {
                Throwable root = e; while (root.getCause() != null) root = root.getCause();
                System.out.println("tenant ostile rifiutato: " + root.getMessage());
            } finally { TenantContext.pulisci(); }

            try (Connection c = DriverManager.getConnection(URL, "sa", ""); Statement s = c.createStatement();
                 var rs = s.executeQuery("SELECT COUNT(*) FROM PUBLIC.Fattura")) {
                rs.next();
                System.out.println("tabella PUBLIC.Fattura ancora presente, righe: " + rs.getInt(1));
            }
        }
    }
}

// Output:
// ACME vede: cliente di ACME
// GLOBEX vede: cliente di GLOBEX
// tenant ostile rifiutato: tenant sconosciuto: ACME; DROP TABLE PUBLIC.Fattura
// tabella PUBLIC.Fattura ancora presente, righe: 0

L'uscita mostra le tre proprietà: ACME e GLOBEX scrivono lo stesso id senza collidere e ciascuno rilegge solo il proprio cliente; un identificatore ostile (ACME; DROP TABLE PUBLIC.Fattura) viene rifiutato prima di arrivare al database, e la tabella pubblica è ancora lì. Il provider qui apre una connessione nuova a ogni richiesta con DriverManager: in un'applicazione vera ci va un pool, ed è qui che il reset dello schema diventa indispensabile.

Quando non usarlo: a pochi clienti il costo non si ripaga, e a molti tenant le migrazioni vanno applicate a ogni schema e il numero di schemi diventa un problema di gestione. Non ho misurato dove sta il limite e non scrivo una soglia: dipende dal tuo database e dalle tue migrazioni. La colonna discriminante (@TenantId) è più semplice ma dà un isolamento più debole, perché un errore in una query può mostrare righe di un altro tenant. Questo programma gira con Hibernate nativo, senza Spring Boot: come collegare i due componenti a un contesto Spring Boot non l'ho provato, e qui non lo descrivo.

Pattern 6: una coda di lavori nel database, con tentativi e lavori morti

Quando un'operazione può fallire per motivi transitori (un server di posta che non risponde) e deve essere completata anche se il processo muore, la scrivi su una tabella invece di eseguirla subito. Un lavoro ha uno stato, un numero di tentativi e un orario per il prossimo tentativo. Il vantaggio rispetto a una coda esterna è che l'inserimento del lavoro può stare nella stessa transazione del dato che lo ha generato.

Nel programma il poller (giro) legge i lavori «da fare» con prossimo_tentativo già passato e li esegue uno per uno, ciascuno nella propria transazione. Se falliscono, il tentativo successivo è dopo 10, 20, poi 40 secondi (BASE moltiplicato per una potenza di due), e al quarto errore il lavoro diventa «MORTO» con l'ultimo messaggio d'errore salvato: nessuno lo ritenta più, e qualcuno deve guardarlo. L'orologio è passato come parametro, così la simulazione di due minuti e mezzo gira istantaneamente.

import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;

public class CodaLavori {
    static final int MAX_TENTATIVI = 4;
    static final Duration BASE = Duration.ofSeconds(10);

    final JdbcTemplate jdbc;
    final TransactionTemplate tx;
    CodaLavori(JdbcTemplate jdbc, TransactionTemplate tx) { this.jdbc = jdbc; this.tx = tx; }

    void accoda(String tipo, Instant quando) {
        jdbc.update("INSERT INTO lavoro(tipo, stato, tentativi, prossimo_tentativo) VALUES (?, 'DA_FARE', 0, ?)",
            tipo, Timestamp.from(quando));
    }

    /** Esegue i lavori scaduti. Ogni lavoro ha la sua transazione: un fallimento non ne trascina altri. */
    void giro(Instant ora, Consumer<String> esegui) {
        List<Map<String, Object>> scaduti = jdbc.queryForList(
            "SELECT id, tipo, tentativi FROM lavoro WHERE stato = 'DA_FARE' AND prossimo_tentativo <= ? ORDER BY id",
            Timestamp.from(ora));
        for (Map<String, Object> l : scaduti) {
            long id = ((Number) l.get("id")).longValue();
            int tentativi = ((Number) l.get("tentativi")).intValue();
            tx.executeWithoutResult(st -> {
                try {
                    esegui.accept((String) l.get("tipo"));
                    jdbc.update("UPDATE lavoro SET stato = 'FATTO', tentativi = ? WHERE id = ?", tentativi + 1, id);
                } catch (RuntimeException e) {
                    int n = tentativi + 1;
                    if (n >= MAX_TENTATIVI) {
                        jdbc.update("UPDATE lavoro SET stato = 'MORTO', tentativi = ?, ultimo_errore = ? WHERE id = ?",
                            n, e.getMessage(), id);
                    } else {
                        Instant prossimo = ora.plus(BASE.multipliedBy(1L << (n - 1)));   // 10 s, 20 s, 40 s
                        jdbc.update("UPDATE lavoro SET tentativi = ?, prossimo_tentativo = ?, ultimo_errore = ? WHERE id = ?",
                            n, Timestamp.from(prossimo), e.getMessage(), id);
                    }
                }
            });
        }
    }

    void stampa(String quando) {
        System.out.println("-- " + quando);
        jdbc.queryForList("SELECT id, tipo, stato, tentativi, prossimo_tentativo FROM lavoro ORDER BY id")
            .forEach(r -> System.out.printf("   #%s %-9s %-8s tentativi=%s prossimo=%s%n",
                r.get("ID"), r.get("TIPO"), r.get("STATO"), r.get("TENTATIVI"),
                ((Timestamp) r.get("PROSSIMO_TENTATIVO")).toInstant().toString().substring(11, 19)));
    }

    public static void main(String[] args) {
        var ds = new DriverManagerDataSource("jdbc:h2:mem:q;DB_CLOSE_DELAY=-1", "sa", "");
        var jdbc = new JdbcTemplate(ds);
        jdbc.execute("CREATE TABLE lavoro(id BIGINT AUTO_INCREMENT PRIMARY KEY, tipo VARCHAR(30), stato VARCHAR(10), "
            + "tentativi INT, prossimo_tentativo TIMESTAMP, ultimo_errore VARCHAR(200))");
        var coda = new CodaLavori(jdbc, new TransactionTemplate(new DataSourceTransactionManager(ds)));

        Instant t0 = Instant.parse("2026-11-13T10:00:00Z");
        coda.accoda("balbetta", t0);   // fallisce 2 volte, poi riesce
        coda.accoda("rotto", t0);      // fallisce sempre
        coda.accoda("sano", t0);

        int[] chiamateBalbetta = {0};
        Consumer<String> esegui = tipo -> {
            switch (tipo) {
                case "balbetta" -> { if (++chiamateBalbetta[0] <= 2) throw new IllegalStateException("timeout SMTP"); }
                case "rotto" -> throw new IllegalStateException("destinatario inesistente");
                default -> { }
            }
        };

        for (int secondi : new int[] {0, 5, 10, 30, 70, 150}) {
            coda.giro(t0.plusSeconds(secondi), esegui);
            coda.stampa("dopo il giro a t0+" + secondi + " s");
        }
        System.out.println("errore del lavoro morto: " + jdbc.queryForObject(
            "SELECT ultimo_errore FROM lavoro WHERE tipo = 'rotto'", String.class));
    }
}

// Output:
// -- dopo il giro a t0+0 s
//    #1 balbetta  DA_FARE  tentativi=1 prossimo=10:00:10
//    #2 rotto     DA_FARE  tentativi=1 prossimo=10:00:10
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// -- dopo il giro a t0+5 s
//    #1 balbetta  DA_FARE  tentativi=1 prossimo=10:00:10
//    #2 rotto     DA_FARE  tentativi=1 prossimo=10:00:10
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// -- dopo il giro a t0+10 s
//    #1 balbetta  DA_FARE  tentativi=2 prossimo=10:00:30
//    #2 rotto     DA_FARE  tentativi=2 prossimo=10:00:30
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// -- dopo il giro a t0+30 s
//    #1 balbetta  FATTO    tentativi=3 prossimo=10:00:30
//    #2 rotto     DA_FARE  tentativi=3 prossimo=10:01:10
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// -- dopo il giro a t0+70 s
//    #1 balbetta  FATTO    tentativi=3 prossimo=10:00:30
//    #2 rotto     MORTO    tentativi=4 prossimo=10:01:10
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// -- dopo il giro a t0+150 s
//    #1 balbetta  FATTO    tentativi=3 prossimo=10:00:30
//    #2 rotto     MORTO    tentativi=4 prossimo=10:01:10
//    #3 sano      FATTO    tentativi=1 prossimo=10:00:00
// errore del lavoro morto: destinatario inesistente

Nell'uscita il lavoro «sano» finisce al primo giro. «balbetta» fallisce due volte e riesce al terzo tentativo, a t0+30 s; «rotto» viene riprogrammato a +10, +30 e +70 secondi, e al quarto errore (a t0+70 s) diventa MORTO con l'errore «destinatario inesistente». I giri a t0+5 s e a t0+150 s non cambiano nulla: nessun lavoro è scaduto, o tutti sono in stato finale. È il comportamento che volevi: le attese crescono e il lavoro irrecuperabile smette di consumare tentativi.

Cosa non fa questo pattern. Il lavoro esterno (inviare la mail) non è dentro la transazione del database, quindi se il processo muore tra l'invio e l'aggiornamento, al riavvio la mail parte di nuovo: la semantica è «almeno una volta», e l'azione deve poter essere ripetuta senza danni, come nel pattern 1. Inoltre un solo poller non basta se il carico cresce; con più istanze serve che ciascuna prenda lavori diversi. La documentazione di SELECT in PostgreSQL dice che SKIP LOCKED salta le righe già bloccate ed è adatto a più consumatori su una tabella di tipo coda, avvertendo che dà una vista incoerente dei dati e non va usato per uso generale. Lo schema è da adattare, non eseguito: H2 qui non è PostgreSQL.

-- PostgreSQL: schema da adattare, NON eseguito
-- ogni istanza prende fino a 10 lavori che nessun'altra istanza sta già lavorando
SELECT id, tipo
FROM lavoro
WHERE stato = 'DA_FARE' AND prossimo_tentativo <= now()
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Che cosa copre ciascun pattern, e che cosa no

PatternTi protegge daNon ti protegge daCom'è stato verificato
Webhook idempotenteConsegne duplicate, corpo alterato, replay oltre la finestraEffetti esterni ripetuti (mail, chiamate)Eseguito su H2, due volte
Circuit breaker e retryAttese a catena su un servizio giùSoglie sbagliate: i predefiniti richiedono 100 chiamateEseguito con un servizio finto, due volte
Token bucketUn cliente che consuma tutto il trafficoPiù istanze, se il secchio è in memoriaEseguito con orologio finto; Redis non eseguito
Expand e contractDue versioni del codice sullo stesso schemaBlocchi di tabella (dipendono dal database)Flyway su H2; lock solo dalla documentazione PostgreSQL
Schema per tenantDati di un tenant visibili a un altro, identificatori ostiliGestione di molti schemi e migrazioniHibernate su H2, due volte
Coda di lavoriPerdere un'operazione transitoria fallitaEsecuzioni ripetute del lavoro esternoEseguito su H2; SKIP LOCKED non eseguito

La tabella riassume l'idea che attraversa l'articolo: ogni pattern sposta il problema in un punto più facile da controllare, ma non lo elimina. Il webhook sposta la sicurezza dell'effetto sul database, il circuit breaker sposta il costo del guasto da cento timeout a quattro chiamate, la coda sposta la ripetizione dal processo al lavoro. Non ci sono qui audit con listener JPA, cache distribuita con Redis, health probe e disaster recovery: sono argomenti che richiedono un server, un broker o un orchestratore che in questo ambiente non ho avviato, e un pattern che non ho potuto eseguire non lo presento come verificato.

Domande frequenti

Quanto deve essere grande la finestra di un circuit breaker?

Non esiste una cifra valida per tutti: dipende dal traffico e dalla tolleranza ai guasti. Ciò che puoi verificare è il valore predefinito: nella documentazione di Resilience4j sia la finestra sia il minimo di chiamate valgono 100, quindi il circuito non si apre prima di cento chiamate registrate. Sceglila osservando quante chiamate al secondo fa il tuo servizio e quanto ti costa ogni timeout.

Perché il webhook va confermato con 200 anche se è un duplicato?

Perché al mittente interessa sapere se hai ricevuto l'evento, non se l'hai elaborato adesso. Se rispondi con un errore a un duplicato, il fornitore continua a ritentare per ore o giorni (Stripe fino a tre giorni, secondo la sua documentazione). Rispondere 200 chiude la consegna senza ripetere l'effetto, come mostra il pattern 1.

Posso usare lo schema per tenant con un pool di connessioni?

Sì, ma ogni connessione restituita al pool deve tornare a uno schema neutro, altrimenti la richiesta successiva eredita lo schema della precedente. Nel programma lo fa releaseConnection. Dove la connessione è già legata a uno schema, come nel database per tenant, la guida di Hibernate suggerisce un pool per tenant: valuta il numero di tenant prima di scegliere.

Il retry di Resilience4j e il circuit breaker vanno usati insieme?

Può servire averli entrambi, ma l'ordine in cui li componi cambia il comportamento e non l'ho verificato: nei programmi sopra ho eseguito i due meccanismi separatamente. Prova la combinazione sul tuo servizio con un test come quello del pattern 2, e decidi in base all'uscita, non a una regola presa a memoria.

Che cosa sai fare adesso

Sai scrivere un handler di webhook che regge alle consegne doppie e agli errori a metà, con la riga dell'evento e l'effetto nella stessa transazione. Sai configurare un circuit breaker conoscendo i valori predefiniti che ti sorprenderebbero, e ripetere una chiamata solo quando è sicuro. Sai far rispettare un limite per fascia con un token bucket e dire a un cliente quanto aspettare. Sai separare una modifica di schema in tre passi che non rompono mai la versione vecchia del codice, isolare i dati di un tenant in uno schema senza aprire la porta a un identificatore ostile, e costruire una coda di lavori con attese crescenti e un posto per quelli irrecuperabili. Per ciascuno sai anche dove smette di bastare, e che cosa va verificato sul tuo database prima di fidarti.

Fonti

  • Spring Boot, elenco delle release (api.spring.io) - versione corrente 4.1.2 (flag current), 3.5.16 e 4.0.9 disponibili, 4.2.0-RC2 prerilascio; letta il 2026-11-13. Il BOM 4.1.2 è stato risolto con Maven il 2026-11-13: Spring Framework 7.0.10, Hibernate 7.4.13.Final, H2 2.4.240, Flyway 12.4.0.
  • Stripe, documentazione dei webhook - verifica della firma sul corpo grezzo, risposta 2xx rapida, tracciamento degli ID evento per i duplicati, tolleranza di 5 minuti delle librerie, ritentativi fino a tre giorni in modalità live; letta il 2026-11-13 (la pagina è stata servita in italiano).
  • Resilience4j, documentazione del CircuitBreaker - valori predefiniti (finestra 100, minimo di chiamate 100, soglia 50%, attesa in stato aperto 60 s) e transizione a HALF_OPEN solo su chiamata; letta il 2026-11-13. Versione 2.4.0 ultima su Maven Central il 2026-11-13.
  • Bucket4j 8.22.0, documentazione - refillGreedy e refillIntervally, getNanosToWaitForRefill, withCustomTimePrecision; letta il 2026-11-13. Il README del progetto elenca i moduli distribuiti (JCache, Redis con Lettuce, Jedis, Redisson), letto lo stesso giorno.
  • Hibernate ORM 7.4, User Guide, capitolo Multitenancy - tre approcci (database, schema, discriminante), @TenantId, MultiTenantConnectionProvider, CurrentTenantIdentifierResolver; letto il 2026-11-13 (capitolo 24).
  • PostgreSQL, ALTER TABLE - lock ACCESS EXCLUSIVE salvo diversa indicazione, ADD COLUMN con default non volatile nei metadati; letta il 2026-11-13.
  • PostgreSQL, CREATE INDEX - costruzione standard che blocca le scritture, opzione CONCURRENTLY; letta il 2026-11-13.
  • PostgreSQL, SELECT - FOR UPDATE SKIP LOCKED per tabelle di tipo coda e avvertenza sulla vista incoerente; letta il 2026-11-13.
  • Comandi: javac -parameters e java sotto OpenJDK 21.0.13 su sei programmi, ciascuno eseguito due volte con uscita identica, il 2026-11-13; le versioni delle librerie sono quelle del classpath prodotto da mvn dependency:build-classpath.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti