Layered Architecture
Use when generating or modifying any Spring Boot class — controllers, services, repositories, DTOs, mappers, or configuration. Enforces strict layer separation and prevents business logic from leaking across boundaries.
View SKILL.mdLayer Rules
@RestController ← HTTP only. No business logic. No JPA entities in responses.
↓ DTOs
@Service ← All business logic lives here. Orchestrates repositories.
↓ Domain objects / Entities
@Repository ← Data access only. No business logic. Returns entities or projections.
↓ JPA / JDBC
Database
Controller Layer
- Handles HTTP: parsing requests, validating input (
@Valid), returning responses - Calls ONE service method per endpoint — no orchestration in controllers
- Never returns
@Entityclasses directly — always map to response DTOs - Never injects
@Repository— always goes through a@Service - Exception handling via
@ControllerAdvice, never try/catch in controllers
// ✅ GOOD
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));
}
// ❌ BAD — business logic in controller
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
if (request.getItems().isEmpty()) throw new RuntimeException("No items");
Order order = orderRepository.save(new Order(request)); // direct repo access
return ResponseEntity.ok(order); // returning entity
}
Service Layer
- Contains all business logic, validation rules, and orchestration
@Transactionallives here, not in controllers or repositories- Constructor injection only — never
@Autowiredfield injection - One service per aggregate root (OrderService, not OrderAndPaymentService)
- Returns domain objects or DTOs — never
HttpServletRequest/HttpServletResponse - Retries and concurrency caps on service methods via Framework 7's
@Retryable/@ConcurrencyLimit(enable with@EnableResilientMethods) — no spring-retry dependency
// ✅ GOOD
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
@Transactional
public Order createOrder(CreateOrderRequest request) {
inventoryService.reserve(request.items()); // records use accessor names, not getX()
Order order = Order.from(request);
return orderRepository.save(order);
}
}
// ❌ BAD — field injection, HTTP concern in service
@Service
public class OrderService {
@Autowired private OrderRepository orderRepository;
public ResponseEntity<Order> createOrder(...) { ... } // HTTP type in service
}
Repository Layer
- Extends
JpaRepository<Entity, ID>orCrudRepository - Custom queries via
@Queryor query derivation — no raw SQL unless unavoidable - Returns entities or Spring Data Projections — never raw
Object[] - No business logic — pure data access
DTOs
- Separate Request / Response DTOs — never use the same class for both
- Validation annotations (
@NotNull,@Size, etc.) on Request DTOs only - Static factory method
ResponseDto.from(Entity entity)for mapping - Use records for immutable DTOs (Java 16+)
// ✅ GOOD
public record OrderResponse(UUID id, String status, List<LineItemResponse> items, Instant createdAt) {
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getStatus().name(),
order.getItems().stream().map(LineItemResponse::from).toList(),
order.getCreatedAt());
}
}
Mapper Pattern
- Keep mapping logic out of controllers and services — use dedicated mapper classes or static factory methods
- Mapper is a plain class or utility — not a Spring bean unless it needs injected dependencies
- Entity → Response DTO: static method on the response DTO (
OrderResponse.from(order)) - Request DTO → Entity: static factory on the entity (
Order.from(request)) or a mapper class - Collection mapping: use
.stream().map(OrderResponse::from).toList()— never manual loops
// ✅ GOOD — dedicated mapper for complex mappings
public class OrderMapper {
public static OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getStatus().name(),
order.getItems().stream().map(OrderMapper::toLineItem).toList(),
order.getCreatedAt()
);
}
public static Order toEntity(CreateOrderRequest request, User user) {
Order order = Order.create(request.customerEmail(), user);
request.items().forEach(item ->
order.addItem(item.productId(), item.quantity()));
return order;
}
private static LineItemResponse toLineItem(OrderItem item) {
return new LineItemResponse(item.getProductId(), item.getQuantity(), item.getPrice());
}
}
Configuration Layer
@Configurationclasses live in aconfig/package — never inservice/orcontroller/- Configuration never imports service or controller classes
- Use
@ConfigurationPropertiesfor type-safe config — never raw@Valuefor groups of related settings - Bean definitions for infrastructure concerns only (RestClient, JsonMapper, SecurityFilterChain)
Cross-Cutting Concerns
- Logging: use
@Slf4j— neverSystem.out.println - Validation:
@Validon controller parameters, custom validators as@Component - Exception handling: single
@RestControllerAdviceclass, never try/catch in controllers - Auditing:
@CreatedDate/@LastModifiedDatewith@EnableJpaAuditing
Gotchas
- Agent tends to put
@Transactionalon controllers — move it to services - Agent uses
@Autowiredfield injection — always use constructor injection (@RequiredArgsConstructor) - Agent returns
List<Entity>from controllers — always map toList<ResponseDto> - Agent creates
OrderAndInventoryServicegod classes — split by aggregate - Agent puts mapping logic inside controllers — extract to mapper class or DTO factory method
- Agent creates
@Configurationclasses that depend on@Servicebeans — configuration should only wire infrastructure - Agent defines an
ObjectMapperbean to customize JSON — Boot 4 uses Jackson 3 (tools.jackson): defineJsonMapperbeans, and@JsonComponentis now@JacksonComponent - Agent adds spring-retry for service-level retries — built into Framework 7 (
@Retryable,@EnableResilientMethods)
Examples & templates
bad-service.javaexamples
Source on GitHub
// ❌ BAD — every common layering violation in one class
@Service
public class OrderService {
@Autowired // field injection instead of constructor
private OrderRepository orderRepository;
@Autowired
private HttpServletRequest httpRequest; // HTTP concern in service layer
public ResponseEntity<Order> createOrder(CreateOrderRequest request) { // returns HTTP type
String token = httpRequest.getHeader("Authorization"); // reads HTTP headers in service
if (request.getItems().isEmpty()) {
return ResponseEntity.badRequest().build(); // HTTP status logic in service
}
Order order = new Order(); // no factory method
order.setEmail(request.getEmail());
order.setStatus("PENDING"); // string instead of enum
Order saved = orderRepository.save(order); // no @Transactional
return ResponseEntity.ok(saved); // returns entity directly
}
@Transactional
public Order findById(Long id) { // Long ID instead of UUID
return orderRepository.findById(id)
.orElse(null); // returns null instead of throwing
}
public List<Order> findAll() { // no pagination, no readOnly
return orderRepository.findAll(); // unbounded query
}
}good-service.javaexamples
Source on GitHub
// ✅ GOOD — proper layer separation, constructor injection, @Transactional on service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public OrderResponse findById(UUID id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderResponse.from(order);
}
public Page<OrderResponse> findAll(Pageable pageable) {
return orderRepository.findAll(pageable).map(OrderResponse::from);
}
@Transactional
public Order createOrder(CreateOrderRequest request) {
inventoryService.reserve(request.items());
Order order = Order.create(request.customerEmail());
request.items().forEach(item ->
order.addItem(item.productId(), item.quantity()));
return orderRepository.save(order);
}
@Transactional
public void cancel(UUID id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.cancel();
inventoryService.release(order.getItems());
}
}CreateOrderRequest.javatemplates
Source on GitHub
package com.example.order.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.List;
import java.util.UUID;
public record CreateOrderRequest(
@NotBlank(message = "Customer email is required")
@Email(message = "Must be a valid email address")
String customerEmail,
@NotEmpty(message = "Order must have at least one item")
@Valid
List<OrderItemRequest> items
) {
public record OrderItemRequest(
@NotNull(message = "Product ID is required") // @NotBlank only supports CharSequence
UUID productId,
@Positive(message = "Quantity must be positive")
int quantity
) {}
}OrderMapper.javatemplates
Source on GitHub
package com.example.order.dto;
import com.example.order.entity.Order;
import com.example.order.entity.OrderItem;
import com.example.order.dto.request.CreateOrderRequest;
import com.example.order.dto.response.OrderResponse;
import com.example.order.dto.response.OrderResponse.LineItemResponse;
import java.util.List;
public class OrderMapper {
private OrderMapper() {} // utility class
public static OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getStatus().name(),
toLineItems(order.getItems()),
order.getCreatedAt()
);
}
public static List<LineItemResponse> toLineItems(List<OrderItem> items) {
return items.stream()
.map(OrderMapper::toLineItem)
.toList();
}
private static LineItemResponse toLineItem(OrderItem item) {
return new LineItemResponse(
item.getProductId(),
item.getProductName(),
item.getQuantity(),
item.getPrice()
);
}
}OrderResponse.javatemplates
Source on GitHub
package com.example.order.dto.response;
import com.example.order.entity.Order;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record OrderResponse(
UUID id,
String status,
List<LineItemResponse> items,
Instant createdAt
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getStatus().name(),
order.getItems().stream()
.map(item -> new LineItemResponse(
item.getProductId(),
item.getProductName(),
item.getQuantity(),
item.getPrice()))
.toList(),
order.getCreatedAt()
);
}
public record LineItemResponse(
UUID productId,
String productName,
int quantity,
java.math.BigDecimal price
) {}
}Verification scope
- Compiled sources
- CreateOrderRequest, both trees
- Runtime checks
- Bean Validation and HTTP validation; not the full architecture
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 4 skills
Install Boot 4 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-4-skills@spring-boot-skills