{
  "slug": "driving-school",
  "title": "Gestionale per autoscuole",
  "label": "gestionale per autoscuole",
  "kind": "personal",
  "tagline": "Monolite modulare con Spring Modulith per più autoscuole sulla stessa installazione.",
  "summary": "Monolite modulare con Spring Modulith per più autoscuole sulla stessa installazione, con i confini fra i moduli verificati dai test.",
  "stack": [
    "Java 21",
    "Spring Boot 3.5",
    "Spring Modulith",
    "PostgreSQL"
  ],
  "status": "stopped",
  "lead": "Un gestionale per autoscuole italiane: allievi, istruttori, guide, esami e pagamenti, per più scuole sulla stessa installazione.",
  "facts": [
    {
      "name": "ruoli",
      "value": "amministrazione, segreteria, istruttore, allievo",
      "href": null
    },
    {
      "name": "codice",
      "value": "repository privato",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "problem",
        "title": "Il problema",
        "paragraphs": [
          "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."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "Com’è fatto",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "modules",
          "modules": [
            "auth",
            "membership",
            "exam",
            "school",
            "scheduling",
            "vehicle",
            "finance",
            "common",
            "quiz"
          ],
          "focus": "scheduling",
          "dependsOn": [
            "membership",
            "school",
            "vehicle",
            "common"
          ],
          "label": "I moduli: scheduling al centro, collegato a membership, school, vehicle e common",
          "focusRole": "il modulo delle guide",
          "notes": [
            {
              "module": "common",
              "text": "tiene quello che serve a tutti i moduli: sicurezza, audit, eventi."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "request",
        "title": "Una richiesta, livello per livello",
        "paragraphs": [
          "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ì."
        ],
        "body": {
          "kind": "request",
          "layers": [
            {
              "name": "GET /api/schools/{id}/agenda",
              "role": "richiesta",
              "note": {
                "status": null,
                "code": "X-School-Id",
                "text": "la scuola su cui si lavora, accanto al token"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "security",
              "role": "Spring Security",
              "note": {
                "status": 403,
                "code": null,
                "text": "nessuna membership attiva in quella scuola"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "controller",
              "role": "endpoint REST",
              "note": {
                "status": 403,
                "code": null,
                "text": "l’utente non è membro della scuola nel path"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "service",
              "role": "modulo scheduling",
              "note": {
                "status": 404,
                "code": null,
                "text": "path e header indicano scuole diverse"
              },
              "focus": true,
              "back": null
            },
            {
              "name": "repository",
              "role": "Spring Data JPA",
              "note": {
                "status": null,
                "code": "WHERE school_id = ?",
                "text": "ogni query filtra per scuola"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "PostgreSQL",
              "role": "database",
              "note": {
                "status": null,
                "code": null,
                "text": "l’indice dell’agenda comincia dalla scuola, così ogni scuola legge solo la sua parte"
              },
              "focus": false,
              "back": null
            }
          ],
          "label": "La richiesta dell’agenda attraverso sei livelli, con le uscite 403 e 404 dei controlli sulla scuola"
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "events",
        "title": "Gli eventi fra i moduli",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "dependencyFlip",
          "label": "Prima scheduling ed exam chiamavano finance; dopo finance ascolta i loro eventi",
          "before": {
            "title": "prima",
            "top": [
              {
                "name": "scheduling",
                "role": "le guide"
              },
              {
                "name": "exam",
                "role": "gli esami"
              }
            ],
            "bottom": {
              "name": "finance",
              "role": "gli addebiti"
            },
            "verb": "chiama",
            "caption": "scheduling ed exam dipendono da finance"
          },
          "after": {
            "title": "dopo",
            "top": [
              {
                "name": "scheduling",
                "role": "evento: guida completata"
              },
              {
                "name": "exam",
                "role": "evento: iscrizione all’esame"
              }
            ],
            "bottom": {
              "name": "finance",
              "role": "gli addebiti"
            },
            "verb": "ascolta",
            "caption": "scheduling ed exam non importano niente da finance"
          }
        },
        "note": null,
        "subsection": {
          "title": "Una guida completata diventa un addebito",
          "paragraphs": [
            "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.",
            "Per questo l’addebito dev’essere idempotente: un controllo prima, un indice unico nel database, e l’errore di quell’indice riconosciuto e ignorato."
          ],
          "body": {
            "kind": "eventFlow",
            "label": "La guida completata e l’evento salvati nella stessa transazione, poi tre listener; l’addebito non si duplica",
            "transaction": {
              "title": "una transazione",
              "blocks": [
                {
                  "title": "guida completata",
                  "role": "modulo scheduling"
                },
                {
                  "title": "evento",
                  "role": "nel registro, una riga per listener"
                }
              ],
              "commit": "commit: tutti e due o nessuno"
            },
            "listeners": [
              {
                "module": "finance",
                "role": "listener",
                "outcome": null
              },
              {
                "module": "common",
                "role": "listener",
                "outcome": "scrive l’audit"
              },
              {
                "module": "common",
                "role": "listener",
                "outcome": "avvisa l’allievo"
              }
            ],
            "defences": [
              {
                "title": "già fatto?",
                "text": "c’è già un addebito nato da questo evento: si ferma"
              },
              {
                "title": "crea l’addebito",
                "text": "un indice unico sull’evento d’origine ne ammette uno solo"
              },
              {
                "title": "vincolo violato",
                "text": "se è quell’indice, un’altra consegna è arrivata prima: si ignora"
              }
            ],
            "caption": "dopo il commit, ogni listener lavora nella sua transazione",
            "job": {
              "title": "job pianificato",
              "role": "legge il registro",
              "text": "ripubblica gli eventi che un listener non ha completato"
            },
            "walk": {
              "label": "La guida completata diventa un addebito, protetto da tre controlli",
              "listener": "listener, nella sua transazione",
              "others": "Gli altri due listener scrivono l’audit e avvisano l’allievo, ognuno nella sua transazione.",
              "job": "Ripubblica gli eventi che un listener non ha completato."
            }
          }
        }
      },
      {
        "id": "concurrent-booking",
        "title": "Due clic sullo stesso slot",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "raceLanes",
          "label": "Due allievi leggono lo stesso slot alla versione 7: il primo prenota, il secondo riceve 409",
          "columns": [
            "legge lo slot",
            "prenota",
            "esito"
          ],
          "lanes": [
            {
              "name": "Allievo A",
              "steps": [
                {
                  "head": "slot · versione 7",
                  "text": "libero",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "UPDATE … WHERE version = 7",
                  "text": "1 riga aggiornata",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "prenotato · versione 8",
                  "text": "la guida è sua",
                  "tone": "success",
                  "focus": false
                }
              ],
              "lit": false
            },
            {
              "name": "Allievo B",
              "steps": [
                {
                  "head": "slot · versione 7",
                  "text": "libero",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "UPDATE … WHERE version = 7",
                  "text": "0 righe: la versione è già 8",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "409",
                  "text": "lo slot è già preso: ricarica l’agenda",
                  "tone": "danger",
                  "focus": false
                }
              ],
              "lit": true
            }
          ],
          "defences": [
            {
              "title": "applicazione",
              "text": "Nessuna sovrapposizione di orari, per l’istruttore e per l’allievo."
            },
            {
              "title": "database",
              "text": "Un indice unico parziale: una sola prenotazione attiva per slot."
            },
            {
              "title": "concorrenza",
              "text": "Una colonna di versione sullo slot: chi scrive per secondo riceve 409."
            }
          ],
          "walk": {
            "columns": [
              "legge lo slot",
              "prenota: UPDATE … WHERE version = 7",
              "esito"
            ],
            "lanes": [
              [
                {
                  "head": "versione 7",
                  "text": "libero",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "1 riga",
                  "text": "aggiornata, ora versione 8",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "prenotato",
                  "text": "la guida è sua",
                  "tone": "success",
                  "focus": false
                }
              ],
              [
                {
                  "head": "versione 7",
                  "text": "libero",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "0 righe",
                  "text": "la versione è già 8",
                  "tone": null,
                  "focus": true
                },
                {
                  "head": "409",
                  "text": "ricarica l’agenda",
                  "tone": "danger",
                  "focus": false
                }
              ]
            ]
          }
        },
        "note": "Nessun retry lato server: lo slot ormai è di un altro, e la scelta torna all’allievo.",
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisioni",
        "paragraphs": [
          "Le altre scelte, con il loro perché."
        ],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "Ruoli per scuola, letti dal database",
              "context": "La stessa persona può essere allievo in una scuola e istruttore in un’altra, e una sospensione deve valere subito.",
              "decision": "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.",
              "consequences": "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."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "Il filtro per scuola, esplicito in ogni query",
              "context": "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.",
              "decision": "La scuola è un parametro esplicito di ogni metodo dei repository, invece di un filtro automatico.",
              "consequences": "Il filtro si vede nella firma di ogni metodo, e un test controlla che nessun repository lo dimentichi. Il prezzo è ripeterlo in ogni metodo."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "Il denaro come registro, in centesimi",
              "context": "Pagamenti parziali, rate, addebiti condonati, credito che avanza.",
              "decision": "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.",
              "consequences": "Rate e condoni sono righe come le altre, senza campi speciali. Il saldo è una vista sul registro, non un campo da tenere allineato."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "solved-problems",
        "title": "Problemi risolti",
        "paragraphs": [
          "Cosa non andava, e com’è adesso."
        ],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "Il FIFO dei pagamenti",
              "text": "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.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "una query per addebito",
                  "text": "più quella che li carica"
                },
                "after": {
                  "value": "due query",
                  "text": "comunque siano tanti gli addebiti"
                }
              }
            },
            {
              "title": "La revoca dei token",
              "text": "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.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "annullata",
                  "text": "la revoca stava nella transazione della richiesta, e l’eccezione che diventava 401 la annullava con il resto"
                },
                "after": {
                  "value": "resta",
                  "text": "la revoca ha una transazione sua, chiusa prima del 401"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "verification",
        "title": "Come lo verifico",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "checks",
          "checks": [
            "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"
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "Cosa ho imparato",
        "paragraphs": [
          "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."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/it/projects/driving-school"
    },
    "alternate": {
      "href": "/en/projects/driving-school",
      "hreflang": "en"
    },
    "up": {
      "href": "/it/projects"
    },
    "next": {
      "href": "/it/projects/travel-agency",
      "title": "Gestionale per agenzia viaggi"
    }
  }
}
