The problem: a controller with 300 lines
A junior developer is asked to add "place an order". The quickest path is to write everything in the controller: parse the request, check stock, calculate the total, save to the database, send an email, build the JSON. It works. Two weeks later a second endpoint needs the same total calculation, someone copies the code, and a bug fix lands in only one of the copies. Testing the calculation requires starting a web layer and a database.
Layering is the cheap cure for this. It is not a ritual: each layer exists because it changes for a different reason and is tested in a different way.
The three layers and what they own
HTTP request
|
v
+-------------+ DTOs in / out, status codes, validation of shape
| Controller |
+-------------+
|
v
+-------------+ business rules, transactions, orchestration
| Service |
+-------------+
|
v
+-------------+ persistence: queries, entities
| Repository |
+-------------+
|
v
DatabaseThe controller is the HTTP adapter. It translates between the outside world and your code: it accepts a request DTO, triggers validation, calls one service method and converts the result into a response DTO with the right status code. It should contain no business decisions and no database access.
The service holds the use cases and the business rules: "an order needs at least one line", "you cannot order more than is in stock", "the total is the sum of the lines minus the discount". It decides where the transaction boundary is and coordinates repositories and other collaborators.
The repository is the persistence adapter. With Spring Data JPA it is usually an interface that declares queries. It knows how to load and store entities and nothing about HTTP or business policy.
A small Order example
The request and response types are plain records. They describe the HTTP contract, not the database.
public record PlaceOrderRequest( @NotNull Long customerId, @NotEmpty List<OrderLineRequest> lines) {}public record OrderLineRequest(@NotNull Long productId, @Positive int quantity) {}public record OrderResponse(Long id, BigDecimal total, String status) {}The controller stays thin:
@RestController@RequestMapping("/orders")class OrderController { private final OrderService orderService; OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request) { OrderResponse created = orderService.place(request); return ResponseEntity.created(URI.create("/orders/" + created.id())).body(created); }}The service contains the rules and the transaction:
@Serviceclass OrderService { private final OrderRepository orders; private final ProductRepository products; OrderService(OrderRepository orders, ProductRepository products) { this.orders = orders; this.products = products; } @Transactional OrderResponse place(PlaceOrderRequest request) { Order order = Order.newFor(request.customerId()); for (OrderLineRequest line : request.lines()) { Product product = products.findById(line.productId()) .orElseThrow(() -> new ProductNotFoundException(line.productId())); order.addLine(product, line.quantity()); // rule: stock check lives here } Order saved = orders.save(order); return new OrderResponse(saved.getId(), saved.total(), saved.getStatus().name()); }}Notice two things. The rule "you cannot exceed stock" is inside the domain object method addLine, called by the service, so any other use case that adds a line gets the same protection. And the entity Order never leaves the service: the controller receives an OrderResponse.
Why DTOs and entities stay apart
It is tempting to return the JPA entity directly. It saves a class, but it creates problems:
- Accidental exposure. Add a column such as an internal note or a password hash and it appears in the JSON unless someone remembers to hide it.
- Coupling the contract to the schema. Renaming a column or changing a relationship breaks API clients.
- Lazy loading surprises. Serialising an entity can trigger queries or throw exceptions outside the transaction.
- Input risks. Accepting an entity as request body lets clients set fields they should never control, such as
idorstatus.
A small mapping step between DTO and entity costs a few lines and gives you freedom to change either side. Records make DTOs short, and a plain method or a mapping library handles the conversion.
Where business rules live
A useful test: if I added a CLI or a message listener that triggers the same use case, which rules would I have to copy? Everything you would copy belongs in the service or the domain model, not the controller. Format checks that depend only on the request shape (non-empty list, positive quantity) are fine as Bean Validation annotations at the edge. Rules that need data or state (stock levels, customer status) belong in the service.
Do not put rules in the repository either. A repository method named findActiveOrdersOlderThanThirtyDays is a query, not a policy. The policy "orders older than thirty days are archived" is a service decision that uses that query.
Package by layer or by feature
Two common layouts:
by layer by feature
controller/ order/
OrderController OrderController
CustomerController OrderService
service/ OrderRepository
OrderService Order
CustomerService customer/
repository/ CustomerController
OrderRepository ...Package-by-layer is easy to explain at the beginning, but a single change touches three packages and every class must be public so other packages can see it. Package-by-feature keeps things that change together in one place and lets you make most classes package-private, which keeps the public surface small. As a project grows, package-by-feature scales better, and it is also the natural step toward a modular monolith. For a tiny service either works; pick one and stay consistent.
Common mistakes
- Calling a repository directly from the controller "because it is only a find".
- Putting
@Transactionalon controllers. The transaction belongs around the use case in the service. - Letting the service return entities and then touching lazy fields in the controller.
- A service that is only a pass-through to the repository for every method. Sometimes that is honest for simple CRUD, but if every rule ends up in the controller, the layer is missing its purpose.
- The opposite: a "god service" with thirty methods. Split by use case or by feature when it grows.
- Shared DTOs for create, update and read. Each has different required fields, so separate records are clearer.
Trade-offs
Layers add classes and mapping code. For a throwaway prototype or a pure CRUD admin screen, the ceremony may not pay back. As soon as there are rules, more than one entry point, or several people on the codebase, the separation pays back in testability: services can be tested with plain unit tests and mocked repositories, controllers with a web slice test, repositories against a real database.
How each layer is tested
The real benefit of layering shows in tests. Service rules can be verified without starting Spring, with the repository mocked:
@Testvoid rejectsLineAboveStock() { Product product = new Product(1L, "Keyboard", 2); // stock: 2 Order order = Order.newFor(10L); assertThatThrownBy(() -> order.addLine(product, 3)) .isInstanceOf(InsufficientStockException.class);}This test runs in milliseconds because there is no web server, database or container. For the controller, @WebMvcTest verifies the HTTP shape (status code, JSON, validation); for the repository, @DataJpaTest or a test against a real database verifies the queries. When each test focuses on one layer, finding the cause of a failure is easier. If all the rules sit in the controller, the only way to verify them is to start the whole stack.
When separating layers, keep one more rule in mind: dependencies point only downwards. The service does not know the controller, and the repository does not know the service. If this is not checked, circular dependencies gradually appear and the layers exist in name only.
A note on configuration and injection: take dependencies through the constructor and make the fields final. This shows clearly which collaborators the service needs, lets you create the object in a test without Spring, and signals that the class does too much when seven or eight parameters pile up in the constructor. That signal is a good reason to split the service.
Checklist
- Controllers only translate HTTP to a service call and back.
- Services own business rules and transaction boundaries.
- Repositories contain queries only.
- Entities are not used as request or response bodies.
- Request DTOs are validated with
@Valid. - Packages are organised so that things that change together live together.
Where to go next
- Turn invalid input into useful responses: validation and error handling.
- Understand what
@Transactionalreally does: Spring transaction pitfalls. - Decide how to test each layer: backend testing strategy.
- See where this path leads in the junior Java developer roadmap.