A few days ago, I came across an unusual bug came from @Cacheable.
The method looked harmless: it returned a collection, and we cached the result. That method was used in several places. Some callers only read the collection. Others modified it - removing items, reordering it, or adding extra data for their own flow.
That worked fine until caching turned the returned collection into shared state.
After @Cacheable, callers were no longer getting a freshly built result. They were often getting the same object from the cache. So once one code path changed that collection, the cached value itself was changed. The next caller could receive an already mutated version.
In a multi-user system, this gets ugly fast. The bug looks random. One request changes something “locally,” another request sees unexpected data, and nothing in the logs clearly points to the real cause.
The issue was not the cache itself. The issue was caching a mutable object and then letting different parts of the code treat it as their own copy.
Since then, I’ve been much more careful with cached method results.
If a method is annotated with @Cacheable, I treat its return value as read-only.
In practice, that usually means:
- return
List.copyOf(...)orSet.copyOf(...) - avoid exposing collections that callers may modify
- create a defensive copy if the ownership boundary is not obvious
A small optimization introduced shared mutable state across requests. That was the whole bug.
A good reminder that caching changes more than performance. It also changes the contract of a method.
Top comments (0)