Professional StructureExtra· 50 min read

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.

LayerAnnotationIts one job
Controller@RestControllerHandle HTTP — read the request, return the response
Service@ServiceHold the business logic (the rules and calculations)
Repository@Repository / JpaRepositoryTalk 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:

Entity, DTO, Service and a thin Controller working together
// 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?

Answer: A DTO lets you send only safe, chosen fields and keeps your public API stable even if the database entity changes. Returning the entity directly leaks every field and couples the API to the schema.

✍️ Practice

  1. Refactor a Product CRUD: move the logic into a ProductService and make the controller delegate to it.
  2. Create a ProductDto (without an internal field of your choice) and return it from the controller.

🏠 Homework

  1. Refactor your Task API into three layers (controller → service → repository) and introduce a TaskDto that the controller returns instead of the Task entity.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →