Phase 07 Projects — Ship Something Real

Reading about Spring Boot is not the same as running a service at 3 a.m. when Postgres won’t accept connections. These three projects convert the theory in files 01–07 into muscle memory: one green-field build, one hardening pass, one honest engineering write-up. By the end you have a public URL that returns JSON, a Grafana dashboard that renders your own metrics, and a blog post that shows you can reason about trade-offs.

Treat these as portfolio-grade. Real repo, real README, real commits, real deployment. If a recruiter clones it and it doesn’t run with docker compose up, you’ve failed the exercise before the code review.


Project A — shortly: Production-Grade URL Shortener

The canonical “small enough to ship, big enough to prove you know Spring.” You’ve seen a dozen tutorials for this. Yours will be different because it will have real persistence, real auth, real observability, real tests, and a real public URL.

Functional Requirements

Endpoint

Method

Auth

Purpose

/api/v1/links

POST

JWT

Create a short link (custom slug optional)

/api/v1/links/{slug}

GET

Public

Metadata (owner, created, hits)

/api/v1/links/{slug}

DELETE

JWT (owner)

Remove a link

/api/v1/links

GET

JWT

Paginated list of caller’s links

/{slug}

GET

Public

301 redirect + async hit counter

/api/v1/auth/register

POST

Public

Create account

/api/v1/auth/login

POST

Public

Exchange credentials for JWT

/actuator/health

GET

Public

Liveness + readiness

/actuator/prometheus

GET

Internal

Scrape endpoint

Stack (non-negotiable)

  • Java 21, Spring Boot 3.4.x

  • PostgreSQL 16 (JPA + Flyway migrations)

  • Redis 7 (slug → URL cache, TTL 1 hour)

  • Spring Security 6 with self-issued JWT (RSA keypair, 15-min access + 7-day refresh)

  • Micrometer + Prometheus + Grafana

  • OpenTelemetry OTLP export to Tempo/Jaeger

  • Testcontainers for every integration test (NO H2)

  • Docker multi-stage build with Application CDS

  • Deployed to Fly.io (free tier) or Render

Architectural Rules

  1. DTOs are records, entities are classes. Never leak @Entity through the controller boundary.

  2. Constructor injection only. No @Autowired on fields.

  3. All errors return RFC 7807 ProblemDetail. Wire it in @RestControllerAdvice.

  4. Redirect must be fast. Cache-aside via Redis; only touch Postgres on cache miss. Increment hit counter asynchronously (via @Async or a queue — do NOT block the redirect on a DB write).

  5. Rate limits from day one. Even if simple (Bucket4j in-memory), enforce 100 req/min for anonymous, 1000/min for authenticated.

  6. Idempotent POST /links. Support Idempotency-Key header (Redis TTL 24h stores the response).

Test Matrix

Layer

Framework

Slice

Unit

JUnit 5 + Mockito + AssertJ

Service classes with mocked repos, Clock injected

Web slice

@WebMvcTest

Controllers, @MockBean service, MockMvc + jsonPath

Data slice

@DataJpaTest + Testcontainers postgres:16-alpine + @ServiceConnection

Repositories, native queries

Full slice

@SpringBootTest(RANDOM_PORT) + Postgres + Redis containers

Happy paths only, ≤10 tests

Load

k6 script in /perf/

/{slug} at 500 rps for 60s, assert p95 < 50ms

Aim: ≥80% line coverage, ≥65% Pitest mutation coverage. Coverage below that and you’re gaming the number.

Observability Deliverables

  • Custom counter: shortly_links_created_total{tier="free|paid"}

  • Custom timer: shortly_redirect_duration_seconds (with percentile histogram enabled)

  • Grafana dashboard JSON checked into /ops/grafana/

  • Alert rules (Prometheus): error rate >1% for 5m, p95 redirect >100ms for 10m, heap >80% for 10m, HikariCP saturation

Deployment Checklist

  • Dockerfile is multi-stage (JDK build, JRE runtime, non-root user, tini as PID 1 or JAVA_TOOL_OPTIONS)

  • Application CDS archive baked into the image (-XX:ArchiveClassesAtExit=/app/app.jsa then run with -XX:SharedArchiveFile=/app/app.jsa)

  • docker-compose.yml boots the whole world locally: shortly, postgres, redis, prometheus, grafana, tempo, loki

  • Public URL live on Fly.io / Render, TLS on, custom health checks configured

  • README.md has: 1-command local start, screenshot of Grafana dashboard, curl examples, openapi.json link

⚠️ What most people get wrong: they skip deployment because “it works locally.” The moment you push to a real host, you discover your app doesn’t handle SIGTERM cleanly, HikariCP times out because Fly.io firewalled Postgres 5432, and Prometheus can’t scrape because you never exposed /actuator/prometheus in the management.endpoints.web.exposure.include. Deployment IS the learning.

Estimated effort: 40–60 hours over 4–5 weeks.


Project B — Harden shortly

Ship the naive version first. Then come back and turn it into something you’d defend in a design review. This is the difference between “I built a URL shortener” and “I ran a URL shortener in production.”

Additions

Feature

Why it matters

Implementation hint

Distributed rate limiting

In-memory Bucket4j resets on redeploy and doesn’t scale horizontally

Bucket4j + bucket4j-redis (Lettuce), key = rl:{userId or IP}:{minute}

Admin dashboard

Real services need an ops UI, not just Postgres shell

Simple Thymeleaf or React SPA at /admin, protected by hasRole('ADMIN'), shows top links / abuse candidates

Soft delete + restore

Real users delete things by mistake

deleted_at TIMESTAMPTZ, Hibernate @SQLRestriction("deleted_at IS NULL"), admin-only POST /links/{slug}/restore

Audit log

Every mutation must be traceable

Separate audit_log table, populated via Spring Data JPA @EntityListeners(AuditingEntityListener.class) or an AOP @Around aspect. Include actor, action, entity, before/after (JSONB), timestamp, request_id

Abuse detection

Shorteners are phishing magnets

Google Safe Browsing API check on create (or the URLhaus feed for a free option), auto-quarantine + 451 response for known-bad targets

Password reset flow

POST /auth/reset → email token → POST /auth/reset/confirm

Store token hash (not plaintext), 1-hour TTL, invalidate on use

Testing Additions

  • Contract test with WireMock for the Safe Browsing dependency (so CI doesn’t hammer Google)

  • Chaos test: use Testcontainers to kill Redis mid-test, assert cache-miss path still returns and no requests fail

  • Integration test for the audit log: mutate an entity, assert an audit_log row with correct actor + action

What You’ll Learn That You Can’t Get From a Tutorial

  • Distributed rate limiting is a distributed systems problem — clock skew, race conditions on the counter, and what happens when Redis is down (fail open or fail closed?)

  • Soft delete looks trivial until you realize every JPQL query has to know about it, and unique constraints (like slug) now need partial indexes

  • Audit logs balloon fast; you’ll need a rotation strategy from day one

Estimated effort: 25–35 hours over 3 weeks.


Project C — The “JPA → jOOQ” Blog Post

The technical portion is small. The value is in the writing and the honesty. This is a public engineering artefact — a real blog post you publish (dev.to, Hashnode, GitHub Pages, Medium, LinkedIn — pick one and commit).

Setup

Pick 3–5 of the heaviest reads in shortly (typically: paginated link list with owner filter, top-N by hit count, per-user analytics rollup). Reimplement each in jOOQ 3.19+ (Java 21 codegen). Keep the JPA version alongside; feature-flag which one serves.

Deliverables

  1. Benchmark harness — JMH microbenchmark AND a JMeter/k6 macro test hitting real HTTP endpoints backed by both stacks. Report:

    • p50, p95, p99 latency for each query

    • Rows/sec throughput at 100 concurrent requests

    • JVM heap and GC pause frequency

    • EXPLAIN ANALYZE output for the SQL each layer produces

  2. The write-up (target ~2000 words):

    • What jOOQ actually gives you (type-safe SQL, no N+1 by construction, better projections, cleaner batch DML)

    • What it costs (codegen step in the build, no free change-tracking, no cascade, more boilerplate for simple CRUD)

    • When the numbers matter and when they don’t (the honest answer is: for 90% of endpoints they don’t)

    • A recommended hybrid pattern: JPA for command-side / simple CRUD, jOOQ for query-side / reporting

    • Migration cost estimate for a real team

  3. Publish it. Post the link on LinkedIn with a short summary. This becomes an study talking point for the next 12 months.

⚠️ What most people get wrong: they conclude “jOOQ is faster, use it everywhere” from a microbenchmark that measured 200µs vs 400µs on a query that runs 50 times a day. Learn to say “the difference is real but does not matter here, and here’s how I decided.” That sentence, delivered confidently in an study, is worth more than the whole benchmark.

Estimated effort: 15–25 hours over 2 weeks.


Portfolio Packaging

At the end of Phase 07 you should have:

  • One repo: github.com/<you>/shortly — full source, one-command local start, README.md with screenshots and public URL

  • One blog post: JPA → jOOQ, with real numbers

  • One CV bullet you can defend line-by-line: “Built and deployed a production-grade URL shortener (Spring Boot 3.4, Postgres, Redis, JWT auth) with Prometheus/Grafana observability, OpenTelemetry tracing, Testcontainers integration tests, and rate limiting. Serves at [public URL].”

That single bullet, plus the ability to whiteboard how it works end-to-end, will pass the “can you build a real service” bar at most MNCs.


Return to README.md · Previous: 07_config_and_deployment.md · Next: ../08_distributed_systems_applied_integration/README.md