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.
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 timepublicclassReportBuilder { ... }
@Component@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)publicclassRequestContext { // safe injection into singletonsprivateUUIDcorrelationId= UUID.randomUUID();
}
WARNING
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
@ServicepublicclassLlmClientService {
private ChatClient chatClient;
@PostConstruct// runs after all dependencies injectedvoidinit() {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a helpful assistant.")
.build();
log.info("LlmClientService initialized");
}
@PreDestroy// runs before bean removed from contextvoidshutdown() {
// 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@ServicepublicclassReportService {
publicReportService(@Qualifier("readReplica") DataSource ds) { ... }
}
// @Lazy — defer initialization until first use (useful for expensive beans)@Service@LazypublicclassHeavyAnalyticsService { ... }
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@ComponentpublicclassPerformanceAspect {
privatestaticfinalLoggerlog= LoggerFactory.getLogger(PerformanceAspect.class);
// Reusable pointcut — all public methods in service package@Pointcut("execution(public * com.example.service..*(..))")publicvoidserviceLayer() {}
// Runs around every matched method@Around("serviceLayer()")public Object measureTime(ProceedingJoinPoint pjp)throws Throwable {
longstart= System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
longelapsed= 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)")publicvoidauditBefore(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")publicvoidafterReturning(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 thispublicclassApplication { ... }
WARNING
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 startuppublicrecordAiProperties(
@NotBlank String provider,
@Positive int maxTokens,
@DecimalMin("0.0")@DecimalMax("2.0")double temperature,
@NotNull LlmModel model,
RetryConfig retry
) {
publicrecordRetryConfig(
@Positive int maxAttempts,
@Positive long backoffMs
) {}
}
publicenumLlmModel { GPT_4O_MINI, LLAMA3, CLAUDE_HAIKU }
Activate via: SPRING_PROFILES_ACTIVE=prod env var, --spring.profiles.active=prod JVM arg, or test annotation.
@Component@Profile("prod")// only registered in prod profilepublicclassProdEmailSenderimplementsEmailSender { ... }
@Component@Profile("!prod")// all profiles except prodpublicclassLogEmailSenderimplementsEmailSender { ... }
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
// 1. Define the eventpublicrecordOrderCreatedEvent(UUID orderId, UUID customerId, BigDecimal total) {}
// 2. Publish it from the service@Service@TransactionalpublicclassOrderService {
privatefinal ApplicationEventPublisher events;
public Order create(CreateOrderRequest req) {
Orderorder= repository.save(toEntity(req));
// Event published synchronously inside the transaction
events.publishEvent(newOrderCreatedEvent(order.getId(), req.customerId(), order.getTotal()));
return order;
}
}
// 3a. Listener that runs IN the same transaction (for audit log to same DB)@ComponentpublicclassAuditListener {
@EventListenerpublicvoidonOrderCreated(OrderCreatedEvent event) {
auditRepository.save(newAuditEntry("ORDER_CREATED", event.orderId()));
}
}
// 3b. Listener that fires AFTER the transaction commits (for Kafka, email)@ComponentpublicclassNotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)@Async// off the commit threadpublicvoidonOrderCreated(OrderCreatedEvent event) {
kafkaTemplate.send("orders.created", event.orderId().toString(), event);
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
TIP
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
Spring Boot layered architectureHTTP client to Web layer; Web layer to Service layer (DTO); Service layer to Data layer (Entity); Data layer to Persistence; Persistence to Data layer; Data layer to Service layer (Entity); Service layer to Web layer (DTO); Web layer to HTTP client (JSON)HTTP clientHTTP clientWeb layer: RestControllerWeb layerRestControllerService layer: ServiceService layerServiceData layer: RepositoryData layerRepositoryPersistence: PostgreSQL / MySQLPersistencePostgreSQL /MySQLDTOEntityEntityDTOJSON
The web layer validates input, maps HTTP to DTOs and handles errors; the service layer holds business logic, orchestration and transactions; the data layer does database access with Spring Data JPA and custom queries.
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
Spring Security filter chainHTTP request to Persistence filter; Persistence filter to JWT auth filter; JWT auth filter to Exception translation; Exception translation to Security interceptor; Security interceptor to RestController (authenticated + authorized); Exception translation to Error response (401 / 403)SECURITY FILTER CHAINHTTP requestHTTP requestPersistence filter: SecurityContextPersistencefilterSecurityContextJWT auth filterJWT authfilterException translation: filterExceptiontranslationfilterSecurity interceptor: PreAuthorizeSecurityinterceptorPreAuthorizeError responseError responseRestControllerRestControllerauthenticated + authorized401 / 403
@ServicepublicclassOrderService {
// @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) { ... }
}
// Interface projection — Spring Data generates the implementationpublicinterfaceOrderSummary {
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 expressionspublicrecordOrderStats(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();
Every production database needs versioned migrations. spring-boot-starter-flyway auto-configures Flyway and runs pending migrations on startup before the app accepts requests.
spring:flyway:enabled:truelocations:classpath:db/migrationbaseline-on-migrate:true# for existing databasesvalidate-on-migrate:true# default: fail if applied migration changed
-- V1__create_orders.sqlCREATE TABLE orders (
id UUID PRIMARY KEYDEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
status VARCHAR(50) NOT NULLDEFAULT'PENDING',
total NUMERIC(12,2) NOT NULL,
version BIGINTNOT NULLDEFAULT0,
created_at TIMESTAMPTZ NOT NULLDEFAULT now(),
updated_at TIMESTAMPTZ NOT NULLDEFAULT 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.sqlCREATE EXTENSION IF NOTEXISTS vector;
CREATE TABLE document_embeddings (
id UUID PRIMARY KEYDEFAULT 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);
WARNING
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.
Flyway startup flowApp startup to Flyway; Flyway to Check schema history; Check schema history to Run V2, V3 (pending); Check schema history to App ready (all applied); Run V2, V3 to Validate; Validate to App ready (ok); Validate to Startup failure (mismatch)App startupApp startupFlywayFlywayCheck schema historyCheck schemahistoryRun V2, V3: in orderRun V2, V3in orderApp readyApp readyValidate: applied checksumsValidateapplied checksumsStartup failureStartupfailurependingall appliedokmismatch
Flyway reads the flyway_schema_history table on startup, runs pending migrations in order, then validates the checksums of applied migrations and fails startup on a mismatch.
Order events fan out through KafkaOrder service to Kafka (on commit); Kafka to Inventory service; Kafka to Notification service; Kafka to Audit serviceOrder service: TransactionalOrder serviceTransactionalKafka: orders.createdKafkaorders.createdInventory service: KafkaListenerInventoryserviceKafkaListenerNotification service: KafkaListenerNotificationserviceKafkaListenerAudit service: KafkaListenerAudit serviceKafkaListeneron commit
11. Async Processing and Scheduling
@Async with a custom executor
@Configuration@EnableAsync@EnableSchedulingpublicclassAsyncConfig {
@Bean("taskExecutor")public Executor taskExecutor() {
varexec=newThreadPoolTaskExecutor();
exec.setCorePoolSize(5);
exec.setMaxPoolSize(20);
exec.setQueueCapacity(200);
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
}
@ServicepublicclassReportService {
@Async("taskExecutor")public CompletableFuture<ReportData> generateReport(UUID reportId) {
// Runs on task executor thread pool, not the HTTP request threadvardata= heavyComputation(reportId);
return CompletableFuture.completedFuture(data);
}
}
TIP
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
@ComponentpublicclassDataRetentionJob {
// Fixed rate — every 30 minutes@Scheduled(fixedRate = 30, timeUnit = TimeUnit.MINUTES)publicvoidpurgeExpiredSessions() { ... }
// Fixed delay — 5 min after previous execution finishes@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)publicvoidsyncCache() { ... }
// Cron — every day at 02:00@Scheduled(cron = "0 0 2 * * *")publicvoidgenerateDailyReport() { ... }
// With zone@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Kolkata")publicvoidsendMorningDigest() { ... }
}
12. Resilience Patterns with Resilience4j
Resilience4j provides circuit breaker, retry, rate limiter, and bulkhead as annotations or functional wrappers — with Spring Boot auto-configuration.
resilience4j:circuitbreaker:instances:inventoryService:slidingWindowSize:10failureRateThreshold:50# open after 50% failureswaitDurationInOpenState:30spermittedNumberOfCallsInHalfOpenState:3retry:instances:inventoryService:maxAttempts:3waitDuration:500msretryExceptions:-java.net.ConnectException-java.util.concurrent.TimeoutExceptionratelimiter:instances:llmApi:limitForPeriod:100limitRefreshPeriod:1stimeoutDuration:200ms
Spring AI component architectureService to ChatClient; ChatClient to Advisors; ChatClient to Tool functions; ChatClient to ChatModel; ChatModel to OpenAI; ChatModel to Groq; ChatModel to Anthropic; ChatModel to Ollama; EmbeddingModel to OpenAI; Advisors to VectorStore; VectorStore to pgvector; VectorStore to Pinecone; VectorStore to QdrantSPRING BOOT APPLICATIONLLM PROVIDERS (SWAPPABLE)VECTOR STORESService: your codeServiceyour codeEmbeddingModelEmbeddingModelChatClient: fluent APIChatClientfluent APIAdvisors: RAG, Memory, LoggingAdvisorsRAG, Memory,LoggingTool functionsTool functionsChatModel: low levelChatModellow levelVectorStoreVectorStoreOpenAI: gpt-4oOpenAIgpt-4oGroq: llama-3.1Groqllama-3.1Anthropic: ClaudeAnthropicClaudeOllama: localOllamalocalpgvectorpgvectorPineconePineconeQdrantQdrant
@ServicepublicclassDocumentSummaryService {
privatefinal ChatClient chatClient;
privatestaticfinalStringSUMMARY_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) {
vartemplate=newPromptTemplate(SUMMARY_TEMPLATE);
varprompt= template.create(Map.of(
"format", format,
"focusAreas", focusAreas,
"content", content
));
return chatClient.prompt(prompt).call().content();
}
}
Spring AI RAG pipelineSource documents to DocumentReader; DocumentReader to Token text splitter; Token text splitter to EmbeddingModel; EmbeddingModel to VectorStore; User question to EmbeddingModel; EmbeddingModel to Similarity search; Similarity search to Retrieved chunks; Retrieved chunks to ChatModel; ChatModel to Grounded answer; VectorStore to Similarity searchDOCUMENT INGESTION (OFFLINE)QUERY TIME (PER REQUEST)Source documents: PDF, HTML, DBSourcedocumentsPDF, HTML, DBDocumentReaderDocumentReaderToken text splitter: chunkingToken textsplitterchunkingEmbeddingModelEmbeddingModelVectorStore: pgvectorVectorStorepgvectorUser questionUser questionEmbeddingModel: embed questionEmbeddingModelembed questionSimilarity search: VectorStoreSimilaritysearchVectorStoreRetrieved chunks: + questionRetrievedchunks+ questionChatModelChatModelGrounded answer: with sourcesGroundedanswerwith sources
@ServicepublicclassRagService {
privatefinal ChatClient chatClient;
publicRagService(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(
newQuestionAnswerAdvisor(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
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
@ServicepublicclassOrderAiService {
privatefinal ChatClient chatClient;
privatefinal OrderRepository orderRepo;
publicOrderAiService(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) {
Instantsince= Instant.now().minus(days, ChronoUnit.DAYS);
return orderRepo.findByCustomerIdAndCreatedAtAfter(customerId, since)
.stream().map(mapper::toSummary).toList();
}
}
# Export traces to Grafana Tempo or Jaeger via OTLPmanagement:otlp:tracing:endpoint:http://tempo:4318/v1/tracestracing:sampling:probability:1.0logging: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
spring:boot:admin:client:enabled:truemanagement:endpoint:health:probes:enabled:true# splits /actuator/health/liveness and /actuator/health/readinesshealth:livenessstate:enabled:truereadinessstate:enabled:true
# 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+
NOTE
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 threadsminimum-idle:5connection-timeout:3000# ms — fail fast if pool exhaustedidle-timeout:600000# 10 minmax-lifetime:1800000# 30 min — rotate before DB kills idle connectionvalidation-timeout:1000jpa:open-in-view:false# ALWAYS — prevents long connection holdsproperties:hibernate:jdbc.batch_size:50order_inserts:truethreads: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.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.
Join engineers building production AI systems
Free: LLM Production Readiness Checklist (PDF)
50 checks across observability, rate limiting, cost optimization, failure handling, and security — for teams shipping AI features to production.