Service Layer & DTOs
You now have every building block: controllers, validation, error handling, entities, repositories and transactions. This lesson puts them together into the structure used by almost every professional Spring Boot project: three layers with clear jobs, and DTOs (data transfer objects) at the edges so that the database model and the public API can change independently.
You will see why returning entities as JSON causes trouble, write request and response records with mapping methods, and build a PlayerService that holds the business rules.
Three layers
| Layer | Annotation | Job | Knows about |
|---|---|---|---|
| Web | @RestController | HTTP: paths, status codes, validation of input | Services, DTOs |
| Service | @Service | Business rules and transactions | Repositories, entities, DTOs |
| Data | repository interface | Database access | Entities |
Each layer talks only to the one below it. Controllers never touch repositories, and repositories never see HTTP. This makes each layer easy to test alone, and lets you, for example, call the same service from a REST controller, a Thymeleaf page and a scheduled job.
Why not return entities?
It is tempting to return a Player entity straight from the controller. It causes four problems:
- Lazy relationships break. With
spring.jpa.open-in-view=false, Jackson touchesplayer.getTeam()after the transaction has ended and Hibernate throwsLazyInitializationException. With it on, you run hidden queries during JSON writing. - Endless loops.
team.playerscontains players whoseteamcontainsplayers... Jackson recurses until it fails. - Leaking fields. Every new column (an internal note, a password hash) appears in the API automatically.
- Tight coupling. Renaming a column renames a JSON field and breaks every mobile app that uses it.
DTOs fix all four: the API shape is written down once, on purpose.
Request and response records
Records are ideal DTOs: short, immutable, and Jackson reads and writes them directly.
public record CreatePlayerRequest(
@NotBlank @Size(max = 60) String name,
@NotNull PlayerRole role,
@NotNull @Min(8) @Max(19) Integer age,
@NotBlank String city,
Long teamId) {}public record PlayerResponse(Long id, String name, PlayerRole role, int age, String city, int runs, String team) {
public static PlayerResponse from(Player player) {
String teamName = player.getTeam() == null ? null : player.getTeam().getName();
return new PlayerResponse(player.getId(), player.getName(), player.getRole(),
player.getAge(), player.getCity(), player.getRuns(), teamName);
}
}The request accepts a teamId, not a whole team object. The response flattens the team to its name, which is what a client wants to display. A static from method keeps the mapping in one obvious place. (Libraries such as MapStruct generate mapping code for large projects; for a handful of fields, hand-written mapping is clearer.)
The service
@Service
@Transactional(readOnly = true)
public class PlayerService {
private static final Logger log = LoggerFactory.getLogger(PlayerService.class);
private final PlayerRepository players;
private final TeamRepository teams;
private final Clock clock;
public PlayerService(PlayerRepository players, TeamRepository teams, Clock clock) {
this.players = players;
this.teams = teams;
this.clock = clock;
}
public PlayerResponse get(long id) {
return players.findById(id)
.map(PlayerResponse::from)
.orElseThrow(() -> new PlayerNotFoundException(id));
}
@Transactional
public PlayerResponse create(CreatePlayerRequest request) {
if (players.existsByNameIgnoreCase(request.name())) {
throw new DuplicatePlayerException(request.name());
}
Player player = new Player(request.name(), request.role(), request.age(),
request.city(), LocalDate.now(clock));
if (request.teamId() != null) {
Team team = teams.findById(request.teamId())
.orElseThrow(() -> new TeamNotFoundException(request.teamId()));
team.addPlayer(player);
}
Player saved = players.save(player);
log.info("Registered player {} with id {}", saved.getName(), saved.getId());
return PlayerResponse.from(saved);
}
@Transactional
public PlayerResponse moveToTeam(long playerId, long teamId) {
Player player = players.findById(playerId)
.orElseThrow(() -> new PlayerNotFoundException(playerId));
Team team = teams.findById(teamId)
.orElseThrow(() -> new TeamNotFoundException(teamId));
player.setTeam(team); // no save() call needed
return PlayerResponse.from(player);
}
}Notice where each rule lives. "Names must be unique" and "the team must exist" are business rules, so they are in the service and throw domain exceptions. The mapping to PlayerResponse happens inside the transaction, so reading player.getTeam().getName() works even though the relationship is lazy. The advice class from Exception Handling with ProblemDetail maps DuplicatePlayerException to 409 Conflict and the not-found exceptions to 404.
The controller stays thin
@PostMapping
public ResponseEntity<PlayerResponse> create(@Valid @RequestBody CreatePlayerRequest request) {
PlayerResponse created = service.create(request);
return ResponseEntity.created(URI.create("/api/players/" + created.id())).body(created);
}
@PutMapping("/{id}/team/{teamId}")
public PlayerResponse move(@PathVariable long id, @PathVariable long teamId) {
return service.moveToTeam(id, teamId);
}The whole flow, called with curl:
curl -i -X POST http://localhost:8080/api/players -H "Content-Type: application/json" -d '{"name": "Isha Kale", "role": "BOWLER", "age": 14, "city": "Pune", "teamId": 1}'HTTP/1.1 201
Location: /api/players/7
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 01 Nov 2026 19:28:14 GMT
{"id":7,"name":"Isha Kale","role":"BOWLER","age":14,"city":"Pune","runs":0,"team":"Deccan Strikers"}The business rules answer with problem details:
POST "isha kale" again -> 409 {"detail":"A player called isha kale is already registered","instance":"/api/players","status":409,"title":"Duplicate player"}
POST with "teamId": 9 -> 404 {"detail":"No team with id 9","instance":"/api/players","status":404,"title":"Not Found"}
PUT /api/players/2/team/2 -> {"id":2,"name":"Priya Deshmukh","role":"BOWLER","age":14,"city":"Pune","runs":88,"team":"Shivneri Lions"}(Three real responses, one per line.) The duplicate check ignored case, thanks to existsByNameIgnoreCase.
Common mistakes
- Business logic in controllers. It cannot be reused by other callers and is hard to unit-test; move it to the service.
- Mapping outside the transaction. Converting entities to DTOs in the controller triggers
LazyInitializationExceptionfor lazy relationships. Map inside the service. - One giant DTO for everything. Separate request and response types; the client should not send
idorruns, and you may not want to show every field. - Services returning
ResponseEntity. HTTP belongs in controllers; services return data or throw exceptions.
Key points
- Controllers handle HTTP, services hold business rules and transactions, repositories access data.
- Do not expose entities as JSON: lazy loading, loops, leaked fields and coupling all cause trouble.
- Use request records (with validation) for input and response records (with a
frommethod) for output. - Map entities to DTOs inside the transactional service method.
- Services throw domain exceptions; the
@RestControllerAdviceclass turns them into status codes.
Exercise
Add a team summary endpoint GET /api/teams/{id}/summary that returns {"team": "Deccan Strikers", "players": 3, "totalRuns": 540, "topScorer": "Aarav Patil"}. Write the response record, a read-only service method that throws TeamNotFoundException for unknown ids, and the controller method. Use the team's players list.
Show solution
public record TeamSummary(String team, int players, int totalRuns, String topScorer) {}
// TeamService
@Transactional(readOnly = true)
public TeamSummary summary(long id) {
Team team = teams.findById(id).orElseThrow(() -> new TeamNotFoundException(id));
List<Player> squad = team.getPlayers();
int total = squad.stream().mapToInt(Player::getRuns).sum();
String top = squad.stream()
.max(Comparator.comparingInt(Player::getRuns))
.map(Player::getName)
.orElse(null);
return new TeamSummary(team.getName(), squad.size(), total, top);
}
// TeamController
@GetMapping("/{id}/summary")
public TeamSummary summary(@PathVariable long id) {
return service.summary(id);
}With the sample data, Deccan Strikers has Aarav (412), Priya (88) and Ananya (40), so the total is 540 and the top scorer is Aarav Patil. The lazy players list is read inside the read-only transaction, so no LazyInitializationException occurs.
Quick Quiz
1. Which layer should check "a player name must be unique"?
Business rules live in services, which can use repositories and run in transactions.
2. Why return a PlayerResponse record instead of the Player entity?
DTOs decouple the public API from the database model.
3. Where should entities be mapped to DTOs?
Lazy relationships can only be read while the transaction is open.
4. What does the service do when a team id does not exist?
Services stay free of HTTP and signal problems with domain exceptions.
Learn Spring Boot with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
