Skip to content

    Spring AI

    Spring Boot Complete Architecture Guide: From Foundation to Spring AI

    An end-to-end guide to Spring Boot architecture for experienced developers — IoC, AOP, security, data, resilience, testing, Spring AI, and deployment.

    By Published 6 min read

    Share

    Sign up for a free account to read this article in full.

    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 master architectureClients to Load balancer; Load balancer to API gateway; API gateway to Spring Security; Spring Security to Resilience4j; Resilience4j to Web layer; Web layer to Service layer; Service layer to Spring AI; Service layer to Data layer; Service layer to Kafka / RabbitMQ; Service layer to Observability; Spring AI to VectorStore; Spring AI to LLM provider; VectorStore to pgvector; Data layer to Redis cache; Data layer to PostgreSQL; Flyway to PostgreSQL; Observability to Prometheus / GrafanaClients: web, API, CLIClientsweb, API, CLILoad balancerLoad balancerAPI gatewayAPI gatewaySpring Security: JWT, OAuth2SpringSecurityJWT, OAuth2Resilience4j: Breaker, Retry, RateLimiterResilience4jBreaker, Retry,RateLimiterWeb layer: RestController, Filters, AOPWeb layerRestController,Filters, AOPService layer: Business logic, TransactionalService layerBusiness logic,TransactionalSpring AI: ChatClient, Advisors, toolsSpring AIChatClient,Advisors, toolsVectorStoreVectorStoreLLM providerLLM providerpgvectorpgvectorKafka / RabbitMQ: message brokerKafka /RabbitMQmessage brokerData layer: Spring Data JPAData layerSpring Data JPARedis cacheRedis cachePostgreSQLPostgreSQLFlyway: migrationsFlywaymigrationsObservability: Actuator, tracingObservabilityActuator, tracingPrometheus / GrafanaPrometheus /Grafana

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

    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.

    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

    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

    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.

    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.

    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
            }
        }
    }
    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
    @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 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;
    
        // 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 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
    @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 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
    @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.

    Keep reading — it's free

    An end-to-end guide to Spring Boot architecture for experienced developers — IoC, AOP, security, data, resilience, testing, Spring AI, and deployment.

    In the rest of this article

    • 4. Application Events
    • 5. Layered Architecture
    • 6. Request Lifecycle: From HTTP to Database
    • 7. Spring Security
    • 8. Data Layer: JPA, Caching, Transactions
    • 9. Database Migrations with Flyway

    A free account unlocks the rest of this article, the full prompt library, quiz answer reviews and completion certificates. The newsletter is optional.

    Ask about this article

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

    Was this helpful?
    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.

    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.

    No spam. Unsubscribe any time.

    Comments