Spring Boot Architecture: Controller, Service and Repository Layers
How to organise a Spring Boot application into controller, service and repository layers, where validation and transactions belong, and why DTOs and constructor injection matter.
SmartCampus Buddy TeamSeptember 11, 20268 min read
Once your first endpoint works, the next question is where to put everything else. A common answer in Spring Boot projects is a layered structure. Each layer has one job, which keeps code easier to read, test and change.
The three layers
- Controller: receives HTTP requests, validates input shape and returns responses. It knows about the web, and nothing else.
- Service: contains the business rules. It decides what should happen.
- Repository: talks to the database. It knows how to save and fetch data.
A request flows from the controller to the service to the repository and back.
A small example
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService service;
public BookController(BookService service) {
this.service = service;
}
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
return service.findById(id);
}
}
@Service
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public BookResponse findById(Long id) {
Book book = repository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
return new BookResponse(book.getId(), book.getTitle());
}
}
public interface BookRepository extends JpaRepository<Book, Long> {}With Spring Data JPA, extending JpaRepository gives you methods such as save, findById and findAll without writing SQL. You add query methods only when you need them.
Constructor injection
Notice that dependencies are passed through constructors. This is the recommended style. It makes dependencies explicit, lets you mark fields final, and makes testing simple because you can pass a fake repository in a unit test without starting Spring.
Do not expose entities directly
An entity mirrors a database table. Returning it from a controller ties your public API to your database schema and can leak fields you did not mean to expose. Use small DTO (data transfer object) classes, such as BookResponse above, for what goes in and out of the API. Records are convenient for DTOs.
Where validation and errors belong
- Input shape, such as required fields and lengths, is checked in the controller layer using Bean Validation annotations and
@Valid. This requires the validation starter dependency. - Business rules, such as "a member cannot borrow more than three books", belong in the service.
- Error responses are best handled in one place with a
@RestControllerAdviceclass that maps exceptions, such asBookNotFoundException, to consistent responses with a 404 status.
Transactions
Mark service methods that change several things together with @Transactional, so they succeed or fail as a unit. Keep the transaction in the service layer, not the controller.
Common mistakes
- Putting business logic in controllers, which makes it hard to reuse and test.
- Returning entities directly from endpoints.
- Field injection with
@Autowiredon fields, which hides dependencies. - Catching exceptions and returning success responses anyway.
Learn the pieces
Layers rely on solid object-oriented basics, which you can check with the Object-Oriented Java quiz, and on SQL knowledge, which the SQL Basics quiz covers. To understand why repository queries need indexes, read SQL indexes explained.
Key takeaways
- Controller handles HTTP, service holds rules, repository handles data.
- Use constructor injection and DTOs.
- Validate input at the edge and enforce business rules in the service.
- Handle errors centrally and keep transactions in the service layer.