Problem: 300 sətirlik controller
Junior developerdən "sifariş vermə" funksiyasını əlavə etmək xahiş olunur. Ən qısa yol hər şeyi controller-də yazmaqdır: request-i oxu, stoku yoxla, cəmi hesabla, bazaya yaz, email göndər, JSON qur. İşləyir. İki həftə sonra ikinci endpoint eyni cəm hesablamasına ehtiyac duyur, kimsə kodu kopyalayır, bug düzəlişi isə kopyalardan yalnız birinə düşür. Hesablamanı test etmək üçün web layer və baza qaldırmaq lazım gəlir.
Layering bunun ucuz çarəsidir. Bu ritual deyil: hər layer fərqli səbəbdən dəyişdiyi və fərqli üsulla test edildiyi üçün mövcuddur.
Üç layer və onların məsuliyyəti
HTTP request
|
v
+-------------+ giriş/çıxış DTO-ları, status kodları, formanın yoxlanması
| Controller |
+-------------+
|
v
+-------------+ biznes qaydaları, transaction, koordinasiya
| Service |
+-------------+
|
v
+-------------+ persistence: sorğular, entity-lər
| Repository |
+-------------+
|
v
DatabaseController HTTP adapteridir. Xarici dünya ilə kodunuz arasında tərcümə edir: request DTO qəbul edir, validasiyanı işə salır, bir service metodunu çağırır və nəticəni düzgün status kodu ilə response DTO-ya çevirir. İçində biznes qərarı və baza girişi olmamalıdır.
Service use case-ləri və biznes qaydalarını saxlayır: "sifarişdə ən azı bir sətir olmalıdır", "stokdan çox sifariş vermək olmaz", "cəm sətirlərin cəmi minus endirimdir". Transaction sərhədinin harada olduğunu o müəyyən edir, repository-ləri və digər əməkdaşları koordinasiya edir.
Repository persistence adapteridir. Spring Data JPA ilə bu, adətən sorğuları elan edən interfeysdir. Entity-ləri necə yükləyib saxlamağı bilir, HTTP və ya biznes siyasəti haqqında heç nə bilmir.
Kiçik Order nümunəsi
Request və response tipləri sadə record-lardır. Onlar bazanı yox, HTTP müqaviləsini təsvir edir.
public record PlaceOrderRequest( @NotNull Long customerId, @NotEmpty List<OrderLineRequest> lines) {}public record OrderLineRequest(@NotNull Long productId, @Positive int quantity) {}public record OrderResponse(Long id, BigDecimal total, String status) {}Controller nazik qalır:
@RestController@RequestMapping("/orders")class OrderController { private final OrderService orderService; OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request) { OrderResponse created = orderService.place(request); return ResponseEntity.created(URI.create("/orders/" + created.id())).body(created); }}Service qaydaları və transaction-ı saxlayır:
@Serviceclass OrderService { private final OrderRepository orders; private final ProductRepository products; OrderService(OrderRepository orders, ProductRepository products) { this.orders = orders; this.products = products; } @Transactional OrderResponse place(PlaceOrderRequest request) { Order order = Order.newFor(request.customerId()); for (OrderLineRequest line : request.lines()) { Product product = products.findById(line.productId()) .orElseThrow(() -> new ProductNotFoundException(line.productId())); order.addLine(product, line.quantity()); // qayda: stok yoxlaması burada } Order saved = orders.save(order); return new OrderResponse(saved.getId(), saved.total(), saved.getStatus().name()); }}İki şeyə diqqət edin. "Stoku aşmaq olmaz" qaydası domen obyektinin addLine metodunun içindədir və service onu çağırır, buna görə sətir əlavə edən hər digər use case eyni qorunmanı alır. Həmçinin Order entity-si service-dən kənara çıxmır: controller OrderResponse alır.
DTO və entity niyə ayrı saxlanılır
JPA entity-ni birbaşa qaytarmaq cazibədardır. Bir class qənaət edir, amma problemlər yaradır:
- Təsadüfi ifşa. Daxili qeyd və ya parol hash-i kimi sütun əlavə etsəniz, kimsə gizlətməyi yadına salmasa JSON-da görünəcək.
- Müqavilənin sxemə bağlanması. Sütunun adını dəyişmək və ya əlaqəni dəyişmək API client-lərini sındırır.
- Lazy loading sürprizləri. Entity-ni serializasiya etmək sorğuları işə sala və ya transaction-dan kənarda exception ata bilər.
- Giriş riskləri. Entity-ni request body kimi qəbul etmək client-ə
idvə yastatuskimi heç vaxt idarə etməməli olduğu sahələri təyin etməyə imkan verir.
DTO ilə entity arasında kiçik mapping addımı bir neçə sətirə başa gəlir və hər iki tərəfi sərbəst dəyişməyə imkan verir. Record-lar DTO-ları qısa edir, çevirməni isə sadə metod və ya mapping kitabxanası görür.
Biznes qaydaları harada yaşayır
Faydalı test: eyni use case-i işə salan CLI və ya message listener əlavə etsəm, hansı qaydaları kopyalamalı olacağam? Kopyalayacağınız hər şey controller-ə yox, service-ə və ya domen modelinə aiddir. Yalnız request formasından asılı olan yoxlamalar (boş olmayan list, müsbət miqdar) kənarda Bean Validation annotasiyaları kimi yaxşıdır. Data və ya vəziyyət tələb edən qaydalar (stok səviyyəsi, müştəri statusu) service-də olmalıdır.
Qaydaları repository-yə də qoymayın. findActiveOrdersOlderThanThirtyDays adlı repository metodu sorğudur, siyasət deyil. "Otuz gündən köhnə sifarişlər arxivlənir" siyasəti həmin sorğudan istifadə edən service qərarıdır.
Layer üzrə, yoxsa feature üzrə package
İki geniş yayılmış quruluş:
layer üzrə feature üzrə
controller/ order/
OrderController OrderController
CustomerController OrderService
service/ OrderRepository
OrderService Order
CustomerService customer/
repository/ CustomerController
OrderRepository ...Package-by-layer əvvəlcə izah etmək üçün asandır, amma bir dəyişiklik üç package-ə toxunur və digər package-lər görsün deyə bütün class-lar public olmalıdır. Package-by-feature birlikdə dəyişənləri bir yerdə saxlayır və əksər class-ları package-private etməyə imkan verir, beləliklə public səth kiçik qalır. Layihə böyüdükcə package-by-feature daha yaxşı miqyaslanır və modular monolitə təbii addımdır. Çox kiçik servis üçün hər ikisi işləyir; birini seçin və ardıcıl olun.
Tez-tez edilən səhvlər
- "Sadəcə find-dır" deyərək repository-ni controller-dən birbaşa çağırmaq.
@Transactional-ı controller-lərə qoymaq. Transaction service-dəki use case-in ətrafında olmalıdır.- Service-in entity qaytarması və controller-də lazy sahələrə toxunmaq.
- Hər metodu sadəcə repository-yə yönləndirən service. Sadə CRUD üçün bu bəzən dürüstdür, amma bütün qaydalar controller-də qalırsa, layer öz məqsədini itirib.
- Əksi: otuz metodlu "god service". Böyüdükdə use case və ya feature üzrə bölün.
- Create, update və read üçün ortaq DTO. Hər birinin məcburi sahələri fərqlidir, buna görə ayrı record-lar daha aydındır.
Trade-off-lar
Layer-lər əlavə class və mapping kodu gətirir. Birdəfəlik prototip və ya sırf CRUD admin ekranı üçün bu mərasim özünü doğrultmaya bilər. Qaydalar, bir neçə giriş nöqtəsi olan və ya üzərində bir neçə nəfərin işlədiyi kod bazasında isə ayrılıq test oluna bilənliklə özünü doğruldur: service-ləri sadə unit test və mock repository ilə, controller-ləri web slice test ilə, repository-ləri real bazaya qarşı test etmək olar.
Hər layer necə test olunur
Layering-in əsl faydası testlərdə görünür. Service qaydalarını Spring qaldırmadan, repository-ni mock edərək yoxlamaq olar:
@Testvoid rejectsLineAboveStock() { Product product = new Product(1L, "Keyboard", 2); // stok: 2 Order order = Order.newFor(10L); assertThatThrownBy(() -> order.addLine(product, 3)) .isInstanceOf(InsufficientStockException.class);}Bu test millisaniyələrlə işləyir, çünki web server, baza və ya konteyner yoxdur. Controller üçün @WebMvcTest HTTP formasını (status kodu, JSON, validasiya) yoxlayır; repository üçün @DataJpaTest və ya real baza ilə test sorğuları yoxlayır. Hər testin bir layer-ə diqqət yetirməsi pozulduqda səbəbi tapmağı asanlaşdırır. Bütün qaydalar controller-dədirsə, onları yoxlamağın yeganə yolu bütün stack-i qaldırmaqdır.
Layer-ləri ayırarkən bir qaydanı da yadda saxlayın: asılılıq yalnız aşağıya doğru gedir. Service controller-i, repository isə service-i tanımır. Bu yoxlanılmasa, tədricən dövrəvi asılılıqlar yaranır və layer-lər adda qalır.
Konfiqurasiya və inyeksiya haqqında da bir qeyd: asılılıqları constructor ilə alın və sahələri final edin. Bu, service-in hansı əməkdaşlara ehtiyac duyduğunu aydın göstərir, testdə obyekti Spring olmadan yaratmağa imkan verir və constructor-da yeddi-səkkiz parametr yığıldıqda class-ın çox iş gördüyünü xəbər verir. Belə siqnal service-i bölmək üçün yaxşı səbəbdir.
Yoxlama siyahısı
- Controller-lər yalnız HTTP-ni service çağırışına və geriyə çevirir.
- Service-lər biznes qaydalarına və transaction sərhədlərinə sahibdir.
- Repository-lər yalnız sorğuları saxlayır.
- Entity-lər request və ya response body kimi istifadə olunmur.
- Request DTO-ları
@Validilə yoxlanılır. - Package-lər birlikdə dəyişənlər bir yerdə olacaq şəkildə qurulub.
Sonra nə oxumalı
- Yanlış girişi faydalı cavaba çevirin: validasiya və xəta idarəsi.
@Transactional-ın əslində nə etdiyini anlayın: Spring transaction tələləri.- Hər layer-i necə test edəcəyinizi seçin: backend test strategiyası.
- Bu yolun hara apardığını junior Java developer roadmap-ində görün.