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
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.
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
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.
@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.
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.
Needs Docker. The compose file builds the Go service from a sibling checkout at ../4. go-auth-service.
docker compose up --buildThat 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 DockerTestcontainers needs Docker Engine's API at version 1.44 or newer; this repo pins Testcontainers 2.0.x specifically for that.
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.
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"}'| 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 |
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.
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.
| 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 |
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 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.