Spring AI
    July 28, 2026

    Spring Boot Complete Architecture Guide: From Foundation to Spring AI

    A comprehensive end-to-end guide to Spring Boot architecture for experienced developers — IoC, AOP, security, data, resilience, testing, Spring AI, and production deployment — with diagrams and production-quality code throughout.

    Share

    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.

    NOTE

    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.

    Spring Boot 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")           // 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();
    }
    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

    @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.

    flowchart LR 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);
    
        // 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 { ... }
    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 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.

    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 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());
        }
    }
    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

    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);
        }
    
        // 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

    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 LR 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())   // 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);
    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.

    flowchart LR 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())   // 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
            }
        }
    }
    flowchart LR 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) {
            // Runs on task executor thread pool, not the HTTP request thread
            var data = 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

    @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
    flowchart LR 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;
    
        // 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

    Spring AI 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();
        }
    
        // 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

    Spring AI 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();
        }
    }

    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
    @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

    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;          // 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
    # 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+
    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 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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments