Skip to content

Repository files navigation

Spring Boot Notes API

CI

A Spring Boot service that stores notes but doesn't do its own authentication. Every request is checked against a separate Go identity service over gRPC. The notes CRUD is intentionally boring; the actual engineering is in what happens around that one gRPC call: caching validations so it's not a network round trip on every request, refusing to serve data when the auth service is unreachable, and measuring whether any of that caching was worth doing in the first place.

30 tests · Testcontainers + in-process gRPC integration tests · k6 load-tested

The problem this solves

A service that checks a token by calling another service over the network on every single request has taken on a real cost: two hops instead of one, and a hard dependency on something outside its own process. The obvious fix is a cache. The obvious risk with a cache is staleness: if the Go service revokes a token, this service could keep honoring it for as long as the cache holds it.

The design here is a bet on bounding that risk rather than eliminating it. Validated tokens go into a Caffeine cache keyed by SHA-256(token), never the raw token, with a TTL of whichever is smaller: 60 seconds, or however long is actually left on the token. So a revoked token can be served here for up to 60 seconds after the Go service revokes it. That's a real cost, and it's worth saying out loud rather than hoping nobody asks: the tradeoff is one gRPC call avoided per cached request against a bounded, known staleness window, and 60 seconds is small next to a 15-minute access token's total lifetime.

How a request actually flows

flowchart TB
    Request(["Incoming HTTP request"])
    Filter["GrpcAuthFilter\nreads the Bearer token"]
    Cache{"Cached and\nnot expired?"}
    RPC["AuthGrpcClient\ncalls Go over gRPC"]
    Controller["NoteController\nyour own notes only"]

    Request --> Filter
    Filter --> Cache
    Cache -- "yes" --> Controller
    Cache -- "no" --> RPC
    RPC --> Controller
Loading

A cache hit skips the network call entirely. A miss calls ValidateToken, and that call carries a 2-second deadline plus a circuit breaker: 50% failures over the last 10 calls trips it open for 10 seconds, so a struggling auth service doesn't get hammered by every request piling up behind a 2-second timeout. If the auth service is down and nothing's cached, the answer is a 503 with Retry-After: 5. Not a guess, not a pass-through: an explicit refusal to serve data it couldn't verify.

The bug that's actually worth explaining

@CircuitBreaker in Spring is implemented with a proxy that intercepts calls arriving from outside the annotated bean. A call from one method to another inside the same class never goes through that proxy, so the breaker attached to the annotation does nothing: no error, no log line, it's just silently absent. That's why the gRPC call lives in its own class, AuthGrpcClient, called from AuthValidationService rather than folded into it. It's not a style preference. Fold the two together and the breaker still compiles, still looks correct, and quietly stops working the first time the auth service actually goes down.

The other design detail that makes the whole failure story hold together lives on the Go side, not here: an expired or revoked token is never a gRPC error, it's a normal successful response with valid: false. If it were an error instead, a routine burst of expired tokens would look identical to a real outage and trip the breaker over nothing. This service's failure handling only works because the thing it's calling is careful about what actually counts as failure.

Is the cache worth it?

k6, 10 virtual users, 40 seconds, same conditions used for the Go service's own load test:

Scenario Throughput p50 p95
GET /api/notes, warm cache ~91.8 req/s 6.3ms 13.4ms
GET /api/notes, cold cache (APP_CACHE_MAX_TTL_SECONDS=0) ~92.9 req/s 6.0ms 9.7ms

The honest result: those two numbers are basically identical. On one machine, gRPC between two containers on the same Docker network runs well under a millisecond, so there's nothing for the cache to save on throughput here. That's not the same as the cache being pointless. Its actual value shows up during an outage, when cached tokens keep working while the auth service is down, and in the load it keeps off the auth service under real traffic, neither of which a single-machine benchmark can produce. A real latency gap would need the two services separated by an actual network or the auth service under real concurrent load.

Running it

Needs Docker. The compose file builds the Go service from a sibling checkout at ../4. go-auth-service.

docker compose up --build

That starts the Go auth service (HTTP :8080, gRPC published on :50051), its Postgres and Redis, a one-shot migration job, this Notes API on :8081, and its own Postgres. There's also a narrated demo script that drives the same compose file end to end, including killing the auth service mid-session to show the 503 and the automatic recovery. It lives in the auth repo at ../4. go-auth-service/demo.sh, since that's the entry point of the whole platform.

./mvnw verify   # unit + Testcontainers integration tests, needs Docker

Testcontainers needs Docker Engine's API at version 1.44 or newer; this repo pins Testcontainers 2.0.x specifically for that.

Where every request goes

Authorization: Bearer <token>
        │
        ▼
GrpcAuthFilter          reads the header, decides 200 / 401 / 503
        │
        ▼
AuthValidationService   checks the cache, calls gRPC on a miss
        │
        ▼
AuthGrpcClient          the actual RPC, own bean, deadline + breaker
        │
        ▼
NoteController          your notes only, enforced one layer down
        │
        ▼
NoteService              findByIdAndOwnerId, never just findById

You can only see and edit your own notes, and that check lives in NoteService, not the controller. A controller method added later can't accidentally skip a rule that sits beneath it. Someone else's note returns 404 rather than 403, on purpose: 403 confirms the note exists, and confirming that to someone who doesn't own it is a small but real information leak.

API

All endpoints except /actuator/health require Authorization: Bearer <JWT> from the Go service.

Method Path Purpose
GET /api/me The authenticated user's ID and roles
POST /api/notes Create a note
GET /api/notes List your own notes
GET /api/notes/{id} Get one of your notes (404 if you don't own it)
PUT /api/notes/{id} Update one of your notes
DELETE /api/notes/{id} Delete one of your notes
GET /actuator/health Health, including circuit breaker state
TOKEN=$(curl -s -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@app.com","password":"admin123"}' | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/me
curl -X POST http://localhost:8081/api/notes \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"First note","body":"Hello"}'

Auth semantics

Situation Response
No Authorization header 403, Spring Security's default for anonymous requests
Token invalid, expired, or revoked 401 with a reason
Auth service down or breaker open, token not cached 503 + Retry-After
Auth service down, token still cached 200, cached tokens ride out short outages
Valid token, someone else's note 404

A few decisions worth explaining

Plain grpc-java with a @Configuration channel bean instead of the net.devh Spring Boot starter, so there's no magic between the config and where the channel actually gets created and torn down. Only successes go into the cache, keyed by token hash, never by the token itself; failures aren't cached, since re-checking a bad token is cheap and caching one could pin a temporary blip in place for a full TTL. The AuthGrpcClient split from AuthValidationService, covered above. Reading the JWT's exp claim locally without checking the signature, since signature verification is the Go service's job and duplicating it here would mean holding a copy of its secret in a second place.

Things it doesn't do

No role-based access control: this version has no admin-only endpoint, because the interesting engineering here is the auth integration, not RBAC. A revoked token can work here for up to the cache TTL, covered above. It trusts the Go service's role strings without questioning them. One instance of each service, no horizontal scaling, left out on purpose. No refresh handling; refresh tokens live in the Go service and clients talk to it directly for that. No metrics export beyond the actuator health endpoint.

Configuration

Variable Default Purpose
GRPC_AUTH_HOST auth-api Auth service gRPC host
GRPC_AUTH_PORT 9090 Auth service gRPC port
APP_CACHE_MAX_TTL_SECONDS 60 Validation-cache TTL cap, 0 disables caching
SPRING_DATASOURCE_URL jdbc:postgresql://localhost:5436/notes_db Notes database
resilience4j.circuitbreaker.instances.authService.* see application.properties Breaker tuning

Project structure

src/main/java/com/ashraf/notesapi/     one flat package, reading top to bottom
├── NotesApiApplication.java           is the request's actual path
├── SecurityConfig.java                filter → validation service → gRPC client,
├── GrpcAuthFilter.java                then controller → service → repository
├── AuthValidationService.java
├── AuthGrpcClient.java
├── TokenCache.java
├── GrpcConfig.java
├── Note.java
├── NoteRepository.java
├── NoteService.java
├── NoteController.java
└── ApiExceptionHandler.java
proto/auth.proto                       copied verbatim from go-auth-service
resources/db/migration/                Flyway
test/java/com/ashraf/notesapi/
├── support/                           FakeAuthService, GrpcTestConfig, base class
└── *Test.java                         30 tests: 17 unit, 13 Testcontainers/in-process-gRPC

The other half

The Go auth service is where identity actually lives: password hashing, JWT signing, refresh-token rotation with reuse detection, and the ValidateToken RPC this service depends on. Its README covers the atomic-update fix that makes reuse detection safe under concurrent replay.

About

Spring Boot resource API that owns its data but not identity — every request is validated against a Go auth service over gRPC. Token-hash-keyed Caffeine cache (60s TTL) and a Resilience4j circuit breaker that fails closed with 503 + Retry-After when auth is down. 30 tests with Testcontainers.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages