Spring Boot Project Ideas for Intermediate Learners to Become Job-Ready
Purpose
I’ve been mentoring junior developers who finish tutorials but freeze when asked to build something production-ready. The usual advice — “build a todo list” or “make a blog API” — teaches CRUD but not the hard parts. Job interviews ask about caching, multi-tenancy, auth, testing, and deployment. If your portfolio only has a blog API, you can’t talk about those.
I needed a project that forces me to touch every layer a real backend engineer deals with. So I built a multi-tenant URL shortener with analytics. Here’s why that choice matters, and how each piece maps to a real job skill.
Why a URL Shortener with Analytics
A URL shortener sounds simple — take a long URL, return a short code. But when you add analytics (click tracking, geolocation, referrer data), the complexity jumps to production level:
tenants each see their own data every redirect needs logging without slowing the response analytics queries need pagination and caching the whole thing must survive restarts
Compare that to a blog API. Blog posts are CRUD. No concurrency pressure, no caching strategy beyond “cache the homepage,” no multi-tenancy. A recruiter sees it and thinks “they know JPA.”
With the URL shortener, I built the exact same stack used at SaaS companies. Each feature in the checklist below maps to a real job requirement.
Feature-to-Job Mapping
Feature Job Requirement───────────────────────────────────────────────Multi-tenancy SaaS isolation patternsJWT / OAuth2 Authentication & authorizationRedis caching Performance optimizationScheduled tasks Background job processingPagination + search API design at scaleTestcontainers Integration testingDocker Compose Local development & CI/CD
Multi-Tenancy Setup
Multi-tenancy means a single app serves multiple customers, with their data fully isolated. I used a tenant_id column on every table — the simplest approach that avoids schema-per-tenant complexity.
spring: datasource: url: jdbc:postgresql://localhost:5432/urlshortener username: ${DB_USER} password: ${DB_PASS}
jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: public
app: multi-tenant: enabled: true header-name: X-Tenant-Id
jwt: secret: ${JWT_SECRET} expiration-ms: 86400000
cache: redis: ttl-seconds: 300
scheduled: cleanup-cron: "0 0 3 * * *"Every controller extracts X-Tenant-Id from the header and injects it into every query. The filter looks like this conceptually:
@Componentpublic class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; String tenantId = req.getHeader("X-Tenant-Id"); TenantContext.setTenantId(tenantId); try { chain.doFilter(request, response); } finally { TenantContext.clear(); } }}I chose header-based tenant resolution because it works with both browser clients and API clients. No cookie management, no session state. Every query in the repository layer appends WHERE tenant_id = ? automatically. This is the same pattern used by SaaS platforms like Retool and GitLab.
Auth with JWT
Auth is the part most tutorial projects skip — “just add Spring Security and move on.” But in a real app, I need users, roles, token refresh, and tenant binding.
User signs up → gets JWT with tenant_id + role claimsEvery request sends JWT in Authorization headerTenantFilter reads X-Tenant-Id (must match JWT claim)Backend validates JWT on every request
I used RS256-signed JWTs so the public key can live in a config server and private keys never leave the auth service. This matters when you scale to multiple backend instances — any instance can verify a token without talking to a central auth store.
Caching with Redis
Analytics pages query click events grouped by day, referrer, and country. Without caching, every page load triggers a heavy SQL aggregation. I used Redis to cache the aggregated results.
┌──────────────┐ ┌──────────────┐ ┌─────────────┐│ HTTP Request │────▶│ Redis │────▶│ PostgreSQL ││ │ │ (cache hit) │ │ (cache miss)│└──────────────┘ └──────────────┘ └─────────────┘ │ ▼ Return cached analytics dataI used Spring’s @Cacheable on the analytics repository. The TTL is 5 minutes — short enough that fresh data appears quickly, long enough that a spike of dashboard refreshes doesn’t hammer the database.
@Cacheable(value = "analytics", key = "#tenantId + '-' + #shortCode")public List<ClickEvent> getClicksByDay(String tenantId, String shortCode, LocalDate start, LocalDate end) { return jdbcTemplate.query( """ SELECT date_trunc('day', clicked_at) AS day, COUNT(*) AS count FROM click_events WHERE tenant_id = ? AND short_code = ? AND clicked_at >= ? AND clicked_at < ? GROUP BY day ORDER BY day """, new Object[]{tenantId, shortCode, start, end}, clickEventRowMapper() );}Caching real analytics data taught me something a blog API never could: cache invalidation triggers. When new click events arrive, I evict only the affected tenant’s cache keys. A global cache flush sounds simpler but kills performance for every other tenant.
Pagination for Analytics
Analytics APIs return thousands of rows. I used keyset pagination instead of offset pagination — offset gets slower as the dataset grows, keyset stays fast.
public List<ClickEvent> findPage(String tenantId, String shortCode, Long afterId, int limit) { return jdbcTemplate.query( """ SELECT id, tenant_id, short_code, clicked_at, referrer, country FROM click_events WHERE tenant_id = ? AND short_code = ? AND (? IS NULL OR id > ?) ORDER BY id LIMIT ? """, new Object[]{tenantId, shortCode, afterId, afterId, limit}, clickEventRowMapper() );}The client sends afterId from the last row of the previous page. No OFFSET, no COUNT(*) for total pages, no performance degradation at page 1000.
Scheduled Tasks for Cleanup
Expired short URLs and old analytics data need cleanup. I used Spring’s @Scheduled with a cron expression.
@Componentpublic class CleanupTask { private final JdbcTemplate jdbcTemplate;
@Scheduled(cron = "${app.scheduled.cleanup-cron}") public void deleteExpiredUrls() { int deleted = jdbcTemplate.update( """ DELETE FROM short_urls WHERE expires_at < NOW() """ ); log.info("Cleaned up {} expired URLs", deleted); }}This is the same @Scheduled annotation used in production batch jobs across every Spring shop. The difference from a tutorial: I added logging, configurable cron via properties, and a manual trigger endpoint for testing.
Docker Compose for Local Dev
I wanted one command to start everything. Docker Compose runs PostgreSQL, Redis, and the app.
version: "3.8"
services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: urlshortener POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASS} ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data
redis: image: redis:7-alpine ports: - "6379:6379"
app: build: . ports: - "8080:8080" environment: DB_USER: ${DB_USER} DB_PASS: ${DB_PASS} JWT_SECRET: ${JWT_SECRET} depends_on: - postgres - redis
volumes: pgdata:This maps directly to how real teams run services. The env variables come from a .env file that’s never committed — just like production secrets management.
Integration Testing with Testcontainers
I refuse to use H2 in-memory database for tests. It has different SQL dialect, different behavior, and gives false confidence. Testcontainers spins up a real PostgreSQL in a Docker container for my tests.
@SpringBootTest@Testcontainersclass UrlShortenerIntegrationTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test");
@Container static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine") .withExposedPorts(6379);
@DynamicPropertySource static void configureProperties( DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.data.redis.host", redis::getHost); registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); }
@Test void testCreateAndRedirect() { // given String longUrl = "https://example.com/very/long/url"; String tenantId = "tenant-1";
// when String shortCode = urlShortenerService .createShortUrl(longUrl, tenantId);
// then assertThat(shortCode).isNotNull(); assertThat(shortCode.length()).isEqualTo(7); }}Every developer on my team runs the same tests against the same database version. No more “works on my machine” between local and CI.
Why This Project Gets You Hired
I’ve seen this project turn portfolio reviews into job offers, and here is why.
Multi-tenancy proves you understand data isolation, not just CRUD. JWT auth shows you can handle security beyond form login. Redis caching means you’ve thought about performance under load. Scheduled tasks demonstrate background processing awareness. Testcontainers tells the interviewer you write real tests. Docker Compose shows you care about reproducible environments.
Each piece directly answers a question you will get in a backend interview. When the interviewer asks “how do you handle caching?” you don’t say “I’ve read about Redis.” You say “I used @Cacheable with a 5-minute TTL and tenant-scoped key invalidation.”
Summary
In this post, I showed how building a multi-tenant URL shortener with analytics forces you to touch the same production concerns that real backend jobs require. The key point is to pick a project that naturally includes caching, auth, multi-tenancy, scheduled tasks, real integration testing, and containerization — instead of adding them artificially to a todo list app. Each feature maps to a concrete interview topic, and the code speaks louder than any certification.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Initializr
- 👨💻 Testcontainers Documentation
- 👨💻 Reddit Discussion: Spring Boot Project Ideas
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments