Case study
AU-Van
A van booking app inside LINE that never sells the same seat twice.
I rebuilt my van seat booking system for students at my university. Students hold a seat, upload a payment slip and get updates without leaving LINE. Staff check payments on an admin site. The database decides who gets each seat. My tests prove it by making students race for one seat.
- Role
- Solo developer: product, backend, frontend and servers
- Timeline
- Sep - Oct 2026 · 433 commits
- Status
- Live demo; it updates every time main passes all checks
- Type
- Personal project · rebuild of an older version
- students race for one seat and exactly one wins
- 8 → 1
- Java, web and end-to-end tests
- 430 / 303 / 10
- health checks passed while AWS swapped in a new version
- 362 / 362
- big design choices written down with reasons
- 19
01
The problem
Students use AU-Van to book seats on the campus vans. My first version used Next.js and MongoDB. It worked when nothing went wrong. But it was weak right where money and seats meet:
- Two students could get the same seat if they tapped at the same moment.
- Payment slips were public. A slip can show a bank account number. Anyone with the link could open it.
- Messages could get lost. A booking could save while its notification quietly failed. Nothing recorded it.
- Unpaid bookings never expired. A seat could stay "waiting for payment" until the van left. There was no waitlist either.
Students book on phones, on campus Wi-Fi, inside LINE. Connections drop and people tap twice. So I rebuilt the system around one rule: when two people want the same thing, the database picks exactly one winner.
02
How it fits together
AU-Van has one backend and one website. The backend is a Spring Boot API (Java 21). The website is built with React. It shows both the student pages inside LINE and the staff portal. I'm the only developer, so one app is easier to build and test.
- Login: LINE tells the app who the student is. The API checks this with LINE once. Then it gives the student a login pass that lasts 15 minutes.
- PostgreSQL 17 (the database) has the final say. Every important rule lives inside it.
- Payment slips go to private file storage. Staff see them only through the API.
- Background jobs send messages, cancel unpaid bookings and move the waitlist along. They run inside the API and read from the database.
03
One seat, one row
The heart of the design is one table, seat_claims. The database allows only one row per seat. If a second row for the same seat comes in, the database itself refuses it.
When a student picks a seat, the API adds a row that holds it for 5 minutes. If someone got there first, the database says no. The student sees "seat taken". An expired hold is simply ignored. The next student's row replaces it.
When the student confirms, the API turns that same row into a booking. So the seat is protected the whole time, from "held" to "booked". The app checks things too. It only does that to show a friendly message. The database decides every race.
04
Booking once, even on bad Wi-Fi
The one-row rule stops two holds on one seat. But confirming a booking changes a row that already exists. Two confirms at the same moment could both pass and make two bookings. So confirming has two extra safeguards:
- Sending again is safe. Each confirm request carries its own key. The API saves its answer together with the booking. If the same request comes again, it gets the same answer. No new booking is made.
- One at a time. The API locks the hold's rows before checking them. A second request must wait until the first is done.
Every error also comes with a short code, so the website can show the right message.
05
Payments: private slips and a real deadline
Students pay by bank transfer and upload a picture of the slip (up to 5 MB). The API saves the file first. Then it moves the booking to PAYMENT_UNDER_REVIEW. Staff see the image only through the API, never through a public link.
The rebuild also added a new rule: a payment deadline. Students must pay within two hours and no later than one hour before the van leaves. Every minute, a job finds late bookings. It cancels them, frees their seats and sends a message. This rule can take a seat away from a student, so my notes say it must be clearly announced.
06
Messages that don't get lost
In AU-Van, every booking change also saves a "message to send" row at the same time. Either both are saved, or neither is. This pattern is called an outbox.
Every 5 seconds, a sender picks up the messages that are due. It marks them "being sent" for two minutes, so no other sender takes them. Then it sends them to LINE. Each message has its own ID. LINE uses it to ignore repeats. So the student gets each message only once.
- LINE says OK, or "already got it" → SENT
- LINE is busy, down or silent → back to PENDING. It tries again later and waits longer each time.
- Five failures, or a clear "no" (for example, the student never added the LINE account) → DEAD. Staff can see it.
Ten kinds of messages go through it, from "booking created" to reminders 24 hours and 1 hour before the trip.
07
A fair waitlist
Students can join the waitlist only when a trip is full. The most common way a seat frees up is a 5-minute hold running out. That sends no signal at all. So I don't wait for a "seat released" signal.
Instead, every minute a job gives the next student in line a seat hold. It lasts 30 minutes (or until an hour before the trip). It's just another hold, so if it races a direct booking, the same database rule decides. The job stops at the first failure, so nobody skips ahead. You get one chance. If you leave and join again, you go to the back.
08
Proving it with tests
It's easy to say a system works. So I spent most of my effort on tests that try to break it.
- 430 Java tests check the API. Most run in seconds on a fast test database. Some start eight users at the exact same moment and check that only one gets the seat.
- Tests on real PostgreSQL check the most important races again. Eight confirms of one hold make exactly one booking.
- 303 website tests cover booking and payment review.
- 10 end-to-end tests use Playwright, which clicks through the real app. They book, pay, get approved or rejected and race a second student for a seat.
- Break-it checks: I delete each safety check, watch a test fail, then put it back.
Two bugs showed up only when the whole system ran. First, nginx blocked files over 1 MB, so real payment slips failed. Second, the scripts that build the database (Flyway) had never run. Tests passed because another tool made the tables. On real PostgreSQL, the app had no tables at all. I fixed it and added a test for it.
09
Servers: a real AWS setup
I also wrote a full AWS setup in Terraform, a tool that describes servers as code:
- CloudFront (a CDN) sits in front. API calls are never cached, so one student can't be shown another student's bookings.
- The app runs on ECS Fargate in two data centers. It updates one copy at a time.
- RDS PostgreSQL 17 holds the data. Slips go in a private, encrypted bucket.
- There is no NAT gateway, to keep costs down.
I launched it once, with two app copies and a backup database. 362 of 362 health checks passed while AWS swapped in a new version. Then I deleted it the same day.
The always-on demo runs on one small EC2 server with Docker Compose. Every time main passes all checks, the new version goes live by itself. A nightly backup keeps one week of copies.
10
Eight checks on every pull request
Eight automatic checks run on every pull request:
- Website: style check, tests and a build.
- Commit messages: one clear format.
- API: all tests.
- Database races: the real PostgreSQL tests.
- Infrastructure: Terraform checks, with no AWS login.
- Deploy scripts: checked with ShellCheck.
- Containers: both images build, don't run as the admin user (root) and start healthy.
- End to end: the Playwright tests.
Three of them (website, commit messages and API) must pass before code can merge. A secret scanner also watches the code for leaked passwords and keys.
11
Trade-offs and next steps
- There is only one server. If it goes down, the demo goes down. Its backups are on the same disk too. The Terraform setup is ready if real traffic comes.
- Some races are tested only on the fast test database. Waitlist, payment review and deadline races should also run on real PostgreSQL.
- Timed jobs mean some waiting. Waitlist moves and messages wait for the next check.
- Next on my list: a cancel button for students, resending failed messages from the portal and cancelling all bookings when a trip is cancelled.
Every choice above, even the ones I'd change, is written down as one of 19 decision records (ADRs).
Stack
- Java 21
- Spring Boot
- React
- TypeScript
- PostgreSQL
- Flyway
- LINE LIFF
- LINE Messaging API
- Docker
- Terraform
- AWS
- GitHub Actions
- Playwright
- Testcontainers
Last updated: