Layering: controller, service, repository — and where logic lives
A Spring application has a natural structure — controller, service, repository — and keeping each layer to its own job is the difference between a codebase that stays workable and one that turns into a tangle. The most common architectural mistake in Spring apps is logic in the wrong layer: business rules in controllers, HTTP concerns in services, queries scattered everywhere. This lesson is the layering that keeps a Spring app clean, and where each kind of logic belongs.
The three layers, and their jobs
A typical Spring REST app has three layers, each with one responsibility:
- Controller (
@RestController) — the web layer. Its job is HTTP: read the request (path variables, query params, body), call a service, and shape the response (status, DTO). Nothing more. It knows about HTTP; it does not know business rules. - Service (
@Service) — the business layer. This is where business logic lives: the rules, calculations, orchestration of an operation, transaction boundaries (@Transactional). It knows the domain; it does not know about HTTP (noHttpServletRequest, no status codes) or SQL. - Repository (
@Repository/ Spring Data) — the data layer. Its job is persistence: queries, saves, mappings. It knows the database; it does not know business rules.
The dependency flows one way: controller → service → repository. The controller calls the service; the service calls the repository. Never the reverse (a repository must not call a service), and never skipping (a controller should not usually call a repository directly, bypassing the service — the service is where the business logic belongs).
Where logic goes: the rule
The single most important layering decision — where business logic lives:
// WRONG — business logic in the controller
@PostMapping
public ResponseEntity<ParcelResponse> deliver(@PathVariable Long id) {
Parcel p = repository.findById(id).orElseThrow();
p.setStatus(DELIVERED);
p.setFee(calculateFee(p)); // business rule in the controller
repository.save(p);
paymentGateway.charge(p.getFee()); // orchestration in the controller
return ResponseEntity.ok(...);
}
// RIGHT — controller thin, logic in the service
@PostMapping
public ResponseEntity<ParcelResponse> deliver(@PathVariable Long id) {
Parcel p = parcelService.deliver(id); // delegate
return ResponseEntity.ok(ParcelResponse.from(p));
}
@Service
public class ParcelService {
@Transactional
public Parcel deliver(Long id) { // the business operation lives here
Parcel p = repository.findById(id).orElseThrow(() -> new NotFoundException("..."));
p.setStatus(DELIVERED);
p.setFee(calculateFee(p));
// ...charge, etc.
return p; // dirty checking persists it in the transaction
}
}
Business logic belongs in the service. The controller becomes thin — read, delegate, respond — which
makes it easy to test (a @WebMvcTest mocking the service) and keeps HTTP concerns out of the business
logic. The service is testable as a unit (mock the repository) and reusable (a scheduled job or another
service can call deliver without going through HTTP). This is the "fat service, thin controller" shape,
the Spring counterpart to the Django course's "fat models, thin views".
Why this matters: testability, reuse, clarity
Each benefit is concrete:
- Testability. A thin controller is tested at the web layer (status, validation) with the service
mocked; a service is unit-tested (logic) with the repository mocked; a repository with
@DataJpaTest. Logic in the wrong layer forces the wrong (slower, harder) test — business logic in a controller can only be tested by driving HTTP. - Reuse. Logic in a service can be called from multiple controllers, a scheduled job, another service. Logic in a controller is trapped behind an HTTP endpoint.
- Clarity and change. When each layer does one thing, a change has one home: an HTTP change touches the controller, a rule change touches the service, a query change touches the repository. Scattered logic means a single change ripples across layers.
Keep the layers from leaking
Watch for the leaks that erode layering:
- HTTP in the service. A service taking an
HttpServletRequestor returning aResponseEntityhas web concerns leaking down — the service should take and return domain objects/DTOs, not HTTP types. - Business logic in the repository. A repository is for queries; putting rules in it (or in a query method that does more than query) misplaces them.
- Entities crossing the web boundary. The controller maps entities to DTOs (the DTO lesson) — entities do not leak out as API responses, and request DTOs do not leak into the service as-is if they carry HTTP concerns.
- Skipping the service. A controller calling a repository directly for anything with logic bypasses the business layer; simple pass-throughs are sometimes tolerable, but the moment there is a rule, it belongs in a service.
The mental model to carry: controllers speak HTTP, services hold business logic and transactions, repositories do persistence — and each stays in its lane. Get this right and the codebase scales gracefully; let logic drift into the wrong layer and every later change is harder than it should be.
Check your work
The three layers. Controller (web: request/response, no business logic), service (business logic, transactions, no HTTP/SQL), repository (persistence, no rules). Dependencies flow controller → service → repository, one way.
Where logic goes. Business logic in the service; the controller is thin (read, delegate, respond) —
"fat service, thin controller". Verified pattern: the controller delegates to parcelService.deliver(id).
Why. Testability (each layer tested at its level), reuse (service logic callable from many places), clarity (a change has one home). Logic in the wrong layer breaks all three.
Leaks to avoid. HTTP types in services, business logic in repositories, entities crossing the web boundary (map to DTOs), controllers skipping the service when there is logic.
Practice
- Take a controller method with business logic crammed in (like the wrong example) and move the logic to a
@Servicemethod; reduce the controller to delegate-and-respond. - Unit-test the extracted service (mock the repository) and
@WebMvcTestthe thin controller (mock the service) — note each is tested at its level. - Call the service method from a second place (a scheduled job or another service) to prove the logic is now reusable.
- Find a service that takes or returns an HTTP type (
ResponseEntity,HttpServletRequest) and refactor it to domain types. - Find a repository doing more than a query and move the logic to the service.
- For three Dakiya operations, state which layer each piece (validation, a status rule, a query, the response shape) belongs in.
Official documentation
- Spring — Stereotype annotations (@Controller/@Service/@Repository) — The layer annotations.
- Spring — Web MVC controllers — The web layer's role.
- Spring — Transaction management — Why transaction boundaries sit in the service.
Next: constructor injection, and why not field injection.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship