Article 16 put the product catalogue in a ConcurrentHashMap inside ProductController and said this article would move it out. Since then the controller has gained DTOs, validation and ProblemDetail errors, and it is still one class that handles HTTP, enforces the business rules and stores the data. This article splits it into a controller, a service and a repository, adds a small order feature that reserves stock through the product service, and then settles the second question that every Spring Boot codebase has to answer: which packages those classes live in.
Everything below was run on Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) on OpenJDK 21.0.6, built with the Gradle 9.7.1 wrapper from a Spring Initializr project with spring-boot-starter-webmvc and spring-boot-starter-validation. Every curl response, stack trace, compiler error, test report and file count is copied from that project on port 8121.
![]()
The layers come first, as classes inside com.example.demo.product. How to spread those classes over packages is a separate decision, and it gets the second half of the article.
The controller that does everything
Articles 18 to 20 kept their examples compact with a ProductStore bean next to the controller. In article 20 that store took a CreateProductRequest and threw DuplicateSkuException itself: a storage class that knows a web DTO and enforces a business rule, which is two layers' jobs in the wrong place. This article starts one step earlier, from the controller as articles 16 and 17 left it, where HTTP, rules and storage all sit in one class, and ends with each of them in its own class.
The catalogue starts from a small domain record and the web types around it. Product is the domain object; its two with methods return a copy with one field changed, which keeps the record immutable:
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, String sku, BigDecimal price, int stock) {
public Product withId(Long newId) {
return new Product(newId, name, sku, price, stock);
}
public Product withStock(int newStock) {
return new Product(id, name, sku, price, newStock);
}
}The request and response DTOs, and the mapper between them and Product:
package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
public record CreateProductRequest(
@NotBlank String name,
@NotBlank String sku,
@NotNull @Positive BigDecimal price,
@NotNull @PositiveOrZero Integer stock) {
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock) {
}package com.example.demo.product;
import org.springframework.stereotype.Component;
@Component
public class ProductMapper {
public Product toProduct(CreateProductRequest request) {
return new Product(null, request.name(), request.sku(), request.price(), request.stock());
}
public ProductResponse toResponse(Product product) {
return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock());
}
}Two exceptions, and a compact version of article 20's GlobalExceptionHandler, which turns them into ProblemDetail responses: 404 for an unknown id, 409 for a conflict with the current state, 422 for a body that breaks a validation rule. Article 20 left that class in com.example.demo and left the question of where it belongs to this article; here it starts in com.example.demo.common, and the package section explains why.
package com.example.demo.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product " + id + " not found");
}
}package com.example.demo.product;
public class DuplicateSkuException extends RuntimeException {
public DuplicateSkuException(String sku) {
super("SKU " + sku + " already exists");
}
}package com.example.demo.common;
import java.util.stream.Collectors;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail notFound(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(DuplicateSkuException.class)
public ProblemDetail conflict(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalid(MethodArgumentNotValidException e) {
String detail = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + " " + error.getDefaultMessage())
.sorted()
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, detail);
}
}HttpStatus.UNPROCESSABLE_CONTENT is the Spring Framework 7 name for 422; the older UNPROCESSABLE_ENTITY constant is still there and marked deprecated.
And the class this article is about. Storage, the id sequence, the seed data, the unique-SKU rule, the mapping and the HTTP response are all in one place:
package com.example.demo.product;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
private final ProductMapper mapper;
public ProductController(ProductMapper mapper) {
this.mapper = mapper;
store(new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
store(new Product(null, "Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
}
@GetMapping
public List<ProductResponse> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.map(mapper::toResponse)
.toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
Product product = products.get(id);
if (product == null) {
throw new ProductNotFoundException(id);
}
return mapper.toResponse(product);
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
boolean skuTaken = products.values().stream().anyMatch(p -> p.sku().equals(request.sku()));
if (skuTaken) {
throw new DuplicateSkuException(request.sku());
}
Product product = store(mapper.toProduct(request));
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.id())
.toUri();
return ResponseEntity.created(location).body(mapper.toResponse(product));
}
private Product store(Product product) {
Product stored = product.withId(sequence.incrementAndGet());
products.put(stored.id(), stored);
return stored;
}
}It works. Before changing anything, record what it does, so the refactored version can be compared against it byte for byte. This script sends six requests and prints each body followed by the status, the content type and the Location header:
#!/bin/sh
# Same requests before and after the refactor: body, then status, content type and Location.
BASE=http://localhost:8121/api/products
FMT='\n -> %{http_code} %{content_type} %header{location}\n'
curl -s -w "$FMT" $BASE
curl -s -w "$FMT" $BASE/2
curl -s -w "$FMT" $BASE/99
curl -s -w "$FMT" -H 'Content-Type: application/json' \
-d '{"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10}' $BASE
curl -s -w "$FMT" -H 'Content-Type: application/json' \
-d '{"name":"Compact keyboard","sku":"KB-01","price":59.00,"stock":5}' $BASE
curl -s -w "$FMT" -H 'Content-Type: application/json' \
-d '{"name":"","sku":"X-1","price":1.00,"stock":1}' $BASE./api-check.sh > before.txt[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25},{"id":2,"name":"Wireless mouse","sku":"MS-01","price":24.50,"stock":3}]
-> 200 application/json
{"id":2,"name":"Wireless mouse","sku":"MS-01","price":24.50,"stock":3}
-> 200 application/json
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Not Found"}
-> 404 application/problem+json
{"id":3,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10}
-> 201 application/json http://localhost:8121/api/products/3
{"detail":"SKU KB-01 already exists","instance":"/api/products","status":409,"title":"Conflict"}
-> 409 application/problem+json
{"detail":"name must not be blank","instance":"/api/products","status":422,"title":"Unprocessable Content"}
-> 422 application/problem+jsonNothing is broken from the client's side. The costs only show up when some other code needs what this class knows.
The business rule cannot run outside an HTTP request
The two seed products are hard-coded in a constructor. The obvious improvement is a startup job that loads them, and later a scheduled import from a supplier feed. A job has no products map of its own, so it has to go through the controller:
package com.example.demo.product;
import java.math.BigDecimal;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class CatalogSeeder implements CommandLineRunner {
private final ProductController controller;
public CatalogSeeder(ProductController controller) {
this.controller = controller;
}
@Override
public void run(String... args) {
controller.create(new CreateProductRequest("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
controller.create(new CreateProductRequest("Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
}
}With the two store(...) lines removed from the constructor, the application no longer starts:
2026-09-13T09:52:43.693+07:00 INFO 14010 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 0.546 seconds (process running for 0.728)
2026-09-13T09:52:43.696+07:00 INFO 14010 --- [demo] [ main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-09-13T09:52:43.700+07:00 ERROR 14010 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: No current ServletRequestAttributes
at org.springframework.util.Assert.state(Assert.java:80) ~[spring-core-7.0.9.jar!/:7.0.9]
at org.springframework.web.servlet.support.ServletUriComponentsBuilder.getCurrentRequest(ServletUriComponentsBuilder.java:178) ~[spring-webmvc-7.0.9.jar!/:7.0.9]
at org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest(ServletUriComponentsBuilder.java:170) ~[spring-webmvc-7.0.9.jar!/:7.0.9]
at com.example.demo.product.ProductController.create(ProductController.java:58) ~[!/:0.0.1-SNAPSHOT]
at com.example.demo.product.CatalogSeeder.run(CatalogSeeder.java:19) ~[!/:0.0.1-SNAPSHOT]
at org.springframework.boot.SpringApplication.lambda$callRunner$1(SpringApplication.java:792) ~[spring-boot-4.1.1.jar!/:4.1.1]
...
at com.example.demo.DemoApplication.main(DemoApplication.java:10) ~[!/:0.0.1-SNAPSHOT]The unique-SKU check passed; what failed is the Location header. fromCurrentRequest() reads the current HTTP request from a thread-bound holder, and a CommandLineRunner runs on the main thread after startup, where there is no request. The log also shows the order: Started DemoApplication is printed first, then the runners run, then the application stops. The rule is reachable only through a method whose other half is HTTP, and the same is true for a @Scheduled job, a message listener, or an order endpoint that needs to check and reduce stock: that last one could only inject another controller and call methods that return response DTOs.
The business rule cannot be tested without HTTP
A plain JUnit test of the happy path hits the same wall. With testLogging switched on in build.gradle so that Gradle prints each result:
tasks.named('test') {
useJUnitPlatform()
testLogging {
events 'passed', 'failed'
showStandardStreams = true
exceptionFormat = 'full'
}
}package com.example.demo.product;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class ProductControllerPlainTest {
@Test
void createsAProduct() {
ProductController controller = new ProductController(new ProductMapper());
ProductResponse created = controller
.create(new CreateProductRequest("USB-C hub", "HUB-07", new BigDecimal("39.00"), 10))
.getBody();
assertEquals(3L, created.id());
}
}./gradlew test --tests '*PlainTest'> Task :test FAILED
ProductControllerPlainTest > createsAProduct() FAILED
java.lang.IllegalStateException: No current ServletRequestAttributes
at org.springframework.util.Assert.state(Assert.java:80)
at org.springframework.web.servlet.support.ServletUriComponentsBuilder.getCurrentRequest(ServletUriComponentsBuilder.java:178)
at org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest(ServletUriComponentsBuilder.java:170)
at com.example.demo.product.ProductController.create(ProductController.java:60)
at com.example.demo.product.ProductControllerPlainTest.createsAProduct(ProductControllerPlainTest.java:15)
1 test completed, 1 failedTo test "a new product gets the next id", the test needs a simulated HTTP request, and its assertions have to read the rule's result out of a ResponseEntity.
Swapping the storage means editing the controller
The ConcurrentHashMap and the AtomicLong are fields of ProductController. When Chapter 4 replaces them with a database, the class that changes is the controller, and every endpoint method in it is edited because every one of them touches the map. The endpoints themselves do not change at all.
Controller, service and repository: what each layer owns
The standard split has three layers, and each has a contract that says what it does and what it must not know about.
- Controller — the web layer. It turns an HTTP request into a call: binds the body into a request DTO, triggers validation with
@Valid, calls one service method, maps the result into a response DTO, and chooses the status code and headers such asLocation. It contains no business rules. The@RestControllerAdvicethat maps exceptions to status codes belongs to this layer too. - Service — the use cases and business rules: a SKU must be unique, stock must never go below zero. It knows nothing about HTTP — no
ResponseEntity, noHttpServletRequest, no status codes. It signals a broken rule by throwing a domain exception such asDuplicateSkuException. When Chapter 4 adds a database, the service method is also where the transaction boundary goes. - Repository — storage access: find, check, save. It answers questions ("does this SKU exist?") and never decides what the answer means; deciding that a duplicate is an error is the service's job.

Every dependency points down: the controller knows the service, the service knows the repository, and nothing knows the layer above it. What crosses each boundary is different. JSON becomes a DTO at the web edge and never goes further; below the controller only domain objects such as Product and plain values such as an id or a quantity travel. Return values and exceptions come back up the call stack, but that is control flow, not a dependency: ProductService throws DuplicateSkuException without knowing that anything will turn it into a 409.
| Layer | Responsibility | May depend on | Must not contain |
|---|---|---|---|
| Controller | Bind and validate the request DTO, call a service, map the result to a response DTO, set status and headers | Services (its own feature's and other features'), its DTOs and mapper | Business rules, storage, Maps of data, transaction handling |
| Service | Carry out use cases and enforce business rules; later, the transaction boundary | Repositories of its own feature, services of other features, domain objects and domain exceptions | ResponseEntity, HttpServletRequest, HttpStatus, request or response DTOs, SQL |
| Repository | Load and store domain objects | Domain objects, the storage technology | Business rules, HTTP types, calls to services |
Where DTO mapping happens
Article 18 introduced CreateProductRequest, ProductResponse and a mapper, and left open which layer uses them. This article's answer: the controller maps; the service takes and returns domain objects. ProductService.create accepts a Product and returns a Product, and ProductMapper sits beside the controller as part of the web layer. Three reasons, in order of weight:
- The service has callers that have no DTO.
CatalogSeederis one; a scheduled import and a message listener are others. Ifcreatetook aCreateProductRequest, each of them would have to build an HTTP request object, validation annotations included, to add a product. - The response shape is an API decision. A second version of the API can expose the same
Productwith different fields. Only the web layer knows which version was called, so only the web layer can pick the mapping. - The dependency direction stays downward. DTOs belong to the web layer. A service that returns
ProductResponsedepends on a type from the layer above it, and every change to the JSON contract becomes a change to the business layer.
This also changes what article 19's @Validated ProductService looks like. Article 19 validated a CreateProductRequest inside the service, as a safety net for imports that never pass through @Valid @RequestBody. Once the service receives a Product, request-shaped validation happens at each entry point instead: @Valid in the controller, and the injected Validator in an importer, as article 19's ProductImporter did. The service keeps the rules that depend on the current state of the data, such as a unique SKU or the stock left.
The one case that bends this rule is a read that assembles data from several places, such as an order with its product names. There the service can return a purpose-built read model, a record from the service's own vocabulary, which the controller still maps into the response DTO.
Refactoring the catalogue into controller, service and repository
The refactor below happens in four steps, all inside com.example.demo.product plus a new com.example.demo.order. The end result is compared against before.txt at the end.
Step 1: move the storage behind a repository interface
The map and the sequence move into a class of their own, behind an interface. save assigns an id to a product that has none, so no caller ever touches the sequence:
package com.example.demo.product;
import java.util.List;
import java.util.Optional;
public interface ProductRepository {
List<Product> findAll();
Optional<Product> findById(Long id);
boolean existsBySku(String sku);
Product save(Product product);
}package com.example.demo.product;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryProductRepository implements ProductRepository {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@Override
public List<Product> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.toList();
}
@Override
public Optional<Product> findById(Long id) {
return Optional.ofNullable(products.get(id));
}
@Override
public boolean existsBySku(String sku) {
return products.values().stream().anyMatch(p -> p.sku().equals(sku));
}
@Override
public Product save(Product product) {
Product stored = product.id() == null ? product.withId(sequence.incrementAndGet()) : product;
products.put(stored.id(), stored);
return stored;
}
}The interface is the seam from article 5: Chapter 4 adds a JPA implementation of ProductRepository, and nothing that uses the interface changes.
Step 2: move the business rules into a service
The unique-SKU rule moves into ProductService, along with the stock rule the order feature needs. Neither method mentions HTTP:
package com.example.demo.product;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public List<Product> findAll() {
return repository.findAll();
}
public Product findById(Long id) {
return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
public Product create(Product product) {
if (repository.existsBySku(product.sku())) {
throw new DuplicateSkuException(product.sku());
}
return repository.save(product);
}
public Product reserveStock(Long id, int quantity) {
Product product = findById(id);
if (product.stock() < quantity) {
throw new InsufficientStockException(product.sku(), product.stock(), quantity);
}
return repository.save(product.withStock(product.stock() - quantity));
}
}package com.example.demo.product;
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String sku, int available, int requested) {
super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
}
}Both rules are check-then-save against a map, so two concurrent requests can both pass the check. The unique constraint and the transaction that close that gap arrive with the database in Chapter 4, and the transaction goes on exactly these service methods.
Step 3: the controller keeps only HTTP
The controller loses the map, the sequence, the seed data and the rule, and gains the service:
package com.example.demo.product;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
private final ProductService service;
private final ProductMapper mapper;
public ProductController(ProductMapper mapper) {
public ProductController(ProductService service, ProductMapper mapper) {
this.service = service;
this.mapper = mapper;
store(new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
store(new Product(null, "Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
}
@GetMapping
public List<ProductResponse> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
return service.findAll().stream()
.map(mapper::toResponse)
.toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
Product product = products.get(id);
if (product == null) {
throw new ProductNotFoundException(id);
}
return mapper.toResponse(product);
return mapper.toResponse(service.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
boolean skuTaken = products.values().stream().anyMatch(p -> p.sku().equals(request.sku()));
if (skuTaken) {
throw new DuplicateSkuException(request.sku());
}
Product product = store(mapper.toProduct(request));
Product product = service.create(mapper.toProduct(request));
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.id())
.toUri();
return ResponseEntity.created(location).body(mapper.toResponse(product));
}
private Product store(Product product) {
Product stored = product.withId(sequence.incrementAndGet());
products.put(stored.id(), stored);
return stored;
}
}Each endpoint method is now two or three statements: map in, call, map out. fromCurrentRequest() stays, because building a Location header is exactly the controller's job. The seeder that crashed the application now calls the service, and passes a domain object instead of a web DTO:
package com.example.demo.product;
import java.math.BigDecimal;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class CatalogSeeder implements CommandLineRunner {
private final ProductController controller;
private final ProductService service;
public CatalogSeeder(ProductController controller) {
this.controller = controller;
public CatalogSeeder(ProductService service) {
this.service = service;
}
@Override
public void run(String... args) {
controller.create(new CreateProductRequest("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
controller.create(new CreateProductRequest("Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
service.create(new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
service.create(new Product(null, "Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
}
}Step 4: an order feature that goes through ProductService
The second feature places an order for a customer, on the URLs this chapter designed in article 15: POST /api/customers/{customerId}/orders answers 201 with Location: /api/orders/{orderId}, and GET /api/orders/{id} reads one. There is no customer feature in this article; the customer id is simply stored on the order. The domain object, its repository and its exception follow the product pattern:
package com.example.demo.order;
import java.math.BigDecimal;
public record Order(Long id, Long customerId, Long productId, int quantity, BigDecimal total) {
public Order withId(Long newId) {
return new Order(newId, customerId, productId, quantity, total);
}
}package com.example.demo.order;
import java.util.Optional;
public interface OrderRepository {
Optional<Order> findById(Long id);
Order save(Order order);
}package com.example.demo.order;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
@Repository
public class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> orders = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@Override
public Optional<Order> findById(Long id) {
return Optional.ofNullable(orders.get(id));
}
@Override
public Order save(Order order) {
Order stored = order.id() == null ? order.withId(sequence.incrementAndGet()) : order;
orders.put(stored.id(), stored);
return stored;
}
}package com.example.demo.order;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Order " + id + " not found");
}
}The service is where the two features meet. OrderService depends on ProductService, not on ProductRepository: the stock rule lives in one place, and orders reach it the same way any other caller does.
package com.example.demo.order;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductService;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ProductService productService;
private final OrderRepository repository;
public OrderService(ProductService productService, OrderRepository repository) {
this.productService = productService;
this.repository = repository;
}
public Order place(Long customerId, Long productId, int quantity) {
Product product = productService.reserveStock(productId, quantity);
BigDecimal total = product.price().multiply(BigDecimal.valueOf(quantity));
return repository.save(new Order(null, customerId, productId, quantity, total));
}
public Order findById(Long id) {
return repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
}The web side of orders, with the response record mapping itself through a static factory; the order has only one response shape, so a separate mapper class would add nothing:
package com.example.demo.order;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record PlaceOrderRequest(@NotNull Long productId, @NotNull @Positive Integer quantity) {
}package com.example.demo.order;
import java.math.BigDecimal;
public record OrderResponse(Long id, Long customerId, Long productId, int quantity, BigDecimal total) {
public static OrderResponse from(Order order) {
return new OrderResponse(order.id(), order.customerId(), order.productId(), order.quantity(), order.total());
}
}package com.example.demo.order;
import java.net.URI;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping("/api/customers/{customerId}/orders")
public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
@Valid @RequestBody PlaceOrderRequest request) {
Order order = service.place(customerId, request.productId(), request.quantity());
URI location = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/orders/{id}")
.buildAndExpand(order.id())
.toUri();
return ResponseEntity.created(location).body(OrderResponse.from(order));
}
@GetMapping("/api/orders/{id}")
public OrderResponse findById(@PathVariable Long id) {
return OrderResponse.from(service.findById(id));
}
}fromCurrentContextPath() starts from the application root rather than from the request URL, because the new order lives under /api/orders, not under the customer path it was posted to. The advice learns the two new exceptions:
package com.example.demo.common;
import java.util.stream.Collectors;
import com.example.demo.order.OrderNotFoundException;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.InsufficientStockException;
import com.example.demo.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
@ExceptionHandler({ProductNotFoundException.class, OrderNotFoundException.class})
public ProblemDetail notFound(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(DuplicateSkuException.class)
@ExceptionHandler({DuplicateSkuException.class, InsufficientStockException.class})
public ProblemDetail conflict(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalid(MethodArgumentNotValidException e) {
String detail = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + " " + error.getDefaultMessage())
.sorted()
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, detail);
}
}Proving the behaviour did not change
The same script against the refactored application, then a diff against the file saved from the old controller:
./api-check.sh > after.txt
diff before.txt after.txtdiff printed nothing and exited with status 0: all six responses are byte-for-byte what the old controller returned, including the seeded products that now arrive through CatalogSeeder and ProductService instead of a constructor, the Location header, and the 404, 409 and 422 bodies.
The order endpoints, which did not exist before. Product 2 starts with 3 in stock:
#!/bin/sh
BASE=http://localhost:8121/api
FMT='\n -> %{http_code} %{content_type} %header{location}\n'
curl -s -w "$FMT" -H 'Content-Type: application/json' -d '{"productId":2,"quantity":2}' $BASE/customers/7/orders
curl -s -w "$FMT" $BASE/orders/1
curl -s -w "$FMT" $BASE/products/2
curl -s -w "$FMT" -H 'Content-Type: application/json' -d '{"productId":2,"quantity":2}' $BASE/customers/7/orders
curl -s -w "$FMT" -H 'Content-Type: application/json' -d '{"productId":99,"quantity":1}' $BASE/customers/7/orders{"id":1,"customerId":7,"productId":2,"quantity":2,"total":49.00}
-> 201 application/json http://localhost:8121/api/orders/1
{"id":1,"customerId":7,"productId":2,"quantity":2,"total":49.00}
-> 200 application/json
{"id":2,"name":"Wireless mouse","sku":"MS-01","price":24.50,"stock":1}
-> 200 application/json
{"detail":"Only 1 of MS-01 in stock, 2 requested","instance":"/api/customers/7/orders","status":409,"title":"Conflict"}
-> 409 application/problem+json
{"detail":"Product 99 not found","instance":"/api/customers/7/orders","status":404,"title":"Not Found"}
-> 404 application/problem+jsonThe first order took the mouse from 3 to 1, the second asked for 2 and got a 409 from the rule in ProductService, and an unknown product is a 404 raised by the product feature on the order's URL.
The rules the controller test could not reach now run with no request at all. The whole fixture is new ProductService(new InMemoryProductRepository()):
package com.example.demo.product;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class ProductServiceTest {
private final ProductService service = new ProductService(new InMemoryProductRepository());
@Test
void rejectsADuplicateSku() {
service.create(new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25));
assertThrows(DuplicateSkuException.class,
() -> service.create(new Product(null, "Compact keyboard", "KB-01", new BigDecimal("59.00"), 5)));
}
@Test
void neverTakesStockBelowZero() {
Product mouse = service.create(new Product(null, "Wireless mouse", "MS-01", new BigDecimal("24.50"), 3));
assertEquals(1, service.reserveStock(mouse.id(), 2).stock());
assertThrows(InsufficientStockException.class, () -> service.reserveStock(mouse.id(), 2));
}
}ProductServiceTest > rejectsADuplicateSku() PASSED
ProductServiceTest > neverTakesStockBelowZero() PASSEDTesting each layer in its own slice, with @WebMvcTest for controllers and mocks for collaborators, is the subject of Chapter 6.
Should a Spring service be an interface or a concrete class?
A lot of Spring code gives every service an interface and a single implementation, ProductService plus ProductServiceImpl. The alternative is the concrete ProductService above. The reasons usually given for the interface are testing, proxies and swappability, and on Spring Boot 4.1.1 the first two no longer need one.
Mocking. OrderService depends on the concrete ProductService. A unit test of OrderService can still replace it with a mock, using the Mockito that spring-boot-starter-webmvc-test brings in, which resolves to mockito-core 5.23.0:
package com.example.demo.order;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductService;
import org.junit.jupiter.api.Test;
class OrderServiceTest {
@Test
void mocksTheConcreteProductService() {
ProductService products = mock(ProductService.class);
when(products.reserveStock(2L, 2))
.thenReturn(new Product(2L, "Wireless mouse", "MS-01", new BigDecimal("24.50"), 1));
OrderService orders = new OrderService(products, new InMemoryOrderRepository());
Order order = orders.place(7L, 2L, 2);
assertEquals(new BigDecimal("49.00"), order.total());
System.out.println("ProductService.class.isInterface() = " + ProductService.class.isInterface());
System.out.println("mock class = " + products.getClass().getName());
}
}OrderServiceTest > mocksTheConcreteProductService() STANDARD_ERROR
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (.../byte-buddy-agent-1.18.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
OrderServiceTest > mocksTheConcreteProductService() STANDARD_OUT
ProductService.class.isInterface() = false
mock class = com.example.demo.product.ProductService
OrderServiceTest > mocksTheConcreteProductService() PASSEDThe test passed, and the mock's class is ProductService itself rather than a generated subclass: Mockito 5 uses the inline mock maker by default, which instruments the class in place, so it needs neither an interface nor a subclass. The warning is about how that mock maker attaches to the JDK, and configuring it is a test-setup matter for Chapter 6.
Proxies. Features such as transactions and method validation wrap a bean in a proxy. Spring Boot's configuration metadata lists spring.aop.proxy-target-class with a default of true, which means subclass-based CGLIB proxies, and a subclass proxy does not need an interface.
Interface + ProductServiceImpl | Concrete ProductService | |
|---|---|---|
| Files per service | Two, with every public method signature written twice | One |
| Navigating from a caller | "Go to definition" lands on the interface | Lands on the code that runs |
| Mocking in a unit test | Works | Works: Mockito 5.23.0 mocks the class |
| Proxies for transactions and method validation | Works | Works: CGLIB proxies are Boot's default |
| A second implementation | Already has somewhere to go | Extract the interface when it arrives, one IDE refactoring |
| Naming | Impl names a class after the fact that it implements something | The class is named after what it does |
Recommendation: make services concrete classes, and give repositories an interface. The difference is that the repository's second implementation is not hypothetical: Chapter 4 adds a JPA one, and the in-memory implementation stays useful after that. A service deserves an interface when there really are several implementations chosen at run time, such as one per payment provider, or when it is the published contract of a module that other teams build against.
Layered architecture anti-patterns
Each one is a shortcut that saves a few lines when it is written and costs more every time the code changes afterwards.
A controller that calls the repository directly
@PostMapping("/import")
public ProductResponse importProduct(@RequestBody CreateProductRequest request) {
return mapper.toResponse(repository.save(mapper.toProduct(request)));
}This is a second way to create a product, one that skips the unique-SKU check. Every rule in the service now has to be enforced on every path that bypasses it, and the next person to add a rule will not know this endpoint exists. The rule "controllers call services" is cheap to follow and hard to audit once there are exceptions.
A service that returns ResponseEntity or throws HTTP exceptions
public ResponseEntity<Product> create(Product product) {
if (repository.existsBySku(product.sku())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "SKU taken");
}
return ResponseEntity.status(HttpStatus.CREATED).body(repository.save(product));
}CatalogSeeder would now receive a ResponseEntity from a startup job, and a scheduled import would catch ResponseStatusException to find out that a SKU was taken. The service depends on Spring's web module, and "a duplicate SKU is a 409" is decided in business code instead of at the web edge. Throw DuplicateSkuException and let the advice choose the status.
A pass-through service
public List<Product> findAll() {
return repository.findAll();
}A pass-through method like this one, inside a service that owns real rules, is fine. It keeps the rule that controllers only talk to services free of exceptions, and when a rule arrives, such as hiding discontinued products, it has one obvious place to go. A pass-through service, where every method only delegates, is a different signal: it usually means the business rules live somewhere else, typically in the controllers. Check where the if statements are before deleting the layer.
Domain objects in the API
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return service.findById(id);
}Returning Product makes the domain record the JSON contract, with the consequences article 18 described. In layer terms, every field added to the domain changes the API for every client, and the web layer can no longer shape responses per endpoint or per version.
Services that depend on each other
Suppose ProductService gains a rule, "a product with open orders cannot be deleted", and injects OrderService to check it. OrderService already injects ProductService. With constructor injection, that is the cycle from article 7: the application stops at startup with "The dependencies of some of the beans in the application context form a cycle". The fix is a direction, not a setting. Orders depend on products; the product feature must not know that orders exist. Put the check on the side that is allowed to know about both, such as a use case in the order feature, or in a third class that depends on both services. Decoupling features with events is a topic for the Advanced course.
Package by layer vs package by feature
So far every class has gone into product, order or common. That is one of two common layouts. The other puts all controllers in one package, all services in another, and so on. Both are layered architectures: the layers are in the classes and the dependency arrows, not in the folder names. Component scanning finds either layout, as long as every package sits under com.example.demo, the package of the @SpringBootApplication class (article 6).
The same application organised by layer
src/main/java/com/example/demo
├── DemoApplication.java
├── config
│ └── CatalogSeeder.java
├── controller
│ ├── OrderController.java
│ └── ProductController.java
├── dto
│ ├── CreateProductRequest.java
│ ├── OrderResponse.java
│ ├── PlaceOrderRequest.java
│ └── ProductResponse.java
├── exception
│ ├── DuplicateSkuException.java
│ ├── GlobalExceptionHandler.java
│ ├── InsufficientStockException.java
│ ├── OrderNotFoundException.java
│ └── ProductNotFoundException.java
├── mapper
│ └── ProductMapper.java
├── model
│ ├── Order.java
│ └── Product.java
├── repository
│ ├── InMemoryOrderRepository.java
│ ├── InMemoryProductRepository.java
│ ├── OrderRepository.java
│ └── ProductRepository.java
└── service
├── OrderService.java
└── ProductService.javaThis is the refactored application with only the package and import lines changed. It started and answered both check scripts with output identical to the feature layout's. Every class that another package uses has to be public, which in this layout is nearly all of them.
The same application organised by feature
src/main/java/com/example/demo
├── DemoApplication.java
├── common
│ └── GlobalExceptionHandler.java
├── order
│ ├── InMemoryOrderRepository.java
│ ├── Order.java
│ ├── OrderController.java
│ ├── OrderNotFoundException.java
│ ├── OrderRepository.java
│ ├── OrderResponse.java
│ ├── OrderService.java
│ └── PlaceOrderRequest.java
└── product
├── CatalogSeeder.java
├── CreateProductRequest.java
├── DuplicateSkuException.java
├── InMemoryProductRepository.java
├── InsufficientStockException.java
├── Product.java
├── ProductController.java
├── ProductMapper.java
├── ProductNotFoundException.java
├── ProductRepository.java
├── ProductResponse.java
└── ProductService.javaThe same 21 classes plus DemoApplication: eight packages in the first tree, three in the second.
One change in both layouts: adding a brand field
Folder layouts are easy to argue about in the abstract, so here is a real change made to both: products get a brand, required on create and returned in every response. Both projects were committed first, then edited identically. In Product the change is one record component plus the two with methods:
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, String sku, BigDecimal price, int stock) {
public record Product(Long id, String name, String brand, String sku, BigDecimal price, int stock) {
public Product withId(Long newId) {
return new Product(newId, name, sku, price, stock);
return new Product(newId, name, brand, sku, price, stock);
}
public Product withStock(int newStock) {
return new Product(id, name, sku, price, newStock);
return new Product(id, name, brand, sku, price, newStock);
}
}CreateProductRequest gains @NotBlank String brand, ProductResponse gains String brand, ProductMapper passes it through in both directions, and CatalogSeeder gives the two seed products a brand. The repositories, the services, the order feature and the advice do not change. Git counts the result:
git diff --statIn the layer layout:
src/main/java/com/example/demo/config/CatalogSeeder.java | 4 ++--
src/main/java/com/example/demo/dto/CreateProductRequest.java | 1 +
src/main/java/com/example/demo/dto/ProductResponse.java | 2 +-
src/main/java/com/example/demo/mapper/ProductMapper.java | 4 ++--
src/main/java/com/example/demo/model/Product.java | 6 +++---
5 files changed, 9 insertions(+), 8 deletions(-)In the feature layout:
src/main/java/com/example/demo/product/CatalogSeeder.java | 4 ++--
src/main/java/com/example/demo/product/CreateProductRequest.java | 1 +
src/main/java/com/example/demo/product/Product.java | 6 +++---
src/main/java/com/example/demo/product/ProductMapper.java | 4 ++--
src/main/java/com/example/demo/product/ProductResponse.java | 2 +-
5 files changed, 9 insertions(+), 8 deletions(-)Both builds answered the same three requests identically:
#!/bin/sh
BASE=http://localhost:8121/api/products
FMT='\n -> %{http_code} %{content_type} %header{location}\n'
curl -s -w "$FMT" $BASE/1
curl -s -w "$FMT" -H 'Content-Type: application/json' \
-d '{"name":"USB-C hub","brand":"Initech","sku":"HUB-07","price":39.00,"stock":10}' $BASE
curl -s -w "$FMT" -H 'Content-Type: application/json' \
-d '{"name":"USB-C hub","sku":"HUB-08","price":39.00,"stock":10}' $BASE{"id":1,"name":"Mechanical keyboard","brand":"Acme","sku":"KB-01","price":89.90,"stock":25}
-> 200 application/json
{"id":3,"name":"USB-C hub","brand":"Initech","sku":"HUB-07","price":39.00,"stock":10}
-> 201 application/json http://localhost:8121/api/products/3
{"detail":"brand must not be blank","instance":"/api/products","status":422,"title":"Unprocessable Content"}
-> 422 application/problem+json
The same five files, the same nine insertions and eight deletions, in four packages by layer and one by feature. Packaging by feature does not make a change smaller; it puts everything the change touches in one directory. The second measurement is the order feature itself, counted from src/main/java:
find . -name '*Order*.java' | sed 's|/[^/]*$||' | sort | uniq -cBy layer:
1 ./com/example/demo/controller
2 ./com/example/demo/dto
1 ./com/example/demo/exception
1 ./com/example/demo/model
2 ./com/example/demo/repository
1 ./com/example/demo/serviceBy feature:
8 ./com/example/demo/orderEight files in six packages, or eight files in one. Reviewing the order feature, handing it to another team, or deleting it is one directory in the second layout and a search across six in the first.
Package-private classes are still injected
The feature layout's bigger advantage is visibility. When a controller, its service and its repository share a package, most of them no longer need to be public. In the feature layout, everything that no other package uses loses its public modifier:
| Class | Visibility | Why |
|---|---|---|
Product, ProductService | public | The order feature uses them |
ProductNotFoundException, DuplicateSkuException, InsufficientStockException, OrderNotFoundException | public | The advice in common handles them |
ProductController, ProductMapper, CreateProductRequest, ProductResponse, CatalogSeeder | package-private | Only product uses them |
ProductRepository, InMemoryProductRepository | package-private | Only ProductService uses them |
OrderController, OrderService, Order, OrderRepository, InMemoryOrderRepository, PlaceOrderRequest, OrderResponse | package-private | Nothing outside order uses them |
The constructors the container calls become package-private too. Two of the diffs:
package com.example.demo.product;
import java.util.List;
import java.util.Optional;
public interface ProductRepository {
interface ProductRepository {
List<Product> findAll();
Optional<Product> findById(Long id);
boolean existsBySku(String sku);
Product save(Product product);
}@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
ProductService(ProductRepository repository) {
this.repository = repository;
}Does component scanning still find a package-private class, and can the container call a package-private constructor? A temporary runner, placed in a separate lab package so that it cannot see any of these types at compile time, looks the beans up by name and prints what reflection says about them:
package com.example.demo.lab;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
@Component
public class VisibilityReport implements CommandLineRunner {
private final ApplicationContext context;
public VisibilityReport(ApplicationContext context) {
this.context = context;
}
@Override
public void run(String... args) {
for (String name : List.of("productController", "productMapper", "productService",
"inMemoryProductRepository", "catalogSeeder",
"orderController", "orderService", "inMemoryOrderRepository")) {
Class<?> type = context.getBean(name).getClass();
Constructor<?> constructor = type.getDeclaredConstructors()[0];
System.out.printf("%-26s %-26s class %-15s constructor %s%n", name, type.getName().substring(17),
visibility(type.getModifiers()), visibility(constructor.getModifiers()));
}
}
private static String visibility(int modifiers) {
if (Modifier.isPublic(modifiers)) return "public";
if (Modifier.isPrivate(modifiers)) return "private";
if (Modifier.isProtected(modifiers)) return "protected";
return "package-private";
}
}productController product.ProductController class package-private constructor package-private
productMapper product.ProductMapper class package-private constructor package-private
productService product.ProductService class public constructor package-private
inMemoryProductRepository product.InMemoryProductRepository class package-private constructor package-private
catalogSeeder product.CatalogSeeder class package-private constructor package-private
orderController order.OrderController class package-private constructor package-private
orderService order.OrderService class package-private constructor package-private
inMemoryOrderRepository order.InMemoryOrderRepository class package-private constructor package-privateEvery bean was found, built and injected. InMemoryProductRepository has no constructor in its source, and the default constructor the compiler generates takes the class's own package-private access. The API was untouched too: api-check.sh and order-check.sh against this build matched the earlier output exactly, so Spring MVC calls package-private controllers, Jackson reads and writes package-private records, and validation still produces the 422.
The compiler keeps the order feature out of product internals
Now the payoff. Someone working on orders decides it is simpler to reduce stock directly through the repository than to go through ProductService:
package com.example.demo.order;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductService;
import com.example.demo.product.ProductRepository;
import org.springframework.stereotype.Service;
@Service
class OrderService {
private final ProductService productService;
private final ProductRepository products;
private final OrderRepository repository;
OrderService(ProductService productService, OrderRepository repository) {
this.productService = productService;
OrderService(ProductRepository products, OrderRepository repository) {
this.products = products;
this.repository = repository;
}
Order place(Long customerId, Long productId, int quantity) {
Product product = productService.reserveStock(productId, quantity);
Product product = products.findById(productId).orElseThrow();
products.save(product.withStock(product.stock() - quantity));
BigDecimal total = product.price().multiply(BigDecimal.valueOf(quantity));
return repository.save(new Order(null, customerId, productId, quantity, total));
}
Order findById(Long id) {
return repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
}That version skips the stock check entirely. It does not compile:
./gradlew -q compileJava.../src/main/java/com/example/demo/order/OrderService.java:6: error: ProductRepository is not public in com.example.demo.product; cannot be accessed from outside package
import com.example.demo.product.ProductRepository;
^
.../src/main/java/com/example/demo/order/OrderService.java:13: error: ProductRepository is not public in com.example.demo.product; cannot be accessed from outside package
private final ProductRepository products;
^
.../src/main/java/com/example/demo/order/OrderService.java:16: error: ProductRepository is not public in com.example.demo.product; cannot be accessed from outside package
OrderService(ProductRepository products, OrderRepository repository) {
^
3 errorsIn the layer layout the same edit compiles, because ProductRepository has to be public for service to reach repository. By feature, public becomes a deliberate statement of what a feature offers the rest of the application: here Product, ProductService and the exceptions. Package-private does not protect a feature from itself, though. ProductController could still call ProductRepository directly, because they share a package.
Enforcing boundaries beyond package-private
Two tools take this further. ArchUnit is a library for writing architecture rules as ordinary unit tests, such as "classes in ..controller.. must not access classes in ..repository..", so the build fails when code breaks one. Spring Modulith treats each direct sub-package of the main application package as an application module, and verifies that modules reach each other only through their top-level packages and do not form cycles. Both are covered in the Advanced course.
Hybrid layouts and shared code
Layer sub-packages inside a large feature
A feature with forty classes is hard to read as one flat directory, and the natural next step is layer sub-packages inside the feature:
src/main/java/com/example/demo/product
├── DuplicateSkuException.java
├── InsufficientStockException.java
├── Product.java
├── ProductNotFoundException.java
├── ProductService.java
├── persistence
│ ├── InMemoryProductRepository.java
│ └── ProductRepository.java
└── web
├── CreateProductRequest.java
├── ProductController.java
├── ProductMapper.java
└── ProductResponse.javaThe cost is visibility. To Java, com.example.demo.product.persistence is a different package from com.example.demo.product; there is no parent-child access between them. Moving the two repository classes into persistence and leaving them package-private:
.../src/main/java/com/example/demo/product/ProductService.java:3: error: ProductRepository is not public in com.example.demo.product.persistence; cannot be accessed from outside package
import com.example.demo.product.persistence.ProductRepository;
^
.../src/main/java/com/example/demo/product/ProductService.java:12: error: ProductRepository is not public in com.example.demo.product.persistence; cannot be accessed from outside package
private final ProductRepository repository;
^
.../src/main/java/com/example/demo/product/ProductService.java:14: error: ProductRepository is not public in com.example.demo.product.persistence; cannot be accessed from outside package
ProductService(ProductRepository repository) {
^
3 errorsTo make it compile, ProductRepository has to become public, and from then on the compiler no longer stops order from using it either. Split a feature into sub-packages when one directory has stopped being readable, not before, and accept that the boundary has to be enforced by something other than public.
Where shared code, DTOs and mappers go
commonholds code that belongs to no feature: the global@RestControllerAdviceand configuration classes. Keep it small and without business logic. This is the answer to the question article 20 left open:GlobalExceptionHandlergoes incommonrather than in the root package, which keepscom.example.demoforDemoApplicationalone, so every other class is either in a feature or explicitly shared. Here the advice imports the feature exceptions, which is the only reason they arepublic. When the list of exceptions grows, features can throw subclasses of a few base exceptions defined incommon, so thatcommonstops importing feature classes.- DTO records go in the feature package, next to the controller that reads and writes them, package-private when nothing else uses them.
- Mappers go with the DTOs, because mapping is web-layer work. That holds for a hand-written class like the one here and for article 18's MapStruct interface alike.
The layout this series uses from Chapter 4 on
From Chapter 4 onwards the sample project is packaged by feature: com.example.demo.product, com.example.demo.order, and com.example.demo.common for cross-cutting code, with package-private classes wherever nothing outside the feature needs them. The first thing Chapter 4 adds is a JPA implementation of ProductRepository, next to the in-memory one.
FAQ
Is layered architecture the same as package by layer?
No. Layered architecture is about responsibilities and dependency direction: the controller handles HTTP, the service holds the rules, the repository handles storage, and dependencies point down. Package by layer is one way to arrange those classes in directories. The feature layout in this article is just as layered; its layers sit side by side in each feature package instead of spread across layer packages.
Should the controller or the service map DTOs?
The controller, with a mapper that lives in the web layer. The service should accept and return domain objects, because it has callers that have no DTO, such as startup jobs, scheduled imports and message listeners, and because the response shape is an API decision that only the web layer can make. A service that returns response DTOs depends on the layer above it.
Does every Spring service need an interface?
No. Mockito 5.23.0, which Spring Boot 4.1.1's test starter brings in, mocks a concrete class directly, and Spring Boot creates class-based CGLIB proxies by default, so transactions and method validation work on concrete classes. Give a service an interface when it really has several implementations or is a published module contract. Repositories are different: they usually do get a second implementation, such as the in-memory one and the JPA one.
Can Spring inject a package-private class or constructor?
Yes. Component scanning registers package-private @Component, @Service, @Repository and @RestController classes, and the container calls their package-private constructors. The run above printed class package-private constructor package-private for seven beans, and the API answered exactly as before. The only requirement is the usual one: the package must be under the @SpringBootApplication class's package.
Can a controller call a repository directly for simple reads?
It works, and some teams allow it for reads with no rules. The cost is that "controllers talk to services" stops being a rule without exceptions, so every new rule has to be checked against every endpoint that bypasses the service. A one-line pass-through method in the service is cheaper than that audit. Writes should always go through the service.
When should a feature package get sub-packages?
When one flat directory has become hard to read, typically once a feature has many controllers or many kinds of classes. A sub-package is a separate package to the compiler, so classes the other sub-packages use have to become public, and the compiler stops enforcing the feature's boundary. Until a feature is that big, one package with package-private classes gives you more protection.
Conclusion
A controller that holds the data, the rules and the HTTP handling works until other code needs its rules. A startup job calling it stopped the application with No current ServletRequestAttributes, a plain unit test failed the same way, and moving to a database would rewrite the class. Split into three layers, the controller maps HTTP to DTOs and back, the service owns the use cases and throws domain exceptions, and the repository only stores. Dependencies point down, DTOs stop at the web edge, and the refactored application returned byte-for-byte the responses the old controller did. Services can be concrete classes, because Mockito 5.23.0 mocks them and Boot proxies classes by default. Repositories get an interface, because a JPA implementation is coming.
Packages are a separate decision. Adding brand changed the same five files in both layouts, spread over four packages by layer and one by feature. The order feature was eight files in six packages, or eight files in one. The feature layout also lets most classes be package-private: Spring still injects them, and the compiler rejects an order class that reaches for ProductRepository. This series uses package by feature from Chapter 4 on.
The next article documents this API: springdoc-openapi, Swagger UI and describing endpoints so a client can use them without reading the code.