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
@Transactionalmethod called another@Transactional(propagation = REQUIRES_NEW)method on the same class viathis.method(). Spring's proxy-based AOP only intercepts calls that arrive through the proxy, so a self-invocation bypasses it entirely — the inner@Transactionalannotation 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 theREQUIRES_NEWwas 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.
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
@Transactionalis 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.
| Layer | Annotation | Owns | Knows about |
|---|---|---|---|
| Controller | @RestController | HTTP semantics, validation, status codes | DTOs only — never entities |
| Service | @Service + @Transactional | Business logic, transaction boundaries | DTOs (input) + entities (internal) |
| Repository | @Repository (or extends JpaRepository) | Database queries, fetch strategies | Entities only |
| DTO | (plain record / class) | API contract surface | Itself — versioned independently from entities |
| Entity | @Entity | Database mapping, lifecycle hooks | Itself — 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();
}
}These three bite production teams every quarter. Memorise the guard, not the consequence.
- Self-invocation of
@Transactional— A@Transactionalmethod calling another@Transactionalmethod on the SAME class viathis.method()bypasses the proxy. The inner annotation is invisible. Fix: extract into a separate Spring-managed bean. @Transactionalspanning 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;
MapStructor hand-written mappers between layers.
Production Checklist
- Service layer uses
@Transactionalon write methods only; read methods usereadOnly = true - No
@Transactionalmethod 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-Matchrequired 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
@Transactionalspans 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: falseThe 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, 500msThe 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
- SOLID Principles in Production: From Code Smells to Architectural Discipline — The design principles behind the layered architecture used throughout this article
- Java Testing with JUnit 5 and Mockito: A Production Guide — Deep dive into
@WebMvcTest,@DataJpaTest, Testcontainers, and mutation testing - GraalVM Native Images in Production: From 5-Second Startup to 50ms — Compile this Spring Boot API to a native binary for instant Kubernetes startup
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
Read Next
Java Testing with JUnit 5 and Mockito: A Production Guide
Write tests that catch real bugs: JUnit 5 lifecycle, parameterized tests, Mockito, WireMock, Testcontainers, and mutation testing.
Go Dynamic JSON: Parsing Unknown Schemas in Production
Handle unpredictable JSON in Go: map[string]any, json.RawMessage, type switches, and defensive patterns for shifting schemas.
SOLID Principles in Production: From Code Smells to Architectural Discipline
SOLID beyond theory: real production anti-patterns, Spring Boot refactorings, and the design constraints that prevent codebase rot.