For small examples, it's okay to have all the logic in one controller class, but as things get bigger, separation of layers becomes critical to maintainability. Learn the Spring camp's customary three-tier structure and the DTO concept that safely passes data between them.
Learning Objectives
- Know the difference in roles between Controller / Service / Repository
- Use
@Service,@Repositoryannotations - Know the constructor injection pattern
- Distinguish between DTO and domain object
- Apply the principle of “business logic is a service”
Key concepts
1) 3 layer picture
HTTP Request → Controller → Service → Repository → DB
(thin) (thick) (thin)
- Controller: HTTP input/output only (input verification/DTO conversion)
- Service: Business logic (verification, transaction, domain behavior)
- Repository: Data storage access (DB · in-memory)
2) Constructor injection
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
}
- Guaranteed immutability with
finalfield - Easy mock injection during testing
- If there is a circular dependency, it is exposed at point Compile/Start
3) DTO vs domain
| Type | Location | Features |
|---|---|---|
| Domain | Inside Service/Repository | Including business activities, external exposure |
| DTO | Controller boundary | Tailored to input/output shape, serialization friendly |
If the domain is exposed as is by the controller, the internal structure is tied to the outside and is vulnerable to change.
4) @Repository
@Repository
public class MemberRepository { ... }
@Service / @Repository / @Controller is @Component, which only has a different meaning. Spring automatically registers with component scanning.
See as example
Example 1 — Member.java: Domain Object
package com.codingnow.lecture.spring25.domain;
public class Member {
private Long id;
private String name;
private String email;
public Member(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters...
}
Example 2 — MemberRepository.java: Memory Store
@Repository
public class MemberRepository {
private final Map<Long, Member> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
public Member save(Member m) { ... }
public Optional<Member> findById(Long id) { ... }
public List<Member> findAll() { ... }
public boolean deleteById(Long id) { ... }
}
Example 3 — MemberService.java: Business Logic
@Service
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) { this.repository = repository; }
public MemberResponse register(MemberCreateRequest req) {
if (req.name() == null || req.name().isBlank())
throw new IllegalArgumentException("name required");
Member saved = repository.save(new Member(null, req.name(), req.email()));
return MemberResponse.from(saved);
}
}
Example 4 — MemberController.java: Using DTO
@RestController
@RequestMapping("/api/members")
public class MemberController {
private final MemberService service;
public MemberController(MemberService service) { this.service = service; }
@PostMapping
public MemberResponse create(@RequestBody MemberCreateRequest req) {
return service.register(req);
}
@GetMapping
public List<MemberResponse> all() { return service.findAll(); }
}
run
cd 06_Spring_Boot/25_Services-and_Layers
mvn spring-boot:run
curl -X POST http://localhost:8085/api/members \
-H "Content-Type: application/json" \
-d '{"name":"Jisoo","email":"[email protected]"}'
#{"id":1,"name":"Jisoo","email":"[email protected]"}
curl http://localhost:8085/api/members
#[{"id":1,"name":"Jisoo","email":"[email protected]"}]
Common Mistakes
- Controller handles all business logic → Difficult to test
- Exposing the domain as is → External API is tied to the internal structure
- Use
@Autowiredfield injection → Use constructor injection - Start with two-way dependency → Spring refuses to start
- Service learns HTTP information → layer invasion
Cleanup
- 3-tier separation is more effective as the scale increases
- If you create a boundary with DTO, you will become stronger against change.
- Constructor injection + final is recommended for dependencies
Try it yourself
cd 06_Spring_Boot/25_Services-and_Layers
mvn spring-boot:run