DEV Community

Cover image for I stopped using Excel: How I built an Enterprise-Grade Job Tracker with Spring Boot 3 & Angular 17
Hari babu Thatikonda
Hari babu Thatikonda

Posted on

I stopped using Excel: How I built an Enterprise-Grade Job Tracker with Spring Boot 3 & Angular 17

Let’s be honest: searching for a job is a full-time job in itself. 😩

Most of us rely on messy spreadsheets, generic Trello boards, or just "hoping for the best" to track applications. I wanted something better. I wanted a tool that didn't just list jobs, but actually visualized my pipeline, calculated my interview conversion rates, and felt like a modern SaaS product.

So, I built JobTrackPro.

But as an engineer, I didn't want to just build a simple CRUD app. I treated this as a System Design Challenge. I focused on security, concurrency, and cloud-native storage patterns.

Here is a deep dive into how I architected this Enterprise-Grade application using Spring Boot 3 (Java 21) and Angular 17.

🔗 The Goods

Before we dive into the code, you can try it yourself


🛠️ The Tech Stack

I chose a stack that balances raw performance with developer experience:

  • Backend: Java 21 + Spring Boot 3.2
  • Security: Spring Security 6 (OAuth2 + JWT)
  • Database: PostgreSQL (Production) / MySQL (Dev)
  • Storage: Cloudflare R2 (AWS S3 Compatible)
  • Email: Spring Mail (SMTP) + Brevo
  • Frontend: Angular 17 (Signals) + TailwindCSS + D3.js
  • DevOps: Docker + Google Cloud Run + GitHub Actions

🧠 Architectural Deep Dive

Here are the four most interesting engineering challenges I solved while building this.

1. Hybrid Authentication (Google/GitHub + Local)

Most tutorials show you how to do either OAuth2 or JWT/Password auth. Doing both in the same system is tricky.

The Goal: Allow users to sign in with Google/GitHub, but also let them set a password later if they want to detach their account.

The Flow:

I implemented a custom OAuth2SuccessHandler. When a user logs in via Google:

  1. We check if the email exists.
  2. If not, we create the user on the fly.
  3. We download their avatar URL and sync it to our system.
  4. We issue a JWT so the frontend remains stateless.
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(...) {
        // Logic to extract Google vs GitHub attributes
        OAuth2User oAuth2User = authToken.getPrincipal();
        String registrationId = authToken.getAuthorizedClientRegistrationId();
        UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes());

        // Upsert User logic...

        // Redirect to Angular with Token
        getRedirectStrategy().sendRedirect(request, response, uiUrl + "/login-success?token=" + token);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Multi-threaded Analytics with CompletableFuture

The dashboard shows four key metrics: Total Applications, Active Pipeline, Interviews, and Offers.

Querying these sequentially would be slow as the dataset grows. Instead of hitting the database 4 times in a row, I used Java's CompletableFuture to run these aggregations in parallel threads.

This reduced the dashboard load time significantly.

// Running database aggregations in parallel
CompletableFuture<Long> activeFuture = CompletableFuture.supplyAsync(() -> 
    jobRepository.countActiveJobs(email), executor
);

CompletableFuture<Long> interviewFuture = CompletableFuture.supplyAsync(() -> 
    jobRepository.countInterviews(email), executor
);

// Wait for all to complete
CompletableFuture.allOf(activeFuture, interviewFuture).join();
Enter fullscreen mode Exit fullscreen mode

3. Atomic Profile Updates & Cloudflare R2

Storing images in a database (BLOBs) bloats backups. Storing them on disk doesn't work in serverless environments (like Cloud Run).

I integrated Cloudflare R2 using the AWS SDK v2.

The tricky part was Data Integrity. I wanted users to preview an image instantly, but not "save" it until they clicked the Save button.

  • Frontend: Uses FileReader to show a local preview instantly (Zero latency).
  • Backend: Handles the File Upload and Text Update in a Single Atomic Transaction.
  • Cleanup: If the user replaces an image, the backend automatically sends a deleteObject command to R2 to remove the old file, preventing "orphaned files" from costing money.

4. Async Email System

For the "Forgot Password" flow, I didn't want the user to wait for the SMTP handshake (which can take 2-3 seconds).

I used Spring's @Async annotation to offload email sending to a background thread. The API returns "Reset link sent" immediately, ensuring a snappy UI experience.


⚡ Angular Signals & Optimistic UI

On the frontend, I went all-in on Angular 17 Signals.

The entire application state (Jobs list, Stats, Profile data) is reactive. This allows for Optimistic UI Updates.

The "Magic" Moment:
When you add a job, the UI updates the Signal list immediately. The POST request fires in the background. The Dashboard charts re-render instantly without waiting for a server round-trip.

// Angular Service: Optimistic Update
async addJob(job: any) {
  // 1. Send Request
  await firstValueFrom(this.http.post(this.apiUrl, job));

  // 2. Trigger smart refresh (Updates signals immediately)
  this.refreshActiveView(); 
}
Enter fullscreen mode Exit fullscreen mode

🏁 Conclusion

Building JobTrackPro taught me that "Full Stack" isn't just about connecting a database to a frontend. It's about handling edge cases—like what happens if an image upload fails, how to secure OAuth2 redirects, and how to make analytics scalable.

I’m keeping this project Open Source to help other developers who might be building similar dashboard applications.

Feel free to fork it, star it, or use it to land your next job! 👇

💬 Discussion

Have you tried using Cloudflare R2 for image storage yet? I found it significantly cheaper than S3. Let me know your thoughts on the architecture in the comments!

Thanks for reading! Happy coding! 🚀

Top comments (2)

Collapse
 
vardan_matevosian_tech profile image
Vardan Matevosian

Hi Hari

This is an awesome project, I’ve starred your repo

I would love to discuss the security aspect a little bit, specifically around authentication and token handling.

I noticed that your backend generates a custom JWT after authentication. Since the user is already authenticated by the IdP (OAuth provider) and redirected back to the frontend, have you considered using the JWT ID token issued by the provider instead of creating your own JWTtoken?

Relying on the provider’s tokens can reduce complexity and maintenance overhead. Token lifecycle concerns such as expiration, rotation, and revocation are already handled by the IdP, whereas issuing your own tokens requires you to implement and maintain those mechanisms yourself.

One small note: GitHub OAuth doesn’t support true global logout; logging out only invalidates the session in your Spring application, and the user logs out from your app, not the GitHub session itself, because GitHub does not support front-channel/back-channel logout OpenID Connect standard.

I understand there are valid reasons to issue an internal token (for example, mapping external identities to internal users, adding domain-specific claims, or supporting multiple auth strategies). In that case, treating the IdP token as proof of authentication and exchanging it for an internal token sounds good.
I would appritiate to hear your thoughts on this design choice a little bit more.

Also, your GitHub profile and project presentation look great. How did you set that up?

Thanks for sharing your work. Let’s keep learning together

Collapse
 
thughari profile image
Hari babu Thatikonda

Hey Vardan, thanks a lot for the kind words and for starring the repo. I really appreciate it.

That’s a great question. I did think about using the IdP issued token directly, but I ended up going with an internal JWT on purpose.

One reason is that ID tokens are mainly about identity, not really meant to be used as API tokens. If I relied directly on Google or GitHub tokens for backend calls, the backend would be tightly coupled to each provider’s token format, claims, and validation rules. This is especially messy with GitHub, which does not fully support standard OIDC ID tokens the same way Google does.

Another reason is flexibility. The app supports OAuth login, local email and password login, and the option for an OAuth user to later detach and use a password. To make that work cleanly, I need a single internal user identity that the backend understands, regardless of how the user authenticated.

Issuing my own JWT also gives me full control over the token lifecycle. I can decide how long it lives, how refresh works, and how revocation behaves, without depending on provider specific behavior or short lived tokens that were never designed for stateless backend APIs.

So my approach is to treat the IdP token as proof that the user is authenticated, then exchange it for an internal JWT that the frontend uses for all API calls. That keeps a clear boundary and keeps the backend simple.

And yes, you are totally right about GitHub logout. Since GitHub does not support front channel or back channel logout, logging out only clears the app session and the internal token.

For my GitHub profile, it is just a README.md named after my username. I’ve added a mix of GitHub stats, trophies, and activity graphs to make it more lively and give a quick snapshot of what I work on.

I’m also using Holopin badges for a bit of personality and visibility around learning and achievements. You can see them here:
An image of @thughari's Holopin badges

Most of the stats come from community tools like github-readme-stats, profile trophies, streak stats, and the activity graph, all wired together with plain Markdown and a bit of HTML for layout.

Thanks again for the thoughtful questions. Always happy to talk more about this stuff.