EthicsComputerCourses
Join training +91 90532 10052

Service Layer & DTOs

Spring Boot · Lesson 18 of 29 Intermediate 5 min read Updated

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

LayerAnnotationJobKnows about
Web@RestControllerHTTP: paths, status codes, validation of inputServices, DTOs
Service@ServiceBusiness rules and transactionsRepositories, entities, DTOs
Datarepository interfaceDatabase accessEntities

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 touches player.getTeam() after the transaction has ended and Hibernate throws LazyInitializationException. With it on, you run hidden queries during JSON writing.
  • Endless loops. team.players contains players whose team contains players... 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.

Java
public record CreatePlayerRequest(
        @NotBlank @Size(max = 60) String name,
        @NotNull PlayerRole role,
        @NotNull @Min(8) @Max(19) Integer age,
        @NotBlank String city,
        Long teamId) {}
Java
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

Java
@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

Java
@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:

Terminal
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}'
Output
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:

Output
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 LazyInitializationException for lazy relationships. Map inside the service.
  • One giant DTO for everything. Separate request and response types; the client should not send id or runs, 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 from method) for output.
  • Map entities to DTOs inside the transactional service method.
  • Services throw domain exceptions; the @RestControllerAdvice class 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
Java
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"?

2. Why return a PlayerResponse record instead of the Player entity?

3. Where should entities be mapped to DTOs?

4. What does the service do when a team id does not exist?

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