diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java
index b4b614559e5..644a20fc5ca 100644
--- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java
+++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java
@@ -52,8 +52,11 @@ class OwnerController {
private final OwnerRepository owners;
- public OwnerController(OwnerRepository owners) {
+ private final OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
+ public OwnerController(OwnerRepository owners, OwnerDirectorySynchronizer ownerDirectorySynchronizer) {
this.owners = owners;
+ this.ownerDirectorySynchronizer = ownerDirectorySynchronizer;
}
@InitBinder
@@ -82,6 +85,7 @@ public String processCreationForm(@Valid Owner owner, BindingResult result, Redi
}
this.owners.save(owner);
+ this.ownerDirectorySynchronizer.ownerAggregateChanged();
redirectAttributes.addFlashAttribute("message", "New Owner Created");
return "redirect:/owners/" + owner.getId();
}
@@ -157,6 +161,7 @@ public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @
owner.setId(ownerId);
this.owners.save(owner);
+ this.ownerDirectorySynchronizer.ownerAggregateChanged();
redirectAttributes.addFlashAttribute("message", "Owner Values Updated");
return "redirect:/owners/{ownerId}";
}
diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java
new file mode 100644
index 00000000000..13793c82b2e
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizer.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.owner;
+
+import org.springframework.samples.petclinic.system.CacheOperations;
+import org.springframework.stereotype.Service;
+
+/**
+ * Keeps front-desk owner-directory projections synchronized after aggregate changes.
+ */
+@Service
+public class OwnerDirectorySynchronizer {
+
+ private final CacheOperations cacheOperations;
+
+ public OwnerDirectorySynchronizer(CacheOperations cacheOperations) {
+ this.cacheOperations = cacheOperations;
+ }
+
+ public void ownerAggregateChanged() {
+ this.cacheOperations.evict(this.cacheOperations.ownerSearchCache());
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java
index d2b3dde40f8..9097eb44dfa 100644
--- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java
+++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java
@@ -20,6 +20,8 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.samples.petclinic.system.CacheOperations;
/**
* Repository class for Owner domain objects. All method names are compliant
@@ -42,6 +44,7 @@ public interface OwnerRepository extends JpaRepository {
* @return a Collection of matching {@link Owner}s (or an empty Collection if none
* found)
*/
+ @Cacheable(CacheOperations.OWNER_SEARCH_CACHE)
Page findByLastNameStartingWith(String lastName, Pageable pageable);
/**
diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java
index 845c116633b..b10e156a602 100644
--- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java
+++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java
@@ -54,9 +54,13 @@ class PetController {
private final PetTypeRepository types;
- public PetController(OwnerRepository owners, PetTypeRepository types) {
+ private final OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
+ public PetController(OwnerRepository owners, PetTypeRepository types,
+ OwnerDirectorySynchronizer ownerDirectorySynchronizer) {
this.owners = owners;
this.types = types;
+ this.ownerDirectorySynchronizer = ownerDirectorySynchronizer;
}
@ModelAttribute("types")
@@ -132,6 +136,7 @@ public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult res
result.rejectValue("name", "duplicate", "already exists");
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
}
+ this.ownerDirectorySynchronizer.ownerAggregateChanged();
redirectAttributes.addFlashAttribute("message", "New Pet has been Added");
return "redirect:/owners/{ownerId}";
}
@@ -174,6 +179,7 @@ public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult resul
result.rejectValue("name", "duplicate", "already exists");
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
}
+ this.ownerDirectorySynchronizer.ownerAggregateChanged();
redirectAttributes.addFlashAttribute("message", "Pet details has been edited");
return "redirect:/owners/{ownerId}";
}
diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java
index b8b270073e8..a39a21371ab 100644
--- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java
+++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java
@@ -44,8 +44,11 @@ class VisitController {
private final OwnerRepository owners;
- public VisitController(OwnerRepository owners) {
+ private final OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
+ public VisitController(OwnerRepository owners, OwnerDirectorySynchronizer ownerDirectorySynchronizer) {
this.owners = owners;
+ this.ownerDirectorySynchronizer = ownerDirectorySynchronizer;
}
@InitBinder
@@ -107,6 +110,7 @@ public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int
owner.addVisit(petId, visit);
this.owners.save(owner);
+ this.ownerDirectorySynchronizer.ownerAggregateChanged();
redirectAttributes.addFlashAttribute("message", "Your visit has been booked");
return "redirect:/owners/{ownerId}";
}
diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java
index 13cb743012d..d8ca882083d 100644
--- a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java
+++ b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java
@@ -34,7 +34,10 @@ class CacheConfiguration {
@Bean
public JCacheManagerCustomizer petclinicCacheConfigurationCustomizer() {
- return cm -> cm.createCache("vets", cacheConfiguration());
+ return cm -> {
+ cm.createCache("vets", cacheConfiguration());
+ cm.createCache(CacheOperations.OWNER_SEARCH_CACHE, cacheConfiguration());
+ };
}
/**
diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java b/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java
new file mode 100644
index 00000000000..df71acf3734
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/system/CacheDiagnosticsController.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.system;
+
+import java.util.Map;
+
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Lightweight operational endpoints for checking and refreshing owner-search data.
+ */
+@RestController
+@RequestMapping("/manage/cache/owner-search")
+class CacheDiagnosticsController {
+
+ private final CacheOperations cacheOperations;
+
+ CacheDiagnosticsController(CacheOperations cacheOperations) {
+ this.cacheOperations = cacheOperations;
+ }
+
+ @GetMapping
+ CacheOperations.CacheStatus status() {
+ return this.cacheOperations.ownerSearchStatus();
+ }
+
+ @DeleteMapping
+ Map evict() {
+ boolean evicted = this.cacheOperations.evictOwnerSearch();
+ return Map.of("cache", CacheOperations.OWNER_SEARCH_CACHE, "evicted", evicted);
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java b/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java
new file mode 100644
index 00000000000..1868cfc4697
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/system/CacheOperations.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.system;
+
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+
+/**
+ * Operational boundary for inspecting and invalidating application caches.
+ */
+@Service
+public class CacheOperations {
+
+ public static final String OWNER_SEARCH_CACHE = "ownerSearch";
+
+ private final CacheManager cacheManager;
+
+ public CacheOperations(CacheManager cacheManager) {
+ this.cacheManager = cacheManager;
+ }
+
+ /**
+ * Invalidate all cached owner-directory searches.
+ * @return {@code true} when the configured cache was available
+ */
+ public boolean evictOwnerSearch() {
+ return evict(ownerSearchCache());
+ }
+
+ /**
+ * Evict every entry from the supplied cache.
+ * @param cache the cache selected by the caller
+ * @return {@code true} when a cache was supplied
+ */
+ public boolean evict(Cache cache) {
+ if (cache == null) {
+ return false;
+ }
+ cache.clear();
+ return true;
+ }
+
+ /**
+ * Report whether the owner-search cache is currently available.
+ */
+ public CacheStatus ownerSearchStatus() {
+ return new CacheStatus(OWNER_SEARCH_CACHE, ownerSearchCache() != null);
+ }
+
+ /**
+ * Resolve the cache used by the owner directory.
+ */
+ public Cache ownerSearchCache() {
+ return this.cacheManager.getCache(OWNER_SEARCH_CACHE);
+ }
+
+ public record CacheStatus(String name, boolean available) {
+ }
+
+}
diff --git a/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java
index dd379a5cbd3..355c2147481 100644
--- a/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java
@@ -70,6 +70,9 @@ class OwnerControllerTests {
@MockitoBean
private OwnerRepository owners;
+ @MockitoBean
+ private OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
private Owner george() {
Owner george = new Owner();
george.setId(TEST_OWNER_ID);
diff --git a/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java
new file mode 100644
index 00000000000..b7e497ac85a
--- /dev/null
+++ b/src/test/java/org/springframework/samples/petclinic/owner/OwnerDirectorySynchronizerTests.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.owner;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.cache.Cache;
+import org.springframework.samples.petclinic.system.CacheOperations;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class OwnerDirectorySynchronizerTests {
+
+ @Test
+ void ownerAggregateChangesInvalidateCachedSearches() {
+ CacheOperations cacheOperations = mock(CacheOperations.class);
+ Cache ownerSearchCache = mock(Cache.class);
+ given(cacheOperations.ownerSearchCache()).willReturn(ownerSearchCache);
+ OwnerDirectorySynchronizer synchronizer = new OwnerDirectorySynchronizer(cacheOperations);
+
+ synchronizer.ownerAggregateChanged();
+
+ verify(cacheOperations).evict(ownerSearchCache);
+ }
+
+}
diff --git a/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java
index b45ffd48d1e..43ed6ec3116 100644
--- a/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java
@@ -67,6 +67,9 @@ class PetControllerTests {
@MockitoBean
private PetTypeRepository types;
+ @MockitoBean
+ private OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
@BeforeEach
void setup() {
PetType cat = new PetType();
diff --git a/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java b/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java
index b608caa6711..20557832823 100644
--- a/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java
@@ -56,6 +56,9 @@ class VisitControllerTests {
@MockitoBean
private OwnerRepository owners;
+ @MockitoBean
+ private OwnerDirectorySynchronizer ownerDirectorySynchronizer;
+
@BeforeEach
void init() {
Owner owner = new Owner();
diff --git a/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java b/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java
new file mode 100644
index 00000000000..fd5217cdc22
--- /dev/null
+++ b/src/test/java/org/springframework/samples/petclinic/system/CacheDiagnosticsControllerTests.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.system;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(CacheDiagnosticsController.class)
+class CacheDiagnosticsControllerTests {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockitoBean
+ private CacheOperations cacheOperations;
+
+ @Test
+ void reportsOwnerSearchCacheStatus() throws Exception {
+ given(this.cacheOperations.ownerSearchStatus())
+ .willReturn(new CacheOperations.CacheStatus(CacheOperations.OWNER_SEARCH_CACHE, true));
+
+ this.mockMvc.perform(get("/manage/cache/owner-search"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.name").value(CacheOperations.OWNER_SEARCH_CACHE))
+ .andExpect(jsonPath("$.available").value(true));
+ }
+
+ @Test
+ void evictsOwnerSearchCache() throws Exception {
+ given(this.cacheOperations.evictOwnerSearch()).willReturn(true);
+
+ this.mockMvc.perform(delete("/manage/cache/owner-search"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.cache").value(CacheOperations.OWNER_SEARCH_CACHE))
+ .andExpect(jsonPath("$.evicted").value(true));
+ }
+
+}
diff --git a/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java b/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java
new file mode 100644
index 00000000000..6a2ea66cf19
--- /dev/null
+++ b/src/test/java/org/springframework/samples/petclinic/system/CacheOperationsTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2012-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.samples.petclinic.system;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class CacheOperationsTests {
+
+ @Test
+ void evictsTheOwnerSearchCache() {
+ CacheManager cacheManager = mock(CacheManager.class);
+ Cache cache = mock(Cache.class);
+ given(cacheManager.getCache(CacheOperations.OWNER_SEARCH_CACHE)).willReturn(cache);
+ CacheOperations operations = new CacheOperations(cacheManager);
+
+ assertThat(operations.evictOwnerSearch()).isTrue();
+
+ verify(cache).clear();
+ }
+
+ @Test
+ void reportsWhenOwnerSearchCacheIsUnavailable() {
+ CacheManager cacheManager = mock(CacheManager.class);
+ CacheOperations operations = new CacheOperations(cacheManager);
+
+ assertThat(operations.evictOwnerSearch()).isFalse();
+ assertThat(operations.ownerSearchStatus())
+ .isEqualTo(new CacheOperations.CacheStatus(CacheOperations.OWNER_SEARCH_CACHE, false));
+ }
+
+}