{
  "slug": "driving-school",
  "title": "Driving school management system",
  "label": "driving school system",
  "kind": "personal",
  "tagline": "A modular monolith on Spring Modulith serving several driving schools from one installation.",
  "summary": "A modular monolith on Spring Modulith serving several driving schools from one installation, with module boundaries checked by tests.",
  "stack": [
    "Java 21",
    "Spring Boot 3.5",
    "Spring Modulith",
    "PostgreSQL"
  ],
  "status": "stopped",
  "lead": "A management system for Italian driving schools: students, instructors, lessons, exams and payments, for several schools on one installation.",
  "facts": [
    {
      "name": "roles",
      "value": "administration, office staff, instructor, student",
      "href": null
    },
    {
      "name": "code",
      "value": "private repository",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "problem",
        "title": "The problem",
        "paragraphs": [
          "The idea comes from the years I worked at a driving school. It was meant to be both a product and a study project, and it stayed the latter: the development environment of the Motorizzazione, the Italian licensing authority, is open only to software companies.",
          "Several schools share the same installation, and the same person can be an instructor at one school and a student at another. The goal was to keep each school’s data apart, and the boundaries between the parts of the domain, while the project grew.",
          "It started with some classmates from my ITS course, and I carried it on alone. The design, the goals and the choices are mine; I wrote the code with the help of Claude Code."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "How it’s built",
        "paragraphs": [
          "A single application, split into modules by area of the domain. Each module declares what it depends on and what it exposes, and Spring Modulith checks it in a test: an undeclared dependency makes it fail."
        ],
        "body": {
          "kind": "modules",
          "modules": [
            "auth",
            "membership",
            "exam",
            "school",
            "scheduling",
            "vehicle",
            "finance",
            "common",
            "quiz"
          ],
          "focus": "scheduling",
          "dependsOn": [
            "membership",
            "school",
            "vehicle",
            "common"
          ],
          "label": "The modules: scheduling in the middle, linked to membership, school, vehicle and common",
          "focusRole": "the lessons module",
          "notes": [
            {
              "module": "common",
              "text": "holds what every module needs: security, audit, events."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "request",
        "title": "One request, layer by layer",
        "paragraphs": [
          "A week’s agenda, from the client to the database. Every school lives in the same database, so each layer checks that the request stays inside its school, and stops it if it doesn’t."
        ],
        "body": {
          "kind": "request",
          "layers": [
            {
              "name": "GET /api/schools/{id}/agenda",
              "role": "request",
              "note": {
                "status": null,
                "code": "X-School-Id",
                "text": "the school being worked on, next to the token"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "security",
              "role": "Spring Security",
              "note": {
                "status": 403,
                "code": null,
                "text": "no active membership in that school"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "controller",
              "role": "REST endpoint",
              "note": {
                "status": 403,
                "code": null,
                "text": "the user isn’t a member of the school in the path"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "service",
              "role": "scheduling module",
              "note": {
                "status": 404,
                "code": null,
                "text": "path and header name different schools"
              },
              "focus": true,
              "back": null
            },
            {
              "name": "repository",
              "role": "Spring Data JPA",
              "note": {
                "status": null,
                "code": "WHERE school_id = ?",
                "text": "every query filters by school"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "PostgreSQL",
              "role": "database",
              "note": {
                "status": null,
                "code": null,
                "text": "the agenda index starts with the school, so each school reads only its own part"
              },
              "focus": false,
              "back": null
            }
          ],
          "label": "The agenda request through six layers, with the 403 and 404 exits of the school checks"
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "events",
        "title": "Events between modules",
        "paragraphs": [
          "Closing a lesson creates a charge, writes the audit log and notifies the student. Scheduling used to call finance; now it publishes an event and finance listens, so the lessons module no longer depends on the payments one."
        ],
        "body": {
          "kind": "dependencyFlip",
          "label": "Before, scheduling and exam called finance; after, finance listens to their events",
          "before": {
            "title": "before",
            "top": [
              {
                "name": "scheduling",
                "role": "lessons"
              },
              {
                "name": "exam",
                "role": "exams"
              }
            ],
            "bottom": {
              "name": "finance",
              "role": "charges"
            },
            "verb": "calls",
            "caption": "scheduling and exam depend on finance"
          },
          "after": {
            "title": "after",
            "top": [
              {
                "name": "scheduling",
                "role": "event: lesson completed"
              },
              {
                "name": "exam",
                "role": "event: exam enrolment"
              }
            ],
            "bottom": {
              "name": "finance",
              "role": "charges"
            },
            "verb": "listens",
            "caption": "scheduling and exam import nothing from finance"
          }
        },
        "note": null,
        "subsection": {
          "title": "A completed lesson becomes a charge",
          "paragraphs": [
            "The event is saved in the same transaction as the lesson, in Spring Modulith’s registry, which lives in the same PostgreSQL: no broker. If a listener fails, a job republishes the event, so delivery is at least once.",
            "That’s why the charge has to be idempotent: a check first, a unique index in the database, and that index’s error recognised and ignored."
          ],
          "body": {
            "kind": "eventFlow",
            "label": "The completed lesson and its event saved in one transaction, then three listeners; the charge is never duplicated",
            "transaction": {
              "title": "one transaction",
              "blocks": [
                {
                  "title": "lesson completed",
                  "role": "scheduling module"
                },
                {
                  "title": "event",
                  "role": "in the registry, one row per listener"
                }
              ],
              "commit": "commit: both or neither"
            },
            "listeners": [
              {
                "module": "finance",
                "role": "listener",
                "outcome": null
              },
              {
                "module": "common",
                "role": "listener",
                "outcome": "writes the audit log"
              },
              {
                "module": "common",
                "role": "listener",
                "outcome": "notifies the student"
              }
            ],
            "defences": [
              {
                "title": "already done?",
                "text": "a charge from this event already exists: stop"
              },
              {
                "title": "create the charge",
                "text": "a unique index on the source event allows only one"
              },
              {
                "title": "constraint violated",
                "text": "if it’s that index, another delivery got there first: ignore it"
              }
            ],
            "caption": "after the commit, each listener runs in its own transaction",
            "job": {
              "title": "scheduled job",
              "role": "reads the registry",
              "text": "republishes the events a listener didn’t complete"
            },
            "walk": {
              "label": "The completed lesson becomes a charge, guarded by three checks",
              "listener": "listener, in its own transaction",
              "others": "The other two listeners write the audit log and notify the student, each in its own transaction.",
              "job": "Republishes the events a listener didn’t complete."
            }
          }
        }
      },
      {
        "id": "concurrent-booking",
        "title": "Two clicks on the same slot",
        "paragraphs": [
          "Two students can book the same slot at the same moment. Optimistic locking settles it: the slot has a version column, and the write goes through only if the version is still the one that was read."
        ],
        "body": {
          "kind": "raceLanes",
          "label": "Two students read the same slot at version 7: the first books it, the second gets 409",
          "columns": [
            "reads the slot",
            "books",
            "outcome"
          ],
          "lanes": [
            {
              "name": "Student A",
              "steps": [
                {
                  "head": "slot · version 7",
                  "text": "available",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "UPDATE … WHERE version = 7",
                  "text": "1 row updated",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "booked · version 8",
                  "text": "the lesson is theirs",
                  "tone": "success",
                  "focus": false
                }
              ],
              "lit": false
            },
            {
              "name": "Student B",
              "steps": [
                {
                  "head": "slot · version 7",
                  "text": "available",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "UPDATE … WHERE version = 7",
                  "text": "0 rows: the version is already 8",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "409",
                  "text": "the slot is taken: reload the agenda",
                  "tone": "danger",
                  "focus": false
                }
              ],
              "lit": true
            }
          ],
          "defences": [
            {
              "title": "application",
              "text": "No overlapping times, for the instructor or for the student."
            },
            {
              "title": "database",
              "text": "A partial unique index: one active booking per slot."
            },
            {
              "title": "concurrency",
              "text": "A version column on the slot: whoever writes second gets 409."
            }
          ],
          "walk": {
            "columns": [
              "reads the slot",
              "books: UPDATE … WHERE version = 7",
              "outcome"
            ],
            "lanes": [
              [
                {
                  "head": "version 7",
                  "text": "available",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "1 row",
                  "text": "updated, now version 8",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "booked",
                  "text": "the lesson is theirs",
                  "tone": "success",
                  "focus": false
                }
              ],
              [
                {
                  "head": "version 7",
                  "text": "available",
                  "tone": null,
                  "focus": false
                },
                {
                  "head": "0 rows",
                  "text": "the version is already 8",
                  "tone": null,
                  "focus": true
                },
                {
                  "head": "409",
                  "text": "reload the agenda",
                  "tone": "danger",
                  "focus": false
                }
              ]
            ]
          }
        },
        "note": "No server-side retry: the slot belongs to someone else by now, and the choice goes back to the student.",
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisions",
        "paragraphs": [
          "The other choices, and why."
        ],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "Per-school roles, read from the database",
              "context": "The same person can be a student at one school and an instructor at another, and a suspension has to apply at once.",
              "decision": "The token carries only the user’s identity. Role and status are read from the membership on every request, for the school being worked on.",
              "consequences": "A suspension applies from the next request, not when the token expires, and one token works for every school. The cost is extra reads on every request."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "The school filter, explicit in every query",
              "context": "Every school lives in the same database, and a query without the filter would show another school’s data. An automatic Hibernate filter doesn’t cover native queries, and if it’s missing on a new entity nobody notices.",
              "decision": "The school is an explicit parameter of every repository method, instead of an automatic filter.",
              "consequences": "The filter shows in every method’s signature, and a test checks that no repository forgets it. The cost is repeating it in every method."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "Money as a ledger, in cents",
              "context": "Partial payments, instalments, waived charges, leftover credit.",
              "decision": "Charges and payments kept apart, linked by allocations, with integer amounts in cents. If a payment doesn’t say what it covers, it covers the open charges, oldest first.",
              "consequences": "Instalments and waivers are rows like any other, with no special fields. The balance is a view over the ledger, not a field to keep in sync."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "solved-problems",
        "title": "Problems solved",
        "paragraphs": [
          "What was wrong, and how it is now."
        ],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "FIFO payment allocation",
              "text": "A payment with no allocation covers the open charges, oldest first. To know what was left on each, I asked the database for the amount already paid, one charge at a time: an N+1. Now a single query fetches all the sums before the loop, and a test counts the calls.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "one query per charge",
                  "text": "plus the one that loads them"
                },
                "after": {
                  "value": "two queries",
                  "text": "however many charges there are"
                }
              }
            },
            {
              "title": "Token revocation",
              "text": "The refresh token rotates on every use, and a reused one is a sign of theft: I revoke all of the user’s tokens and answer 401. An integration test found that the revocation didn’t stick.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "rolled back",
                  "text": "the revocation ran in the request’s transaction, and the exception behind the 401 rolled it back with everything else"
                },
                "after": {
                  "value": "kept",
                  "text": "the revocation has its own transaction, committed before the 401"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "verification",
        "title": "How it’s tested",
        "paragraphs": [
          "In the first version the tests ran on H2. But the schema uses PostgreSQL enum types and partial unique indexes, and those indexes are exactly what stops double bookings and double charges: now the integration tests run on a real PostgreSQL, in a container."
        ],
        "body": {
          "kind": "checks",
          "checks": [
            "A test creates data in two schools and checks that neither sees the other’s",
            "A test delivers the same event twice and finds a single charge",
            "The cancellation window is tested across daylight saving changes, with an injected clock instead of system time",
            "Migrations only go forward, and at startup the schema is validated instead of updated"
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "What I learned",
        "paragraphs": [
          "As long as the module boundaries were a convention, I kept to them less than I thought: the first Spring Modulith check found cycles I hadn’t seen. Events remove a dependency, but delivery becomes at-least-once, and every listener has to handle the same event more than once."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/en/projects/driving-school"
    },
    "alternate": {
      "href": "/it/projects/driving-school",
      "hreflang": "it"
    },
    "up": {
      "href": "/en/projects"
    },
    "next": {
      "href": "/en/projects/travel-agency",
      "title": "Travel agency management system"
    }
  }
}
