270+ applications, one markdown file, and the moment a Ctrl-F search stopped being a system.
Tracking 270+ job applications across formal ones, cold outreach, freelance proposals, and bridge income roles in a single markdown file is not for the weary. When I first started, it worked to see at a glance the current structure I was building. As the file got longer, I was losing the direct ability to make sure I was following up with roles, which one I had heard back from, which ones were going forward without me. This API replaces it with a proper Postgres-backed data model. Easy to query and update instead of hoping a Ctrl-F command found what I was looking for.
Six weeks into a job search generating dozens of applications a week — formal submissions, cold outreach, bridge income, follow-ups — a spreadsheet stopped being enough. I needed something that could enforce structure: an application has a status, a status has valid transitions, and a follow-up date shouldn't exist floating in a text cell where it's easy to miss.
I also wanted a reason to build something outside Unity, while dusting off Java skills I learned while at university. Six years in C# taught me one language's idioms deeply. Spring Boot forced me to relearn dependency injection, annotation-driven configuration, and a request lifecycle that has nothing to do with a game loop; while working with data I actually cared about getting right.
The API manages the full lifecycle of a job application: create, read, update, and delete records across applications, contacts, and follow-up schedules, backed by a relational schema in PostgreSQL.
The application follows a standard layered structure, but "standard" is doing real work here. Coming from Unity, where a MonoBehaviour can touch anything it has a reference to, the discipline of a strict repository → service → controller separation was the actual re-learning curve.
Controller → handles HTTP requests, delegates to Service, returns responses Service → owns business logic, calls Repository, never touches HTTP directly Repository → JPA interface, owns all direct database interaction Entity → the persisted shape of the data, annotated for JPA/Hibernate
No layer skips another. The controller never talks to the repository directly, and the service never constructs an HTTP response. That separation is what makes the PUT endpoint, as well as the other GET, DELETE, and POST endpoints safe to add without touching anything else — the service method for a partial update lives next to the service method for a full create, and the controller just routes to the right one.
@GetMapping("/{id}")
public ResponseEntity getApplicationById(@PathVariable Long id){
return ResponseEntity.of(service.getApplicationById(id));
}
@PostMapping("/bulk")
public List saveAllApplications(@RequestBody List applications) {
return service.saveAllApplications(applications);
}
@PutMapping("/{id}")
public ResponseEntity updateApplication(@PathVariable Long id, @RequestBody JobApplication application) {
return ResponseEntity.of(service.updateApplication(id, application));
}
@DeleteMapping("/{id}")
public ResponseEntity deleteApplication(@PathVariable Long id){
service.deleteApplication(id);
return ResponseEntity.noContent().build();
}
Update semantics aren't free. JpaRepository.save() will happily insert or update depending on whether the entity's id is null, which means a naive PUT implementation can silently create a phantom row instead of failing when the target id doesn't exist. Fixed by checking findById() first and returning a proper 404 when nothing's there, rather than trusting save() to do the right thing by default.
Local vs. deployed config isn't a single file. Railway's DATABASE_URL comes in Postgres's native connection-string format (postgresql://user:pass@host:port/db), not the jdbc:postgresql://... format Spring's datasource expects — so the fix was building the JDBC URL from Railway's individual PGHOST/PGPORT/PGDATABASE variables instead of trying to use the combined URL directly. Combined with Spring profiles (application-local.properties for dev, environment variables for prod), this is the first project where I've had to think seriously about environment separation rather than just "does it run."
Migrating between environments is its own small problem. Once local and Railway were both live, syncing data between them meant writing a short script that pulls from the local API, strips the id field (so inserts don't collide with existing rows), and re-POSTs to Railway's bulk endpoint — a real, if small, example of the kind of data-migration tooling that comes up constantly in backend work.
Why It Matters
This is the first backend-only, non-Unity project I've shipped end to end — no game engine, no C#, just Java, Spring, SQL, and a deployed URL. It's also functionally in use: this now runs my actual job search instead of describing it.