The Service Layer & DTOs
Real apps split work into three layers — controller, service, repository — and never expose database entities directly. Here is the pattern every team uses.
What you will learn
- Explain the three-layer (controller → service → repository) architecture
- Move business logic out of the controller into a @Service
- Use a DTO so the database entity is never exposed directly
The problem with fat controllers
So far our controllers did everything: read the request, run the logic, and call the database. That is fine for tiny demos, but in a real app it becomes a tangled mess — logic is hard to find, hard to reuse, and hard to test.
Professional Spring apps use a layered architecture: each layer has one job, and they call each other in a straight line.
| Layer | Annotation | Its one job |
|---|---|---|
| Controller | @RestController | Handle HTTP — read the request, return the response |
| Service | @Service | Hold the business logic (the rules and calculations) |
| Repository | @Repository / JpaRepository | Talk to the database |
The flow is always one direction: Controller → Service → Repository. The controller never touches the database directly; it asks the service, and the service asks the repository.
Tip: Analogy: a restaurant. The waiter (controller) takes your order and brings your food but does not cook. The chef (service) does the actual cooking — the real work. The pantry (repository) just stores and fetches ingredients. Each role stays in its lane, so the kitchen runs smoothly.
What is a DTO, and why bother?
A DTO (Data Transfer Object) is a small, plain class that carries only the fields you want to send or receive over the web — separate from your database entity. Why have two classes for what feels like the same data? Three solid reasons:
- Safety: your entity might have fields you must never expose — a password hash, internal flags. A DTO sends only the safe fields.
- Stability: you can change the database table without breaking the JSON your clients depend on, because the DTO is a separate contract.
- Shape: the JSON a client wants is often not shaped like your table (e.g. an author’s name flattened into a book’s response). A DTO lets you choose that shape.
Putting it together
Here is a complete slice: an entity (the database row), a DTO (what we send to clients), a service (the logic) and a thin controller. Read each class and notice how thin the controller becomes:
// 1) The ENTITY — maps to the database, may hold sensitive fields
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String passwordHash; // must NEVER be sent to clients
// getters / setters ...
}
// 2) The DTO — only the safe fields we want the client to see
public class UserDto {
private Long id;
private String name;
private String email; // note: no passwordHash here
public UserDto(Long id, String name, String email) {
this.id = id; this.name = name; this.email = email;
}
// getters ...
}
// 3) The SERVICE — holds the logic, returns DTOs not entities
@Service
public class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) { this.repo = repo; }
public UserDto getUser(Long id) {
User u = repo.findById(id)
.orElseThrow(() -> new NotFoundException("No user " + id));
// map entity -> DTO (drop the password)
return new UserDto(u.getId(), u.getName(), u.getEmail());
}
}
// 4) The CONTROLLER — thin: just delegates to the service
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
public UserController(UserService service) { this.service = service; }
@GetMapping("/{id}")
public UserDto one(@PathVariable Long id) {
return service.getUser(id);
}
}Note: Output (visiting /users/1): {"id":1,"name":"Ashish","email":"ashish@codingclave.com"} The response carries id, name and email — but NOT passwordHash, because the service mapped the User entity to a UserDto first. The controller is now just two lines: receive the request, ask the service.
The mapping step (and a shortcut)
Turning an entity into a DTO (and back) is called mapping. Above we did it by hand — clear and simple. For many fields this gets repetitive, so teams often use a mapper library like ModelMapper or MapStruct that copies matching fields for you. Hand-mapping is perfect to start; reach for a mapper when there are lots of fields.
Watch out: A classic mistake is returning your @Entity straight from the controller. That leaks every field (including secrets) and ties your public JSON to your database schema. Return a DTO from controllers — keep entities inside the service and repository layers.
Q. Why return a DTO from a controller instead of the @Entity directly?
✍️ Practice
- Refactor a Product CRUD: move the logic into a
ProductServiceand make the controller delegate to it. - Create a
ProductDto(without an internal field of your choice) and return it from the controller.
🏠 Homework
- Refactor your Task API into three layers (controller → service → repository) and introduce a
TaskDtothat the controller returns instead of the Task entity.