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.
Master Architecture Diagram
The complete picture — every layer in a production Spring Boot service including Spring AI.
Spring Boot · Master Architecture
flowchart TD
subgraph CLIENT["Clients"]
WEB[Web / Mobile]
API_C[API Consumers]
CLI[CLI / Schedulers]
end
subgraph INFRA["Infrastructure"]
LB[Load Balancer]
GW["API Gateway"]
end
subgraph SECURITY["Spring Security"]
AUTH["Authentication - JWT / OAuth2"]
AUTHZ["Authorization - PreAuthorize"]
end
subgraph RESILIENCE["Resilience - Resilience4j"]
CB["CircuitBreaker"]
RETRY["Retry"]
RL["RateLimiter"]
end
subgraph WEB_LAYER["Web Layer"]
CTRL["RestController"]
FILTER["Servlet Filters"]
AOP_L["AOP Aspects"]
end
subgraph SERVICE_LAYER["Service Layer"]
SVC["Service - Business Logic"]
TX["Transactional"]
EVENTS["ApplicationEventPublisher"]
end
subgraph AI_LAYER["Spring AI Layer"]
CC[ChatClient]
VS[VectorStore]
ADV["Advisors - RAG / Memory"]
TOOL["Tool Functions"]
end
subgraph DATA_LAYER["Data Layer"]
REPO["Repository - Spring Data JPA"]
FLY["Flyway Migrations"]
CACHE["Redis Cache"]
end
subgraph STORAGE["Storage"]
DB[(PostgreSQL)]
VDB[(pgvector)]
REDIS[(Redis)]
BROKER["Kafka / RabbitMQ"]
end
subgraph EXTERNAL["External"]
LLM["LLM Provider"]
MON["Prometheus / Grafana"]
end
subgraph OBS["Observability"]
ACT["Actuator - Health / Metrics"]
TRACE[Micrometer Tracing]
end
CLIENT --> INFRA
INFRA --> SECURITY
SECURITY --> RESILIENCE
RESILIENCE --> WEB_LAYER
WEB_LAYER --> SERVICE_LAYER
SERVICE_LAYER --> AI_LAYER
SERVICE_LAYER --> DATA_LAYER
AI_LAYER --> VS
AI_LAYER --> LLM
VS --> VDB
DATA_LAYER --> DB
DATA_LAYER --> CACHE
FLY --> DB
SERVICE_LAYER --> BROKER
SERVICE_LAYER --> OBS
OBS --> MON
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")
public class ReportBuilder { ... }
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
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
void init() {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a helpful assistant.")
.build();
log.info("LlmClientService initialized");
}
@PreDestroy
void shutdown() {
}
}
Disambiguation: @Primary, @Qualifier, @Lazy
@Bean @Primary
DataSource primaryDataSource() { ... }
@Bean
@Qualifier("readReplica")
DataSource readReplicaDataSource() { ... }
@Service
public class ReportService {
public ReportService(@Qualifier("readReplica") DataSource ds) { ... }
}
@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.
flowchart TD
CALLER[Caller] --> PROXY["Spring CGLIB Proxy"]
PROXY --> BEFORE["Before Advice"]
BEFORE --> TARGET["Target Method"]
TARGET --> AFTER["After / Around Advice"]
AFTER --> CALLER
PROXY -->|"self-invocation bypass"| DIRECT["Direct call - NOT intercepted"]
Complete @Aspect example
@Aspect
@Component
public class PerformanceAspect {
private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class);
@Pointcut("execution(public * com.example.service..*(..))")
public void serviceLayer() {}
@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);
}
}
@Before("serviceLayer() && @annotation(com.example.annotation.Audited)")
public void auditBefore(JoinPoint jp) {
log.info("AUDIT {} called by {}",
jp.getSignature().toShortString(),
SecurityContextHolder.getContext().getAuthentication().getName());
}
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
}
}
Enable AOP (auto-enabled with spring-boot-starter-aop):
@SpringBootApplication
@EnableAspectJAutoProxy
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
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
temperature: 0.7
model: gpt-4o-mini
retry:
max-attempts: 3
backoff-ms: 500
Register in @SpringBootApplication class or use @EnableConfigurationProperties(AiProperties.class).
Profiles
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
jpa:
show-sql: true
---
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")
public class ProdEmailSender implements EmailSender { ... }
@Component
@Profile("!prod")
public class LogEmailSender implements EmailSender { ... }
4. Application Events
Spring's event system decouples producers from consumers without external infrastructure.
sequenceDiagram
participant SVC as Service
participant PUB as ApplicationEventPublisher
participant BUS as Spring Event Bus
participant L1 as EventListener
participant L2 as TransactionalEventListener
SVC->>PUB: publishEvent - OrderCreatedEvent
PUB->>BUS: dispatch
BUS->>L1: onOrderCreated - sync, in same TX
BUS->>L2: onOrderCreated - fires AFTER_COMMIT
Note over L2: Safe for Kafka publish and email send
public record OrderCreatedEvent(UUID orderId, UUID customerId, BigDecimal total) {}
@Service
@Transactional
public class OrderService {
private final ApplicationEventPublisher events;
public Order create(CreateOrderRequest req) {
Order order = repository.save(toEntity(req));
events.publishEvent(new OrderCreatedEvent(order.getId(), req.customerId(), order.getTotal()));
return order;
}
}
@Component
public class AuditListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
auditRepository.save(new AuditEntry("ORDER_CREATED", event.orderId()));
}
}
@Component
public class NotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
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
flowchart TD
CLIENT([HTTP Client]) --> WL
subgraph WL["Web Layer — RestController"]
C1["Validate input / Map HTTP to DTO / Handle errors"]
end
subgraph SL["Service Layer — Service"]
C2["Business logic / Orchestration / Transactional"]
end
subgraph DL["Data Layer — Repository"]
C3["Database access / Spring Data JPA / Custom queries"]
end
subgraph DB["Persistence"]
C4[(PostgreSQL / MySQL)]
end
WL -->|DTO| SL
SL -->|Entity| DL
DL --> DB
DB --> DL
DL -->|Entity| SL
SL -->|DTO| WL
WL -->|JSON response| CLIENT
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);
}
@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
sequenceDiagram
participant C as HTTP Client
participant TOM as Tomcat
participant SF as Security Filter Chain
participant DS as DispatcherServlet
participant INT as Interceptors
participant AOP as AOP Proxy
participant CTRL as RestController
participant SVC as Service
participant REPO as Repository
participant DB as Database
C->>TOM: HTTP Request
TOM->>SF: Filter chain - JWT validate, rate limit
SF->>DS: Route request
DS->>INT: Pre-handle - set correlation ID
INT->>AOP: Through proxy
AOP->>AOP: Before advice - timing, audit
AOP->>CTRL: Invoke handler
CTRL->>CTRL: Input validation
CTRL->>SVC: Call service via proxy
SVC->>SVC: Begin transaction
SVC->>REPO: Query
REPO->>DB: SQL via Hibernate
DB-->>REPO: ResultSet
REPO-->>SVC: Entity
SVC-->>CTRL: DTO
CTRL-->>DS: ResponseEntity
DS->>DS: Serialize JSON - Jackson
DS-->>C: HTTP Response
7. Spring Security
flowchart TD
REQ[HTTP Request] --> CHAIN
subgraph CHAIN["Security Filter Chain"]
F1[SecurityContextPersistenceFilter]
F2["JWT Authentication Filter"]
F3[ExceptionTranslationFilter]
F4["FilterSecurityInterceptor - PreAuthorize"]
end
F1 --> F2 --> F3 --> F4
F4 -->|"Authenticated + Authorized"| CTRL[RestController]
F3 -->|"401 / 403"| ERR[Error Response]
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())
.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("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
public OrderResponse findById(UUID id) { ... }
@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
private Long version;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
}
Specification API for dynamic queries
public interface OrderRepository extends JpaRepository<Order, UUID>,
JpaSpecificationExecutor<Order> {}
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);
}
}
@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
public interface OrderSummary {
UUID getId();
OrderStatus getStatus();
BigDecimal getTotal();
@Value("#{target.total.multiply(T(java.math.BigDecimal).valueOf(1.2))}")
BigDecimal getTotalWithTax();
}
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
public interface OrderRepositoryCustom {
List<Order> findWithComplexCriteria(ComplexSearchRequest req);
}
@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);
return em.createQuery(cq).getResultList();
}
}
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
validate-on-migrate: true
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
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);
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.
flowchart TD
APP[App Startup] --> FLY[Flyway]
FLY --> CHECK["Check flyway_schema_history"]
CHECK -->|"pending migrations"| RUN["Run V2, V3 in order"]
CHECK -->|"all applied"| READY[App Ready]
RUN --> VALIDATE["Validate checksums of applied"]
VALIDATE -->|"mismatch"| FAIL["Startup Failure"]
VALIDATE -->|"ok"| READY
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())
.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);
}
}
@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();
} catch (TransientException e) {
}
}
}
flowchart TD
ORD["Order Service - Transactional"] -->|"publish on commit"| KAFKA[("Kafka - orders.created")]
KAFKA --> INV["Inventory Service - KafkaListener"]
KAFKA --> NOTIFY["Notification Service - KafkaListener"]
KAFKA --> AUDIT["Audit Service - KafkaListener"]
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) {
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 {
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.MINUTES)
public void purgeExpiredSessions() { ... }
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
public void syncCache() { ... }
@Scheduled(cron = "0 0 2 * * *")
public void generateDailyReport() { ... }
@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
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
flowchart TD
CALL[Service Call] --> CB{"CircuitBreaker"}
CB -->|"CLOSED - normal"| SVC[External Service]
CB -->|"OPEN - threshold exceeded"| FB[Fallback]
CB -->|"HALF-OPEN - probing"| SVC
SVC -->|"success"| CLOSE[Stay / go CLOSED]
SVC -->|"failure"| COUNT["Count failure"]
COUNT -->|"threshold crossed"| OPEN["Go OPEN"]
@Service
public class InventoryService {
private final InventoryClient client;
@CircuitBreaker(name = "inventoryService", fallbackMethod = "stockFallback")
@Retry(name = "inventoryService")
public StockResponse getStock(UUID productId) {
return client.getStock(productId);
}
private StockResponse stockFallback(UUID productId, Throwable t) {
log.warn("Inventory unavailable for {}, using cached/default", productId, t);
return StockResponse.unknown(productId);
}
}
@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:
@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
Spring AI · Component Architecture
flowchart TD
subgraph SAPP["Spring Boot Application"]
SVC2["Service - Your Code"]
CC2["ChatClient - fluent API"]
CM["ChatModel - low level"]
EM[EmbeddingModel]
VS2[VectorStore]
ADV2["Advisors - RAG / Memory / Logging"]
TOOL2[Tool Functions]
end
subgraph PROVIDERS["LLM Providers - swappable"]
OAI["OpenAI - gpt-4o"]
GROQ["Groq - llama-3.1"]
ANT["Anthropic - Claude"]
OLL["Ollama - Local"]
end
subgraph STORES["Vector Stores"]
PG[pgvector]
PIN[Pinecone]
QDR[Qdrant]
end
SVC2 --> CC2
CC2 --> ADV2
CC2 --> TOOL2
CC2 --> CM
CM --> OAI
CM --> GROQ
CM --> ANT
CM --> OLL
EM --> OAI
VS2 --> PG
VS2 --> PIN
VS2 --> QDR
ADV2 --> VS2
@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();
}
public String ask(String question) {
return chatClient.prompt().user(question).call().content();
}
public ProductSummary summarizeProduct(String description) {
return chatClient.prompt()
.user("Summarize this product: " + description)
.call()
.entity(ProductSummary.class);
}
public String askWithHighTemperature(String question) {
return chatClient.prompt()
.user(question)
.options(OpenAiChatOptions.builder().temperature(1.2).maxTokens(512).build())
.call()
.content();
}
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
@Bean
public ChatMemory inMemoryChatMemory() {
return new InMemoryChatMemory();
}
@Bean
public ChatMemory redisChatMemory(RedisTemplate<String, Object> redis) {
return new RedisChatMemory(redis, Duration.ofHours(24));
}
@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
Spring AI · RAG Pipeline
flowchart TD
subgraph INGEST["Document Ingestion - offline"]
DOCS["Source Documents - PDF / HTML / DB"] --> LOADER[DocumentReader]
LOADER --> SPLIT["TokenTextSplitter - Chunking"]
SPLIT --> EMB2[EmbeddingModel]
EMB2 --> VS3[("VectorStore - pgvector")]
end
subgraph QUERY["Query Time - per request"]
Q[User Question] --> QE["EmbeddingModel - embed question"]
QE --> SIM["Similarity Search - VectorStore"]
SIM --> CTX["Retrieved Chunks + Question"]
CTX --> LLM2[ChatModel]
LLM2 --> ANS["Grounded Answer with sources"]
end
VS3 --> SIM
@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();
}
}
sequenceDiagram
participant U as User
participant SVC as OrderAiService
participant LLM as ChatModel
participant TOOL as Tool Method
participant DB as Database
U->>SVC: What is the status of order ABC?
SVC->>LLM: Prompt plus tool definitions
LLM-->>SVC: ToolCall - getOrder ABC
SVC->>TOOL: Invoke getOrder
TOOL->>DB: SELECT from orders WHERE id = ABC
DB-->>TOOL: Order entity
TOOL-->>SVC: OrderSummary - status SHIPPED
SVC->>LLM: Tool result plus original prompt
LLM-->>SVC: Order ABC was shipped on Jul 24
SVC-->>U: Natural-language answer
@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
<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>
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
flowchart TD
E2E["End-to-End Tests\n@SpringBootTest + Testcontainers\nSlower - fewer"]
INT["Integration / Slice Tests\n@WebMvcTest, @DataJpaTest\nMedium"]
UNIT["Unit Tests\nMockito, no Spring context\nFast - many"]
UNIT --> INT --> E2E
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;
@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());
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));
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());
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;
@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
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Cloud Native Buildpacks (recommended)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest
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
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
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
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
./mvnw -Pnative spring-boot:build-image
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
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 1000
jpa:
open-in-view: false
properties:
hibernate:
jdbc.batch_size: 50
order_inserts: true
threads:
virtual:
enabled: true
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.