What Spring Boot Built-in Features Do Developers Commonly Reinvent Instead of Using?
Problem
I keep writing custom implementations for features that I later discover Spring Boot already provides. This wastes time and introduces bugs that Spring’s battle-tested code has already solved.
For example, I wrote my own caching logic:
@Servicepublic class ProductService { private final Map<Long, Product> cache = new ConcurrentHashMap<>(); private final ProductRepository repository;
public Product getProduct(Long id) { if (cache.containsKey(id)) { return cache.get(id); } Product product = repository.findById(id) .orElseThrow(() -> new NotFoundException("Product not found")); cache.put(id, product); return product; }
public void updateProduct(Product product) { repository.save(product); cache.put(product.getId(), product); // Manual invalidation } // Missing: TTL, eviction policies, distributed caching}Then I discovered Spring’s @Cacheable does this in one line.
Environment
- Spring Boot 3.x
- Spring Framework 6.x
- Java 17+
What happened?
I reimplemented four common features before learning Spring already had them:
1. Caching - Wrote manual ConcurrentHashMap logic
2. Retry logic - Wrote while loops with Thread.sleep()
3. Validation - Wrote manual if statements for every field
4. HTTP responses - Built responses with Map<String, Object>
Each time, I could have used a Spring annotation instead.
How to solve it?
Feature 1: Caching with @Cacheable
Instead of manual caching:
@Servicepublic class ProductService { private final Map<Long, Product> cache = new ConcurrentHashMap<>();
public Product getProduct(Long id) { if (cache.containsKey(id)) { return cache.get(id); } Product product = repository.findById(id).orElseThrow(...); cache.put(id, product); return product; }}Use Spring’s declarative caching:
@Servicepublic class ProductService { private final ProductRepository repository;
@Cacheable(value = "products", key = "#id") public Product getProduct(Long id) { return repository.findById(id) .orElseThrow(() -> new NotFoundException("Product not found")); }
@CacheEvict(value = "products", key = "#product.id") public Product updateProduct(Product product) { return repository.save(product); }}Enable caching in your application:
@SpringBootApplication@EnableCachingpublic class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); }}Feature 2: Retry with @Retryable
Instead of manual retry loops:
@Servicepublic class PaymentService { private static final int MAX_RETRIES = 3; private static final long RETRY_DELAY_MS = 1000;
public PaymentResult processPayment(PaymentRequest request) { int attempts = 0; while (attempts < MAX_RETRIES) { try { return callPaymentGateway(request); } catch (PaymentException e) { attempts++; if (attempts >= MAX_RETRIES) { throw new PaymentFailedException("Failed after " + attempts, e); } Thread.sleep(RETRY_DELAY_MS * attempts); } } throw new IllegalStateException("Should not reach here"); }}Use Spring Retry:
@Servicepublic class PaymentService {
@Retryable( retryFor = PaymentException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2) ) public PaymentResult processPayment(PaymentRequest request) { return callPaymentGateway(request); }
@Recover public PaymentResult recover(PaymentException e, PaymentRequest request) { log.error("Payment failed after retries for order: {}", request.getOrderId()); throw new PaymentFailedException("Payment processing unavailable", e); }}Add the dependency:
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId></dependency>Feature 3: Validation with @Valid
Instead of manual validation:
@PostMapping("/users")public User createUser(@RequestBody Map<String, Object> body) { if (body.get("email") == null) { throw new ValidationException("Email is required"); } String email = (String) body.get("email"); if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { throw new ValidationException("Invalid email format"); } if (body.get("name") == null) { throw new ValidationException("Name is required"); } // ... more validation}Use Bean Validation:
public record CreateUserRequest( @NotBlank(message = "Email is required") @Email(message = "Invalid email format") String email,
@NotBlank(message = "Name is required") @Size(min = 2, max = 100, message = "Name must be 2-100 characters") String name,
@Min(value = 18, message = "Must be at least 18") Integer age) {}
@RestController@Validatedpublic class UserController { @PostMapping("/users") public User createUser(@Valid @RequestBody CreateUserRequest request) { // All validation happens automatically return userService.createUser(request.email(), request.name()); }}Feature 4: HTTP Responses with ResponseEntity
Instead of manual response building:
@GetMapping("/products/{id}")public Map<String, Object> getProduct(@PathVariable Long id) { Product product = productService.findById(id); Map<String, Object> response = new HashMap<>(); response.put("data", product); response.put("status", "success"); return response;}Use ResponseEntity:
@GetMapping("/{id}")public ResponseEntity<Product> getProduct(@PathVariable Long id) { return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(30, TimeUnit.MINUTES)) .eTag(productService.getVersion(id)) .body(productService.findById(id));}
@PostMappingpublic ResponseEntity<Product> createProduct( @Valid @RequestBody ProductRequest request, UriComponentsBuilder uriBuilder) { Product product = productService.create(request); URI location = uriBuilder.path("/products/{id}") .buildAndExpand(product.getId()) .toUri(); return ResponseEntity.created(location).body(product);}
@DeleteMapping("/{id}")public ResponseEntity<Void> deleteProduct(@PathVariable Long id) { productService.delete(id); return ResponseEntity.noContent().build();}You can see that Spring annotations handle the complexity for you.
The reason
Spring’s built-in features are battle-tested and integrate with the ecosystem:
┌────────────────────────────────────────────────────────────────┐│ Custom Implementation │├────────────────────────────────────────────────────────────────┤│ ││ Your code ──▶ Bugs you didn't expect ──▶ Maintenance burden ││ ││ - No TTL support ││ - No distributed caching ││ - No monitoring integration ││ - No exponential backoff ││ - Hard to test │└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐│ Spring Built-in │├────────────────────────────────────────────────────────────────┤│ ││ Annotation ──▶ Spring handles everything ──▶ It just works ││ ││ + TTL and eviction policies ││ + Redis/Hazelcast support ││ + Actuator metrics ││ + Exponential backoff ││ + Easy to test │└────────────────────────────────────────────────────────────────┘Comparison
| Feature | Custom Code | Spring Annotation |
|---|---|---|
| Caching | 50+ lines, manual invalidation | 1 annotation |
| Retry | 20+ lines, linear backoff | 1 annotation |
| Validation | 10+ lines per field | 1 annotation per field |
| Responses | 5+ lines per endpoint | Builder pattern |
Quick reference
| Feature | Annotation | Dependency |
|---|---|---|
| Caching | @Cacheable, @CacheEvict | spring-boot-starter-cache |
| Retry | @Retryable, @Recover | spring-retry |
| Validation | @Valid, @NotBlank, @Size | spring-boot-starter-validation |
| HTTP Responses | ResponseEntity | spring-boot-starter-web |
| Transactions | @Transactional | spring-boot-starter-data-jpa |
| Scheduling | @Scheduled, @Async | spring-boot-starter |
Common mistakes
Mistake 1: Not exploring Spring annotations before coding
Always check if Spring has an annotation for what you’re about to implement.
Mistake 2: Over-engineering for “control”
Spring’s implementations are highly configurable. You can extend and customize when needed.
Mistake 3: Ignoring ecosystem integration
Built-in features work with Actuator, Security, and other Spring projects. Custom implementations miss these integrations.
Mistake 4: Writing code first, then discovering the annotation
Read the documentation. Your 50-line implementation might be one annotation.
Summary
In this post, I showed four Spring Boot built-in features that developers commonly reimplement: caching, retry logic, validation, and HTTP responses. The key point is to check if Spring provides an annotation before writing custom code. The @Cacheable, @Retryable, @Valid, and ResponseEntity features alone can eliminate hundreds of lines of custom code while adding enterprise-grade capabilities.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Framework Documentation: Cache Abstraction
- 👨💻 Spring Retry Documentation
- 👨💻 Bean Validation (JSR-380)
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments