# 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:

```java
@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:

1.  Update the `CAPSULE_DETAILS_CACHE` with the new data (so the detail view stays fresh)
    
2.  Invalidate the `CAPSULE_LIST_CACHE` entirely (because the list could reflect stale data — title, metadata, etc.)
    

```java
@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`:

```java

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:

```plaintext
[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:

```java
@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:

```java
@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.** `userId` everywhere, not `userId` in one place and `user_id` in 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](https://github.com/akshansh029/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.
