Layered Architecture
Layer 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
@Entity classes 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
@Transactional lives here, not in controllers or repositories
- Constructor injection only — never
@Autowired field 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.getItems());
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> or CrudRepository
- Custom queries via
@Query or 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) {
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getStatus().name(),
order.getItems().stream().map(LineItemResponse::from).toList());
}
}
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
@Configuration classes live in a config/ package — never in service/ or controller/
- Configuration never imports service or controller classes
- Use
@ConfigurationProperties for type-safe config — never raw @Value for groups of related settings
- Bean definitions for infrastructure concerns only (RestClient, JsonMapper, SecurityFilterChain)
Cross-Cutting Concerns
- Logging: use
@Slf4j — never System.out.println
- Validation:
@Valid on controller parameters, custom validators as @Component
- Exception handling: single
@RestControllerAdvice class, never try/catch in controllers
- Auditing:
@CreatedDate / @LastModifiedDate with @EnableJpaAuditing
Gotchas
- Agent tends to put
@Transactional on controllers — move it to services
- Agent uses
@Autowired field injection — always use constructor injection (@RequiredArgsConstructor)
- Agent returns
List<Entity> from controllers — always map to List<ResponseDto>
- Agent creates
OrderAndInventoryService god classes — split by aggregate
- Agent puts mapping logic inside controllers — extract to mapper class or DTO factory method
- Agent creates
@Configuration classes that depend on @Service beans — configuration should only wire infrastructure
- Agent defines an
ObjectMapper bean to customize JSON — Boot 4 uses Jackson 3 (tools.jackson): define JsonMapper beans, and @JsonComponent is now @JacksonComponent
- Agent adds spring-retry for service-level retries — built into Framework 7 (
@Retryable, @EnableResilientMethods)
1---2name: layered-architecture-23description: 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.4---56# Layered Architecture78## Layer Rules910```11@RestController ← HTTP only. No business logic. No JPA entities in responses.12 ↓ DTOs13@Service ← All business logic lives here. Orchestrates repositories.14 ↓ Domain objects / Entities15@Repository ← Data access only. No business logic. Returns entities or projections.16 ↓ JPA / JDBC17Database18```1920## Controller Layer21- Handles HTTP: parsing requests, validating input (`@Valid`), returning responses22- Calls ONE service method per endpoint — no orchestration in controllers23- Never returns `@Entity` classes directly — always map to response DTOs24- Never injects `@Repository` — always goes through a `@Service`25- Exception handling via `@ControllerAdvice`, never try/catch in controllers2627```java28// ✅ GOOD29@PostMapping("/orders")30public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {31 Order order = orderService.createOrder(request);32 return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));33}3435// ❌ BAD — business logic in controller36@PostMapping("/orders")37public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {38 if (request.getItems().isEmpty()) throw new RuntimeException("No items");39 Order order = orderRepository.save(new Order(request)); // direct repo access40 return ResponseEntity.ok(order); // returning entity41}42```4344## Service Layer45- Contains all business logic, validation rules, and orchestration46- `@Transactional` lives here, not in controllers or repositories47- Constructor injection only — never `@Autowired` field injection48- One service per aggregate root (OrderService, not OrderAndPaymentService)49- Returns domain objects or DTOs — never `HttpServletRequest` / `HttpServletResponse`50- Retries and concurrency caps on service methods via Framework 7's `@Retryable` / `@ConcurrencyLimit`51 (enable with `@EnableResilientMethods`) — no spring-retry dependency5253```java54// ✅ GOOD55@Service56@RequiredArgsConstructor57public class OrderService {58 private final OrderRepository orderRepository;59 private final InventoryService inventoryService;6061 @Transactional62 public Order createOrder(CreateOrderRequest request) {63 inventoryService.reserve(request.getItems());64 Order order = Order.from(request);65 return orderRepository.save(order);66 }67}6869// ❌ BAD — field injection, HTTP concern in service70@Service71public class OrderService {72 @Autowired private OrderRepository orderRepository;7374 public ResponseEntity<Order> createOrder(...) { ... } // HTTP type in service75}76```7778## Repository Layer79- Extends `JpaRepository<Entity, ID>` or `CrudRepository`80- Custom queries via `@Query` or query derivation — no raw SQL unless unavoidable81- Returns entities or Spring Data Projections — never raw `Object[]`82- No business logic — pure data access8384## DTOs85- Separate Request / Response DTOs — never use the same class for both86- Validation annotations (`@NotNull`, `@Size`, etc.) on Request DTOs only87- Static factory method `ResponseDto.from(Entity entity)` for mapping88- Use records for immutable DTOs (Java 16+)8990```java91// ✅ GOOD92public record OrderResponse(UUID id, String status, List<LineItemResponse> items) {93 public static OrderResponse from(Order order) {94 return new OrderResponse(order.getId(), order.getStatus().name(),95 order.getItems().stream().map(LineItemResponse::from).toList());96 }97}98```99100## Mapper Pattern101- Keep mapping logic out of controllers and services — use dedicated mapper classes or static factory methods102- Mapper is a plain class or utility — not a Spring bean unless it needs injected dependencies103- Entity → Response DTO: static method on the response DTO (`OrderResponse.from(order)`)104- Request DTO → Entity: static factory on the entity (`Order.from(request)`) or a mapper class105- Collection mapping: use `.stream().map(OrderResponse::from).toList()` — never manual loops106107```java108// ✅ GOOD — dedicated mapper for complex mappings109public class OrderMapper {110111 public static OrderResponse toResponse(Order order) {112 return new OrderResponse(113 order.getId(),114 order.getStatus().name(),115 order.getItems().stream().map(OrderMapper::toLineItem).toList(),116 order.getCreatedAt()117 );118 }119120 public static Order toEntity(CreateOrderRequest request, User user) {121 Order order = Order.create(request.customerEmail(), user);122 request.items().forEach(item ->123 order.addItem(item.productId(), item.quantity()));124 return order;125 }126127 private static LineItemResponse toLineItem(OrderItem item) {128 return new LineItemResponse(item.getProductId(), item.getQuantity(), item.getPrice());129 }130}131```132133## Configuration Layer134- `@Configuration` classes live in a `config/` package — never in `service/` or `controller/`135- Configuration never imports service or controller classes136- Use `@ConfigurationProperties` for type-safe config — never raw `@Value` for groups of related settings137- Bean definitions for infrastructure concerns only (RestClient, JsonMapper, SecurityFilterChain)138139## Cross-Cutting Concerns140- Logging: use `@Slf4j` — never `System.out.println`141- Validation: `@Valid` on controller parameters, custom validators as `@Component`142- Exception handling: single `@RestControllerAdvice` class, never try/catch in controllers143- Auditing: `@CreatedDate` / `@LastModifiedDate` with `@EnableJpaAuditing`144145## Gotchas146- Agent tends to put `@Transactional` on controllers — move it to services147- Agent uses `@Autowired` field injection — always use constructor injection (`@RequiredArgsConstructor`)148- Agent returns `List<Entity>` from controllers — always map to `List<ResponseDto>`149- Agent creates `OrderAndInventoryService` god classes — split by aggregate150- Agent puts mapping logic inside controllers — extract to mapper class or DTO factory method151- Agent creates `@Configuration` classes that depend on `@Service` beans — configuration should only wire infrastructure152- Agent defines an `ObjectMapper` bean to customize JSON — Boot 4 uses Jackson 3 (`tools.jackson`): define `JsonMapper` beans, and `@JsonComponent` is now `@JacksonComponent`153- Agent adds spring-retry for service-level retries — built into Framework 7 (`@Retryable`, `@EnableResilientMethods`)