This is the guide for developers who already know Spring Boot basics and want the full picture: every layer, every pattern, every production concern — from bean lifecycle internals through Resilience4j circuit breakers to Spring AI RAG pipelines and Kubernetes deployment. Each section assumes you know the basics and focuses on the decisions, pitfalls, and code that matter in production.
Targets Spring Boot 3.2+ on Java 21. Relevant prerequisites: Spring Boot 3 New Features and Virtual Threads in Spring Boot.
Master Architecture Diagram
The complete picture — every layer in a production Spring Boot service including Spring AI.
1. IoC Container: Bean Scopes and Lifecycle
Scopes
| Scope | Lifetime | Typical use |
|---|---|---|
singleton (default) |
One per ApplicationContext | Services, repositories, clients |
prototype |
New instance per injection | Stateful helpers, builders |
request |
One per HTTP request | Request-scoped data holders |
session |
One per HTTP session | User session state (rare in stateless APIs) |
@Component
@Scope("prototype") // new instance injected each time
public class ReportBuilder { ... }
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext { // safe injection into singletons
private UUID correlationId = UUID.randomUUID();
}
Injecting a prototype or request-scoped bean into a singleton without a scoped proxy means the singleton captures the first instance and reuses it forever. Always use proxyMode = ScopedProxyMode.TARGET_CLASS for short-lived scopes injected into long-lived ones.
Bean lifecycle hooks
@Service
public class LlmClientService {
private ChatClient chatClient;
@PostConstruct // runs after all dependencies injected
void init() {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a helpful assistant.")
.build();
log.info("LlmClientService initialized");
}
@PreDestroy // runs before bean removed from context
void shutdown() {
// flush metrics, close connections
}
}
Disambiguation: @Primary, @Qualifier, @Lazy
// Multiple DataSource beans — mark one as default
@Bean @Primary
DataSource primaryDataSource() { ... }
@Bean
@Qualifier("readReplica")
DataSource readReplicaDataSource() { ... }
// Injection — explicit qualifier wins over @Primary
@Service
public class ReportService {
public ReportService(@Qualifier("readReplica") DataSource ds) { ... }
}
// @Lazy — defer initialization until first use (useful for expensive beans)
@Service @Lazy
public class HeavyAnalyticsService { ... }
2. AOP: Aspect-Oriented Programming
AOP lets you add cross-cutting behaviour (logging, timing, security checks, auditing) to any method without modifying it. Spring AOP uses CGLIB proxies — only external calls through the Spring proxy are intercepted.
Complete @Aspect example
@Aspect
@Component
public class PerformanceAspect {
private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class);
// Reusable pointcut — all public methods in service package
@Pointcut("execution(public * com.example.service..*(..))")
public void serviceLayer() {}
// Runs around every matched method
@Around("serviceLayer()")
public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
long elapsed = System.currentTimeMillis() - start;
log.info("{}.{} took {}ms",
pjp.getTarget().getClass().getSimpleName(),
pjp.getSignature().getName(),
elapsed);
}
}
// Only before — e.g., audit log
@Before("serviceLayer() && @annotation(com.example.annotation.Audited)")
public void auditBefore(JoinPoint jp) {
log.info("AUDIT {} called by {}",
jp.getSignature().toShortString(),
SecurityContextHolder.getContext().getAuthentication().getName());
}
// After returning — post-process result without try/catch
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
// publish domain event, update cache key, etc.
}
}
Enable AOP (auto-enabled with spring-boot-starter-aop):
@SpringBootApplication
@EnableAspectJAutoProxy // explicit, but Spring Boot auto-configures this
public class Application { ... }
Self-invocation is not intercepted. If OrderService.create() calls this.validate() where validate() has @Around advice, the advice never fires — the call bypasses the proxy. The same limitation applies to @Transactional. Fix: extract into a separate Spring-managed bean, or inject self via ApplicationContext.getBean().
3. Configuration Properties and Profiles
Typed @ConfigurationProperties
Prefer @ConfigurationProperties over @Value for anything beyond a single property — it gives you type safety, IDE completion, and validation.
@ConfigurationProperties(prefix = "app.ai")
@Validated // enforce constraints at startup
public record AiProperties(
@NotBlank String provider,
@Positive int maxTokens,
@DecimalMin("0.0") @DecimalMax("2.0") double temperature,
@NotNull LlmModel model,
RetryConfig retry
) {
public record RetryConfig(
@Positive int maxAttempts,
@Positive long backoffMs
) {}
}
public enum LlmModel { GPT_4O_MINI, LLAMA3, CLAUDE_HAIKU }
app:
ai:
provider: openai
max-tokens: 1024 # relaxed binding: maxTokens
temperature: 0.7
model: gpt-4o-mini
retry:
max-attempts: 3
backoff-ms: 500
Register in @SpringBootApplication class or use @EnableConfigurationProperties(AiProperties.class).
Profiles
# application.yml — shared defaults
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
jpa:
show-sql: true
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
datasource:
url: ${DB_URL}
hikari:
maximum-pool-size: 50
Activate via: SPRING_PROFILES_ACTIVE=prod env var, --spring.profiles.active=prod JVM arg, or test annotation.
@Component
@Profile("prod") // only registered in prod profile
public class ProdEmailSender implements EmailSender { ... }
@Component
@Profile("!prod") // all profiles except prod
public class LogEmailSender implements EmailSender { ... }
4. Application Events
Spring's event system decouples producers from consumers without external infrastructure.
// 1. Define the event
public record OrderCreatedEvent(UUID orderId, UUID customerId, BigDecimal total) {}
// 2. Publish it from the service
@Service
@Transactional
public class OrderService {
private final ApplicationEventPublisher events;
public Order create(CreateOrderRequest req) {
Order order = repository.save(toEntity(req));
// Event published synchronously inside the transaction
events.publishEvent(new OrderCreatedEvent(order.getId(), req.customerId(), order.getTotal()));
return order;
}
}
// 3a. Listener that runs IN the same transaction (for audit log to same DB)
@Component
public class AuditListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
auditRepository.save(new AuditEntry("ORDER_CREATED", event.orderId()));
}
}
// 3b. Listener that fires AFTER the transaction commits (for Kafka, email)
@Component
public class NotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async // off the commit thread
public void onOrderCreated(OrderCreatedEvent event) {
kafkaTemplate.send("orders.created", event.orderId().toString(), event);
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
Use @TransactionalEventListener(phase = AFTER_COMMIT) for side effects that depend on the data being committed (Kafka publish, email). If the transaction rolls back, the listener never fires — exactly what you want.
5. Layered Architecture
Web Layer — @RestController
@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable UUID id) {
return ResponseEntity.ok(orderService.findById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@RequestBody @Valid CreateOrderRequest request) {
return orderService.create(request);
}
// File upload
@PostMapping("/attachments")
public ResponseEntity<String> uploadAttachment(
@RequestPart("file") MultipartFile file,
@RequestParam UUID orderId) {
if (file.getSize() > 10 * 1024 * 1024) {
throw new FileTooLargeException("Max 10 MB");
}
return ResponseEntity.ok(storageService.store(orderId, file));
}
}
CORS Configuration
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://buildingai.in")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600);
}
}
SSE — Server-Sent Events for streaming
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamOrderUpdates(@RequestParam UUID orderId) {
return orderUpdateService.subscribe(orderId)
.map(update -> ServerSentEvent.<String>builder()
.id(update.id())
.event("order-update")
.data(update.toJson())
.build());
}
6. Request Lifecycle: From HTTP to Database
7. Spring Security
Full JWT setup with custom UserDetailsService
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(user -> User.builder()
.username(user.getEmail())
.password(user.getPasswordHash()) // BCrypt hash
.roles(user.getRoles().toArray(new String[0]))
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").hasRole("ACTUATOR")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
.build();
}
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("https://auth.example.com/.well-known/jwks.json").build();
}
}
Row-level security with @PostAuthorize:
@Service
public class OrderService {
// @PreAuthorize runs before method — blocks early
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
public OrderResponse findById(UUID id) { ... }
// @PostAuthorize runs after — use when you need the return value to decide
@PostAuthorize("returnObject.customerId == authentication.name")
public OrderResponse findByIdForCustomer(UUID id) { ... }
}
8. Data Layer: JPA, Caching, Transactions
Entity with Spring Data Auditing
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@Entity
public class Order extends AuditableEntity {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Version // optimistic locking
private Long version;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
// Wire auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
}
Specification API for dynamic queries
// Repository
public interface OrderRepository extends JpaRepository<Order, UUID>,
JpaSpecificationExecutor<Order> {}
// Specifications as composable predicates
public class OrderSpecs {
public static Specification<Order> byCustomer(UUID customerId) {
return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> withStatus(OrderStatus status) {
return (root, query, cb) ->
status == null ? cb.conjunction() : cb.equal(root.get("status"), status);
}
public static Specification<Order> createdAfter(Instant since) {
return (root, query, cb) ->
since == null ? cb.conjunction() : cb.greaterThan(root.get("createdAt"), since);
}
}
// Usage — compose at call site
@Service
public class OrderService {
public Page<OrderResponse> search(OrderSearchRequest req, Pageable pageable) {
var spec = Specification.where(byCustomer(req.customerId()))
.and(withStatus(req.status()))
.and(createdAfter(req.since()));
return orderRepository.findAll(spec, pageable).map(mapper::toResponse);
}
}
Projections
// Interface projection — Spring Data generates the implementation
public interface OrderSummary {
UUID getId();
OrderStatus getStatus();
BigDecimal getTotal();
// Computed via SpEL
@Value("#{target.total.multiply(T(java.math.BigDecimal).valueOf(1.2))}")
BigDecimal getTotalWithTax();
}
// Class/DTO projection — use in @Query for constructor expressions
public record OrderStats(OrderStatus status, long count, BigDecimal totalRevenue) {}
@Query("SELECT new com.example.dto.OrderStats(o.status, COUNT(o), SUM(o.total)) " +
"FROM Order o GROUP BY o.status")
List<OrderStats> getStatsByStatus();
Custom repository implementation
// Interface — add to your repository
public interface OrderRepositoryCustom {
List<Order> findWithComplexCriteria(ComplexSearchRequest req);
}
// Impl — suffix must be "Impl"
@Repository
public class OrderRepositoryImpl implements OrderRepositoryCustom {
private final EntityManager em;
@Override
public List<Order> findWithComplexCriteria(ComplexSearchRequest req) {
var cb = em.getCriteriaBuilder();
var cq = cb.createQuery(Order.class);
var root = cq.from(Order.class);
// Build dynamic criteria...
return em.createQuery(cq).getResultList();
}
}
// Extend both
public interface OrderRepository extends JpaRepository<Order, UUID>,
JpaSpecificationExecutor<Order>,
OrderRepositoryCustom {}
Caching with Redis
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductDto findById(UUID id) {
return repository.findById(id).map(mapper::toDto).orElse(null);
}
@CachePut(value = "products", key = "#result.id")
@Transactional
public ProductDto update(UpdateProductRequest req) {
return mapper.toDto(repository.save(mapper.toEntity(req)));
}
@CacheEvict(value = "products", key = "#id")
@Transactional
public void delete(UUID id) {
repository.deleteById(id);
}
}
9. Database Migrations with Flyway
Every production database needs versioned migrations. spring-boot-starter-flyway auto-configures Flyway and runs pending migrations on startup before the app accepts requests.
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true # for existing databases
validate-on-migrate: true # default: fail if applied migration changed
src/main/resources/db/migration/
V1__create_orders.sql
V2__add_order_items.sql
V3__add_pgvector_extension.sql
R__create_reporting_views.sql # Repeatable: re-runs when checksum changes
-- V1__create_orders.sql
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
total NUMERIC(12,2) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255),
updated_by VARCHAR(255)
);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
-- V3__add_pgvector_extension.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB
);
CREATE INDEX idx_embeddings ON document_embeddings
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
Never change an applied migration. Flyway validates checksums on startup — a modified V1__create_orders.sql causes startup failure in all environments. If you need to change schema, add a new V{n}__alter_orders.sql. Use R__ (repeatable) only for views and functions that change frequently.
10. Integration Patterns
Declarative HTTP Clients
@HttpExchange("https://api.inventory.internal")
public interface InventoryClient {
@GetExchange("/products/{id}/stock")
StockResponse getStock(@PathVariable UUID id);
@PostExchange("/reservations")
ReservationResponse reserve(@RequestBody ReservationRequest request);
}
@Configuration
public class ClientConfig {
@Bean
InventoryClient inventoryClient(RestClient.Builder builder) {
var client = builder
.baseUrl("https://api.inventory.internal")
.requestInterceptor(new BearerTokenInterceptor()) // add auth
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build()
.createClient(InventoryClient.class);
}
}
Kafka Producer + Consumer
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafka;
@Transactional
public void publish(OrderEvent event) {
kafka.send("orders.created", event.orderId().toString(), event);
// Message sent only on transaction commit
}
}
@Component
public class InventoryConsumer {
@KafkaListener(topics = "orders.created", groupId = "inventory-svc",
containerFactory = "kafkaListenerContainerFactory")
public void onOrderCreated(OrderEvent event, Acknowledgment ack) {
try {
inventoryService.deduct(event.items());
ack.acknowledge(); // manual commit after successful processing
} catch (TransientException e) {
// don't ack — message will be redelivered
}
}
}
11. Async Processing and Scheduling
@Async with a custom executor
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
var exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(5);
exec.setMaxPoolSize(20);
exec.setQueueCapacity(200);
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
}
@Service
public class ReportService {
@Async("taskExecutor")
public CompletableFuture<ReportData> generateReport(UUID reportId) {
// Runs on task executor thread pool, not the HTTP request thread
var data = heavyComputation(reportId);
return CompletableFuture.completedFuture(data);
}
}
With virtual threads enabled (spring.threads.virtual.enabled=true), you can often replace custom ThreadPoolTaskExecutor with a virtual-thread executor for I/O-bound async tasks — one less pool to tune.
@Scheduled
@Component
public class DataRetentionJob {
// Fixed rate — every 30 minutes
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.MINUTES)
public void purgeExpiredSessions() { ... }
// Fixed delay — 5 min after previous execution finishes
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
public void syncCache() { ... }
// Cron — every day at 02:00
@Scheduled(cron = "0 0 2 * * *")
public void generateDailyReport() { ... }
// With zone
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Kolkata")
public void sendMorningDigest() { ... }
}
12. Resilience Patterns with Resilience4j
Resilience4j provides circuit breaker, retry, rate limiter, and bulkhead as annotations or functional wrappers — with Spring Boot auto-configuration.
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
resilience4j:
circuitbreaker:
instances:
inventoryService:
slidingWindowSize: 10
failureRateThreshold: 50 # open after 50% failures
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
retry:
instances:
inventoryService:
maxAttempts: 3
waitDuration: 500ms
retryExceptions:
- java.net.ConnectException
- java.util.concurrent.TimeoutException
ratelimiter:
instances:
llmApi:
limitForPeriod: 100
limitRefreshPeriod: 1s
timeoutDuration: 200ms
@Service
public class InventoryService {
private final InventoryClient client;
// Annotations stack: retry fires inside the circuit breaker
@CircuitBreaker(name = "inventoryService", fallbackMethod = "stockFallback")
@Retry(name = "inventoryService")
public StockResponse getStock(UUID productId) {
return client.getStock(productId);
}
// Fallback — same signature + Throwable parameter
private StockResponse stockFallback(UUID productId, Throwable t) {
log.warn("Inventory unavailable for {}, using cached/default", productId, t);
return StockResponse.unknown(productId); // degrade gracefully
}
}
@Service
public class LlmService {
@RateLimiter(name = "llmApi", fallbackMethod = "rateLimitFallback")
public String call(String prompt) {
return chatClient.prompt().user(prompt).call().content();
}
private String rateLimitFallback(String prompt, RequestNotPermitted ex) {
return "Too many requests — please try again in a moment.";
}
}
13. Spring Security: Advanced Patterns
See section 7 for the base SecurityFilterChain. Additional patterns:
// Rate limiting at filter level using Bucket4j
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String clientIp = req.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build());
if (bucket.tryConsume(1)) {
chain.doFilter(req, res);
} else {
res.setStatus(429);
res.getWriter().write("Rate limit exceeded");
}
}
}
14. Spring AI: Core Integration
@Service
public class AiAssistantService {
private final ChatClient chatClient;
public AiAssistantService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You are a helpful enterprise assistant. Be concise and accurate.")
.build();
}
// Simple call
public String ask(String question) {
return chatClient.prompt().user(question).call().content();
}
// Structured output — no manual JSON parsing
public ProductSummary summarizeProduct(String description) {
return chatClient.prompt()
.user("Summarize this product: " + description)
.call()
.entity(ProductSummary.class);
}
// Per-call option override
public String askWithHighTemperature(String question) {
return chatClient.prompt()
.user(question)
.options(OpenAiChatOptions.builder().temperature(1.2).maxTokens(512).build())
.call()
.content();
}
// Streaming
public Flux<String> streamAnswer(String question) {
return chatClient.prompt().user(question).stream().content();
}
}
15. Spring AI: PromptTemplate and Chat Memory
PromptTemplate with variables
@Service
public class DocumentSummaryService {
private final ChatClient chatClient;
private static final String SUMMARY_TEMPLATE = """
You are a technical writer. Summarize the following document in {format} format.
Focus on: {focusAreas}.
Document:
{content}
""";
public String summarize(String content, String format, String focusAreas) {
var template = new PromptTemplate(SUMMARY_TEMPLATE);
var prompt = template.create(Map.of(
"format", format,
"focusAreas", focusAreas,
"content", content
));
return chatClient.prompt(prompt).call().content();
}
}
Chat Memory strategies
// In-memory (single instance — not suitable for multi-pod)
@Bean
public ChatMemory inMemoryChatMemory() {
return new InMemoryChatMemory();
}
// Redis-backed (production — survives restarts, works across pods)
@Bean
public ChatMemory redisChatMemory(RedisTemplate<String, Object> redis) {
return new RedisChatMemory(redis, Duration.ofHours(24));
}
// Wire into ChatClient with conversation scoping
@Service
public class ConversationalAssistant {
private final ChatClient chatClient;
public ConversationalAssistant(ChatClient.Builder builder, ChatMemory memory) {
this.chatClient = builder
.defaultAdvisors(new MessageChatMemoryAdvisor(memory))
.build();
}
public String chat(String conversationId, String message) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID_KEY, conversationId))
.call()
.content();
}
}
Multimodal (image + text)
@Service
public class ImageAnalysisService {
private final ChatClient chatClient;
public String analyzeImage(byte[] imageBytes, String question) {
var imageMessage = new UserMessage(question,
List.of(new Media(MimeTypeUtils.IMAGE_JPEG,
new ByteArrayResource(imageBytes))));
return chatClient.prompt()
.messages(imageMessage)
.call()
.content();
}
}
16. Spring AI: RAG Pipeline
@Service
public class RagService {
private final ChatClient chatClient;
public RagService(ChatClient.Builder builder, VectorStore vectorStore) {
this.chatClient = builder
.defaultSystem("""
Use only the provided context to answer.
If the answer is not in the context, say so clearly.
Always cite the source document.
""")
.defaultAdvisors(
new QuestionAnswerAdvisor(vectorStore,
SearchRequest.defaults()
.withTopK(5)
.withSimilarityThreshold(0.75)))
.build();
}
public String answer(String question) {
return chatClient.prompt().user(question).call().content();
}
}
17. Spring AI: Tool Calling
@Service
public class OrderAiService {
private final ChatClient chatClient;
private final OrderRepository orderRepo;
public OrderAiService(ChatClient.Builder builder, OrderRepository orderRepo) {
this.orderRepo = orderRepo;
this.chatClient = builder.defaultTools(this).build();
}
public String handleQuery(String userMessage) {
return chatClient.prompt().user(userMessage).call().content();
}
@Tool(description = "Look up an order by its ID")
public OrderSummary getOrder(UUID orderId) {
return orderRepo.findById(orderId).map(mapper::toSummary).orElseThrow();
}
@Tool(description = "List recent orders for a customer in the last N days")
public List<OrderSummary> listRecentOrders(UUID customerId, int days) {
Instant since = Instant.now().minus(days, ChronoUnit.DAYS);
return orderRepo.findByCustomerIdAndCreatedAtAfter(customerId, since)
.stream().map(mapper::toSummary).toList();
}
}
18. Observability
Custom HealthIndicator
@Component
public class LlmHealthIndicator implements HealthIndicator {
private final ChatModel chatModel;
@Override
public Health health() {
try {
var response = chatModel.call(new Prompt("ping"));
return response != null
? Health.up().withDetail("provider", "openai").build()
: Health.down().withDetail("reason", "null response").build();
} catch (Exception e) {
return Health.down(e).withDetail("reason", e.getMessage()).build();
}
}
}
Custom Actuator endpoint
@Component
@Endpoint(id = "ai-stats")
public class AiStatsEndpoint {
private final AiMetricsCollector metrics;
@ReadOperation
public AiStatsResponse stats() {
return new AiStatsResponse(
metrics.totalCalls(),
metrics.totalInputTokens(),
metrics.totalOutputTokens(),
metrics.averageLatencyMs()
);
}
}
Access at /actuator/ai-stats.
Structured logging + trace correlation
<!-- logback-spring.xml -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"service":"order-service","env":"${SPRING_PROFILES_ACTIVE}"}</customFields>
</encoder>
</appender>
# Export traces to Grafana Tempo or Jaeger via OTLP
management:
otlp:
tracing:
endpoint: http://tempo:4318/v1/traces
tracing:
sampling:
probability: 1.0
logging:
pattern:
console: "%d{ISO8601} [%X{traceId}/%X{spanId}] %-5level %logger{36} - %msg%n"
Every log line carries the traceId so you can correlate logs and traces in Grafana.
19. Testing
Testing pyramid
Unit tests — no Spring context
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repository;
@Mock InventoryClient inventoryClient;
@InjectMocks OrderService orderService;
@Test
void createOrder_shouldReserveInventoryAndSave() {
var request = new CreateOrderRequest(/* ... */);
var savedOrder = new Order(UUID.randomUUID(), request.customerId());
when(repository.save(any())).thenReturn(savedOrder);
var result = orderService.create(request);
verify(inventoryClient).reserve(request.items());
verify(repository).save(any(Order.class));
assertThat(result.id()).isEqualTo(savedOrder.getId());
}
}
Web layer slice — @WebMvcTest
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // only the service the controller needs
@Autowired ObjectMapper objectMapper;
@Test
@WithMockUser(roles = "USER")
void getOrder_shouldReturn200() throws Exception {
var response = new OrderResponse(UUID.randomUUID(), OrderStatus.SHIPPED, BigDecimal.TEN);
when(orderService.findById(any())).thenReturn(response);
mockMvc.perform(get("/api/v1/orders/{id}", response.id())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SHIPPED"));
}
@Test
@WithMockUser(roles = "USER")
void createOrder_withInvalidBody_shouldReturn422() throws Exception {
var body = new CreateOrderRequest(null, List.of()); // invalid — null customerId
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isUnprocessableEntity());
}
}
Data layer slice — @DataJpaTest + Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired OrderRepository repository;
@Test
void findByCustomerId_shouldReturnMatchingOrders() {
var customerId = UUID.randomUUID();
repository.save(buildOrder(customerId, OrderStatus.SHIPPED));
repository.save(buildOrder(customerId, OrderStatus.PENDING));
repository.save(buildOrder(UUID.randomUUID(), OrderStatus.SHIPPED)); // other customer
var orders = repository.findByCustomerIdAndStatus(customerId, OrderStatus.SHIPPED);
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getStatus()).isEqualTo(OrderStatus.SHIPPED);
}
}
Integration test — full context with Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired TestRestTemplate rest;
@Test
void createAndFetchOrder_endToEnd() {
var request = new CreateOrderRequest(UUID.randomUUID(), List.of(/* items */));
var created = rest.postForObject("/api/v1/orders", request, OrderResponse.class);
assertThat(created).isNotNull();
assertThat(created.status()).isEqualTo(OrderStatus.PENDING);
var fetched = rest.getForObject("/api/v1/orders/{id}", OrderResponse.class, created.id());
assertThat(fetched.id()).isEqualTo(created.id());
}
}
Testing Spring AI
@SpringBootTest
class RagServiceTest {
@MockBean ChatModel chatModel; // avoid real LLM calls in tests
@MockBean VectorStore vectorStore;
@Autowired RagService ragService;
@Test
void answer_shouldQueryVectorStoreAndCallModel() {
var docs = List.of(new Document("Spring Boot auto-configures Flyway migrations."));
when(vectorStore.similaritySearch(any())).thenReturn(docs);
when(chatModel.call(any(Prompt.class)))
.thenReturn(new ChatResponse(List.of(new Generation("Flyway runs on startup."))));
var answer = ragService.answer("How does Flyway work?");
assertThat(answer).contains("Flyway");
verify(vectorStore).similaritySearch(any());
verify(chatModel).call(any(Prompt.class));
}
}
20. Production: Containerization and Deployment
Graceful shutdown
server:
shutdown: graceful # wait for in-flight requests to complete
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # max wait per phase
Cloud Native Buildpacks (recommended)
# No Dockerfile needed — Spring Boot 3 builds an OCI image directly
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest
# Run it
docker run -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e DB_URL=jdbc:postgresql://db:5432/myapp \
myapp:latest
Minimal Dockerfile alternative
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/myapp.jar app.jar
# Container-aware JVM flags
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseZGC -XX:+ZGenerational"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
Kubernetes liveness and readiness probes
spring:
boot:
admin:
client:
enabled: true
management:
endpoint:
health:
probes:
enabled: true # splits /actuator/health/liveness and /actuator/health/readiness
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
# kubernetes deployment snippet
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
GraalVM native image
# Requires GraalVM JDK; Spring Boot 3 AOT generates reflection hints automatically
./mvnw -Pnative spring-boot:build-image
# Startup: ~80ms vs 4-8s JVM; memory: ~50MB vs 300MB+
Dynamic class loading and reflection need explicit hints via @RegisterReflectionForBinding or reflect-config.json. Run integration tests with -Pnative to catch missing hints before production.
21. Production Best Practices
HikariCP tuning
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20 # virtual threads: can be smaller than platform threads
minimum-idle: 5
connection-timeout: 3000 # ms — fail fast if pool exhausted
idle-timeout: 600000 # 10 min
max-lifetime: 1800000 # 30 min — rotate before DB kills idle connection
validation-timeout: 1000
jpa:
open-in-view: false # ALWAYS — prevents long connection holds
properties:
hibernate:
jdbc.batch_size: 50
order_inserts: true
threads:
virtual:
enabled: true # Java 21 — free scalability win
Comprehensive best practices
| Area | Rule |
|---|---|
| Injection | Constructor only — never field injection |
| Transactions | Service layer, readOnly = true default, keep short |
| N+1 prevention | LAZY default + @EntityGraph or join-fetch in queries that need associations |
| Caching | Cache reads, evict on write — never cache mutable state without a TTL |
| Migrations | Flyway versioned migrations in production — never ddl-auto: update |
| Validation | Bean Validation at controller boundary; @ConfigurationProperties + @Validated at config |
| Error handling | @RestControllerAdvice + RFC 9457 Problem Details |
| Security | @PreAuthorize on services, not just controllers; explicit permitAll list |
| Resilience | Circuit breaker + retry on every external call (HTTP, LLM API) |
| Async | @TransactionalEventListener(AFTER_COMMIT) for side effects after DB commit |
| Testing | Unit → @WebMvcTest → @DataJpaTest → @SpringBootTest + Testcontainers |
| Observability | Micrometer Observation API; structured logs with traceId; custom HealthIndicators |
| Deployment | server.shutdown=graceful; liveness vs readiness probes; MaxRAMPercentage for containers |
| Spring AI | One ChatClient per use case; Redis-backed ChatMemory for multi-pod; instrument token usage |
FAQ
When should I use @TransactionalEventListener vs @EventListener?
@EventListener runs inside the publishing transaction — use it when the listener writes to the same database (audit log, derived table). @TransactionalEventListener(AFTER_COMMIT) fires only after the transaction commits — required for Kafka publishes, emails, and any side effect that must not run if the DB rolls back.
How do I choose between Specification and @Query?
Use Specification when filter combinations are dynamic and not known at compile time (user-facing search). Use @Query for complex fixed queries — it's simpler and easier to read when the query shape doesn't change.
Should I use virtual threads or a custom ThreadPoolTaskExecutor for @Async?
With spring.threads.virtual.enabled=true, most @Async uses can drop the custom executor — virtual threads handle I/O concurrency efficiently. Keep a named executor only when you need bounded concurrency (e.g., limiting concurrent LLM calls to avoid rate limits).
Is Resilience4j's @CircuitBreaker compatible with virtual threads?
Yes — Resilience4j 2.x is virtual-thread compatible. The thread-pool bulkhead type should be replaced with semaphore bulkhead when using virtual threads (thread-pool bulkhead creates platform threads internally).
How do I test Resilience4j circuit breakers?
Use CircuitBreakerRegistry in your test to force the circuit open: registry.circuitBreaker("inventoryService").transitionToOpenState(). Then assert your fallback is called.
When should I switch to Spring WebFlux? With virtual threads, most reactive migration motivations (concurrency, back-pressure) disappear for typical CRUD apps. Choose WebFlux when you need true streaming backpressure end-to-end (SSE with slow consumers, live data pipelines), or when your whole stack (including drivers) is non-blocking.
Spring Boot is the most complete Java backend platform — from IoC internals through resilience, observability, and Spring AI. The master diagram shows every layer; the sections above give you the production-quality code, the gotchas, and the decisions for each. Build each layer deliberately, test each layer in isolation, and you'll have a system that's both fast to change and safe to operate.
More: Spring Boot 3 New Features, Virtual Threads in Spring Boot, Spring AI overview, pgvector RAG with Spring AI, LLM Observability, and AI ROI Framework.
Architecting a Spring Boot system and want an expert architecture review? Let's talk.