Skip to content

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 map
Feature Job Requirement
───────────────────────────────────────────────
Multi-tenancy SaaS isolation patterns
JWT / OAuth2 Authentication & authorization
Redis caching Performance optimization
Scheduled tasks Background job processing
Pagination + search API design at scale
Testcontainers Integration testing
Docker Compose Local development & CI/CD

System architecture diagram of a multi-tenant URL shortener showing multiple tenants connecting to a Spring Boot application backed by PostgreSQL and Redis

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.

src/main/resources/application.yml
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:

TenantFilter.java (conceptual)
@Component
public 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.

Auth flow
User signs up → gets JWT with tenant_id + role claims
Every request sends JWT in Authorization header
TenantFilter reads X-Tenant-Id (must match JWT claim)
Backend validates JWT on every request

JWT authentication flow diagram showing user signup, token generation with tenant_id and role claims, and subsequent request validation via TenantFilter

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.

Cache strategy
┌──────────────┐ ┌──────────────┐ ┌─────────────┐
│ HTTP Request │────▶│ Redis │────▶│ PostgreSQL │
│ │ │ (cache hit) │ │ (cache miss)│
└──────────────┘ └──────────────┘ └─────────────┘
Return cached
analytics data

I 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.

AnalyticsRepository.java
@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.

ClickEventRepository.java
public List&lt;ClickEvent&gt; 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.

CleanupTask.java
@Component
public 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.

docker-compose.yml
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.

UrlShortenerIntegrationTest.java
@SpringBootTest
@Testcontainers
class 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments