package com.example.caching.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
@Configuration
public class ShallowEtagConfig {
@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagFilter() {
FilterRegistrationBean<ShallowEtagHeaderFilter> registration =
new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
registration.addUrlPatterns("/api/*");
registration.setName("etagFilter");
registration.setOrder(1);
return registration;
}
}
package com.example.caching.web;
import com.example.caching.ProductService;
import com.example.caching.model.Product;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable String id) {
Product product = productService.findById(id);
CacheControl cacheControl = CacheControl
.maxAge(60, TimeUnit.SECONDS)
.mustRevalidate()
.cachePublic();
return ResponseEntity.ok()
.cacheControl(cacheControl)
.body(product);
}
}
package com.example.caching;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
class ProductServiceTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc() {
return MockMvcBuilders.webAppContextSetup(context)
.addFilters(new ShallowEtagHeaderFilter())
.build();
}
@Test
void returns304WhenEtagMatches() throws Exception {
MockMvc mvc = mockMvc();
MvcResult first = mvc.perform(get("/api/products/42"))
.andExpect(status().isOk())
.andExpect(header().exists(HttpHeaders.ETAG))
.andReturn();
String etag = first.getResponse().getHeader(HttpHeaders.ETAG);
assertThat(etag).isNotBlank();
mvc.perform(get("/api/products/42").header(HttpHeaders.IF_NONE_MATCH, etag))
.andExpect(status().isNotModified())
.andExpect(content().string(""));
}
}
This snippet shows how a Spring Boot service returns conditional GET responses so clients can skip re-downloading unchanged resources. The core idea is HTTP validation caching: the server tags each response body with an ETag, the client echoes it back in If-None-Match, and when nothing changed the server answers 304 Not Modified with an empty body. This trades a little server CPU (hashing the payload) for large savings in bandwidth and client render time.
In ShallowEtagConfig, a ShallowEtagHeaderFilter is registered as a servlet Filter. It is called shallow because it buffers the fully rendered response, computes an MD5 hash over the bytes, and sets that as the ETag. On the next request it compares the incoming If-None-Match against the freshly computed tag; on a match it discards the body and writes a 304. The registration is scoped to /api/* via addUrlPatterns so only API responses pay the hashing cost, and setBeanName keeps it distinct from any auto-registered instance. Note the trade-off: the body is still generated in full, so this saves network transfer, not server work.
ProductController layers expiration caching on top of validation caching. Its getProduct handler builds a CacheControl with maxAge plus mustRevalidate, so browsers may reuse the response for a short window and then revalidate using the ETag. Because ShallowEtagHeaderFilter handles ETag generation and the 304 short-circuit automatically, the controller never touches If-None-Match itself — it simply returns the resource and its cache policy through ResponseEntity.
ProductServiceTest uses MockMvc to prove the behavior end to end: a first GET returns 200 with an ETag header, and a second GET carrying that value in If-None-Match returns 304 with no body. A key pitfall the test guards against is filter ordering — the ETag filter must wrap the controller, and maxAge must not be so long that clients never revalidate. This pattern fits read-heavy, cacheable endpoints where payloads change infrequently and correctness of staleness matters.
Related snips
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
Share this code
Here's the card — post it anywhere.