Foundation · Build
Controller → Service → Repository
Split one fat controller into three collaborating layers and see what each is allowed to know about the others.
Primary source: Spring Framework 6.2: Core Technologies
Lesson 1 of 2
1d streakThe Architectural Question
A controller that talks to the database directly cannot be unit tested without HTTP, cannot be reused by another entry point, and mixes transport formatting with business rules.
What this guide proves
- Fat controller problems
- Layer responsibilities
- Dependency direction
Assign each decision to the layer with the right knowledge
| Layer | May know | Must not own |
|---|---|---|
| Controller | HTTP routes, DTOs, status, headers | Database queries and business branching |
| Service | Use cases, policies, transaction boundaries | Servlet types and JSON formatting |
| Repository | Persistence operations and query intent | HTTP status and workflow orchestration |
| Mapper | Explicit boundary transformations | I/O or business decisions |
The useful rule is not that every request must pass through exactly three classes. The rule is that transport, application decisions, and persistence have different reasons to change. Introduce a seam when it protects one of those boundaries.
Make dependency direction visible in code
@RestController
@RequestMapping("/api/tasks")
class TaskController {
private final TaskService tasks;
TaskController(TaskService tasks) { this.tasks = tasks; }
@PostMapping
ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
var created = tasks.create(request);
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
}@Service
class TaskService {
private final TaskRepository repository;
TaskService(TaskRepository repository) { this.repository = repository; }
@Transactional
TaskResponse create(CreateTaskRequest request) {
if (repository.existsByTitleIgnoreCase(request.title())) {
throw new DuplicateTaskException(request.title());
}
return TaskMapper.toResponse(repository.save(TaskMapper.toEntity(request)));
}
}
interface TaskRepository extends JpaRepository<TaskEntity, Long> {
boolean existsByTitleIgnoreCase(String title);
}Transactions belong around the use case
Use the layers as focused test seams
- 01Use a controller slice to prove DTO binding, status, headers, JSON, and advice while mocking TaskService.
- 02Use a plain unit test to prove duplicate-title policy in TaskService while faking TaskRepository.
- 03Use a repository slice to prove derived queries, mappings, and database constraints.
- 04Keep a small integration path to prove the selected implementations wire together.
- 05Add an architecture rule or review check that controllers never import repositories.
Active Recall Checkpoint
A scheduled job and an HTTP endpoint must enforce the same task-limit rule. Where should the rule live?
Hint: Both entry points should call the same use case.
Show answer & explanation
Put the rule in the application service and have both adapters invoke it. Controller validation may still reject malformed HTTP input, but the shared business decision cannot belong to either transport adapter.
Runtime Architecture & Flow
Dependency direction
Boundaries follow ownership
Each layer answers one kind of question: HTTP translation, business decisions, persistence—never all three in one class.
Fat controller problems
B1Layer responsibilities
B2Dependency direction
B3Read these as design constraints. The arrows belong in the implementation only after the dependency rule is chosen.
Working Implementation
Code Walkthrough
Refactor a controller that validates, queries, and formats into TaskController + TaskService + TaskRepository and keep every test green.
package com.example.layers;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Demonstrates strict 3-tier layering:
* Controller: Handles HTTP contracts, validation, status codes (Zero business logic).
* Service: Enforces domain rules, invariants, and transactional boundaries.
* Repository: Encapsulates persistence queries and data storage.
*/
public class LayeredArchitectureLab {
public record CreateAccountRequest(String email, long initialDepositCents) {}
public record AccountResponse(String accountId, String email, long balanceCents) {}
@RestController
@RequestMapping("/api/v1/accounts")
public static class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping
public ResponseEntity<AccountResponse> createAccount(@RequestBody CreateAccountRequest req) {
AccountResponse response = accountService.createAccount(req.email(), req.initialDepositCents());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
@Service
public static class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional
public AccountResponse createAccount(String email, long deposit) {
if (deposit < 0) throw new IllegalArgumentException("Deposit cannot be negative");
String accountId = "ACC-" + Math.abs(email.hashCode());
accountRepository.save(accountId, email, deposit);
return new AccountResponse(accountId, email, deposit);
}
}
@Repository
public static class AccountRepository {
private final Map<String, AccountRecord> store = new ConcurrentHashMap<>();
public record AccountRecord(String id, String email, long balance) {}
public void save(String id, String email, long balance) {
store.put(id, new AccountRecord(id, email, balance));
}
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Call TaskRepository from TaskController and duplicate its validation in a second endpoint
- Observe: The same rule drifts between endpoints and one fix misses the other copy
- Repair: Rules live in services so every entry point enforces them once; repositories stay persistence-only.
Production Acceptance Criteria
- Controllers contain no SQL, no rules, and no mapping beyond HTTP concerns
- Services hold business decisions and transaction boundaries
- Dependencies point controller -> service -> repository, never backward or sideways
Key Takeaway
Controllers translate HTTP, services own use-case decisions and transactions, and repositories own persistence operations with dependencies pointing inward.
Architectural & Technical Review
Core Interview Questions
Q01What determines whether a responsibility belongs in a controller or service?Show answer
Transport knowledge such as routes, DTO binding, status, and headers belongs in the controller. Rules shared by entry points and transactional orchestration belong in the service.
Q02Why is the service the usual transaction boundary?Show answer
It knows which repository reads and writes form one business operation; neither one repository method nor HTTP response formatting has that complete view.
Q03How should tests follow the three seams?Show answer
Use MVC slices for HTTP translation, plain service tests for business decisions, repository slices for mappings and queries, and a small integration set for wiring.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Injecting repositories into controllers — It couples transport directly to persistence and invites duplicated rules across HTTP, jobs, and messaging adapters.
- Pitfall: Adding pass-through services mechanically — A seam should protect decisions or a transaction. A class that only forwards every call adds indirection without ownership.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources