| name | springboot-patterns |
| description | Spring Boot architecture patterns, REST API design, hexagonal (ports & adapters) architecture, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. |
Spring Boot Development Patterns
Spring Boot architecture and API patterns for scalable, production-grade services.
When to Activate
- Building REST APIs with Spring MVC or WebFlux
- Structuring adapters → use cases → domain (hexagonal / ports & adapters)
- Configuring Spring Data JPA, caching, or async processing
- Adding validation, exception handling, or pagination
- Setting up profiles for dev/staging/production environments
- Implementing event-driven patterns with Spring Events or Kafka
REST API Structure
@RestController
@RequestMapping("/api/markets")
@Validated
class MarketController {
private final MarketService marketService;
MarketController(MarketService marketService) {
this.marketService = marketService;
}
@GetMapping
ResponseEntity<Page<MarketResponse>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Market> markets = marketService.list(PageRequest.of(page, size));
return ResponseEntity.ok(markets.map(MarketResponse::from));
}
@PostMapping
ResponseEntity<MarketResponse> create(@Valid @RequestBody CreateMarketRequest request) {
Market market = marketService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(MarketResponse.from(market));
}
}
Repository Pattern (Spring Data JPA)
public interface MarketRepository extends JpaRepository<MarketEntity, Long> {
@Query("select m from MarketEntity m where m.status = :status order by m.volume desc")
List<MarketEntity> findActive(@Param("status") MarketStatus status, Pageable pageable);
}
Use Case (Application Service) with Transactions
Use cases implement input ports and depend on output ports — no JPA or Spring framework imports in domain:
public interface CreateMarketUseCase {
Market create(CreateMarketCommand command);
}
public interface MarketRepository {
Market save(Market market);
Optional<Market> findBySlug(String slug);
}
@Transactional
public class CreateMarketService implements CreateMarketUseCase {
private final MarketRepository marketRepository;
public CreateMarketService(MarketRepository marketRepository) {
this.marketRepository = marketRepository;
}
@Override
public Market create(CreateMarketCommand command) {
var market = Market.create(command.name(), command.slug());
return marketRepository.save(market);
}
}
@Repository
class JpaMarketRepository implements MarketRepository {
private final MarketJpaRepository jpaRepo;
JpaMarketRepository(MarketJpaRepository jpaRepo) {
.jpaRepo = jpaRepo;
}
Market {
MarketMapper.toDomain(jpaRepo.save(MarketMapper.toEntity(market)));
}
Optional<Market> {
jpaRepo.findBySlug(slug).map(MarketMapper::toDomain);
}
}
DTOs and Validation
public record CreateMarketRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Size(max = 2000) String description,
@NotNull @FutureOrPresent Instant endDate,
@NotEmpty List<@NotBlank String> categories) {}
public record MarketResponse(Long id, String name, MarketStatus status) {
static MarketResponse from(Market market) {
return new MarketResponse(market.id(), market.name(), market.status());
}
}
Exception Handling (RFC 7807 / RFC 9457 Problem Details)
Spring Boot 4 has native RFC 7807 support via ProblemDetail. Enable it in application.yml:
spring:
mvc:
problemdetails:
enabled: true
This automatically handles MethodArgumentNotValidException, NoResourceFoundException, etc.
For domain exceptions, add a @RestControllerAdvice:
@RestControllerAdvice
class ProblemDetailsAdvice {
@ExceptionHandler(MarketNotFoundException.class)
ProblemDetail handleNotFound(MarketNotFoundException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.example.com/problems/not-found"));
pd.setTitle("Not Found");
pd.setProperty("instance", req.getRequestURI());
return pd;
}
@ExceptionHandler(ConstraintViolationException.class)
ProblemDetail handleValidation(ConstraintViolationException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setType(URI.create("https://api.example.com/problems/validation-failed"));
pd.setTitle("Validation Failed");
pd.setDetail("One or more fields failed validation.");
pd.setProperty("instance", req.getRequestURI());
pd.setProperty("errors", ex.getConstraintViolations().stream()
.map(v -> Map.of("field", v.getPropertyPath().toString(), "detail", v.getMessage()))
.toList());
return pd;
}
@ExceptionHandler(AccessDeniedException.class)
ProblemDetail handleAccessDenied(HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
pd.setType(URI.create("https://api.example.com/problems/forbidden"));
pd.setTitle("Forbidden");
pd.setProperty(, req.getRequestURI());
pd;
}
ProblemDetail {
log.error(, req.getRequestURI(), ex);
ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setType(URI.create());
pd.setTitle();
pd;
}
}
Response example:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "Market 'crypto-btc' not found.",
"instance": "/api/markets/crypto-btc"
}
See skill: problem-details for the full RFC 7807/9457 reference and multi-language examples.
Caching
Requires @EnableCaching on a configuration class.
@Service
public class MarketCacheService {
private final MarketRepository repo;
public MarketCacheService(MarketRepository repo) {
this.repo = repo;
}
@Cacheable(value = "market", key = "#id")
public Market getById(Long id) {
return repo.findById(id)
.map(Market::from)
.orElseThrow(() -> new EntityNotFoundException("Market not found"));
}
@CacheEvict(value = "market", key = "#id")
public void evict(Long id) {}
}
Async Processing
Requires @EnableAsync on a configuration class.
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendAsync(Notification notification) {
return CompletableFuture.completedFuture(null);
}
}
Logging (SLF4J)
@Service
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
public Report generate(Long marketId) {
log.info("generate_report marketId={}", marketId);
try {
} catch (Exception ex) {
log.error("generate_report_failed marketId={}", marketId, ex);
throw ex;
}
return new Report();
}
}
Middleware / Filters
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - start;
log.info("req method={} uri={} status={} durationMs={}",
request.getMethod(), request.getRequestURI(), response.getStatus(), duration);
}
}
}
Pagination and Sorting
PageRequest page = PageRequest.of(pageNumber, pageSize, Sort.by("createdAt").descending());
Page<Market> results = marketService.list(page);
Error-Resilient External Calls
public <T> T withRetry(Supplier<T> supplier, int maxRetries) {
int attempts = 0;
while (true) {
try {
return supplier.get();
} catch (Exception ex) {
attempts++;
if (attempts >= maxRetries) {
throw ex;
}
try {
Thread.sleep((long) Math.pow(2, attempts) * 100L);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ex;
}
}
}
}
Rate Limiting (Filter + Bucket4j)
Security Note: The X-Forwarded-For header is untrusted by default because clients can spoof it.
Only use forwarded headers when:
- Your app is behind a trusted reverse proxy (nginx, AWS ALB, etc.)
- You have registered
ForwardedHeaderFilter as a bean
- You have configured
server.forward-headers-strategy=NATIVE or FRAMEWORK in application properties
- Your proxy is configured to overwrite (not append to) the
X-Forwarded-For header
When ForwardedHeaderFilter is properly configured, request.getRemoteAddr() will automatically
return the correct client IP from the forwarded headers. Without this configuration, use
request.getRemoteAddr() directly—it returns the immediate connection IP, which is the only
trustworthy value.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp,
k -> Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
.build());
(bucket.tryConsume()) {
filterChain.doFilter(request, response);
} {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
}
}
}
Background Jobs
Use Spring’s @Scheduled or integrate with queues (e.g., Kafka, SQS, RabbitMQ). Keep handlers idempotent and observable.
Observability
- Structured logging (JSON) via Logback encoder
- Metrics: Micrometer + Prometheus/OTel
- Tracing: Micrometer Tracing with OpenTelemetry or Brave backend
Production Defaults
Remember: Keep domain framework-free, use cases focused on business logic, adapters thin (map only), and errors handled centrally. Dependency arrows always point inward — toward domain. Optimize for testability and replaceability of adapters.