
Spring Boot Engineer
- 1 installs
- 19 repo stars
- Updated July 14, 2026
- jetbrains/junie-extensions
spring-boot-engineer skill documents Generates Spring Boot 3.
About
spring-boot-engineer skill documents Generates Spring Boot 3.x configurations, creates REST controllers, implements Spring Security 6 authentication flows, sets up Spring Data JPA repositories, configures reactive WebFlux endpoints, and applies Resilience4j fault-tolerance patterns. Use when building Spring Boot 3.x applications, micro. name: spring-boot-engineer description: Generates Spring Boot 3.x configurations, creates REST controllers, implements Spring Security 6 authentication flows, sets up Spring Data JPA repositories, configures reactive WebFlux endpoints, and applies Resilience4j fault-tolerance patterns. Use when building Spring Boot 3.x applications, microservices, or reactive Java/Kotlin applications.
- Generates Spring Boot 3.
- Platform-specific setup patterns for spring-boot-engineer.
- Evidence-backed steps from upstream SKILL.md.
- When-to-use criteria for spring-boot-engineer versus alternatives.
Spring Boot Engineer by the numbers
- 1 all-time installs (skills.sh)
- Ranked #642 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Jul 25, 2026 (Skillselion catalog sync)
spring-boot-engineer capabilities & compatibility
- Capabilities
- spring boot engineer quick start · spring boot engineer when to use guidance · spring boot engineer integration patterns
- Use cases
- documentation
- IDEs
- intellij · jetbrains
npx skills add https://github.com/jetbrains/junie-extensions --skill spring-boot-engineerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 14, 2026 |
| Repository | jetbrains/junie-extensions ↗ |
How do I use spring-boot-engineer correctly?
Generates Spring Boot 3.x configurations, creates REST controllers, implements Spring Security 6 authentication flows, sets up Spring Data JPA repositories, configures reactive WebFlux endpoints, and
Who is it for?
Teams implementing spring-boot-engineer workflows from the catalog.
Skip if: Skip when requirements clearly match a different specialized stack.
When should I use this skill?
User asks about spring-boot-engineer, generates spring boot 3.x configurations, creates rest controllers, implements spring secu.
What you get
Working spring-boot-engineer setup with validated configuration and next steps.
Files
Spring Boot Engineer
Core Workflow
1. Analyze requirements — Identify service boundaries, APIs, data models, security needs 2. Design architecture — Plan microservices, data access, cloud integration, security; confirm design before coding 3. Implement — Create services with constructor injection and layered architecture (see Quick Start below) 4. Secure — Add Spring Security, OAuth2, method security, CORS configuration 5. Test — Write unit, integration, and slice tests; confirm all pass before proceeding 6. Deploy — Configure health checks and observability via Actuator; validate /actuator/health returns UP
Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|---|---|---|
| Web Layer | references/web.md | Controllers, REST APIs, validation, exception handling |
| Data Access | references/data.md | Spring Data JPA, repositories, transactions, projections |
| Security | references/security.md | Spring Security 6, OAuth2, JWT, method security |
| Cloud Native | references/cloud.md | Spring Cloud, Config, Discovery, Gateway, resilience |
| Testing | references/testing.md | @SpringBootTest, MockMvc, Testcontainers, test slices |
| Kotlin | references/kotlin.md | Kotlin controllers, services, DTOs, coroutines, WebFlux suspend |
| Event-Driven | references/event-driven.md | Domain events, @TransactionalEventListener, Kafka, outbox pattern |
| Resilience | references/resilience.md | Circuit breaker, retry, rate limiter, bulkhead, time limiter |
| Reactive (WebFlux) | references/reactive.md | Mono/Flux operators, reactive controllers, SSE, anti-patterns |
Quick Start — Minimal Working Structure
Entity
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String name;
@DecimalMin("0.0")
private BigDecimal price;
// getters / setters or use @Data (Lombok)
}Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
}Service (constructor injection)
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
@Transactional(readOnly = true)
public List<Product> search(String name) {
return repo.findByNameContainingIgnoreCase(name);
}
@Transactional
public Product create(ProductRequest request) {
var product = new Product();
product.setName(request.name());
product.setPrice(request.price());
return repo.save(product);
}
}REST Controller
@RestController
@RequestMapping("/api/v1/products")
@Validated
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@GetMapping
public List<ProductResponse> search(@RequestParam(defaultValue = "") String name) {
return service.search(name);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse create(@Valid @RequestBody ProductRequest request) {
return service.create(request);
}
}DTOs (records)
public record ProductRequest(
@NotBlank String name,
@DecimalMin("0.0") BigDecimal price
) {}
public record ProductResponse(Long id, String name, BigDecimal price) {
public static ProductResponse from(Product p) {
return new ProductResponse(p.getId(), p.getName(), p.getPrice());
}
}Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage()).toList());
return problem;
}
@ExceptionHandler(EntityNotFoundException.class)
public ProblemDetail handleNotFound(EntityNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}Test Slice
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mockMvc;
@MockBean ProductService service;
@Test
void createProduct_validRequest_returns201() throws Exception {
var product = new Product(); product.setName("Widget"); product.setPrice(BigDecimal.TEN);
when(service.create(any())).thenReturn(product);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name":"Widget","price":10.0}"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Widget"));
}
}Constraints
MUST DO
| Rule | Correct Pattern |
|---|---|
| Constructor injection | public MyService(Dep dep) { this.dep = dep; } |
| Validate API input | @Valid @RequestBody MyRequest req on every mutating endpoint |
| Type-safe config | @ConfigurationProperties(prefix = "app") bound to a record/class |
| Appropriate stereotype | @Service for business logic, @Repository for data, @RestController for HTTP |
| Transaction scope | @Transactional on multi-step writes; @Transactional(readOnly = true) on reads |
| Hide internals | Catch domain exceptions in @RestControllerAdvice; return problem details, not stack traces |
| Externalize secrets | Use environment variables or Spring Cloud Config — never application.properties |
MUST NOT DO
- Use field injection (
@Autowiredon fields) - Skip input validation on API endpoints
- Use
@Componentwhen@Service/@Repository/@Controllerapplies - Mix blocking and reactive code (e.g., calling
.block()inside a WebFlux chain) - Store secrets or credentials in
application.properties/application.yml - Hardcode URLs, credentials, or environment-specific values
- Use deprecated Spring Boot 2.x patterns (e.g.,
WebSecurityConfigurerAdapter)
Cloud Native - Spring Cloud
Spring Cloud Config Server
// Config Server
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
// application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/example/config-repo
default-label: main
search-paths: '{application}'
username: ${GIT_USERNAME}
password: ${GIT_PASSWORD}
native:
search-locations: classpath:/config
security:
user:
name: config-user
password: ${CONFIG_PASSWORD}
// Config Client
@SpringBootApplication
public class ClientApplication {
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}
// application.yml (Config Client)
spring:
application:
name: user-service
config:
import: "configserver:http://localhost:8888"
cloud:
config:
username: config-user
password: ${CONFIG_PASSWORD}
fail-fast: true
retry:
max-attempts: 6
initial-interval: 1000Dynamic Configuration Refresh
@RestController
@RefreshScope
public class ConfigController {
@Value("${app.feature.enabled:false}")
private boolean featureEnabled;
@Value("${app.max-connections:100}")
private int maxConnections;
@GetMapping("/config")
public Map<String, Object> getConfig() {
return Map.of(
"featureEnabled", featureEnabled,
"maxConnections", maxConnections
);
}
}
// Refresh configuration via Actuator endpoint:
// POST /actuator/refreshService Discovery - Eureka
// Eureka Server
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
// application.yml (Eureka Server)
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
// Eureka Client
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
// application.yml (Eureka Client)
spring:
application:
name: user-service
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
registry-fetch-interval-seconds: 5
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 10
lease-expiration-duration-in-seconds: 30Spring Cloud Gateway
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r
.path("/api/users/**")
.filters(f -> f
.rewritePath("/api/users/(?<segment>.*)", "/users/${segment}")
.addRequestHeader("X-Gateway", "Spring-Cloud-Gateway")
.circuitBreaker(config -> config
.setName("userServiceCircuitBreaker")
.setFallbackUri("forward:/fallback/users")
)
.retry(config -> config
.setRetries(3)
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE)
)
)
.uri("lb://user-service")
)
.route("order-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.rewritePath("/api/orders/(?<segment>.*)", "/orders/${segment}")
.requestRateLimiter(config -> config
.setRateLimiter(redisRateLimiter())
.setKeyResolver(userKeyResolver())
)
)
.uri("lb://order-service")
)
.build();
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(10, 20); // replenishRate, burstCapacity
}
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
);
}
}
// application.yml (Gateway)
spring:
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin
globalcors:
cors-configurations:
'[/**]':
allowed-origins: "*"
allowed-methods:
- GET
- POST
- PUT
- DELETE
allowed-headers: "*"Circuit Breaker - Resilience4j
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final WebClient webClient;
@CircuitBreaker(name = "externalApi", fallbackMethod = "getFallbackData")
@Retry(name = "externalApi")
@RateLimiter(name = "externalApi")
public Mono<ExternalData> getData(String id) {
return webClient
.get()
.uri("/data/{id}", id)
.retrieve()
.bodyToMono(ExternalData.class)
.timeout(Duration.ofSeconds(3));
}
private Mono<ExternalData> getFallbackData(String id, Exception e) {
log.warn("Fallback triggered for id: {}, error: {}", id, e.getMessage());
return Mono.just(new ExternalData(id, "Fallback data", LocalDateTime.now()));
}
}
// application.yml
resilience4j:
circuitbreaker:
instances:
externalApi:
register-health-indicator: true
sliding-window-size: 10
minimum-number-of-calls: 5
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
wait-duration-in-open-state: 5s
failure-rate-threshold: 50
event-consumer-buffer-size: 10
retry:
instances:
externalApi:
max-attempts: 3
wait-duration: 1s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
ratelimiter:
instances:
externalApi:
limit-for-period: 10
limit-refresh-period: 1s
timeout-duration: 0sDistributed Tracing - Micrometer Tracing
// application.yml
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
// Custom spans
@Service
@RequiredArgsConstructor
public class OrderService {
private final Tracer tracer;
private final OrderRepository orderRepository;
public Order processOrder(OrderRequest request) {
Span span = tracer.nextSpan().name("processOrder").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
span.tag("order.type", request.type());
span.tag("order.items", String.valueOf(request.items().size()));
// Business logic
Order order = createOrder(request);
span.event("order.created");
return order;
} finally {
span.end();
}
}
}Load Balancing with Spring Cloud LoadBalancer
@Configuration
@LoadBalancerClient(name = "user-service", configuration = UserServiceLoadBalancerConfig.class)
public class LoadBalancerConfiguration {
}
@Configuration
public class UserServiceLoadBalancerConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
LoadBalancerClientFactory clientFactory,
ObjectProvider<LoadBalancerProperties> properties) {
return new RandomLoadBalancer(
clientFactory.getLazyProvider("user-service", ServiceInstanceListSupplier.class),
"user-service"
);
}
}
@Service
@RequiredArgsConstructor
public class UserClientService {
private final WebClient.Builder webClientBuilder;
public Mono<User> getUser(Long id) {
return webClientBuilder
.baseUrl("http://user-service")
.build()
.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
}Health Checks & Actuator
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
boolean serviceUp = checkExternalService();
if (serviceUp) {
return Health.up()
.withDetail("externalService", "Available")
.withDetail("timestamp", LocalDateTime.now())
.build();
} else {
return Health.down()
.withDetail("externalService", "Unavailable")
.withDetail("error", "Connection timeout")
.build();
}
}
private boolean checkExternalService() {
// Check external dependency
return true;
}
}
// application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
probes:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}Kubernetes Deployment
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: user-service:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "kubernetes"
- name: JAVA_OPTS
value: "-Xmx512m -Xms256m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- port: 80
targetPort: 8080
type: ClusterIPDocker Configuration
# Dockerfile (Multi-stage)
FROM eclipse-temurin:17-jdk-alpine AS build
WORKDIR /workspace/app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src src
RUN ./mvnw install -DskipTests
RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar)
FROM eclipse-temurin:17-jre-alpine
VOLUME /tmp
ARG DEPENDENCY=/workspace/app/target/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.Application"]Feign Client
Declarative HTTP client — annotate an interface, Spring generates the implementation.
// Dependency: spring-cloud-starter-openfeign
@SpringBootApplication
@EnableFeignClients
public class MyApplication { ... }
@FeignClient(
name = "inventory-service", // service name for load balancing
fallback = InventoryClientFallback.class
)
public interface InventoryClient {
@GetMapping("/api/inventory/{productId}/stock")
Integer getAvailableStock(@PathVariable Long productId);
@PostMapping("/api/inventory/reserve")
ReservationResponse reserve(@RequestBody ReserveRequest request);
}
// Fallback — returned when circuit breaker opens or service is down
@Component
public class InventoryClientFallback implements InventoryClient {
@Override
public Integer getAvailableStock(Long productId) {
return 0; // safe default
}
@Override
public ReservationResponse reserve(ReserveRequest request) {
throw new ServiceUnavailableException("Inventory service is unavailable");
}
}spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 5000
loggerLevel: BASIC
circuitbreaker:
enabled: true # integrates with Resilience4jQuick Reference
| Component | Purpose |
|---|---|
| Config Server | Centralized configuration management |
| Eureka | Service discovery and registration |
| Gateway | API gateway with routing, filtering, load balancing |
| Feign Client | Declarative HTTP client with load balancing and fallback |
| Circuit Breaker | Fault tolerance and fallback patterns |
| Load Balancer | Client-side load balancing |
| Tracing | Distributed tracing across services |
| Actuator | Production-ready monitoring and management |
| Kubernetes | Container orchestration and deployment |
Data Access - Spring Data JPA
JPA Entity Pattern
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true),
@Index(name = "idx_username", columnList = "username")
})
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
@Builder.Default
private Boolean active = true;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Address> addresses = new ArrayList<>();
@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@Builder.Default
private Set<Role> roles = new HashSet<>();
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@Version
private Long version;
// Helper methods for bidirectional relationships
public void addAddress(Address address) {
addresses.add(address);
address.setUser(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setUser(null);
}
}Spring Data JPA Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(@Param("email") String email);
@Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt >= :since")
List<User> findActiveUsersSince(@Param("since") LocalDateTime since);
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :threshold")
int deactivateInactiveUsers(@Param("threshold") LocalDateTime threshold);
// Projection for read-only DTOs
@Query("SELECT new com.example.dto.UserSummary(u.id, u.username, u.email) " +
"FROM User u WHERE u.active = true")
List<UserSummary> findAllActiveSummaries();
}Repository with Specifications
public class UserSpecifications {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) ->
email == null ? null : cb.equal(root.get("email"), email);
}
public static Specification<User> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
public static Specification<User> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), date);
}
public static Specification<User> hasRole(String roleName) {
return (root, query, cb) -> {
Join<User, Role> roles = root.join("roles", JoinType.INNER);
return cb.equal(roles.get("name"), roleName);
};
}
}
// Usage in service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Page<User> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
Specification<User> spec = Specification
.where(UserSpecifications.hasEmail(criteria.email()))
.and(UserSpecifications.isActive())
.and(UserSpecifications.createdAfter(criteria.createdAfter()));
return userRepository.findAll(spec, pageable);
}
}Transaction Management
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final NotificationService notificationService;
@Transactional
public Order createOrder(OrderCreateRequest request) {
// All operations in single transaction
Order order = Order.builder()
.customerId(request.customerId())
.status(OrderStatus.PENDING)
.build();
request.items().forEach(item -> {
inventoryService.reserveStock(item.productId(), item.quantity());
order.addItem(item);
});
order = orderRepository.save(order);
try {
paymentService.processPayment(order);
order.setStatus(OrderStatus.PAID);
} catch (PaymentException e) {
order.setStatus(OrderStatus.PAYMENT_FAILED);
throw e; // Transaction will rollback
}
return orderRepository.save(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderEvent(Long orderId, String event) {
// Separate transaction - will commit even if parent rolls back
OrderEvent orderEvent = new OrderEvent(orderId, event);
orderEventRepository.save(orderEvent);
}
@Transactional(noRollbackFor = NotificationException.class)
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
order.setStatus(OrderStatus.COMPLETED);
orderRepository.save(order);
// Won't rollback transaction if notification fails
try {
notificationService.sendCompletionEmail(order);
} catch (NotificationException e) {
log.error("Failed to send notification for order {}", orderId, e);
}
}
}Auditing Configuration
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> {
Authentication authentication = SecurityContextHolder
.getContext()
.getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return Optional.of("system");
}
return Optional.of(authentication.getName());
};
}
}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@CreatedBy
@Column(nullable = false, updatable = false, length = 100)
private String createdBy;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@LastModifiedBy
@Column(nullable = false, length = 100)
private String updatedBy;
}Projections
// Interface-based projection
public interface UserSummary {
Long getId();
String getUsername();
String getEmail();
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
// Class-based projection (DTO)
public record UserSummaryDto(
Long id,
String username,
String email
) {}
// Usage
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findAllBy();
<T> List<T> findAllBy(Class<T> type);
}
// Service usage
List<UserSummary> summaries = userRepository.findAllBy();
List<UserSummaryDto> dtos = userRepository.findAllBy(UserSummaryDto.class);Query Optimization
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserQueryService {
private final UserRepository userRepository;
private final EntityManager entityManager;
// N+1 problem solved with JOIN FETCH
@Query("SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.addresses " +
"LEFT JOIN FETCH u.roles " +
"WHERE u.active = true")
List<User> findAllActiveWithAssociations();
// Batch fetching
@BatchSize(size = 25)
@OneToMany(mappedBy = "user")
private List<Order> orders;
// EntityGraph for dynamic fetching
@EntityGraph(attributePaths = {"addresses", "roles"})
List<User> findAllByActiveTrue();
// Pagination to avoid loading all data
public Page<User> findAllUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
// Native query for complex queries
@Query(value = """
SELECT u.* FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= :since
GROUP BY u.id
HAVING COUNT(o.id) >= :minOrders
""", nativeQuery = true)
List<User> findFrequentBuyers(@Param("since") LocalDateTime since,
@Param("minOrders") int minOrders);
}Connection Pool (HikariCP)
HikariCP is the default pool in Spring Boot. Always configure it explicitly for production:
spring:
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/mydb}
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
hikari:
maximum-pool-size: 10 # default 10 — tune based on DB capacity
minimum-idle: 5
connection-timeout: 20000 # ms to wait for connection (default 30s)
idle-timeout: 600000 # ms before idle connection removed
max-lifetime: 1800000 # ms max connection lifetime (< DB wait_timeout)
leak-detection-threshold: 60000 # warn if connection held > 60s
jpa:
properties:
hibernate:
jdbc:
batch_size: 20 # enable JDBC batching
order_inserts: true # group inserts by type for better batching
order_updates: true
open-in-view: false # always disable — avoids lazy-loading in web layerStreaming Large Datasets
Use Stream to process large result sets without loading everything into memory:
@Transactional(readOnly = true)
public void exportUsers(OutputStream out) {
try (Stream<User> stream = userRepository.streamByActiveTrue()) {
stream.forEach(user -> writeCsv(out, user));
}
// Stream MUST be closed — always use try-with-resources
}
// Repository
public interface UserRepository extends JpaRepository<User, Long> {
Stream<User> streamByActiveTrue();
}Rule:Stream-returning repository methods must be called inside a@Transactionalmethod and closed explicitly.
Database Migrations (Flyway)
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_active ON users(active);
-- V2__create_addresses_table.sql
CREATE TABLE addresses (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
street VARCHAR(200) NOT NULL,
city VARCHAR(100) NOT NULL,
country VARCHAR(2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_addresses_user_id ON addresses(user_id);Quick Reference
| Annotation | Purpose |
|---|---|
@Entity | Marks class as JPA entity |
@Table | Specifies table details and indexes |
@Id | Marks primary key field |
@GeneratedValue | Auto-generated primary key strategy |
@Column | Column constraints and mapping |
@OneToMany/@ManyToOne | One-to-many/many-to-one relationships |
@ManyToMany | Many-to-many relationships |
@JoinColumn/@JoinTable | Join column/table configuration |
@Transactional | Declares transaction boundaries |
@Query | Custom JPQL/native queries |
@Modifying | Marks query as UPDATE/DELETE |
@EntityGraph | Defines fetch graph for associations |
@Version | Optimistic locking version field |
Event-Driven Architecture — Spring Boot
Domain Event Base
// Immutable event — use records (Java 16+)
public record ProductCreatedEvent(
UUID eventId,
UUID correlationId,
LocalDateTime occurredAt,
String productId,
String name,
BigDecimal price
) {
public static ProductCreatedEvent of(String productId, String name, BigDecimal price) {
return new ProductCreatedEvent(
UUID.randomUUID(), UUID.randomUUID(), LocalDateTime.now(),
productId, name, price
);
}
}Aggregate Root Collecting Events
public class Product {
private String id;
private String name;
private BigDecimal price;
@Transient
private final List<Object> domainEvents = new ArrayList<>();
public static Product create(String name, BigDecimal price) {
Product p = new Product();
p.id = UUID.randomUUID().toString();
p.name = name;
p.price = price;
p.domainEvents.add(ProductCreatedEvent.of(p.id, name, price));
return p;
}
public List<Object> getDomainEvents() { return List.copyOf(domainEvents); }
public void clearDomainEvents() { domainEvents.clear(); }
}Publishing Events in Application Service
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public ProductResponse createProduct(CreateProductRequest request) {
Product product = Product.create(request.name(), request.price());
Product saved = productRepository.save(product);
saved.getDomainEvents().forEach(eventPublisher::publishEvent);
saved.clearDomainEvents();
return ProductResponse.from(saved);
}
}Transactional Event Listeners
@Component
@RequiredArgsConstructor
@Slf4j
public class ProductEventHandler {
private final NotificationService notificationService;
private final InventoryService inventoryService;
// Fires only AFTER the transaction commits — safest default
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductCreated(ProductCreatedEvent event) {
log.info("Product created: {}", event.productId());
notificationService.sendCreatedNotification(event.name(), event.price());
inventoryService.register(event.productId());
}
}| Phase | When it fires |
|---|---|
BEFORE_COMMIT | Just before commit — can still roll back |
AFTER_COMMIT | After successful commit (recommended) |
AFTER_ROLLBACK | On rollback |
AFTER_COMPLETION | Either outcome |
Warning:@TransactionalEventListenerfires on the same thread after commit. For long-running work, use@Asyncor publish to Kafka.
Async Event Handling
@Component
@Slf4j
public class AsyncProductEventHandler {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductCreated(ProductCreatedEvent event) {
// Runs on a separate thread — transaction is already committed
sendEmailAsync(event);
}
}
// Enable in @SpringBootApplication class:
@SpringBootApplication
@EnableAsync
public class MyApplication { ... }Kafka Event Publishing
@Component
@RequiredArgsConstructor
@Slf4j
public class KafkaEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishProductCreated(ProductCreatedEvent event) {
kafkaTemplate.send("product-events", event.productId(), event)
.whenComplete((result, ex) -> {
if (ex != null) log.error("Failed to publish {}", event.productId(), ex);
});
}
}spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
group-id: product-service
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"Transactional Outbox Pattern
Use when you need guaranteed delivery — stores events atomically with business data, polls and publishes separately:
@Entity
@Table(name = "outbox_events")
@Builder @Getter
@NoArgsConstructor @AllArgsConstructor
public class OutboxEvent {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "TEXT")
private String payload; // JSON
private LocalDateTime createdAt;
private LocalDateTime publishedAt; // null = pending
}
// In application service — same transaction as business data
@Transactional
public ProductResponse createProduct(CreateProductRequest request) {
Product product = productRepository.save(Product.create(request.name(), request.price()));
outboxRepository.save(OutboxEvent.builder()
.aggregateId(product.getId())
.eventType("ProductCreated")
.payload(objectMapper.writeValueAsString(ProductCreatedEvent.from(product)))
.createdAt(LocalDateTime.now())
.build());
return ProductResponse.from(product);
}
// Scheduled publisher (separate transaction)
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxPublisher {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 5_000)
@Transactional
public void publishPending() {
outboxRepository.findByPublishedAtIsNull().forEach(event -> {
try {
kafkaTemplate.send("product-events", event.getAggregateId(), event.getPayload());
event.setPublishedAt(LocalDateTime.now());
} catch (Exception ex) {
log.error("Outbox publish failed for {}", event.getId(), ex);
}
});
}
}Idempotent Consumer
@Component
@RequiredArgsConstructor
public class IdempotentProductConsumer {
private final ProcessedEventRepository processedEvents;
@KafkaListener(topics = "product-events", groupId = "inventory-service")
public void consume(ProductCreatedEvent event) {
String key = event.eventId().toString();
if (processedEvents.existsByEventId(key)) return; // already handled
doProcess(event);
processedEvents.save(new ProcessedEvent(key, LocalDateTime.now()));
}
}Best Practices
- Name events in past tense:
ProductCreated, notCreateProduct - Keep events immutable (records in Java, data classes in Kotlin)
- Include
eventId+correlationIdfor tracing - Use
AFTER_COMMITphase — prevents handlers firing on rolled-back transactions - Design consumers to be idempotent — distributed systems may deliver duplicates
- Use outbox pattern for cross-service guaranteed delivery (avoids dual-write problem)
Kotlin — Spring Boot Patterns
Controller
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {
@GetMapping
fun getAll(): List<UserResponse> = userService.findAll()
@GetMapping("/{id}")
fun getById(@PathVariable id: Long): ResponseEntity<UserResponse> =
ResponseEntity.ok(userService.findById(id))
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateUserRequest): UserResponse =
userService.create(request)
@PutMapping("/{id}")
fun update(@PathVariable id: Long, @Valid @RequestBody request: UpdateUserRequest): UserResponse =
userService.update(id, request)
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(@PathVariable id: Long) = userService.delete(id)
}Service (constructor injection — no @RequiredArgsConstructor needed)
@Service
@Transactional(readOnly = true)
class UserService(
private val userRepository: UserRepository,
private val userMapper: UserMapper,
) {
fun findAll(): List<UserResponse> = userRepository.findAll().map(userMapper::toResponse)
fun findById(id: Long): UserResponse =
userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow { ResourceNotFoundException("User", id) }
@Transactional
fun create(request: CreateUserRequest): UserResponse {
val user = userMapper.toEntity(request)
return userMapper.toResponse(userRepository.save(user))
}
@Transactional
fun delete(id: Long) {
if (!userRepository.existsById(id)) throw ResourceNotFoundException("User", id)
userRepository.deleteById(id)
}
}DTOs — use @field: prefix for Bean Validation
// Request DTO
data class CreateUserRequest(
@field:NotBlank(message = "Name is required")
@field:Size(min = 2, max = 100)
val name: String,
@field:Email(message = "Invalid email format")
@field:NotBlank
val email: String,
@field:Min(18)
val age: Int,
)
// Response DTO — immutable data class
data class UserResponse(
val id: Long,
val name: String,
val email: String,
val createdAt: LocalDateTime,
)Rule: Always use @field: in Kotlin data classes. Without it, the annotation targets the constructor parameter, not the backing field — Bean Validation silently skips it.Configuration Properties
@ConfigurationProperties(prefix = "app.jwt")
@Validated
data class JwtProperties(
@field:NotBlank val secret: String,
@field:Min(60000) val expiration: Long = 86400000,
)Entity
@Entity
@Table(name = "users")
class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null, // nullable — assigned by DB
@Column(nullable = false)
var name: String,
@Column(nullable = false, unique = true)
var email: String,
)Prefer mutablevarfor fields JPA needs to set. KeepidasLong?and never set it manually.
Anti-Patterns
// ❌ lateinit for repository — use constructor injection
@Service
class UserService {
@Autowired lateinit var userRepository: UserRepository // Bad
}
// ✅ constructor injection
@Service
class UserService(private val userRepository: UserRepository)
// ❌ @NotBlank without @field: — silently ignored
data class Request(@NotBlank val name: String)
// ✅
data class Request(@field:NotBlank val name: String)---
Kotlin + Coroutines (WebFlux)
For reactive Spring WebFlux with coroutines, see references/reactive.md → Kotlin section.Suspend Controller (WebFlux only)
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {
@GetMapping
fun getAll(): Flow<UserResponse> = userService.findAll()
@GetMapping("/{id}")
suspend fun getById(@PathVariable id: Long): UserResponse = userService.findById(id)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(@Valid @RequestBody request: CreateUserRequest): UserResponse =
userService.create(request)
}Coroutine Repository (R2DBC)
interface UserRepository : CoroutineCrudRepository<User, Long> {
suspend fun findByEmail(email: String): User?
fun findByActiveTrue(): Flow<User>
}Parallel Calls with coroutineScope
suspend fun getDashboard(userId: Long): DashboardResponse = coroutineScope {
val user = async { userService.findById(userId) }
val orders = async { orderService.findByUser(userId) }
val balance = async { accountService.getBalance(userId) }
DashboardResponse(user.await(), orders.await(), balance.await())
}Blocking Code — use Dispatchers.IO
// ❌ Never block on the WebFlux event loop
suspend fun bad(): String = File("data.txt").readText()
// ✅ Offload to IO dispatcher
suspend fun good(): String = withContext(Dispatchers.IO) { File("data.txt").readText() }Spring WebFlux — Reactive Patterns
When to Use WebFlux vs MVC
| Factor | Spring MVC | Spring WebFlux |
|---|---|---|
| I/O model | Thread-per-request | Non-blocking event loop |
| Blocking calls (JDBC, files) | Natural | Requires Schedulers.boundedElastic() |
| Database | JPA + Hibernate | R2DBC required |
| Best for | CRUD services, JPA | High-concurrency I/O, streaming, SSE |
Rule of thumb: Using Spring Data JPA? Stay with MVC. Switch to WebFlux + R2DBC only for non-blocking end-to-end.
---
Reactive Controller
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public Flux<UserResponse> getAll() {
return userService.findAll();
}
@GetMapping("/{id}")
public Mono<UserResponse> getById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<UserResponse> create(@Valid @RequestBody Mono<CreateUserRequest> request) {
return request.flatMap(userService::create);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(@PathVariable Long id) {
return userService.delete(id);
}
}Reactive Service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Flux<UserResponse> findAll() {
return userRepository.findAll().map(UserResponse::from);
}
public Mono<UserResponse> findById(Long id) {
return userRepository.findById(id)
.map(UserResponse::from)
.switchIfEmpty(Mono.error(
new ResponseStatusException(HttpStatus.NOT_FOUND, "User " + id + " not found")));
}
public Mono<UserResponse> create(CreateUserRequest request) {
return userRepository.save(User.from(request)).map(UserResponse::from);
}
}R2DBC Repository
public interface UserRepository extends ReactiveCrudRepository<User, Long> {
Mono<User> findByEmail(String email);
Flux<User> findByActiveTrue();
}---
Essential Operators
// Transform element
Mono<String> name = userMono.map(User::getName);
// Async transform (returns Mono/Flux)
Mono<Order> order = userMono.flatMap(user -> orderService.getLatest(user.getId()));
// Flat-map Flux
Flux<OrderItem> items = orderFlux.flatMap(order -> itemService.getItems(order.getId()));
// Filter
Flux<User> active = userFlux.filter(User::isActive);
// Default if empty
Mono<User> user = userRepository.findById(id)
.switchIfEmpty(Mono.error(new NotFoundException("User not found")));
// Recover from error
Mono<User> safe = userRepository.findById(id)
.onErrorReturn(User.anonymous());
// Parallel independent calls
Mono<DashboardResponse> dashboard = Mono.zip(
userService.findById(userId),
orderService.findLatest(userId),
accountService.getBalance(userId)
).map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2(), tuple.getT3()));
// Merge parallel streams
Flux<Event> events = Flux.merge(userEvents, orderEvents, paymentEvents);---
Error Handling
// ✅ Lazy error — use Mono.defer to avoid eager evaluation
return Mono.defer(() -> Mono.error(new BusinessException("not allowed")));
// ❌ Eager error — exception created immediately, even if not subscribed
return Mono.error(new BusinessException("not allowed"));
// Map specific errors
Mono<User> result = userService.findById(id)
.onErrorMap(DatabaseException.class, ex ->
new ServiceException("Database unavailable", ex));
// Fallback on error
Mono<Config> config = configService.load()
.onErrorResume(ex -> Mono.just(Config.defaults()));---
Blocking Code — Scheduler Rules
Never block on the Netty event loop thread. Offload to boundedElastic:
// ❌ Blocks Netty event loop — deadlock risk
Mono<String> bad = Mono.just("path")
.map(p -> Files.readString(Path.of(p))); // blocking I/O
// ✅ Offload to boundedElastic scheduler
Mono<String> good = Mono.just("path")
.publishOn(Schedulers.boundedElastic())
.map(p -> Files.readString(Path.of(p)));
// ✅ Or wrap existing blocking call
Mono<String> wrapped = Mono.fromCallable(() -> Files.readString(Path.of("file.txt")))
.subscribeOn(Schedulers.boundedElastic());| Scheduler | Use for |
|---|---|
boundedElastic() | Blocking I/O (files, JDBC, legacy libs) |
parallel() | CPU-intensive work |
single() | Single-thread tasks |
---
Server-Sent Events (SSE)
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<String>builder()
.id(String.valueOf(seq))
.event("message")
.data("Event #" + seq)
.build());
}WebClient (non-blocking HTTP client)
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(WebClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final WebClient webClient;
public Mono<ExternalData> fetchData(String id) {
return webClient.get()
.uri("/data/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
Mono.error(new ResourceNotFoundException("Resource not found: " + id)))
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new ServiceUnavailableException("External service down")))
.bodyToMono(ExternalData.class)
.timeout(Duration.ofSeconds(5));
}
}---
Anti-Patterns
// ❌ .block() on Netty thread — deadlock
Mono<User> userMono = userService.findById(1L);
User user = userMono.block(); // Never do this inside a reactive chain
// ❌ subscribe() inside a reactive chain — detached stream, errors swallowed
return userMono.map(u -> {
notificationService.sendEmail(u).subscribe(); // fire-and-forget — bad
return u;
});
// ✅ Use flatMap instead
return userMono.flatMap(u ->
notificationService.sendEmail(u).thenReturn(u));
// ❌ if/else in reactive chain — breaks operators
return userMono.map(user -> {
if (user.isActive()) { return doA(user); }
else { return doB(user); }
});
// ✅ Use filter / switchIfEmpty / flatMap
return userMono
.filter(User::isActive)
.flatMap(this::doA)
.switchIfEmpty(userMono.flatMap(this::doB));
// ❌ Imperative throw in reactive chain
return userMono.map(user -> {
if (!user.hasPermission()) throw new ForbiddenException(); // escapes chain
return user;
});
// ✅ Lazy Mono.error via defer
return userMono.flatMap(user ->
user.hasPermission()
? Mono.just(user)
: Mono.defer(() -> Mono.error(new ForbiddenException())));---
Kotlin Coroutines with WebFlux
// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {
@GetMapping
fun getAll(): Flow<UserResponse> = userService.findAll() // Flux equivalent
@GetMapping("/{id}")
suspend fun getById(@PathVariable id: Long): UserResponse = userService.findById(id)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(@Valid @RequestBody request: CreateUserRequest): UserResponse =
userService.create(request)
}
@Service
class UserService(private val userRepository: UserRepository) {
fun findAll(): Flow<UserResponse> =
userRepository.findAll().map { UserResponse.from(it) }
suspend fun findById(id: Long): UserResponse =
userRepository.findById(id)?.let { UserResponse.from(it) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $id not found")
}Reactor ↔ Coroutines Interop
val result: T = mono.awaitSingle()
val nullable: T? = mono.awaitSingleOrNull()
val flow: Flow<T> = flux.asFlow()
val mono: Mono<T> = mono { suspendFunction() }
val flux: Flux<T> = flow.asFlux()
// Blocking I/O — use Dispatchers.IO
suspend fun readFile(path: String): String =
withContext(Dispatchers.IO) { File(path).readText() }---
Backpressure — Processing Large Streams
Use buffer + flatMap with concurrency limit to process large datasets without overwhelming downstream:
// Process in batches of 100, max 5 batches concurrently
Flux<Result> results = dataRepository.findAll()
.buffer(100)
.flatMap(batch -> processBatch(batch), 5);
// With delay between batches (rate limiting)
Flux<Result> throttled = dataRepository.findAll()
.buffer(50)
.delayElements(Duration.ofMillis(100))
.flatMap(this::processBatch);
// Collect results
Mono<List<Result>> all = dataRepository.findAll()
.buffer(100)
.flatMap(batch -> processBatch(batch), 5)
.collectList();Without a concurrency limit in flatMap, all buffers will be processed simultaneously — use the second argument to control parallelism.Testing Reactive Code
// StepVerifier for Mono/Flux
@Test
void shouldFindUser() {
StepVerifier.create(userService.findById(1L))
.assertNext(user -> assertThat(user.id()).isEqualTo(1L))
.verifyComplete();
}
// WebTestClient for controllers
@WebFluxTest(UserController.class)
class UserControllerTest {
@Autowired WebTestClient webTestClient;
@MockBean UserService userService;
@Test
void shouldReturnUser() {
when(userService.findById(1L)).thenReturn(Mono.just(new UserResponse(1L, "Alice")));
webTestClient.get().uri("/api/v1/users/1")
.exchange()
.expectStatus().isOk()
.expectBody(UserResponse.class)
.value(u -> assertThat(u.name()).isEqualTo("Alice"));
}
}Resilience4j — Fault Tolerance Patterns
Dependencies
<!-- Maven -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>// Gradle (Kotlin DSL)
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
implementation("org.springframework.boot:spring-boot-starter-aop")
// For Kotlin suspend functions:
implementation("io.github.resilience4j:resilience4j-kotlin:2.2.0")---
Circuit Breaker
@Service
@RequiredArgsConstructor
public class PaymentService {
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResponse processPayment(PaymentRequest request) {
return restTemplate.postForObject("http://payment-api/process",
request, PaymentResponse.class);
}
private PaymentResponse paymentFallback(PaymentRequest request, Throwable ex) {
return new PaymentResponse("PENDING", "Service temporarily unavailable");
}
}resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50 # % of failures to open
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
instances:
paymentService:
baseConfig: defaultStates: CLOSED (normal) → OPEN (failing, rejects calls) → HALF_OPEN (trial calls) → CLOSED
---
Retry
@Service
@RequiredArgsConstructor
public class ProductService {
@Retry(name = "productService", fallbackMethod = "getProductFallback")
public Product getProduct(Long productId) {
return restTemplate.getForObject("/products/" + productId, Product.class);
}
private Product getProductFallback(Long productId, Throwable ex) {
return new Product(productId, "Unavailable", false);
}
}resilience4j:
retry:
configs:
default:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.BusinessException # Don't retry business errors
instances:
productService:
baseConfig: default
maxAttempts: 5Warning: Only retry idempotent operations. Never use @Retry on non-idempotent POST endpoints.---
Rate Limiter
@Service
@RequiredArgsConstructor
public class NotificationService {
@RateLimiter(name = "notificationService", fallbackMethod = "rateLimitFallback")
public void sendEmail(EmailRequest request) {
emailClient.send(request);
}
private void rateLimitFallback(EmailRequest request, Throwable ex) {
throw new RateLimitExceededException("Too many requests. Retry after 1s.");
}
}resilience4j:
ratelimiter:
instances:
notificationService:
limitForPeriod: 10 # max calls per period
limitRefreshPeriod: 1s
timeoutDuration: 500ms # how long to wait for a permit---
Bulkhead
Use SEMAPHORE for synchronous methods, THREADPOOL for async/CompletableFuture:
@Service
public class ReportService {
@Bulkhead(name = "reportService", type = Bulkhead.Type.SEMAPHORE)
public Report generateReport(ReportRequest request) {
return reportGenerator.generate(request);
}
@Bulkhead(name = "analyticsService", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<AnalyticsResult> runAnalytics(AnalyticsRequest request) {
return CompletableFuture.supplyAsync(() -> analyticsEngine.analyze(request));
}
}resilience4j:
bulkhead:
instances:
reportService:
maxConcurrentCalls: 5
maxWaitDuration: 100ms
thread-pool-bulkhead:
instances:
analyticsService:
maxThreadPoolSize: 8
coreThreadPoolSize: 4---
Time Limiter
@Service
public class SearchService {
@TimeLimiter(name = "searchService", fallbackMethod = "searchFallback")
public CompletableFuture<SearchResults> search(SearchQuery query) {
return CompletableFuture.supplyAsync(() -> searchEngine.execute(query));
}
private CompletableFuture<SearchResults> searchFallback(SearchQuery query, Throwable ex) {
return CompletableFuture.completedFuture(SearchResults.empty("Search timed out"));
}
}resilience4j:
timelimiter:
instances:
searchService:
timeoutDuration: 3s
cancelRunningFuture: true---
Combining Patterns
Execution order: Retry → CircuitBreaker → RateLimiter → Bulkhead → Method
@Service
public class OrderService {
@CircuitBreaker(name = "orderService")
@Retry(name = "orderService")
@RateLimiter(name = "orderService")
@Bulkhead(name = "orderService")
public Order createOrder(OrderRequest request) {
return orderClient.create(request);
}
}---
Kotlin Suspend Functions
Annotation-based Resilience4j does not work on suspend functions — use the functional API:
@Service
class PaymentService(
private val cbRegistry: CircuitBreakerRegistry,
private val retryRegistry: RetryRegistry,
private val webClient: WebClient,
) {
private val cb = cbRegistry.circuitBreaker("paymentService")
private val retry = retryRegistry.retry("paymentService")
suspend fun processPayment(request: PaymentRequest): PaymentResponse =
retry.executeSuspendFunction {
cb.executeSuspendFunction {
webClient.post().uri("/process")
.bodyValue(request)
.retrieve()
.awaitBody<PaymentResponse>()
}
}
}---
Exception Handler for Resilience4j
@RestControllerAdvice
public class ResilienceExceptionHandler {
@ExceptionHandler(CallNotPermittedException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleCircuitOpen(CallNotPermittedException ex) {
return new ErrorResponse("SERVICE_UNAVAILABLE", "Service temporarily unavailable");
}
@ExceptionHandler(RequestNotPermitted.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public ErrorResponse handleRateLimited(RequestNotPermitted ex) {
return new ErrorResponse("TOO_MANY_REQUESTS", "Rate limit exceeded");
}
@ExceptionHandler(BulkheadFullException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleBulkheadFull(BulkheadFullException ex) {
return new ErrorResponse("CAPACITY_EXCEEDED", "Service at capacity");
}
}Monitoring (Actuator)
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,retries,ratelimiters
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: trueEndpoints: GET /actuator/circuitbreakers, GET /actuator/metrics/resilience4j.circuitbreaker.calls
Best Practices
- Always provide fallback methods with meaningful degraded responses
- Use exponential backoff for retries (
exponentialBackoffMultiplier: 2) - Set
failureRateThresholdbetween 50–70% depending on error tolerance - Only retry transient errors (network, 5xx) — never business exceptions (4xx)
- Size bulkheads from expected concurrent load × average latency
- Enable
registerHealthIndicator: trueon all instances for visibility
Security - Spring Security 6
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/auth/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
)
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletRequest response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
log.error("JWT validation failed", e);
}
filterChain.doFilter(request, response);
}
}JWT Service
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long jwtExpiration;
@Value("${jwt.refresh-expiration}")
private long refreshExpiration;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return generateToken(extraClaims, userDetails);
}
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails, refreshExpiration);
}
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration) {
return Jwts
.builder()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}UserDetailsService Implementation
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByEmailWithRoles(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with email: " + username));
return org.springframework.security.core.userdetails.User
.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.accountExpired(false)
.accountLocked(!user.getActive())
.credentialsExpired(false)
.disabled(!user.getActive())
.build();
}
}Authentication Controller
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@Valid @RequestBody RegisterRequest request) {
AuthenticationResponse response = authenticationService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(
@Valid @RequestBody LoginRequest request) {
AuthenticationResponse response = authenticationService.login(request);
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
public ResponseEntity<AuthenticationResponse> refreshToken(
@RequestBody RefreshTokenRequest request) {
AuthenticationResponse response = authenticationService.refreshToken(request);
return ResponseEntity.ok(response);
}
@PostMapping("/logout")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> logout() {
SecurityContextHolder.clearContext();
return ResponseEntity.noContent().build();
}
}Authentication Service
@Service
@RequiredArgsConstructor
@Transactional
public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthenticationResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = User.builder()
.email(request.email())
.password(passwordEncoder.encode(request.password()))
.username(request.username())
.active(true)
.roles(Set.of(Role.builder().name("USER").build()))
.build();
user = userRepository.save(user);
String accessToken = jwtService.generateToken(convertToUserDetails(user));
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
return new AuthenticationResponse(accessToken, refreshToken);
}
public AuthenticationResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(),
request.password()
)
);
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String accessToken = jwtService.generateToken(convertToUserDetails(user));
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
return new AuthenticationResponse(accessToken, refreshToken);
}
public AuthenticationResponse refreshToken(RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.refreshToken());
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDetails userDetails = convertToUserDetails(user);
if (!jwtService.isTokenValid(request.refreshToken(), userDetails)) {
throw new InvalidTokenException("Invalid refresh token");
}
String accessToken = jwtService.generateToken(userDetails);
return new AuthenticationResponse(accessToken, request.refreshToken());
}
private UserDetails convertToUserDetails(User user) {
return org.springframework.security.core.userdetails.User
.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.build();
}
}Method Security
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public User getUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
@PreAuthorize("isAuthenticated()")
@PostAuthorize("returnObject.email == authentication.principal.username")
public User updateProfile(Long userId, UserUpdateRequest request) {
User user = getUserById(userId);
// Update logic
return userRepository.save(user);
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
}OAuth2 Resource Server (JWT)
@Configuration
@EnableWebSecurity
public class OAuth2ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("https://auth.example.com");
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}Quick Reference
| Annotation | Purpose |
|---|---|
@EnableWebSecurity | Enables Spring Security |
@EnableMethodSecurity | Enables method-level security annotations |
@PreAuthorize | Checks authorization before method execution |
@PostAuthorize | Checks authorization after method execution |
@Secured | Role-based method security |
@WithMockUser | Mock authenticated user in tests |
@AuthenticationPrincipal | Inject current user in controller |
Security Best Practices
- Always use HTTPS in production
- Store JWT secret in environment variables
- Use strong password encoding (BCrypt with strength 12+)
- Implement token refresh mechanism
- Add rate limiting to authentication endpoints
- Validate all user inputs
- Log security events
- Keep dependencies updated
- Use CSRF protection for state-changing operations
- Implement proper session timeout
Testing - Spring Boot Test
Unit Testing with JUnit 5
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
@DisplayName("Should create user successfully")
void shouldCreateUser() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
User user = User.builder()
.id(1L)
.email(request.email())
.username(request.username())
.build();
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(passwordEncoder.encode(request.password())).thenReturn("encodedPassword");
when(userRepository.save(any(User.class))).thenReturn(user);
// When
UserResponse response = userService.create(request);
// Then
assertThat(response).isNotNull();
assertThat(response.email()).isEqualTo(request.email());
verify(userRepository).existsByEmail(request.email());
verify(passwordEncoder).encode(request.password());
verify(userRepository).save(any(User.class));
}
@Test
@DisplayName("Should throw exception when email already exists")
void shouldThrowExceptionWhenEmailExists() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
when(userRepository.existsByEmail(request.email())).thenReturn(true);
// When & Then
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(DuplicateResourceException.class)
.hasMessageContaining("Email already registered");
verify(userRepository, never()).save(any(User.class));
}
}Integration Testing with @SpringBootTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
@Order(1)
@DisplayName("Should create user via API")
void shouldCreateUserViaApi() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
// When
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
"/api/v1/users",
request,
UserResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().email()).isEqualTo(request.email());
assertThat(response.getHeaders().getLocation()).isNotNull();
}
@Test
@Order(2)
@DisplayName("Should return validation error for invalid request")
void shouldReturnValidationError() {
// Given
UserCreateRequest request = new UserCreateRequest(
"invalid-email",
"short",
"u",
15
);
// When
ResponseEntity<ValidationErrorResponse> response = restTemplate.postForEntity(
"/api/v1/users",
request,
ValidationErrorResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().errors()).isNotEmpty();
}
}Web Layer Testing with MockMvc
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should get all users")
void shouldGetAllUsers() throws Exception {
// Given
Page<UserResponse> users = new PageImpl<>(List.of(
new UserResponse(1L, "user1@example.com", "user1", 25, true, null, null),
new UserResponse(2L, "user2@example.com", "user2", 30, true, null, null)
));
when(userService.findAll(any(Pageable.class))).thenReturn(users);
// When & Then
mockMvc.perform(get("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.content[0].email").value("user1@example.com"))
.andDo(print());
}
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should create user")
void shouldCreateUser() throws Exception {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
UserResponse response = new UserResponse(
1L,
request.email(),
request.username(),
request.age(),
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.create(any(UserCreateRequest.class))).thenReturn(response);
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.email").value(request.email()))
.andExpect(jsonPath("$.username").value(request.username()))
.andDo(print());
}
@Test
@WithMockUser(roles = "USER")
@DisplayName("Should return 403 for non-admin user")
void shouldReturn403ForNonAdmin() throws Exception {
mockMvc.perform(get("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
}Data JPA Testing
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("Should find user by email")
void shouldFindUserByEmail() {
// Given
User user = User.builder()
.email("test@example.com")
.password("password")
.username("testuser")
.active(true)
.build();
entityManager.persistAndFlush(user);
// When
Optional<User> found = userRepository.findByEmail("test@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
@Test
@DisplayName("Should check if email exists")
void shouldCheckIfEmailExists() {
// Given
User user = User.builder()
.email("test@example.com")
.password("password")
.username("testuser")
.active(true)
.build();
entityManager.persistAndFlush(user);
// When
boolean exists = userRepository.existsByEmail("test@example.com");
// Then
assertThat(exists).isTrue();
}
@Test
@DisplayName("Should fetch user with roles")
void shouldFetchUserWithRoles() {
// Given
Role adminRole = Role.builder().name("ADMIN").build();
entityManager.persist(adminRole);
User user = User.builder()
.email("admin@example.com")
.password("password")
.username("admin")
.active(true)
.roles(Set.of(adminRole))
.build();
entityManager.persistAndFlush(user);
entityManager.clear();
// When
Optional<User> found = userRepository.findByEmailWithRoles("admin@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getRoles()).hasSize(1);
assertThat(found.get().getRoles()).extracting(Role::getName).contains("ADMIN");
}
}Testcontainers for Database
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class UserServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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
private UserService userService;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
@DisplayName("Should create and find user in real database")
void shouldCreateAndFindUser() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
// When
UserResponse created = userService.create(request);
UserResponse found = userService.findById(created.id());
// Then
assertThat(found).isNotNull();
assertThat(found.email()).isEqualTo(request.email());
}
}Testing Reactive Endpoints with WebTestClient
@WebFluxTest(UserReactiveController.class)
class UserReactiveControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private UserReactiveService userService;
@Test
@DisplayName("Should get user reactively")
void shouldGetUserReactively() {
// Given
UserResponse user = new UserResponse(
1L,
"test@example.com",
"testuser",
25,
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.findById(1L)).thenReturn(Mono.just(user));
// When & Then
webTestClient.get()
.uri("/api/v1/users/{id}", 1L)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody(UserResponse.class)
.value(response -> {
assertThat(response.id()).isEqualTo(1L);
assertThat(response.email()).isEqualTo("test@example.com");
});
}
@Test
@DisplayName("Should create user reactively")
void shouldCreateUserReactively() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
UserResponse response = new UserResponse(
1L,
request.email(),
request.username(),
request.age(),
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.create(any(UserCreateRequest.class))).thenReturn(Mono.just(response));
// When & Then
webTestClient.post()
.uri("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.body(Mono.just(request), UserCreateRequest.class)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody(UserResponse.class)
.value(user -> {
assertThat(user.email()).isEqualTo(request.email());
});
}
}Testing Configuration
// application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
security:
user:
name: test
password: test
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
// Test Configuration Class
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(4); // Faster for tests
}
@Bean
public Clock fixedClock() {
return Clock.fixed(
Instant.parse("2024-01-01T00:00:00Z"),
ZoneId.of("UTC")
);
}
}Test Fixtures with @DataJpaTest
@Component
public class TestDataFactory {
public static User createUser(String email, String username) {
return User.builder()
.email(email)
.password("encodedPassword")
.username(username)
.active(true)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
public static UserCreateRequest createUserRequest() {
return new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
}
}Quick Reference
| Annotation | Purpose |
|---|---|
@SpringBootTest | Full application context integration test |
@WebMvcTest | Test MVC controllers with mocked services |
@WebFluxTest | Test reactive controllers |
@DataJpaTest | Test JPA repositories with in-memory database |
@MockBean | Add mock bean to Spring context |
@WithMockUser | Mock authenticated user for security tests |
@Testcontainers | Enable Testcontainers support |
@ActiveProfiles | Activate specific Spring profiles for test |
Testing Best Practices
- Write tests following AAA pattern (Arrange, Act, Assert)
- Use descriptive test names with @DisplayName
- Mock external dependencies, use real DB with Testcontainers
- Achieve 85%+ code coverage
- Test happy path and edge cases
- Use @Transactional for test data cleanup
- Separate unit tests from integration tests
- Use parameterized tests for multiple scenarios
- Test security rules and validation
- Keep tests fast and independent
Web Layer - Controllers & REST APIs
REST Controller Pattern
@RestController
@RequestMapping("/api/v1/users")
@Validated
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> getUsers(
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
Page<UserResponse> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
UserResponse user = userService.findById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserCreateRequest request) {
UserResponse user = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(user.id())
.toUri();
return ResponseEntity.created(location).body(user);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
UserResponse user = userService.update(id, request);
return ResponseEntity.ok(user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}Request DTOs with Validation
public record UserCreateRequest(
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).*$",
message = "Password must contain uppercase, lowercase, and digit")
String password,
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must be alphanumeric")
String username,
@Min(value = 18, message = "Must be at least 18")
@Max(value = 120, message = "Must be at most 120")
Integer age
) {}
public record UserUpdateRequest(
@Email(message = "Email must be valid")
String email,
@Size(min = 3, max = 50)
String username
) {}Response DTOs
public record UserResponse(
Long id,
String email,
String username,
Integer age,
Boolean active,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getEmail(),
user.getUsername(),
user.getAge(),
user.getActive(),
user.getCreatedAt(),
user.getUpdatedAt()
);
}
}Global Exception Handling
Spring Boot 3.x supports RFC 7807 ProblemDetail natively — prefer it over custom ErrorResponse records:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleDataIntegrity(DataIntegrityViolationException ex) {
log.error("Data integrity violation", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Data integrity violation - resource may already exist");
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
log.error("Unexpected error", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred");
problem.setProperty("timestamp", Instant.now());
return problem;
}
}ProblemDetail implements RFC 7807 — returns structured JSON with type, title, status, detail, plus custom properties. No extra record classes needed.
Custom Validation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
@RequiredArgsConstructor
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true;
return !userRepository.existsByEmail(email);
}
}WebClient for External APIs
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(WebClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.filter(logRequest())
.build();
}
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(request -> {
log.info("Request: {} {}", request.method(), request.url());
return Mono.just(request);
});
}
}
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final WebClient webClient;
public Mono<ExternalDataResponse> fetchData(String id) {
return webClient
.get()
.uri("/data/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response ->
Mono.error(new ResourceNotFoundException("External resource not found")))
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new ServiceUnavailableException("External service unavailable")))
.bodyToMono(ExternalDataResponse.class)
.timeout(Duration.ofSeconds(5))
.retry(3);
}
}CORS Configuration
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "https://example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}Quick Reference
| Annotation | Purpose |
|---|---|
@RestController | Marks class as REST controller (combines @Controller + @ResponseBody) |
@RequestMapping | Maps HTTP requests to handler methods |
@GetMapping/@PostMapping | HTTP method-specific mappings |
@PathVariable | Extracts values from URI path |
@RequestParam | Extracts query parameters |
@RequestBody | Binds request body to method parameter |
@Valid | Triggers validation on request body |
@RestControllerAdvice | Global exception handling for REST controllers |
@ResponseStatus | Sets HTTP status code for method |
Related skills
FAQ
What does spring-boot-engineer do?
spring-boot-engineer skill documents Generates Spring Boot 3.
When should I use spring-boot-engineer?
User asks about spring-boot-engineer, generates spring boot 3.x configurations, creates rest controllers, implements spring secu.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.