Skip to content

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:

ProductService.java
@Service
public 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:

Bad: Manual caching
@Service
public 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:

Good: @Cacheable
@Service
public 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:

Application.java
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

Feature 2: Retry with @Retryable

Instead of manual retry loops:

Bad: Manual retry
@Service
public 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:

Good: @Retryable
@Service
public 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:

pom.xml
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>

Feature 3: Validation with @Valid

Instead of manual validation:

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

Good: @Valid with DTO
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
@Validated
public 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:

Bad: Manual response
@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:

Good: 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));
}
@PostMapping
public 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

FeatureCustom CodeSpring Annotation
Caching50+ lines, manual invalidation1 annotation
Retry20+ lines, linear backoff1 annotation
Validation10+ lines per field1 annotation per field
Responses5+ lines per endpointBuilder pattern

Quick reference

FeatureAnnotationDependency
Caching@Cacheable, @CacheEvictspring-boot-starter-cache
Retry@Retryable, @Recoverspring-retry
Validation@Valid, @NotBlank, @Sizespring-boot-starter-validation
HTTP ResponsesResponseEntityspring-boot-starter-web
Transactions@Transactionalspring-boot-starter-data-jpa
Scheduling@Scheduled, @Asyncspring-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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments