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. The lines join it to the modules it depends on.
common holds what every module needs: security, audit, events.
auth
membership, a dependency of scheduling
exam
school, a dependency of scheduling
scheduling
vehicle, a dependency of scheduling
finance
common, a dependency of scheduling
quiz
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.
GET /api/schools/{id}/agendarequest
X-School-Idthe school being worked on, next to the token
securitySpring Security
403no active membership in that school
controllerREST endpoint
403the user isn’t a member of the school in the path
servicescheduling module
404path and header name different schools
repositorySpring Data JPA
WHERE school_id = ?every query filters by school
PostgreSQLdatabase
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
schedulinglessons
examexams
callscalls
financecharges
scheduling and exam depend on finance
after
schedulingevent: lesson completed
examevent: exam enrolment
listenslistens
financecharges
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.
one transaction
lesson completedscheduling module
eventin the registry, one row per listener
commit: both or neither
financelistener
1already done?a charge from this event already exists: stop
2create the chargea unique index on the source event allows only one
3constraint violatedif it’s that index, another delivery got there first: ignore it
commonlistener
writes the audit log
commonlistener
notifies the student
after the commit, each listener runs in its own transaction
scheduled jobreads the registry
republishes the events a listener didn’t complete
one transaction
lesson completedscheduling module
eventin the registry, one row per listener
commit: both or neither
financelistener, in its own transaction
already done?1a charge from this event already exists: stop
create the charge2a unique index on the source event allows only one
constraint violated3if 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 jobRepublishes the events a listener didn’t complete.
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.
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.
reads the slotbooksoutcome
Student A
reads the slot: slot · version 7available
books: UPDATE … WHERE version = 71 row updated
outcome: booked · version 8the lesson is theirs
Student B
reads the slot: slot · version 7available
books: UPDATE … WHERE version = 70 rows: the version is already 8
outcome: 409the slot is taken: reload the agenda
Student AStudent B
reads the slot
Student A: version 7available
Student B: version 7available
books: UPDATE … WHERE version = 7
Student A: 1 rowupdated, now version 8
Student B: 0 rowsthe version is already 8
outcome
Student A: bookedthe lesson is theirs
Student B: 409reload 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 001Accepted
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 002Accepted
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.
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 chargeplus the one that loads them
after
two querieshowever 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 backthe revocation ran in the request’s transaction, and the exception behind the 401 rolled it back with everything else
after
keptthe 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.