Iter
Richieste di ferie con un workflow di approvazione a tre ruoli.

Il problema
Le approvazioni hanno bisogno di regole chiare su chi può fare cosa, e di una traccia di chi ha approvato cosa e quando. Se quelle regole vivono solo nell’interfaccia, chiunque abbia un client REST può saltarle.
- Stato
- Completato, demo online con utenti di prova
- Stack
- Java 21, Spring Boot 3, Spring Security, PostgreSQL 16, Flyway, React 19, TanStack Query, Testcontainers, Docker
- Facts
- 32 metodi di test su Postgres vero
- 7 endpoint REST
- CI a ogni push
Le scelte che vale la pena spiegare
Una sola macchina a stati decide anche chi può agire
La macchina a stati non dipende da Spring. Ogni coppia stato e azione porta a una transizione che indica lo stato successivo, il ruolo autorizzato e se serve una motivazione. Un’azione non consentita dallo stato attuale risponde 409 Conflict.
Ogni transizione lascia una traccia
Ogni transizione scrive una riga di evento con stato precedente, stato nuovo, autore e motivazione, nella stessa transazione del cambiamento. Se la traccia non si può scrivere, il cambiamento non avviene.
Il ruolo dipende dalla richiesta, non dal titolo
Il ruolo di chi agisce si calcola dal suo rapporto con la richiesta, proprietario o responsabile diretto, non dal ruolo globale. Un responsabile non può approvare le proprie ferie e non vede le bozze non ancora inviate. I test di integrazione girano su un Postgres vero con Testcontainers.
Un pezzo del codice
Stato, ruolo e motivazione si controllano in un solo punto, senza framework di mezzo.
public StatoRichiesta applica(StatoRichiesta statoCorrente, Azione azione, RuoloAttore ruoloAttore, String motivazione) {
Transizione transizione = TRANSIZIONI.get(statoCorrente).get(azione);
if (transizione == null) {
throw new TransizioneNonConsentitaException(
"L'azione %s non è consentita dallo stato %s".formatted(azione, statoCorrente));
}
if (transizione.ruoloRichiesto() != ruoloAttore) {
throw new TransizioneNonConsentitaException(
"L'azione %s richiede il ruolo %s, non %s".formatted(azione, transizione.ruoloRichiesto(), ruoloAttore));
}
if (transizione.motivazioneObbligatoria() && (motivazione == null || motivazione.isBlank())) {
throw new TransizioneNonConsentitaException(
"L'azione %s richiede una motivazione".formatted(azione));
}
return transizione.statoSuccessivo();
}