Controller, Service and Repository Layers
Split a Spring Boot API into controllers for HTTP, services for business operations and repositories for data access, and keep each layer to its own job.
@RestController -> HTTP in, DTO out
calls
@Service + @Transactional -> one method per business operation
calls
Repository (JpaRepository / JdbcClient) -> load and storeAlmost every Spring Boot API ends up with the same three kinds of class. Naming them and giving each one a single job is what keeps a project readable after its fiftieth endpoint.
- Controller (
@RestController): speaks HTTP. It binds the path, query and body, triggers validation, calls one service method and turns the result into a status code and a response body. It knows nothing about SQL. - Service (
@Service): one public method per business operation, such asplaceOrderorcancelBooking. It owns the transaction boundary, checks business rules and coordinates repositories and other services. It knows nothing about HTTP. - Repository (a Spring Data interface or a class using
JdbcClient): loads and stores data. No business decisions live here.
Dependencies point one way: controller to service to repository. A repository never calls a service, and a service never takes an HttpServletRequest or returns a ResponseEntity.
Why bother
Each layer can be tested on its own terms. A @WebMvcTest checks status codes and JSON with the service mocked; a plain unit test checks the business rules without a web server; a @DataJpaTest checks queries against a real schema. The same service method can also be reused by a scheduled job or a message listener, which would be awkward if the logic sat in a controller.
The service is also where @Transactional belongs. A controller that calls two repositories directly runs two separate transactions, so a failure halfway leaves half the data written.
Mistakes that creep in
- Fat controllers that load entities, compute prices and save results. The logic cannot be reused or tested without HTTP.
- Pass-through services where every method just forwards to a repository. A service earns its place when an operation has rules; a purely read-only lookup can stay thin, but do not let controllers skip the service just because it is thin today.
- Returning entities from controllers. The JSON shape then follows the table, lazy relations trigger extra queries or fail during serialisation, and internal fields leak. Return records instead (see the next lesson).
- Interfaces for everything. An
OrderServiceinterface with a singleOrderServiceImpladds a file and nothing else. Mockito mocks concrete classes, and Spring proxies them with CGLIB. Add an interface when there really are two implementations.
The stereotype annotations matter less than the boundaries. @Service and @Component register beans in exactly the same way; @Repository additionally marks a class you write yourself for persistence exception translation, so JPA and Hibernate exceptions thrown inside it reach callers as Spring's DataAccessException types. (JdbcTemplate and JdbcClient already translate SQL errors on their own.)
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.net.URI;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
class Product {
private Long id;
private String name;
private long priceCents;
protected Product() {
}
Product(String name, long priceCents) {
this.name = name;
this.priceCents = priceCents;
}
Long getId() { return id; }
String getName() { return name; }
long getPriceCents() { return priceCents; }
}
// Repository layer: data access only.
interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsByNameIgnoreCase(String name);
}
record CreateProductRequest( String name, long priceCents) {
}
record ProductResponse(long id, String name, long priceCents) {
}
class DuplicateProductException extends RuntimeException {
DuplicateProductException(String name) {
super("A product called '" + name + "' already exists");
}
}
// Service layer: business rules and the transaction boundary. No HTTP types.
class ProductService {
private final ProductRepository products;
ProductService(ProductRepository products) {
this.products = products;
}
ProductResponse create(CreateProductRequest request) {
String name = request.name().strip();
// Friendly early check; a unique constraint on the column still guards concurrent requests.
if (products.existsByNameIgnoreCase(name)) {
throw new DuplicateProductException(name);
}
Product saved = products.save(new Product(name, request.priceCents()));
return new ProductResponse(saved.getId(), saved.getName(), saved.getPriceCents());
}
}
// Controller layer: HTTP in, DTO out.
("/api/products")
public class ProductController {
private final ProductService service;
ProductController(ProductService service) {
this.service = service;
}
ResponseEntity<ProductResponse> create( CreateProductRequest request) {
ProductResponse created = service.create(request);
return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
}
}
When to use it
- A new checkout endpoint reuses the same OrderService.placeOrder method that the existing mobile endpoint and a nightly batch job already call.
- A team splits controller tests (@WebMvcTest with a mocked service) from business-rule tests (plain JUnit) so the slow suite shrinks.
- A reporting query moves out of a controller into a @Repository class with JdbcClient so its SQL can be reviewed and tested in one place.
More examples
A fat controller and its refactor
The controller now has one job: map a URL to a call and the outcome to <code>204 No Content</code>. The rule that an invoice can be paid only once lives on the invoice and is used through the service, so the reconciliation job calls <code>InvoiceService.markPaid</code> and gets the same rule and the same transaction. Injecting a <code>Clock</code> bean keeps the time testable.
import java.time.Clock;
import java.time.Instant;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
interface Invoices {
Invoice find(long id);
void save(Invoice invoice);
}
class Invoice {
private Instant paidAt;
boolean isPaid() { return paidAt != null; }
void markPaid(Instant when) {
if (isPaid()) {
throw new IllegalStateException("Invoice already paid");
}
paidAt = when;
}
}
@Service
class InvoiceService {
private final Invoices invoices;
private final Clock clock;
InvoiceService(Invoices invoices, Clock clock) {
this.invoices = invoices;
this.clock = clock;
}
// Before: this code lived in the controller, so the nightly
// reconciliation job had to duplicate it.
@Transactional
void markPaid(long invoiceId) {
Invoice invoice = invoices.find(invoiceId);
invoice.markPaid(Instant.now(clock));
invoices.save(invoice);
}
}
@RestController
public class InvoiceController {
private final InvoiceService service;
InvoiceController(InvoiceService service) {
this.service = service;
}
@PostMapping("/api/invoices/{id}/payment")
ResponseEntity<Void> pay(@PathVariable long id) {
service.markPaid(id);
return ResponseEntity.noContent().build();
}
}
Testing the service without a web server
Because the service takes no HTTP types and gets its collaborators through the constructor, the business rule is checked with a plain JUnit test that runs in milliseconds. No Spring context starts. The controller still deserves a <code>@WebMvcTest</code>, but that test only needs to check status codes and JSON, not the duplicate rule.
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
record Signup(String email) {
}
interface SignupStore {
boolean emailTaken(String email);
void add(Signup signup);
}
class SignupService {
private final SignupStore store;
SignupService(SignupStore store) {
this.store = store;
}
void register(String email) {
String normalised = email.strip().toLowerCase();
if (store.emailTaken(normalised)) {
throw new IllegalArgumentException("Email already registered");
}
store.add(new Signup(normalised));
}
}
class SignupServiceTest {
static class InMemoryStore implements SignupStore {
final List<Signup> saved = new ArrayList<>();
public boolean emailTaken(String email) {
return saved.stream().anyMatch(s -> s.email().equals(email));
}
public void add(Signup signup) {
saved.add(signup);
}
}
@Test
void normalisesAndRejectsDuplicates() {
InMemoryStore store = new InMemoryStore();
SignupService service = new SignupService(store);
service.register(" [email protected] ");
assertThat(store.saved).containsExactly(new Signup("[email protected]"));
assertThatThrownBy(() -> service.register("[email protected]"))
.isInstanceOf(IllegalArgumentException.class);
}
}
A repository class for hand-written SQL
Not every repository is a Spring Data interface. A class annotated with <code>@Repository</code> that wraps <code>JdbcClient</code> is still the data layer: it holds SQL and maps rows, and the service above it decides what the numbers mean. <code>JdbcClient</code> already turns vendor-specific SQL exceptions into Spring's <code>DataAccessException</code> types, so services can catch one hierarchy whatever the driver; <code>@Repository</code> marks the class as the data layer and adds the same translation for JPA exceptions if the class later uses an <code>EntityManager</code>.
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
record TopCustomer(long customerId, String name, long totalCents) {
}
@Repository
public class ReportRepository {
private final JdbcClient jdbc;
ReportRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
List<TopCustomer> topCustomers(int limit) {
return jdbc.sql("""
select c.id as customer_id, c.name, sum(o.total_cents) as total_cents
from customers c
join orders o on o.customer_id = c.id
group by c.id, c.name
order by total_cents desc
limit :limit
""")
.param("limit", limit)
.query(TopCustomer.class)
.list();
}
}
Discussion