Why this matters
- Layered architecture is the default pattern for Spring Boot REST services — violating it creates untestable controllers and duplicated business logic.
- Clear boundaries let you swap JPA for JDBC or add caching without touching controllers.
- Code reviews and interviews expect you to articulate what belongs in each layer.
The three layers
Responsibilities in BookStore
- Controller — Parse HTTP, validate input, call service, map to response DTO, set status codes. No business rules.
- Service — Business logic, transaction boundaries, orchestration across repositories. No HTTP awareness.
- Repository — Data access only. Queries, saves, deletes. No business validation.
Controller layer
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/{id}")
public BookResponse getBook(@PathVariable Long id) {
return BookResponse.from(bookService.findById(id));
}
}
The controller knows about HTTP (@GetMapping, status codes) and DTOs. It delegates all decisions to BookService.
Service layer
@Service
@Transactional(readOnly = true)
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public Book findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
@Transactional
public Book createBook(CreateBookRequest request) {
if (repository.existsByIsbn(request.isbn())) {
throw new DuplicateIsbnException(request.isbn());
}
Book book = new Book(request.title(), request.isbn(), request.price());
return repository.save(book);
}
}
Business rules live here: duplicate ISBN check, price validation, transaction demarcation.
Repository layer
public interface BookRepository extends JpaRepository<Book, Long> {
boolean existsByIsbn(String isbn);
List<Book> findByGenre(String genre);
}
Pure data access. Spring Data generates the implementation from method names.
Dependency direction
Controller → Service → Repository → Database
Never:
Forbidden dependencies
- Controller → Repository (skips business logic)
- Repository → Service (upward dependency)
- Service → Controller (circular dependency)
DTOs at layer boundaries
Entities should not leak to the HTTP layer:
public record BookResponse(Long id, String title, String isbn, BigDecimal price) {
public static BookResponse from(Book book) {
return new BookResponse(book.getId(), book.getTitle(),
book.getIsbn(), book.getPrice());
}
}
Controllers return BookResponse; services work with Book entities internally.
Package structure
com.example.bookstore/
├── controller/
│ ├── BookController.java
│ └── AuthorController.java
├── service/
│ ├── BookService.java
│ └── AuthorService.java
├── repository/
│ ├── BookRepository.java
│ └── AuthorRepository.java
├── model/
│ ├── Book.java
│ └── Author.java
└── dto/
├── BookResponse.java
└── CreateBookRequest.java
One package per layer keeps navigation predictable as BookStore grows.
Cross-cutting concerns
Logging, security, and caching sit outside the three layers via AOP and filters:
Cross-cutting placement
- Security filter — Authenticates before the controller runs.
- @Transactional — Applied on service methods, not controllers.
- @ControllerAdvice — Centralises exception-to-HTTP mapping.
When to add more layers
| Addition | Trigger |
|---|---|
| mapper package | Complex entity-to-DTO mapping (MapStruct) |
| client package | Outbound HTTP calls to other services |
| event package | Domain events between modules |
mapper package
TriggerComplex entity-to-DTO mapping (MapStruct)client package
TriggerOutbound HTTP calls to other servicesevent package
TriggerDomain events between modules
Start with three layers. Split only when a layer grows unwieldy.
Quick recall
Everything you need if you only revisit this box.
- Three layers: controller (HTTP), service (business logic), repository (data access).
- Dependencies flow downward — never controller-to-repository or repository-to-service.
- Services own
@Transactionalboundaries and business validation. - DTOs separate the HTTP contract from JPA entities.
- Cross-cutting concerns (security, logging) use AOP and filters, not layer violations.
- Organise packages by layer for predictable navigation in growing codebases.
Test yourself
Answer these before moving on — recall is what makes it stick.