Salta al contenuto

GET/it/projects/driving-school

Gestionale per autoscuole

Un gestionale per autoscuole italiane: allievi, istruttori, guide, esami e pagamenti, per più scuole sulla stessa installazione.

stack
Java 21, Spring Boot 3.5, Spring Modulith, PostgreSQL
ruoli
amministrazione, segreteria, istruttore, allievo
codice
repository privato
stato
Fermo

Il problema

L’idea viene dagli anni in cui ho lavorato in autoscuola. Doveva essere sia un prodotto sia un progetto di studio, ed è rimasto il secondo: l’ambiente di sviluppo della Motorizzazione è aperto solo alle software house.

Più scuole condividono la stessa installazione, e la stessa persona può essere istruttore in una scuola e allievo in un’altra. L’obiettivo era tenere separati i dati di ogni scuola, e i confini fra le parti del dominio, mentre il progetto cresceva.

È partito con dei compagni dell’ITS e l’ho portato avanti da solo. Il disegno, gli obiettivi e le scelte sono miei; il codice l’ho scritto con l’aiuto di Claude Code.

Com’è fatto

Un’applicazione sola, divisa in moduli per area del dominio. Ogni modulo dichiara da chi dipende e cosa espone, e Spring Modulith lo verifica in un test: una dipendenza non dichiarata lo fa fallire.

In evidenza scheduling, il modulo delle guide, con i moduli da cui dipende.

scheduling

dipende da

  • membership
  • school
  • vehicle
  • common

Gli altri moduli: auth, exam, finance, quiz.

common tiene quello che serve a tutti i moduli: sicurezza, audit, eventi.

Una richiesta, livello per livello

L’agenda di una settimana, dal client al database. Tutte le scuole stanno nello stesso database, quindi ogni livello controlla che la richiesta resti dentro la sua scuola, e la ferma se non è così.

  1. GET /api/schools/{id}/agenda richiesta

    X-School-Id la scuola su cui si lavora, accanto al token

  2. security Spring Security

    403 nessuna membership attiva in quella scuola

  3. controller endpoint REST

    403 l’utente non è membro della scuola nel path

  4. service modulo scheduling

    404 path e header indicano scuole diverse

  5. repository Spring Data JPA

    WHERE school_id = ? ogni query filtra per scuola

  6. PostgreSQL database

    l’indice dell’agenda comincia dalla scuola, così ogni scuola legge solo la sua parte

Gli eventi fra i moduli

Chiudere una guida crea un addebito, scrive l’audit e avvisa l’allievo. Prima scheduling chiamava finance; ora pubblica un evento, e finance lo ascolta, così il modulo delle guide non dipende più da quello dei pagamenti.

  • prima

    scheduling le guide

    exam gli esami

    finance gli addebiti

    scheduling ed exam dipendono da finance

  • dopo

    scheduling evento: guida completata

    exam evento: iscrizione all’esame

    finance gli addebiti

    scheduling ed exam non importano niente da finance

Una guida completata diventa un addebito

L’evento si salva nella stessa transazione della guida, nel registro di Spring Modulith, che sta nello stesso PostgreSQL: niente broker. Se un listener fallisce, un job ripubblica l’evento, quindi la consegna è almeno una volta.

  1. una transazione

    guida completata modulo scheduling

    evento nel registro, una riga per listener

    commit: tutti e due o nessuno

  2. finance listener, nella sua transazione

  3. già fatto? 1 c’è già un addebito nato da questo evento: si ferma

  4. crea l’addebito 2 un indice unico sull’evento d’origine ne ammette uno solo

  5. vincolo violato 3 se è quell’indice, un’altra consegna è arrivata prima: si ignora

    Gli altri due listener scrivono l’audit e avvisano l’allievo, ognuno nella sua transazione.

job pianificato Ripubblica gli eventi che un listener non ha completato.

Due clic sullo stesso slot

Due allievi possono prenotare lo stesso slot nello stesso momento. Decide un lock ottimistico: lo slot ha una colonna di versione, e la scrittura passa solo se la versione è ancora quella letta.

  1. legge lo slot

    • Allievo A: versione 7 libero
    • Allievo B: versione 7 libero
  2. prenota: UPDATE … WHERE version = 7

    • Allievo A: 1 riga aggiornata, ora versione 8
    • Allievo B: 0 righe la versione è già 8
  3. esito

    • Allievo A: prenotato la guida è sua
    • Allievo B: 409 ricarica l’agenda
  1. applicazione. Nessuna sovrapposizione di orari, per l’istruttore e per l’allievo.

  2. database. Un indice unico parziale: una sola prenotazione attiva per slot.

  3. concorrenza. Una colonna di versione sullo slot: chi scrive per secondo riceve 409.

Nessun retry lato server: lo slot ormai è di un altro, e la scelta torna all’allievo.

Decisioni

Le altre scelte, con il loro perché.

  • ADR 001 Accettata

    Ruoli per scuola, letti dal database

    contesto
    La stessa persona può essere allievo in una scuola e istruttore in un’altra, e una sospensione deve valere subito.
    decisione
    Il token porta solo l’identità dell’utente. Ruolo e stato si leggono dalla membership a ogni richiesta, per la scuola su cui si lavora.
    conseguenze
    Una sospensione vale dalla richiesta dopo, non alla scadenza del token, e un token solo basta per tutte le scuole. Il prezzo sono letture in più a ogni richiesta.
  • ADR 002 Accettata

    Il filtro per scuola, esplicito in ogni query

    contesto
    Tutte le scuole stanno nello stesso database, e una query senza filtro mostrerebbe i dati di un’altra scuola. Un filtro automatico di Hibernate non copre le query native, e se manca su un’entità nuova non se ne accorge nessuno.
    decisione
    La scuola è un parametro esplicito di ogni metodo dei repository, invece di un filtro automatico.
    conseguenze
    Il filtro si vede nella firma di ogni metodo, e un test controlla che nessun repository lo dimentichi. Il prezzo è ripeterlo in ogni metodo.
  • ADR 003 Accettata

    Il denaro come registro, in centesimi

    contesto
    Pagamenti parziali, rate, addebiti condonati, credito che avanza.
    decisione
    Addebiti e pagamenti separati, collegati da allocazioni, con importi interi in centesimi. Se un pagamento non dice cosa copre, copre gli addebiti aperti dal più vecchio.
    conseguenze
    Rate e condoni sono righe come le altre, senza campi speciali. Il saldo è una vista sul registro, non un campo da tenere allineato.

Problemi risolti

Cosa non andava, e com’è adesso.

  • Il FIFO dei pagamenti

    Un pagamento senza allocazione copre gli addebiti aperti dal più vecchio. Per sapere quanto restava su ognuno chiedevo al database la somma già pagata, un addebito alla volta: un N+1. Ora una query sola prende le somme di tutti prima del ciclo, e un test conta le chiamate.

    prima
    una query per addebito più quella che li carica
    dopo
    due query comunque siano tanti gli addebiti
  • La revoca dei token

    Il refresh token cambia a ogni uso, e se ne torna uno già usato è il segno di un furto: revoco tutti quelli dell’utente e rispondo 401. Un test di integrazione ha trovato che la revoca non restava.

    prima
    annullata la revoca stava nella transazione della richiesta, e l’eccezione che diventava 401 la annullava con il resto
    dopo
    resta la revoca ha una transazione sua, chiusa prima del 401

Come lo verifico

Nella prima versione i test giravano su H2. Ma lo schema usa i tipi enum e gli indici unici parziali di PostgreSQL, e sono proprio quegli indici a fermare le doppie prenotazioni e i doppi addebiti: ora i test di integrazione girano su un PostgreSQL vero, in un container.

  • Un test crea dati in due scuole e controlla che nessuna veda quelli dell’altra
  • Un test consegna due volte lo stesso evento e trova un addebito solo
  • La finestra di cancellazione è testata sul cambio dell’ora legale, con un orologio iniettato al posto dell’ora di sistema
  • Le migrazioni vanno solo in avanti, e all’avvio lo schema si valida invece di aggiornarsi da solo

Cosa ho imparato

Finché i confini fra i moduli erano una convenzione, li rispettavo meno di quanto pensassi: la prima verifica di Spring Modulith ha trovato dei cicli che non avevo visto. Gli eventi tolgono una dipendenza, ma la consegna diventa almeno una volta, e ogni listener deve reggere lo stesso evento più di una volta.