Skip to content

GET/en/projects/driving-school

Driving school management system

A management system for Italian driving schools: students, instructors, lessons, exams and payments, for several schools on one installation.

stack
Java 21, Spring Boot 3.5, Spring Modulith, PostgreSQL
roles
administration, office staff, instructor, student
code
private repository
status
Stopped

The problem

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.

How it’s built

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.

Highlighted: scheduling, the lessons module, with the modules it depends on.

scheduling

depends on

  • membership
  • school
  • vehicle
  • common

The other modules: auth, exam, finance, quiz.

common holds what every module needs: security, audit, events.

One request, layer by layer

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.

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

    X-School-Id the school being worked on, next to the token

  2. security Spring Security

    403 no active membership in that school

  3. controller REST endpoint

    403 the user isn’t a member of the school in the path

  4. service scheduling module

    404 path and header name different schools

  5. repository Spring Data JPA

    WHERE school_id = ? every query filters by school

  6. PostgreSQL database

    the agenda index starts with the school, so each school reads only its own part

Events between modules

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.

  • before

    scheduling lessons

    exam exams

    finance charges

    scheduling and exam depend on finance

  • after

    scheduling event: lesson completed

    exam event: exam enrolment

    finance charges

    scheduling and exam import nothing from finance

A completed lesson becomes a charge

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.

  1. one transaction

    lesson completed scheduling module

    event in the registry, one row per listener

    commit: both or neither

  2. finance listener, in its own transaction

  3. already done? 1 a charge from this event already exists: stop

  4. create the charge 2 a unique index on the source event allows only one

  5. constraint violated 3 if it’s that index, another delivery got there first: ignore it

    The other two listeners write the audit log and notify the student, each in its own transaction.

scheduled job Republishes the events a listener didn’t complete.

Two clicks on the same slot

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.

  1. reads the slot

    • Student A: version 7 available
    • Student B: version 7 available
  2. books: UPDATE … WHERE version = 7

    • Student A: 1 row updated, now version 8
    • Student B: 0 rows the version is already 8
  3. outcome

    • Student A: booked the lesson is theirs
    • Student B: 409 reload the agenda
  1. application. No overlapping times, for the instructor or for the student.

  2. database. A partial unique index: one active booking per slot.

  3. concurrency. A version column on the slot: whoever writes second gets 409.

No server-side retry: the slot belongs to someone else by now, and the choice goes back to the student.

Decisions

The other choices, and why.

  • ADR 001 Accepted

    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.
  • ADR 002 Accepted

    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.
  • ADR 003 Accepted

    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.

Problems solved

What was wrong, and how it is now.

  • FIFO payment allocation

    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.

    before
    one query per charge plus the one that loads them
    after
    two queries however many charges there are
  • Token revocation

    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.

    before
    rolled back the revocation ran in the request’s transaction, and the exception behind the 401 rolled it back with everything else
    after
    kept the revocation has its own transaction, committed before the 401

How it’s tested

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.

  • 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

What I learned

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.