Case study · 2025
Extrabite
Surplus food and the people who need it are rarely separated by supply. They are separated by coordination - who has what, for how long, and who can collect it before it stops being food. Extrabite is my attempt to put that coordination in software.
The problem worth solving
A restaurant with thirty unsold meals at 10pm and a shelter two kilometres away is not a logistics problem in the usual sense. Nobody needs a fleet. What is missing is a shared, current picture: what exists right now, where, for how long, and whether someone has already claimed it.
Before software, that picture lives in WhatsApp groups and phone calls. It goes stale within minutes, and the cost of being wrong is not an inconvenience - it is food thrown away, or somebody travelling to collect something that is already gone.
So the interesting engineering here is not CRUD. It is keeping the listing honest and proving the handover actually happened. Most of what follows is about those two things.
How it fits together
A React front end on Vercel, a Spring Boot service in a Docker container, and PostgreSQL underneath. Every request crosses two authentication filters before it reaches a controller, and a set of scheduled jobs runs against the same database on its own clock.
Three decisions worth explaining
1. Two layers of authentication, not one
A request has to answer two different questions before it does anything. The first filter
checks an EXTRABITE-API-KEY header: is this call coming from the
platform at all? The second reads the bearer token: and who is making it?
These are genuinely separate concerns, and conflating them is a common mistake. The API key does not identify a person and grants no permissions - it only keeps the surface from being a completely open API that anyone can enumerate. Authorisation is entirely the JWT's job, carried in claims for id, name, email, role and whether the profile is active.
What it does not buy: an API key shipped to a browser is not a secret - anyone can read it out of the network tab. It raises the cost of casual scraping; it is not a security boundary, and I did not treat it as one. Every route that matters still requires a real token.
2. Stateless tokens that can still be revoked
JWTs are attractive because the server does not have to remember anything. That is also their problem: a token stays valid until it expires, so "log me out everywhere" is not something the standard gives you.
I chose to give up part of that statelessness. Logging out writes the token into a blacklist table, and the JWT filter checks that table before it trusts any token. A scheduled job clears out entries whose tokens have expired anyway, so the table tracks active sessions rather than growing forever.
The trade-off, stated plainly: this costs one database lookup on every authenticated request, and it means the service is no longer purely stateless. I took that deal because a logout that does not actually log you out is a security bug, not a performance feature - and at this scale one indexed lookup is cheap.
At a larger scale the same design still works, but the lookup belongs in Redis or a short-TTL in-memory cache rather than the primary database.
3. An OTP handover that proves the pickup happened
This is the part I would want to talk about in an interview. When a fulfiller accepts a request, the obvious design is to let them mark it complete afterwards. That design is wrong: it lets one side of a two-party event decide, on their own, that the event occurred. On a platform with ratings attached to completions, that is an invitation.
So the completion is split so that neither party can produce it alone:
- A requester posts what they need. The request is
OPEN. - Someone offers to fulfil it. The request becomes
OFFERED. - The requester accepts. The status moves to
AWAITING_PICKUPand a six-digit pickup code is generated. - The code is returned only to the requester, through an endpoint the fulfiller cannot call. The fulfiller never sees it in any response.
- At the handover the requester reads the code out. The fulfiller submits it. Only then does the request become
COMPLETED.
The code is worthless without physical presence, which is exactly the property wanted: a completed request now means two people actually met. The same six digits also settle disputes, because a completion cannot exist without one.
Keeping the listing honest
A donation board that shows food which is no longer edible is worse than no board at all, because somebody travels for nothing. Freshness expires in two different ways here, so there are two jobs rather than one general-purpose one.
- Cooked food runs on a countdown. The donor sets how long it stays good for, and the job expires anything whose countdown has run out since it was posted.
- Packaged food runs on a date. A printed expiry is an absolute timestamp, so that job simply compares it against now.
Both run every ten minutes and both can be switched off at runtime, which mattered during testing: seeding a realistic board is impossible if a scheduler keeps expiring your fixtures underneath you.
Browsing that board is built on JPA Specifications rather than a repository method per filter combination. Status, food name, location, free-or-paid and expiry are each a predicate, and the browse endpoint composes whichever ones the caller supplied. Five independent filters are thirty-one method names the other way; this way they are five predicates.
What I would build differently
This shipped, it was tested by 50-odd people during the pilot, and the coordination it replaced was manual. It also has decisions in it I can now argue against - which is the more useful half of any project.
-
The expiry job reads the whole table
The countdown job loads every donation and filters in memory, every ten minutes.
At pilot size that is invisible; at a hundred thousand rows it is a self-inflicted
outage. The condition belongs in the query - or better, in a single bulk
UPDATEthat never loads rows into the application at all. - The scheduler switches live in static fields Toggling a job at runtime was the right feature. Storing that flag in a static variable was not: it resets on restart, and with more than one instance running each would hold its own opinion. Configuration state should be in the database or a config service, not in process memory.
- The blacklist is checked against Postgres Correct, and the right first version, but it puts a query on the hot path of every authenticated request. This is what a cache is for.
- Debug logging printed the bearer token A development aid that should never have survived into a deployed build. Tokens are credentials; logging one writes a working key into the log file. If I need to trace auth now, I log a decision and a user id, never the token.
- Route rules accumulated exceptions Endpoints opened up for internal callbacks during development were never tightened afterwards, and the security configuration grew a list of one-off exemptions. Internal calls should authenticate as internal callers rather than be exempted, and the default should stay closed.
What I took from it
The parts I am still pleased with are the ones where I asked what the software has to prove, not what it has to store. A pickup code is a schema detail until you notice it is the only evidence that two strangers met. An expiry job is a cron entry until you notice it is the reason someone does not waste a trip.
The parts I would redo are almost all the same mistake: something that was obviously fine at the size I was testing, and obviously not fine one order of magnitude later. Writing them down is cheaper than rediscovering them.
Alok Maurya · Software Engineer
← Back to portfolio