Bài 16 đặt catalogue sản phẩm trong một ConcurrentHashMap ngay bên trong ProductController và hứa bài này sẽ đưa nó ra ngoài. Từ đó tới giờ controller đã có thêm DTO, validation và lỗi dạng ProblemDetail, nhưng vẫn là một class vừa xử lý HTTP, vừa giữ business rule, vừa lưu dữ liệu. Bài này tách nó thành controller, service và repository, thêm một feature order nhỏ đặt hàng và trừ stock thông qua product service, rồi trả lời câu hỏi thứ hai mà codebase Spring Boot nào cũng phải trả lời: các class đó nằm trong package nào.
Mọi thứ bên dưới chạy trên Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) với OpenJDK 21.0.6, build bằng Gradle wrapper 9.7.1 từ một project Spring Initializr có spring-boot-starter-webmvc và spring-boot-starter-validation. Mọi response của curl, stack trace, lỗi compile, kết quả test và số file đếm được đều chép từ project đó chạy ở port 8121.
![]()
Phần đầu nói về các layer, vẫn là các class nằm trong com.example.demo.product. Rải các class đó ra những package nào là một quyết định riêng, và nó chiếm nửa sau của bài.
Controller làm tất cả mọi việc
Bài 18 đến bài 20 giữ ví dụ gọn bằng một bean ProductStore nằm cạnh controller. Ở bài 20, store đó nhận CreateProductRequest và tự ném DuplicateSkuException: một class lưu trữ vừa biết DTO của web vừa thực thi business rule, tức là việc của hai layer đặt sai chỗ. Bài này bắt đầu sớm hơn một bước, từ controller như bài 16 và 17 để lại, nơi HTTP, rule và storage cùng nằm trong một class, và kết thúc khi mỗi thứ có class riêng của nó.
Catalogue bắt đầu từ một domain record nhỏ và các type web xung quanh nó. Product là domain object; hai method with trả về một bản sao đã đổi một field, nhờ vậy record vẫn 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);
}
}Hai DTO cho request và response, cùng mapper chuyển giữa chúng và 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());
}
}Hai exception, và một bản rút gọn của GlobalExceptionHandler từ bài 20, chuyển chúng thành response ProblemDetail: 404 cho id không tồn tại, 409 khi xung đột với trạng thái hiện tại của dữ liệu, 422 cho body vi phạm một rule validation. Bài 20 để class này trong com.example.demo và để dành câu hỏi nó thuộc về đâu cho bài này; ở đây nó nằm trong com.example.demo.common ngay từ đầu, và phần nói về package giải thích lý do.
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 là tên của 422 trong Spring Framework 7; hằng UNPROCESSABLE_ENTITY cũ vẫn còn và bị đánh dấu deprecated.
Và đây là class mà bài này nói tới. Storage, bộ đếm id, dữ liệu seed, rule SKU không trùng, việc map và HTTP response đều nằm một chỗ:
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;
}
}Nó chạy được. Trước khi sửa gì, hãy ghi lại nó đang làm gì, để bản refactor so được với nó tới từng byte. Script này gửi sáu request và in từng body, kèm status, content type và header Location:
#!/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+jsonTừ phía client thì không có gì hỏng. Cái giá chỉ lộ ra khi một đoạn code khác cần thứ mà class này đang nắm.
Business rule không chạy được ngoài HTTP request
Hai sản phẩm seed đang được hard-code trong constructor. Cải tiến hiển nhiên là một job chạy lúc khởi động để nạp chúng, rồi sau này là một job import định kỳ từ feed của nhà cung cấp. Job không có map sản phẩm của riêng nó, nên phải đi qua 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));
}
}Bỏ hai dòng store(...) khỏi constructor, application không khởi động được nữa:
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]Bước kiểm tra SKU đã qua; thứ hỏng là header Location. fromCurrentRequest() đọc HTTP request hiện tại từ một holder gắn với thread, còn CommandLineRunner chạy trên thread main sau khi khởi động xong, lúc không có request nào. Log cũng cho thấy thứ tự: Started DemoApplication được in trước, sau đó các runner chạy, rồi application dừng. Rule chỉ với tới được qua một method mà nửa còn lại là HTTP, và điều đó cũng đúng với một job @Scheduled, một message listener, hay một endpoint đặt hàng cần kiểm tra và trừ stock: endpoint đó chỉ còn cách inject một controller khác và gọi những method trả về response DTO.
Business rule không test được nếu không có HTTP
Một JUnit test thường cho happy path cũng đụng đúng bức tường đó. Bật testLogging trong build.gradle để Gradle in từng kết quả:
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 failedMuốn test "sản phẩm mới nhận id kế tiếp", test phải giả lập một HTTP request, và assertion phải đọc kết quả của rule từ bên trong một ResponseEntity.
Đổi storage nghĩa là phải sửa controller
ConcurrentHashMap và AtomicLong là field của ProductController. Khi Chương 4 thay chúng bằng database, class phải sửa là controller, và mọi endpoint method trong đó đều bị sửa vì method nào cũng đụng tới map. Trong khi bản thân các endpoint không đổi gì cả.
Controller, service và repository: mỗi layer chịu trách nhiệm gì
Cách tách chuẩn có ba layer, và mỗi layer có một hợp đồng nói rõ nó làm gì và không được biết gì.
- Controller — web layer. Nó chuyển một HTTP request thành một lời gọi: bind body vào request DTO, kích hoạt validation bằng
@Valid, gọi một method của service, map kết quả thành response DTO, rồi chọn status code và các header nhưLocation. Nó không chứa business rule nào.@RestControllerAdvicemap exception sang status code cũng thuộc layer này. - Service — use case và business rule: SKU phải không trùng, stock không bao giờ được âm. Nó không biết gì về HTTP — không
ResponseEntity, khôngHttpServletRequest, không status code. Khi một rule bị vi phạm, nó ném domain exception nhưDuplicateSkuException. Khi Chương 4 thêm database, method của service cũng là nơi đặt ranh giới transaction. - Repository — truy cập storage: tìm, kiểm tra, lưu. Nó trả lời câu hỏi ("SKU này đã có chưa?") và không bao giờ quyết định câu trả lời có nghĩa gì; quyết định rằng trùng SKU là lỗi là việc của service.

Mọi dependency đều chỉ xuống: controller biết service, service biết repository, và không layer nào biết layer phía trên nó. Thứ đi qua mỗi ranh giới thì khác nhau. JSON trở thành DTO ở rìa web và không đi xa hơn; bên dưới controller chỉ còn domain object như Product và những giá trị đơn giản như id hay số lượng. Giá trị trả về và exception đi ngược lên theo call stack, nhưng đó là luồng điều khiển chứ không phải dependency: ProductService ném DuplicateSkuException mà không cần biết có ai sẽ đổi nó thành 409.
| Layer | Trách nhiệm | Được phép dùng | Không được chứa |
|---|---|---|---|
| Controller | Bind và validate request DTO, gọi service, map kết quả thành response DTO, đặt status và header | Service (của feature mình và của feature khác), DTO và mapper của nó | Business rule, storage, Map chứa dữ liệu, xử lý transaction |
| Service | Thực hiện use case và giữ business rule; sau này là ranh giới transaction | Repository của feature mình, service của feature khác, domain object và domain exception | ResponseEntity, HttpServletRequest, HttpStatus, request hay response DTO, SQL |
| Repository | Nạp và lưu domain object | Domain object, công nghệ storage | Business rule, type của HTTP, lời gọi tới service |
DTO được map ở layer nào?
Bài 18 giới thiệu CreateProductRequest, ProductResponse và một mapper, rồi để ngỏ chuyện layer nào dùng chúng. Câu trả lời của bài này: controller map; service nhận và trả về domain object. ProductService.create nhận một Product và trả về một Product, còn ProductMapper nằm cạnh controller như một phần của web layer. Có ba lý do, xếp theo mức độ quan trọng:
- Service có những caller không có DTO.
CatalogSeederlà một; job import định kỳ và message listener là những caller khác. NếucreatenhậnCreateProductRequest, mỗi caller đó phải dựng một object của HTTP request, kèm cả annotation validation, chỉ để thêm một sản phẩm. - Hình dạng response là quyết định của API. Phiên bản thứ hai của API có thể trả cùng một
Productvới các field khác. Chỉ web layer biết phiên bản nào đang được gọi, nên chỉ web layer chọn được cách map. - Dependency vẫn chỉ xuống. DTO thuộc web layer. Một service trả về
ProductResponselà service dùng tới type của layer phía trên, và mọi thay đổi trên hợp đồng JSON trở thành thay đổi ở business layer.
Điều này cũng làm thay đổi ProductService có @Validated của bài 19. Bài 19 validate một CreateProductRequest ngay trong service, làm lưới an toàn cho những luồng import không bao giờ đi qua @Valid @RequestBody. Khi service nhận Product, việc validate theo hình dạng request diễn ra ở từng điểm vào: @Valid trong controller, và Validator được inject trong importer, như ProductImporter của bài 19 đã làm. Service giữ những rule dựa trên trạng thái hiện tại của dữ liệu, như SKU không trùng hay số stock còn lại.
Trường hợp duy nhất làm rule này cong đi là một thao tác đọc gom dữ liệu từ nhiều nơi, như một order kèm tên sản phẩm. Khi đó service có thể trả về một read model dựng riêng cho mục đích đó, một record thuộc từ vựng của service, và controller vẫn map nó thành response DTO.
Refactor catalogue thành controller, service và repository
Việc refactor dưới đây đi qua bốn bước, tất cả trong com.example.demo.product cộng thêm một package mới là com.example.demo.order. Kết quả cuối cùng được so với before.txt ở cuối phần.
Bước 1: đưa storage ra sau một repository interface
Map và bộ đếm chuyển sang một class riêng, đứng sau một interface. save gán id cho sản phẩm chưa có id, nên không caller nào phải đụng tới bộ đếm:
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;
}
}Interface đó chính là chỗ nối từ bài 5: Chương 4 thêm một implementation JPA cho ProductRepository, và không thứ gì đang dùng interface phải sửa.
Bước 2: chuyển business rule vào service
Rule SKU không trùng chuyển vào ProductService, cùng với rule về stock mà feature order cần. Không method nào nhắc tới 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");
}
}Cả hai rule đều kiểm tra rồi mới lưu trên một map, nên hai request đồng thời có thể cùng qua được bước kiểm tra. Unique constraint và transaction lấp khe hở đó sẽ tới cùng database ở Chương 4, và transaction được đặt đúng trên các method service này.
Bước 3: controller chỉ còn lo HTTP
Controller mất map, bộ đếm, dữ liệu seed và rule, rồi nhận 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;
}
}Mỗi endpoint method giờ chỉ còn hai ba dòng: map vào, gọi service, map ra. fromCurrentRequest() vẫn ở lại, vì dựng header Location đúng là việc của controller. Seeder từng làm crash application giờ gọi service, và truyền domain object thay vì 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));
}
}Bước 4: feature order đi qua ProductService
Feature thứ hai đặt hàng cho một customer, trên các URL mà chương này đã thiết kế ở bài 15: POST /api/customers/{customerId}/orders trả 201 kèm Location: /api/orders/{orderId}, và GET /api/orders/{id} đọc một order. Bài này không có feature customer; customer id chỉ đơn giản được lưu trên order. Domain object, repository và exception của nó theo đúng khuôn của product:
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");
}
}Service là nơi hai feature gặp nhau. OrderService dùng ProductService, không dùng ProductRepository: rule về stock nằm ở một chỗ, và order chạm tới nó theo cùng một cách như mọi caller khác.
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));
}
}Phía web của order, với response record tự map qua một static factory; order chỉ có một hình dạng response, nên một mapper class riêng chẳng thêm được gì:
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() bắt đầu từ gốc của application thay vì từ URL của request, vì order mới nằm dưới /api/orders chứ không nằm dưới đường dẫn customer mà nó được POST vào. Advice biết thêm hai exception mới:
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);
}
}Chứng minh hành vi không đổi
Chạy lại cùng script với application đã refactor, rồi diff với file đã lưu từ controller cũ:
./api-check.sh > after.txt
diff before.txt after.txtdiff không in gì và thoát với status 0: cả sáu response giống từng byte với những gì controller cũ trả về, kể cả hai sản phẩm seed giờ đi qua CatalogSeeder và ProductService thay vì constructor, header Location, và các body 404, 409, 422.
Các endpoint của order, vốn trước đây chưa có. Sản phẩm 2 bắt đầu với 3 cái trong kho:
#!/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+jsonOrder đầu tiên đưa con chuột từ 3 xuống 1, order thứ hai xin 2 cái và nhận 409 từ rule trong ProductService, còn sản phẩm không tồn tại là 404 do feature product ném ra trên URL của order.
Những rule mà test của controller không với tới giờ chạy mà không cần request nào. Toàn bộ fixture là 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() PASSEDTest từng layer trong slice riêng của nó, với @WebMvcTest cho controller và mock cho collaborator, là chủ đề của Chương 6.
Service trong Spring nên là interface hay class cụ thể?
Rất nhiều code Spring cho mỗi service một interface và đúng một implementation, ProductService cộng ProductServiceImpl. Cách còn lại là ProductService dạng class cụ thể như ở trên. Lý do thường được đưa ra cho interface là test, proxy và khả năng thay thế, và trên Spring Boot 4.1.1 hai lý do đầu không còn cần tới interface.
Mock. OrderService dùng ProductService dạng class cụ thể. Unit test của OrderService vẫn thay được nó bằng mock, dùng Mockito mà spring-boot-starter-webmvc-test kéo vào, resolve ra 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() PASSEDTest pass, và class của mock chính là ProductService chứ không phải một subclass được sinh ra: Mockito 5 mặc định dùng inline mock maker, instrument thẳng class đó, nên không cần interface cũng không cần subclass. Cảnh báo kia là về cách mock maker đó gắn vào JDK, và cấu hình nó là chuyện thiết lập test của Chương 6.
Proxy. Các tính năng như transaction và method validation bọc bean trong một proxy. Configuration metadata của Spring Boot ghi spring.aop.proxy-target-class với giá trị mặc định true, nghĩa là proxy CGLIB dựa trên subclass, và proxy dạng subclass không cần interface.
Interface + ProductServiceImpl | ProductService dạng class cụ thể | |
|---|---|---|
| Số file mỗi service | Hai, mọi signature của method public viết hai lần | Một |
| Đi từ caller tới code | "Go to definition" dừng ở interface | Tới thẳng code đang chạy |
| Mock trong unit test | Được | Được: Mockito 5.23.0 mock thẳng class |
| Proxy cho transaction và method validation | Được | Được: proxy CGLIB là mặc định của Boot |
| Implementation thứ hai | Đã có sẵn chỗ | Tách interface khi nó thật sự xuất hiện, một thao tác refactor trong IDE |
| Đặt tên | Impl đặt tên class theo việc nó implement một thứ khác | Class được đặt tên theo việc nó làm |
Khuyến nghị: để service là class cụ thể, và cho repository một interface. Khác biệt nằm ở chỗ implementation thứ hai của repository không phải giả định: Chương 4 thêm bản JPA, và bản in-memory vẫn hữu ích sau đó. Service đáng có interface khi thật sự có nhiều implementation được chọn lúc runtime, như mỗi nhà cung cấp thanh toán một bản, hoặc khi nó là hợp đồng công khai của một module mà các team khác dựa vào.
Các anti-pattern của kiến trúc phân lớp
Mỗi anti-pattern dưới đây là một lối tắt tiết kiệm được vài dòng lúc viết, rồi tốn thêm công mỗi lần code thay đổi sau đó.
Controller gọi thẳng repository
@PostMapping("/import")
public ProductResponse importProduct(@RequestBody CreateProductRequest request) {
return mapper.toResponse(repository.save(mapper.toProduct(request)));
}Đây là cách thứ hai để tạo sản phẩm, một cách bỏ qua bước kiểm tra SKU không trùng. Mọi rule trong service giờ phải được thực thi lại trên mọi đường vòng qua nó, và người tiếp theo thêm rule sẽ không biết endpoint này tồn tại. Quy tắc "controller gọi service" rẻ khi tuân theo và khó kiểm soát một khi đã có trường hợp đặc biệt.
Service trả về ResponseEntity hoặc ném HTTP exception
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 khi đó sẽ nhận một ResponseEntity từ một job khởi động, còn job import định kỳ phải catch ResponseStatusException mới biết một SKU đã bị dùng. Service giờ có dependency vào web module của Spring, và "SKU trùng là 409" bị quyết định trong business code thay vì ở rìa web. Hãy ném DuplicateSkuException và để advice chọn status.
Service chỉ chuyển tiếp lời gọi
public List<Product> findAll() {
return repository.findAll();
}Một method chuyển tiếp như thế này, nằm trong một service có rule thật, là ổn. Nó giữ cho quy tắc controller chỉ nói chuyện với service không có trường hợp đặc biệt, và khi có rule mới, chẳng hạn ẩn các sản phẩm đã ngừng bán, rule đó có sẵn một chỗ hiển nhiên để vào. Một service chuyển tiếp, nơi method nào cũng chỉ ủy quyền, là tín hiệu khác: nó thường có nghĩa là business rule đang nằm ở chỗ khác, điển hình là trong controller. Hãy xem các if nằm ở đâu trước khi xóa layer đó.
Domain object lộ ra API
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return service.findById(id);
}Trả về Product khiến domain record trở thành hợp đồng JSON, với những hậu quả bài 18 đã mô tả. Xét theo layer, mỗi field thêm vào domain đều đổi API của mọi client, và web layer không còn định hình được response theo từng endpoint hay từng phiên bản.
Service tạo dependency vòng với nhau
Giả sử ProductService có thêm rule "sản phẩm còn order đang mở thì không được xóa", và inject OrderService để kiểm tra. OrderService vốn đã inject ProductService. Với constructor injection, đó là vòng lặp ở bài 7: application dừng lúc khởi động với thông báo "The dependencies of some of the beans in the application context form a cycle". Cách sửa là một hướng đi, không phải một cấu hình. Order dùng product; feature product không được biết order tồn tại. Hãy đặt phần kiểm tra ở phía được phép biết cả hai, như một use case trong feature order, hoặc trong một class thứ ba dùng cả hai service. Tách feature bằng event là chủ đề của khóa Advanced.
Tổ chức package theo layer hay theo feature
Tới giờ mọi class đều vào product, order hoặc common. Đó là một trong hai cách tổ chức phổ biến. Cách còn lại gom mọi controller vào một package, mọi service vào package khác, và cứ thế. Cả hai đều là layered architecture: layer nằm ở các class và hướng của dependency, không nằm ở tên thư mục. Component scanning tìm được cả hai cách, miễn là mọi package nằm dưới com.example.demo, package của class @SpringBootApplication (bài 6).
Cùng một application, tổ chức theo 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.javaĐây là application đã refactor, chỉ đổi dòng package và import. Nó khởi động và trả lời cả hai script kiểm tra với output giống hệt cách tổ chức theo feature. Class nào được package khác dùng thì phải public, và trong cách tổ chức này gần như là tất cả.
Cùng application đó, tổ chức theo 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.javaVẫn 21 class đó cộng DemoApplication: tám package ở cây thứ nhất, ba ở cây thứ hai.
Một thay đổi trên cả hai cách tổ chức: thêm field brand
Tranh luận về cách xếp thư mục trên lý thuyết thì dễ, nên đây là một thay đổi thật làm trên cả hai: sản phẩm có thêm brand, bắt buộc khi tạo và được trả về trong mọi response. Cả hai project được commit trước, rồi sửa y hệt nhau. Trong Product, thay đổi là một record component cộng hai method with:
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 thêm @NotBlank String brand, ProductResponse thêm String brand, ProductMapper truyền nó qua cả hai chiều, và CatalogSeeder gán brand cho hai sản phẩm seed. Repository, service, feature order và advice không đổi. Git đếm kết quả:
git diff --statVới cách tổ chức theo layer:
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(-)Với cách tổ chức theo feature:
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(-)Cả hai bản build trả lời ba request giống hệt nhau:
#!/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
Cùng năm file, cùng chín dòng thêm và tám dòng xóa, nằm trong bốn package khi tổ chức theo layer và một khi theo feature. Tổ chức theo feature không làm thay đổi nhỏ đi; nó đặt mọi thứ mà thay đổi đụng tới vào một thư mục. Phép đo thứ hai là chính feature order, đếm từ src/main/java:
find . -name '*Order*.java' | sed 's|/[^/]*$||' | sort | uniq -cTheo 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/serviceTheo feature:
8 ./com/example/demo/orderTám file trong sáu package, hay tám file trong một. Review feature order, giao nó cho team khác, hay xóa nó là một thư mục ở cách thứ hai, và là một cuộc tìm kiếm qua sáu package ở cách thứ nhất.
Class package-private vẫn được inject
Lợi thế lớn hơn của cách tổ chức theo feature là visibility. Khi controller, service và repository của nó chung một package, phần lớn chúng không cần public nữa. Trong cách tổ chức theo feature, thứ gì không package nào khác dùng thì bỏ modifier public:
| Class | Visibility | Lý do |
|---|---|---|
Product, ProductService | public | Feature order dùng chúng |
ProductNotFoundException, DuplicateSkuException, InsufficientStockException, OrderNotFoundException | public | Advice trong common xử lý chúng |
ProductController, ProductMapper, CreateProductRequest, ProductResponse, CatalogSeeder | package-private | Chỉ product dùng |
ProductRepository, InMemoryProductRepository | package-private | Chỉ ProductService dùng |
OrderController, OrderService, Order, OrderRepository, InMemoryOrderRepository, PlaceOrderRequest, OrderResponse | package-private | Không gì bên ngoài order dùng |
Các constructor mà container gọi cũng thành package-private. Hai trong số các diff:
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;
}Component scanning có còn tìm thấy class package-private không, và container có gọi được constructor package-private không? Một runner tạm, đặt trong package lab riêng để lúc compile nó không nhìn thấy bất kỳ type nào ở trên, tra bean theo tên và in ra những gì reflection cho biết:
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-privateMọi bean đều được tìm thấy, dựng lên và inject. InMemoryProductRepository không có constructor trong source, và default constructor mà compiler sinh ra mang đúng access package-private của class. API cũng không bị ảnh hưởng: api-check.sh và order-check.sh chạy với bản build này khớp chính xác output trước đó, nghĩa là Spring MVC gọi được controller package-private, Jackson đọc và ghi được record package-private, và validation vẫn trả 422.
Compiler chặn feature order khỏi phần bên trong của product
Giờ tới phần đáng giá. Một người làm feature order thấy trừ stock thẳng qua repository thì đơn giản hơn là đi qua 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));
}
}Bản đó bỏ hẳn bước kiểm tra stock. Và nó không 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 errorsỞ cách tổ chức theo layer, đúng thay đổi đó compile bình thường, vì ProductRepository phải public để service với tới repository. Theo feature, public trở thành một tuyên bố có chủ ý về những gì feature cung cấp cho phần còn lại của application: ở đây là Product, ProductService và các exception. Dù vậy, package-private không bảo vệ một feature khỏi chính nó: ProductController vẫn gọi thẳng được ProductRepository, vì chúng chung package.
Giữ ranh giới vượt ra ngoài package-private
Hai công cụ đi xa hơn. ArchUnit là thư viện viết rule kiến trúc dưới dạng unit test bình thường, như "class trong ..controller.. không được truy cập class trong ..repository..", để build fail khi code vi phạm một rule. Spring Modulith coi mỗi sub-package trực tiếp của package chính là một application module, và kiểm tra rằng các module chỉ chạm vào nhau qua package cấp cao nhất của chúng và không tạo vòng. Cả hai được nói trong khóa Advanced.
Cách tổ chức lai và code dùng chung
Sub-package theo layer bên trong một feature lớn
Một feature bốn mươi class rất khó đọc khi là một thư mục phẳng, và bước tự nhiên tiếp theo là các sub-package theo layer bên trong 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.javaCái giá là visibility. Với Java, com.example.demo.product.persistence là một package khác hẳn com.example.demo.product; giữa chúng không có quyền truy cập cha con nào. Chuyển hai class repository vào persistence và vẫn để 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 errorsMuốn compile được, ProductRepository phải thành public, và từ đó compiler cũng không còn chặn order dùng nó nữa. Chỉ tách feature thành sub-package khi một thư mục không còn đọc nổi, không sớm hơn, và chấp nhận rằng ranh giới phải được giữ bằng thứ gì đó khác ngoài public.
Code dùng chung, DTO và mapper đặt ở đâu?
commonchứa code không thuộc feature nào:@RestControllerAdvicetoàn cục và các configuration class. Giữ nó nhỏ và không có business logic. Đây là câu trả lời cho câu hỏi bài 20 để ngỏ:GlobalExceptionHandlervàocommonchứ không nằm ở root package, nhờ vậycom.example.demochỉ cònDemoApplication, và mọi class khác hoặc thuộc một feature, hoặc được chia sẻ một cách tường minh. Ở đây advice import exception của các feature, và đó là lý do duy nhất khiến chúng phảipublic. Khi danh sách exception dài ra, feature có thể ném subclass của vài exception gốc định nghĩa trongcommon, đểcommonthôi import class của feature.- DTO record nằm trong package của feature, cạnh controller đọc và ghi chúng, và là package-private khi không ai khác dùng.
- Mapper đi cùng DTO, vì map là việc của web layer. Điều đó đúng cho một class viết tay như ở đây lẫn interface MapStruct của bài 18.
Cách tổ chức series này dùng từ Chương 4
Từ Chương 4 trở đi, project mẫu được tổ chức package theo feature: com.example.demo.product, com.example.demo.order, và com.example.demo.common cho code dùng chung, với class package-private ở mọi chỗ không có gì bên ngoài feature cần tới. Thứ đầu tiên Chương 4 thêm vào là một implementation JPA của ProductRepository, đặt cạnh bản in-memory.
FAQ
Kiến trúc phân lớp có giống tổ chức package theo layer không?
Không. Layered architecture nói về trách nhiệm và hướng của dependency: controller xử lý HTTP, service giữ rule, repository lo storage, và dependency chỉ xuống. Tổ chức package theo layer chỉ là một cách xếp các class đó vào thư mục. Cách tổ chức theo feature trong bài này vẫn phân layer y như vậy; các layer nằm cạnh nhau trong từng package của feature thay vì trải ra các package theo layer.
Controller hay service nên map DTO?
Controller, với một mapper nằm trong web layer. Service nên nhận và trả về domain object, vì nó có những caller không có DTO, như job khởi động, job import định kỳ và message listener, và vì hình dạng response là quyết định của API mà chỉ web layer đưa ra được. Service trả về response DTO là service dùng tới layer phía trên nó.
Có phải service nào trong Spring cũng cần interface?
Không. Mockito 5.23.0, được test starter của Spring Boot 4.1.1 kéo vào, mock thẳng một class cụ thể, và Spring Boot mặc định tạo proxy CGLIB dựa trên class, nên transaction và method validation hoạt động trên class cụ thể. Hãy cho service một interface khi nó thật sự có nhiều implementation hoặc là hợp đồng công khai của một module. Repository thì khác: nó thường có implementation thứ hai thật, như bản in-memory và bản JPA.
Spring có inject được class hoặc constructor package-private không?
Có. Component scanning đăng ký các class @Component, @Service, @Repository và @RestController package-private, và container gọi constructor package-private của chúng. Lần chạy ở trên in class package-private constructor package-private cho bảy bean, và API trả lời y như trước. Yêu cầu duy nhất vẫn là yêu cầu quen thuộc: package phải nằm dưới package của class @SpringBootApplication.
Controller có được gọi thẳng repository cho các thao tác đọc đơn giản không?
Chạy được, và một số team cho phép với những thao tác đọc không có rule. Cái giá là "controller nói chuyện với service" thôi là một quy tắc không có trường hợp đặc biệt, nên mỗi rule mới phải được đối chiếu với mọi endpoint đi vòng qua service. Một method chuyển tiếp một dòng trong service rẻ hơn việc đối chiếu đó. Còn thao tác ghi thì luôn phải đi qua service.
Khi nào một feature package nên có sub-package?
Khi một thư mục phẳng đã khó đọc, thường là lúc một feature có nhiều controller hoặc nhiều loại class. Với compiler, sub-package là một package riêng, nên class nào các sub-package khác dùng thì phải thành public, và compiler thôi giữ ranh giới của feature. Chừng nào feature chưa lớn tới mức đó, một package với các class package-private bảo vệ bạn tốt hơn.
Kết luận
Một controller giữ dữ liệu, rule và phần xử lý HTTP vẫn chạy tốt cho tới khi code khác cần rule của nó. Một job khởi động gọi nó làm application dừng với No current ServletRequestAttributes, một unit test thường fail đúng như vậy, và chuyển sang database sẽ phải viết lại class. Tách thành ba layer thì controller map HTTP sang DTO và ngược lại, service giữ use case và ném domain exception, còn repository chỉ lưu trữ. Dependency chỉ xuống, DTO dừng ở rìa web, và application sau refactor trả về từng byte giống những response mà controller cũ đã trả. Service có thể là class cụ thể, vì Mockito 5.23.0 mock được chúng và Boot mặc định proxy theo class. Repository có interface, vì bản JPA đang tới.
Package là một quyết định riêng. Thêm brand sửa cùng năm file ở cả hai cách tổ chức, rải trên bốn package khi theo layer và một khi theo feature. Feature order là tám file trong sáu package, hay tám file trong một. Tổ chức theo feature còn cho phép phần lớn class là package-private: Spring vẫn inject chúng, và compiler từ chối một class của order thò tay vào ProductRepository. Series này tổ chức package theo feature từ Chương 4 trở đi.
Bài tiếp theo viết tài liệu cho API này: tài liệu API với springdoc-openapi — Swagger UI và mô tả endpoint để client dùng được mà không cần đọc code.