Skip to content

Spring Boot REST: JPA, Validation, Exception Handling, and Testing

BackendBytes Engineering Team
BackendBytes Engineering Team
8 min read
Spring Boot REST: JPA, Validation, Exception Handling, and Testing

Key Takeaways

  • @Transactional calls on the same class skip Spring's proxy — a method calling another @Transactional(REQUIRES_NEW) method via this.method() silently runs the inner code in the OUTER transaction; the inner annotation is ignored, so you lose the independent commit/rollback boundary you thought you had
  • DTOs decouple API responses from entity schema; renaming a database column no longer breaks API clients; they also prevent leaking internal IDs, relationships, and audit fields
  • Optimistic locking via @Version prevents silent overwrites on concurrent updates; Hibernate adds WHERE version = ? to every UPDATE so conflicting writes get caught as OptimisticLockException, not data loss
  • @Valid + global @RestControllerAdvice catch validation errors and map domain exceptions to RFC 9457 Problem Detail responses — never expose stack traces or raw database errors to clients

The classic Spring Boot self-invocation transaction bug. A team ships a Spring Boot REST API. Demo works perfectly — CRUD endpoints, clean validation, 200 OK. Then a partial-failure bug surfaces in production: an audit row that was supposed to survive a rolled-back order kept disappearing, and an order that should have been atomic with its payment occasionally half-committed.

A @Transactional method called another @Transactional(propagation = REQUIRES_NEW) method on the same class via this.method(). Spring's proxy-based AOP only intercepts calls that arrive through the proxy, so a self-invocation bypasses it entirely — the inner @Transactional annotation is silently ignored. There is no second connection and no separate transaction; the inner code just runs inside the outer transaction. The team thought they had an independent commit/rollback boundary; they didn't, and the lost isolation only showed up under the exact failure path the REQUIRES_NEW was meant to protect. We've debugged variants of this on multiple Spring Boot services.

This is what separates a working endpoint from production: proper HTTP[RFC 9110, 2022] semantics, validation that returns useful errors, exception handling that doesn't leak stack traces, ETag-based concurrency control, and @Transactional gotchas that will ruin your weekend.

Building a production Spring Boot REST API requires five core patterns: JPA entities with optimistic locking, DTOs that decouple API from schema, global exception handlers with RFC 9457 responses, controllers with proper HTTP semantics (201 Created, 204 No Content, ETags)[RFC 9110, 2022], and a test pyramid (MockMvc → @DataJpaTest → full-stack with Testcontainers)[JUnit 5 User Guide]. The code here is Spring Boot 3.4 on Java 21; everything applies to 3.2+ unchanged.

TL;DR

Five layers, one pattern each: JPA entities with @Version for optimistic locking; DTOs separate from entities; @Valid + @RestControllerAdvice for validation and error handling; controllers that return correct status codes and ETags[RFC 9110, 2022]; tests at three levels (controller mock, repo with H2, full-stack with Postgres).

  • Use DTOs to decouple API contracts from database schema changes
  • @Transactional is proxy-based — never call between transactional methods on the same class
  • @WebMvcTest@DataJpaTest@SpringBootTest + Testcontainers covers all layers

The Five-Layer Pattern

graph LR
    Client["Client"] -->|"HTTP"| C["Controller<br/>@RestController"]
    C -->|"DTO → Entity"| S["Service<br/>@Transactional"]
    S --> R["Repository<br/>JpaRepository"]
    R --> DB[(PostgreSQL)]

    C -.->|"@Valid"| V["Bean Validation"]
    C -.->|"@ControllerAdvice"| EH["Exception Handler<br/>→ ProblemDetail"]

A production API has five layers: controllers (HTTP), services (business logic), repositories (queries), DTOs (API contract), entities (database). Dependencies flow one direction: controller → service → repository → entity.

LayerAnnotationOwnsKnows about
Controller@RestControllerHTTP semantics, validation, status codesDTOs only — never entities
Service@Service + @TransactionalBusiness logic, transaction boundariesDTOs (input) + entities (internal)
Repository@Repository (or extends JpaRepository)Database queries, fetch strategiesEntities only
DTO(plain record / class)API contract surfaceItself — versioned independently from entities
Entity@EntityDatabase mapping, lifecycle hooksItself — never leaks above the service layer
controller/ → service/ → repository/ → entity/ → database/

Package layout: config/, controller/, dto/, entity/, exception/, repository/, service/.

Entities and Repositories

[PostgreSQL Docs]

An entity maps database tables to Java objects. The @Version field enables optimistic locking — Hibernate adds WHERE version = ? to every UPDATE, so concurrent modifications get caught instead of silently overwriting each other:

@Entity
@Table(name = "products", indexes = {
    @Index(name = "idx_product_sku", columnList = "sku", unique = true),
    @Index(name = "idx_product_category", columnList = "category")
})
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Version
    private Long version;
 
    @Column(nullable = false, length = 200)
    private String name;
 
    @Column(nullable = false, unique = true, length = 50)
    private String sku;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
 
    @Column(nullable = false)
    private Integer stockQuantity;
 
    @Column(nullable = false, length = 100)
    private String category;
 
    @Column(nullable = false)
    private Boolean active = true;
 
    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private Instant createdAt;
 
    @UpdateTimestamp
    @Column(nullable = false)
    private Instant updatedAt;
    // Getters, setters, constructors…
}

Use @GeneratedValue(strategy = GenerationType.IDENTITY) for PostgreSQL SERIAL and MySQL AUTO_INCREMENT.

Spring Data repositories derive queries from method names or use @Query for joins:

public interface ProductRepository extends JpaRepository<Product, Long> {
    Optional<Product> findBySku(String sku);
    Page<Product> findByActiveTrue(Pageable pageable);
 
    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max AND p.active = true")
    List<Product> findByPriceRange(@Param("min") BigDecimal min, @Param("max") BigDecimal max);
 
    @Modifying
    @Query("UPDATE Product p SET p.active = false WHERE p.stockQuantity = 0")
    int deactivateOutOfStockProducts();
}

Always pair @Modifying with @Transactional in the service layer, or the UPDATE silently fails.

DTOs and Validation

Never expose JPA entities in your API — they leak database concerns and couple the API contract to schema changes. Use Java records with Bean Validation:

public record CreateProductRequest(
    @NotBlank @Size(min = 2, max = 200) String name,
    @NotBlank @Pattern(regexp = "^[A-Z]{2,4}-\\d{4,8}$") String sku,
    @NotNull @DecimalMin("0.01") @DecimalMax("999999.99") BigDecimal price,
    @NotNull @Min(0) Integer stockQuantity,
    @NotBlank String category
) {}
 
public record ProductResponse(
    Long id, String name, String sku, BigDecimal price,
    Integer stockQuantity, String category, Boolean active,
    Long version, Instant createdAt, Instant updatedAt
) {
    public static ProductResponse from(Product entity) {
        return new ProductResponse(entity.getId(), entity.getName(), entity.getSku(),
            entity.getPrice(), entity.getStockQuantity(), entity.getCategory(),
            entity.getActive(), entity.getVersion(), entity.getCreatedAt(), entity.getUpdatedAt());
    }
}

Records are immutable, auto-generate equals/hashCode, and work natively with Jackson. Expose version for ETags.

Controllers: HTTP Semantics and Validation

The controller layer handles HTTP concerns: status codes, headers, ETags, Location responses, and validation. Use @Valid on request bodies to trigger Bean Validation before business logic runs. Return ResponseEntity for full control over status and headers:

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    private final ProductService productService;
 
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(
            @PathVariable Long id,
            HttpServletRequest request) {
        ProductResponse product = productService.findById(id);
        String etag = "\"" + product.version() + "\"";
        if (request.checkNotModified(etag)) {
            return null; // Spring returns 304 Not Modified
        }
        return ResponseEntity.ok().eTag(etag).body(product);
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @Valid @RequestBody CreateProductRequest request) {
        ProductResponse created = productService.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        String etag = "\"" + created.version() + "\"";
        return ResponseEntity.created(location).eTag(etag).body(created);
    }
 
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody UpdateProductRequest request,
            @RequestHeader(value = "If-Match", required = false) String ifMatch) {
        Long version = parseVersion(ifMatch);
        ProductResponse updated = productService.update(id, request, version);
        return ResponseEntity.ok().eTag("\"" + updated.version() + "\"").body(updated);
    }
 
    @PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
    public ResponseEntity<ProductResponse> patchProduct(
            @PathVariable Long id,
            @RequestBody PatchProductRequest request,
            @RequestHeader(value = "If-Match", required = false) String ifMatch) {
        Long version = parseVersion(ifMatch);
        ProductResponse patched = productService.patch(id, request, version);
        return ResponseEntity.ok().eTag("\"" + patched.version() + "\"").body(patched);
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
 
    private Long parseVersion(String ifMatch) {
        if (ifMatch == null || ifMatch.isBlank()) return null;
        String raw = ifMatch.replaceAll("(W/)?\"", "");
        return Long.parseLong(raw);
    }
}

HTTP semantics: POST → 201 Created; PUT → 200 OK; DELETE → 204 No Content; GET → 200 OK or 304 Not Modified. Use If-Match header for optimistic locking.

Validation with @Valid triggers automatically. Failures are caught by the global exception handler and return 400 with field-level errors.

Global Exception Handling and @Transactional Pitfalls

[Effective Java]
graph LR
    Caller[Caller method<br/>OUTSIDE class] -->|"proxy intercepts<br/>opens transaction"| Proxy[Spring AOP proxy]
    Proxy --> Method["Annotated method<br/>@Transactional"]
    Method -->|"this.otherMethod()<br/>SELF-INVOCATION"| Self["Other @Transactional method<br/>same class"]
    Self -.->|"❌ proxy bypassed —<br/>no new transaction"| Method
    Caller2[Caller method<br/>OUTSIDE class] -->|"correct call"| Proxy2[Proxy → other class]
    Proxy2 --> Other["OtherService.method<br/>@Transactional"]
    style Self fill:#fee
    style Other fill:#efe

The diagram shows the self-invocation bug: @Transactional only fires when a method is called through the proxy. A method calling another @Transactional method on the same class with this.method() bypasses the proxy entirely — the inner annotation is invisible. The fix is always the same: extract the inner method into a separate Spring-managed bean.

A @RestControllerAdvice catches exceptions and returns RFC 9457 Problem Detail responses:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidationError(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        problem.setTitle("Validation Error");
        problem.setProperty("errors", ex.getBindingResult().getFieldErrors()
            .stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage)));
        return ResponseEntity.badRequest().body(problem);
    }
 
    @ExceptionHandler(OptimisticLockingFailureException.class)
    public ResponseEntity<ProblemDetail> handleConcurrency(OptimisticLockingFailureException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Resource was modified"));
    }
}

Three @Transactional gotchas in production: (1) Self-invocation bypasses the proxy — extract to a separate class; (2) Checked exceptions don't rollback by default — use @Transactional(rollbackFor = Exception.class); (3) Long transactions hold connections — keep them short.

Testing Strategy

Three test levels: Controller tests (MockMvc) mock the service and verify HTTP routing and validation. Repository tests (@DataJpaTest) verify queries with H2. Integration tests (@SpringBootTest + Testcontainers) exercise the full stack with real PostgreSQL.

@DataJpaTest
class ProductRepositoryTest {
    @Autowired private ProductRepository repo;
 
    @Test
    void findBySku_returnsProduct() {
        Product p = new Product();
        p.setName("Test");
        p.setSku("TP-1234");
        p.setPrice(new BigDecimal("10.00"));
        repo.save(p);
        
        assertThat(repo.findBySku("TP-1234")).isPresent()
            .get().extracting(Product::getName).isEqualTo("Test");
    }
}
 
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ProductIntegrationTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Autowired private TestRestTemplate restTemplate;
 
    @Test
    void fullCrudLifecycle() {
        var createRequest = Map.of("name", "Widget", "sku", "WG-5678", "price", 49.99, "stockQuantity", 200, "category", "Testing");
        ResponseEntity<ProductResponse> response = restTemplate.postForEntity("/api/v1/products", createRequest, ProductResponse.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isNotNull();
    }
}
Three Spring Boot footguns to avoid

These three bite production teams every quarter. Memorise the guard, not the consequence.

  • Self-invocation of @Transactional — A @Transactional method calling another @Transactional method on the SAME class via this.method() bypasses the proxy. The inner annotation is invisible. Fix: extract into a separate Spring-managed bean.
  • @Transactional spanning network I/O — Holding a database transaction open across an HTTP/Kafka call exhausts the connection pool when the network slows. Fix: commit before the network call; use the outbox pattern for "DB + event" atomicity.
  • Returning entities from controllers — Couples the API contract to the database schema. Adding a column accidentally breaks every consumer. Fix: explicit DTOs at the controller boundary; MapStruct or hand-written mappers between layers.

Production Checklist

  • Service layer uses @Transactional on write methods only; read methods use readOnly = true
  • No @Transactional method calls another on the same class — use constructor injection of dependencies
  • Global exception handler catches MethodArgumentNotValidException, OptimisticLockingFailureException, ResourceNotFoundException
  • Controllers return correct status codes: 201 (Create), 204 (Delete), 304 (Not Modified), 400 (Validation), 409/412 (Concurrency)
  • ETag headers on all GET responses; If-Match required for PUT/PATCH if using optimistic locking
  • DTOs separate from entities; repositories never expose entities
  • Connection pool configured (hikari.maximum-pool-size, idle-timeout, max-lifetime)
  • Tests span three levels: MockMvc (fast), @DataJpaTest (medium), full-stack (Testcontainers)
  • No @Transactional spans network calls, I/O, or external APIs

HikariCP and JPA configuration that survives production

The default Spring Boot connection pool sizing (10 connections, 30s connection timeout) is fine for laptops and breaks under load. The configuration below is what we run on services pushing 200+ RPS against Postgres — sized for the connection pool to be smaller than max_connections / replica_count, with timeouts that fail fast instead of hanging:

# application.yml — production HikariCP + JPA defaults
spring:
  datasource:
    hikari:
      # Pool size: leave headroom on Postgres. With 5 replicas of this service
      # and Postgres max_connections=200, 20 per pod = 100 total, 50% headroom.
      maximum-pool-size: 20
      minimum-idle: 5
 
      # Timeout chain: each one fails faster than the next layer up.
      # connection-timeout: how long an app thread waits for a free connection
      connection-timeout: 3000              # 3s — never block a request for longer
      validation-timeout: 1000              # 1s — connection-test query budget
      idle-timeout: 600000                  # 10min
      max-lifetime: 1800000                 # 30min — must be < Postgres idle_in_transaction_timeout
      keepalive-time: 60000                 # 1min — beats most NAT/firewall idle drops
 
      # Leak detection: log a warning if a connection is held > 30s.
      # Always-on in staging; disable in prod only if false-positive noise is unbearable.
      leak-detection-threshold: 30000
 
  jpa:
    properties:
      hibernate:
        # Statement-level timeout — every query inherits this unless overridden.
        # Tighter than HTTP request timeout (typically 5s) so DB doesn't hold the budget.
        jdbc.timeout: 2000
        # Disable open-session-in-view: forces explicit fetch decisions in service layer
        # instead of N+1 queries materialising silently in the controller.
        enable_lazy_load_no_trans: false
    open-in-view: false

The two settings teams forget that cause production incidents:

// TransactionConfig.java — the timeout that defaults wrong
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.TransactionManagementConfigurer;
import org.springframework.transaction.PlatformTransactionManager;
 
@Configuration
@EnableTransactionManagement
public class TransactionConfig implements TransactionManagementConfigurer {
 
    private final PlatformTransactionManager txManager;
 
    public TransactionConfig(PlatformTransactionManager txManager) {
        // Default timeout for every @Transactional that doesn't specify one.
        // Spring's default is "no timeout" — a hung query holds a connection
        // until the OS-level TCP keepalive triggers, often minutes.
        if (txManager instanceof org.springframework.orm.jpa.JpaTransactionManager jpa) {
            jpa.setDefaultTimeout(5);  // seconds
        }
        this.txManager = txManager;
    }
 
    @Override
    public PlatformTransactionManager annotationDrivenTransactionManager() {
        return txManager;
    }
}

Run SHOW idle_in_transaction_session_timeout on your Postgres instance — if it's 0 (the default), the max-lifetime: 1800000 setting above is doing your only protection against connection leaks. Setting idle_in_transaction_session_timeout = '60s' on Postgres gives you a defence-in-depth that catches the bug HikariCP can't — a @Transactional method that opens a transaction, makes an external HTTP call, and parks for an hour while holding a row lock.

WebClient Resilience: Timeouts, Retries, and Circuit Breakers

A Spring Boot service that calls another service over HTTP is the most common source of cascading failures. The default WebClient has no read timeout, no connect timeout, no retry policy, and no circuit breaker — a slow downstream takes your service down with it. Wire WebClient with Resilience4j and a Reactor Netty HttpClient configured with explicit timeouts; do not rely on JVM defaults:

@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient downstreamClient(
            ReactiveCircuitBreakerFactory<?, ?> cbFactory,
            @Value("${downstream.base-url}") String baseUrl) {
 
        // Reactor Netty HttpClient with explicit timeouts at every layer.
        // connect-timeout: TCP handshake budget. response-timeout: total round-trip.
        // ReadTimeoutHandler/WriteTimeoutHandler: per-byte stalls between TCP packets.
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
                .responseTimeout(Duration.ofSeconds(3))
                .doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(3, TimeUnit.SECONDS))
                        .addHandlerLast(new WriteTimeoutHandler(3, TimeUnit.SECONDS)));
 
        return WebClient.builder()
                .baseUrl(baseUrl)
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .filter((request, next) -> next.exchange(request)
                        // Bound retries to idempotent verbs only — POST replays double-bill customers.
                        .transformDeferred(mono -> isIdempotent(request)
                                ? mono.retryWhen(Retry.backoff(2, Duration.ofMillis(200))
                                        .maxBackoff(Duration.ofSeconds(2))
                                        .filter(WebClientConfig::isRetryable))
                                : mono))
                .build();
    }
 
    private static boolean isIdempotent(ClientRequest req) {
        HttpMethod m = req.method();
        return m == HttpMethod.GET || m == HttpMethod.HEAD
                || m == HttpMethod.PUT || m == HttpMethod.DELETE;
    }
 
    private static boolean isRetryable(Throwable t) {
        if (t instanceof WebClientResponseException ex) {
            int code = ex.getStatusCode().value();
            return code == 502 || code == 503 || code == 504;
        }
        return t instanceof IOException || t instanceof TimeoutException;
    }
}

The matching service-layer call wraps the request in a Resilience4j CircuitBreaker and Bulkhead so a flapping downstream cannot drain the event-loop thread budget:

@Service
public class InventoryClient {
    private final WebClient downstreamClient;
    private final ReactiveCircuitBreaker breaker;
 
    public InventoryClient(WebClient downstreamClient, ReactiveCircuitBreakerFactory<?, ?> cbFactory) {
        this.downstreamClient = downstreamClient;
        // Default config: 50% failure rate over 100 calls trips the breaker for 30s.
        // Tune slidingWindowType=TIME_BASED if traffic is bursty so a single 1-RPS minute
        // can't keep an unhealthy breaker closed.
        this.breaker = cbFactory.create("inventory");
    }
 
    public Mono<StockLevel> stockFor(String sku) {
        Mono<StockLevel> upstream = downstreamClient.get()
                .uri("/inventory/{sku}", sku)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, resp ->
                        // 4xx is a contract bug — fail fast, do NOT trip the breaker
                        resp.createException().flatMap(Mono::error))
                .bodyToMono(StockLevel.class);
 
        return breaker.run(upstream, throwable -> {
            // Fallback path: serve the last cached value or a stock-aware default.
            // Returning Mono.error here would propagate, so callers see graceful degradation.
            return Mono.just(StockLevel.unknown(sku));
        });
    }
}

Two rules teams break: classifying 4xx as "retryable" (it never is — the request is malformed), and sharing a single circuit breaker across heterogeneous endpoints. Create one breaker per downstream dependency so a slow /search does not trip the breaker for a healthy /checkout.

JPA Query Plan Caching and Hibernate Statistics

Hibernate parses every JPQL or HQL query into an AST and translates it to SQL on first execution. Without a query plan cache, that parse cost runs on every call — measurable at 100+ RPS. The plan cache is on by default but sized at 2048 entries, which a service with dynamic Criteria queries can blow through in minutes. Configure the plan cache and turn on statistics in non-prod so you can see the hit rate:

@Configuration
public class HibernateTuningConfig implements HibernatePropertiesCustomizer {
 
    @Override
    public void customize(Map<String, Object> props) {
        // Plan cache: bigger is fine — entries are small and parse cost is high.
        // Default is 2048; raise on services with many @Query variants or Criteria queries.
        props.put("hibernate.query.plan_cache_max_size", 4096);
        props.put("hibernate.query.plan_parameter_metadata_max_size", 256);
 
        // Statement caching at the JDBC level — separate from plan cache.
        // Reuses PreparedStatement objects so the JDBC driver doesn't re-prepare on Postgres.
        props.put("hibernate.jdbc.batch_size", 50);
        props.put("hibernate.order_inserts", true);
        props.put("hibernate.order_updates", true);
 
        // Statistics: log slow queries and expose JMX/Micrometer metrics.
        // Keep off in prod hot paths — there's a small per-query overhead — but turn on
        // in staging or behind a feature flag during incident triage.
        props.put("hibernate.generate_statistics", true);
        props.put("hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS", 50);
    }
}

Bind Hibernate's statistics into Micrometer so the plan-cache hit rate becomes a Grafana panel rather than a JMX archaeological dig. A hit rate below 95% during steady traffic almost always means the plan cache is too small or queries are being constructed with literal parameters instead of bind variables.

Actuator and K8s Readiness: Composing Health Indicators

Spring Boot Actuator's default /actuator/health/readiness returns UP as soon as the application context loads — long before the database connection pool is warm or downstream dependencies have been probed. Kubernetes will route traffic to the pod immediately and the first 50 requests will time out. Compose the readiness group from explicit indicators so the pod is only marked ready when it can actually serve:

@Component
public class DownstreamReadinessIndicator implements ReactiveHealthIndicator {
    private final InventoryClient inventoryClient;
 
    public DownstreamReadinessIndicator(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }
 
    @Override
    public Mono<Health> health() {
        // Use a known-cheap probe SKU. Do NOT call /healthz on the downstream — it lies.
        // Probe the same code path real traffic uses so a misconfigured route is caught.
        return inventoryClient.stockFor("__probe__")
                .timeout(Duration.ofMillis(500))
                .map(stock -> Health.up().withDetail("sku", stock.sku()).build())
                .onErrorResume(ex -> Mono.just(Health.outOfService()
                        .withDetail("reason", ex.getClass().getSimpleName())
                        .build()));
    }
}

Pair the indicator with the readiness-group config so Kubernetes only sees UP when the app, the connection pool, and the downstream are all healthy:

# application.yml — readiness composition + Micrometer wiring
management:
  endpoint:
    health:
      probes:
        enabled: true
      show-details: when-authorized
      group:
        readiness:
          # Order matters for the response payload but not for the aggregate status.
          # Liveness is intentionally narrower — only "is the JVM stuck?" trips a pod restart.
          include: db, diskSpace, downstreamReadiness
        liveness:
          include: livenessState
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics
  metrics:
    tags:
      application: ${spring.application.name}
      env: ${SPRING_PROFILES_ACTIVE:unknown}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      slo:
        http.server.requests: 50ms, 100ms, 200ms, 500ms

The liveness group is deliberately narrower than readiness — a flaky downstream should send the pod OUT_OF_SERVICE (drain traffic) but should not restart the pod (livenessState: DOWN), because restarting fixes nothing and amplifies the outage. Wire the same http.server.requests percentiles into a Micrometer histogram and you get p50/p95/p99 latency per route in Prometheus without writing a single timer.

Frequently Asked Questions

Why expose DTOs instead of entities? Entities couple your API contract to database schema changes. DTOs create a stable interface that evolves independently of the schema.

How do I handle N+1 queries? Use @Query with FETCH JOIN or @EntityGraph to eagerly load associations. JPQL with explicit joins is clearer than relying on lazy loading.

When should I use @Transactional(readOnly = true)? On query methods and service read methods. It signals intent to maintainers and enables database optimizations.

How do I test @Transactional methods? Use @DataJpaTest (wraps tests in a transaction that rolls back), or use @SpringBootTest with Testcontainers and a real PostgreSQL database.

Keep Reading

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next