Leveling Up My Spring Boot App — Caching, Logging, and Writing Code That Doesn't Embarrass Future Me

Last week I wrote about building the Time Capsule app — the what, the how, the things that broke. This week I went back into the same codebase and added two things I'd been putting off: caching and structured logging. They're not glamorous features. Nobody opens your app and says "wow, I love the log format." But after actually sitting with these concepts and implementing them, I get why senior devs treat them as non-negotiables.
Here's what I learned and how I applied it.
Why Caching Though?
The short version: I was making database calls for the same data over and over again. Every time a user opened a capsule detail page, I was hitting the DB — even if nothing had changed since the last request 5 seconds ago.
Caching fixes that by storing the result of a computation or DB call in a faster storage layer, so the next identical request gets served from there instead. The benefits are pretty obvious once you see it:
Faster response times — memory reads are orders of magnitude faster than DB queries
Less DB load — fewer queries means less pressure on your database, which matters when you scale
Lower cost — if you're on a managed DB with read pricing, this directly hits your bill
The catch? Stale data. You're trading freshness for speed, which means you have to be intentional about when you cache and when you invalidate.
Types of Caching — More Than You'd Think
Before jumping into Spring, I wanted to understand the landscape. Caching isn't just one thing:
CDN Caching — Sits at the network edge. Caches static assets (HTML, JS, CSS, images) close to the user's physical location. You've used this every time a website loaded fast from the other side of the world.
Web Server Caching — Nginx or similar can cache responses before they even hit your application. Useful for publicly accessible, frequently read endpoints.
Application / In-Memory Caching — This is what I implemented. The cache lives inside the application's JVM heap. Fast, simple, no external dependencies. Works great for single-instance apps; gets complicated with multiple instances (you'd need Redis or a distributed cache to keep them in sync).
Database Caching — Most databases do this automatically with query result caches and buffer pools. Postgres, for example, keeps frequently accessed pages in shared buffers. You can also layer something like Redis in front of your DB for heavier read workloads.
For Time Capsule right now, in-memory was the right call. It's a learning project, it runs as a single instance, and I wanted to understand the basics before reaching for Redis.
Spring Boot's Caching Layer and How It Actually Works
Spring's caching abstraction is genuinely clever. You add @EnableCaching to your config, annotate methods, and Spring intercepts those method calls using AOP (aspect-oriented programming) — essentially wrapping them with proxy logic that checks the cache before executing the actual method body.
The flow looks like this:
Request comes in
→ Spring AOP proxy intercepts the method call
→ Checks the cache using the configured key
→ Cache HIT? Return cached value, skip method body
→ Cache MISS? Execute method, store result, return it
The CacheManager is what ties everything together. It's the interface Spring uses to interact with whatever backing store you've configured — could be ConcurrentMapCacheManager (in-memory HashMap), RedisCacheManager, EhCacheCacheManager, etc. You swap the manager, and your annotations work the same way. That abstraction is the whole point which makes developer's job much easier.
I went with ConcurrentMapCacheManager for now — no extra dependencies, works out of the box.
What I Implemented in Time Capsule
The two main annotations I used were @Cacheable, @CachePut, and @CacheEvict (okay, three).
@Cacheable on the getCapsuleDetails method — cache the result when a user fetches a capsule. Key is a composite of the user's ID and the capsule slug, so each user's view of each capsule is cached independently:
@Cacheable(
cacheNames = CAPSULE_DETAILS_CACHE,
key = "{T(com.akshansh.timecapsulebackend.util.UserUtil).getCurrentUser().getUserId(), #slug}"
)
@Transactional
public CapsuleDto getCapsuleDetails(String slug) {
// ...
}
The key expression uses Spring's SpEL (Spring Expression Language) to dynamically build the cache key. T(...) is SpEL for calling a static/utility class method, and #slug pulls the method argument. First time this runs per user+slug combo, it hits the DB. After that — straight from cache.
@CachePut + @CacheEvict on updateCapsule — this was the more interesting one. When a capsule is updated, I need to:
Update the
CAPSULE_DETAILS_CACHEwith the new data (so the detail view stays fresh)Invalidate the
CAPSULE_LIST_CACHEentirely (because the list could reflect stale data — title, metadata, etc.)
@Transactional
@CachePut(
cacheNames = CAPSULE_DETAILS_CACHE,
key = "{T(com.akshansh.timecapsulebackend.util.UserUtil).getCurrentUser().getUserId(), #slug}"
)
@CacheEvict(cacheNames = CAPSULE_LIST_CACHE, allEntries = true)
public CapsuleDto updateCapsule(UpdateCapsuleRequest request, String slug) {
// ...
}
allEntries = true on @CacheEvict nukes everything in that cache. A bit blunt, but fine for now — the list gets rebuilt fresh on next request.
I actually got this wrong the first time. My original @CachePut key was just the userId — I'd forgotten to include #slug:
key = "T(com.akshansh.timecapsulebackend.util.UserUtil).getCurrentUser().getUserId()"
Seemed fine at first. Then I noticed something off: after updating one capsule, fetching a different capsule would return the updated data from the first one. Took me a bit to figure out what was happening.
Because the key was only userId, every capsule for a given user mapped to the same cache slot. So when @CachePut ran on an update, it didn't write to "this user's entry for this specific capsule" — it wrote to "this user's entry," period. Whatever capsule was updated last would silently overwrite every other cached entry for that user. A user with five capsules effectively had one cache slot shared between all of them. The stale entries for the other four capsules were still sitting there, they just got clobbered the moment any update happened.
The fix was obvious once I understood it — include #slug in the key so each user+capsule combination gets its own slot, which is exactly what the @Cacheable on the read method was already doing. Always make sure your @CachePut key matches your @Cacheable key exactly. If they don't match, the "put" writes to a different slot than the "get" reads from, and you've got a cache that's quietly lying to you.
Cache Invalidation — The Hard Part
There's a famous quote that goes something like: "there are only two hard things in computer science — cache invalidation and naming things." Spent a few hours this week understanding why.
The main invalidation strategies:
TTL (Time-to-Live) — Every cache entry expires after N seconds, regardless of whether the underlying data changed. Simple, predictable, but you're always serving potentially stale data up until that expiry.
Event-based / Explicit eviction — What I'm doing. On a write/update, manually evict the relevant cache entries. Fresher data, but more code to maintain and more surface area for bugs (forget to evict somewhere and you've got a stale cache that's hard to debug).
Write-through — On every write, update the cache AND the DB simultaneously. Cache is always in sync. More writes per operation, but you eliminate the "stale window" problem.
Cache-aside (Lazy loading) — Don't pre-populate. On a cache miss, fetch from DB, populate the cache, return. What Spring's @Cacheable does by default.
For something like Time Capsule where data accuracy matters (you don't want someone opening their capsule and seeing old content), event-based eviction made the most sense to me.
Logging — The Part Most Tutorials Skip
Almost every Spring Boot tutorial which I referred, showed how to write log.info("something happened"). Very few talk about what you should actually write and why.
The elements of a logging framework worth knowing:
Logger — the thing you call (
LoggerFactory.getLogger(...))Appender — where logs go (console, file, external system like Loki or Datadog)
Encoder/Layout — how logs are formatted (plain text, JSON, logfmt)
Log level — how severe the event is
The log levels, in order: TRACE → DEBUG → INFO → WARN → ERROR
The rule I try to follow: if you'd want to know about it during an incident at 2am, log it at WARN or ERROR. Everything else that's just "the app is working normally" is INFO.
Structured Log Format and MDC
This was the part that genuinely changed how I think about logs.
Random log.info("updated capsule") messages are useless at scale. You can't filter them, you can't correlate them across a request lifecycle, and you definitely can't query them in any log aggregation tool.
The format I settled on follows this pattern:
[LEVEL] event=<what_happened> | <identity_context> | <request_context> | <error_context> | duration=<ms>
And for each log message, I think about it as: Who → did what → on what → got what result → why
The real unlock though was MDC (Mapped Diagnostic Context). MDC lets you attach key-value pairs to the current thread, and they automatically appear in every log line that thread writes — without you having to pass them manually through every method.
I implemented a RequestLoggingFilter that runs on every incoming request:
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String userId = extractUserIdFromSecurityContext();
String requestId = UUID.randomUUID().toString().substring(0, 8);
long start = System.currentTimeMillis();
MDC.put("requestId", requestId);
MDC.put("userId", userId);
log.info("IN method={} uri={} userId={} ip={}",
request.getMethod(),
request.getRequestURI(),
userId,
request.getRemoteAddr());
try {
filterChain.doFilter(request, response);
} finally {
log.info("OUT method={} uri={} status={} userId={} duration={}ms",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
userId,
System.currentTimeMillis() - start);
MDC.clear();
}
}
// ...
}
The MDC.put(...) at the top and MDC.clear() in the finally block are critical. Every log line written during that request's thread will now carry requestId and userId automatically. And the finally ensures the MDC is cleared even if an exception is thrown — you don't want a previous request's context bleeding into the next one on thread pool reuse.
This is also where MDC earns its keep in error handling. I have a GlobalExceptionHandler that catches things like ResourceNotFoundException, and because the filter already put userId and requestId into MDC at the start of the request, I can pull them out here without them being passed through a single method parameter:
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
HttpStatus.NOT_FOUND.getReasonPhrase(),
ex.getMessage(),
request.getRequestURI()
);
log.warn("Client error event=resourceNotFound status=404 method={} uri={} userId={} errorType={} message=\"{}\" requestId={}",
request.getMethod(),
request.getRequestURI(),
MDC.get("userId"),
ex.getClass().getSimpleName(),
ex.getMessage(),
MDC.get("requestId")
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}
MDC.get("userId") and MDC.get("requestId") just work — because they were set at the filter layer and they're still on the same thread. No ThreadLocal juggling, no passing context objects around. The exception handler doesn't need to know anything about the request lifecycle to produce a fully-contextualized log line. That's the whole point of MDC.
A few logging best practices I picked up:
No PII in logs. Don't log email addresses, passwords, tokens, or anything personally identifiable. Logs often end up in places with weaker access controls than your main DB.
Log at boundaries. Service method entry/exit, external API calls, DB operations — these are the places logs are actually useful.
Be consistent with your key names.
userIdeverywhere, notuserIdin one place anduser_idin another. Querying inconsistent logs is painful.Don't log inside tight loops. A
log.debug(...)inside a loop that runs 10,000 times will wreck your performance and flood your log pipeline.
What I'd Do Differently
A few things I'm already thinking about:
Switch to Redis for the cache. ConcurrentMapCacheManager works but it lives in the JVM heap, has no TTL support by default, and won't work across multiple instances. Redis is the obvious next step.
Add TTL to cache entries. Right now, cached capsule details live forever until explicitly evicted. A TTL backstop would be a good safety net.
Move to JSON-structured logging. Right now logs are human-readable text. If I ever hook this up to a log aggregation tool, JSON format plays nicer.
Write more integration tests around cache behavior. I tested the service logic, but I didn't write explicit tests to verify "this method was called once even though the endpoint hit it three times." That's a gap.
This week felt more like "actually understanding the production side of Spring Boot" rather than just adding features. Caching and logging aren't things you notice when they're done right — but you definitely notice when they're missing.
If interested, you can find the source code at Github:Time-Capsule.
What do you use for caching in your Spring Boot projects? Still on ConcurrentMapCacheManager or have you moved to Redis? Drop it in the comments.



