Слоеве
Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.
Проблем
Отстъпката на касата и в отчета
Магазинът дава 10% отстъпка на количка от 100 € нагоре. Касата смята отстъпката в контролера, който обработва заявката. Отчетът за счетоводството е SQL заявка към същата таблица и има свое условие в WHERE:
// Касата: контролерът чете количката и сам смята отстъпкатаclass CheckoutController { private final Map<String, Integer> carts;
CheckoutController(Map<String, Integer> carts) { this.carts = carts; }
String post(String customer) { int total = carts.get(customer); if (total >= 100) total = total - total / 10; return "%s плаща %d €".formatted(customer, total); }}
// Отчетът е SQL заявка към същата таблица:// SELECT customer FROM carts WHERE subtotal > 100// Тук таблицата е речник, а WHERE е if.class DiscountReport { static List<String> discounted(Map<String, Integer> carts) { List<String> names = new ArrayList<>(); for (var row : carts.entrySet()) if (row.getValue() > 100) names.add(row.getKey()); return names; }}Map<String, Integer> carts = new LinkedHashMap<>();carts.put("Мария", 100);carts.put("Иван", 150);carts.put("Петя", 80);CheckoutController checkout = new CheckoutController(carts);for (String customer : carts.keySet()) System.out.println("Касата: " + checkout.post(customer));List<String> names = DiscountReport.discounted(carts);System.out.println("Отчетът: с отстъпка са " + String.join(", ", names));Касата: Мария плаща 90 €Касата: Иван плаща 135 €Касата: Петя плаща 80 €Отчетът: с отстъпка са ИванМария плаща 90 €, а отчетът казва, че няма отстъпка. В касата е >= 100, в заявката е > 100. Счетоводството вижда 10 € разлика и никой не знае коя версия е вярната.
Има и втора болка. Правилото е залепено за контролера: за да провериш дали >= 100 е вярно, ти трябват заявка, количка в таблица и отговор, който да прочетеш. Мобилното приложение ще иска трета версия.
Идеята
Четири слоя и една посока
В урока Repository видя контролер, услуга и хранилище един под друг. Тук ги подреждаме като цяло приложение и им даваме правило. Слоестата архитектура (layered architecture) дели кода на четири слоя:
- представяне: контролерът. Чете заявката и показва отговора. Не смята;
- приложение: услугата. Знае стъпките на една задача („вземи количката, сметни цената“), но не и правилата;
- домейн: правилата на магазина.
Pricing.totalзнае „от 100 € нагоре е 10%“ и нищо друго: нито заявки, нито таблици; - данни: хранилищата. Откъде идва количката.
Правилото на слоевете е едно: зависимостите сочат надолу. Горният слой вика долния, а долният не знае, че горният съществува. Затова домейнът може да се ползва от касата, от отчета и от мобилното приложение, без да се пипа.
Слоят не е непременно клас. В голям проект слоят е пакет или проект, в който стоят много класове.
Виж го
Заявката през слоевете
Четирите слоя на касата, отгоре надолу:
// Представяне: заявката влиза, отговорът излизаclass CheckoutController { private final CheckoutService service;
CheckoutController(CheckoutService service) { this.service = service; }
String post(String customer) { int total = service.checkout(customer); return "%s плаща %d €".formatted(customer, total); }}
// Приложение: стъпките на една задачаclass CheckoutService { private final CartRepository carts;
CheckoutService(CartRepository carts) { this.carts = carts; }
int checkout(String customer) { int subtotal = carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоclass Pricing { static int total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface CartRepository { int subtotalOf(String customer);}Хранилището в паметта и отчетът, който сега пита домейна, вместо да пише свое условие:
class InMemoryCarts implements CartRepository { private final Map<String, Integer> subtotals = new HashMap<>();
void put(String customer, int subtotal) { subtotals.put(customer, subtotal); }
public int subtotalOf(String customer) { return subtotals.getOrDefault(customer, 0); }}// Приложение: отчетът пита домейна, не пише свое правилоclass DiscountReport { private final CartRepository carts;
DiscountReport(CartRepository carts) { this.carts = carts; }
List<String> discounted(List<String> customers) { List<String> names = new ArrayList<>(); for (String customer : customers) { int subtotal = carts.subtotalOf(customer); if (Pricing.total(subtotal) < subtotal) names.add(customer); } return names; }}Сглобяването: касата и отчетът над едни и същи данни.
InMemoryCarts carts = new InMemoryCarts();carts.put("Мария", 100);carts.put("Иван", 150);carts.put("Петя", 80);List<String> customers = List.of("Мария", "Иван", "Петя");
var web = new CheckoutController(new CheckoutService(carts));for (String customer : customers) System.out.println(web.post(customer));var report = new DiscountReport(carts);List<String> names = report.discounted(customers);System.out.println("С отстъпка: " + String.join(", ", names));Мария плаща 90 €Иван плаща 135 €Петя плаща 80 €С отстъпка: Мария, Иван// Представяне: заявката влиза, отговорът излизаclass CheckoutController { private final CheckoutService service;
CheckoutController(CheckoutService service) { this.service = service; }
String post(String customer) { int total = service.checkout(customer); return "%s плаща %d €".formatted(customer, total); }}
// Приложение: стъпките на една задачаclass CheckoutService { private final CartRepository carts;
CheckoutService(CartRepository carts) { this.carts = carts; }
int checkout(String customer) { int subtotal = carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоclass Pricing { static int total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface CartRepository { int subtotalOf(String customer);}// Представяне: заявката влиза, отговорът излизаclass CheckoutController { private final CheckoutService service;
CheckoutController(CheckoutService service) { this.service = service; }
String post(String customer) { int total = service.checkout(customer); return "%s плаща %d €".formatted(customer, total); }}
// Приложение: стъпките на една задачаclass CheckoutService { private final CartRepository carts;
CheckoutService(CartRepository carts) { this.carts = carts; }
int checkout(String customer) { int subtotal = carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоclass Pricing { static int total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface CartRepository { int subtotalOf(String customer);}class InMemoryCarts implements CartRepository { private final Map<String, Integer> subtotals = new HashMap<>();
void put(String customer, int subtotal) { subtotals.put(customer, subtotal); }
public int subtotalOf(String customer) { int sum = subtotals.getOrDefault(customer, 0); // отчетът искаше сумата след отстъпката return Pricing.total(sum); }}Стъпка по стъпка, с думи
- Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
- Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
- Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
- Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
- После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
- Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
- Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
- Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
- Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.
Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.
Пробвай
Правилото без нищо около него
Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с JUnit.
Какво печата програмата?
for (int subtotal : new int[] { 99, 100, 101 }) System.out.println("%d € → %d €".formatted(subtotal, Pricing.total(subtotal)));Границата е включена: 100 € вече дава отстъпка. От 101 € се сваля 10 €, защото делението е цяло и 101 / 10 е 10. Точно тези граници (99, 100, 101) са първият тест на всяко правило за пари.
Пробвай
Намери грешката
Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:
class InMemoryCarts implements CartRepository { private final Map<String, Integer> subtotals = new HashMap<>();
void put(String customer, int subtotal) { subtotals.put(customer, subtotal); }
public int subtotalOf(String customer) { int sum = subtotals.getOrDefault(customer, 0); // отчетът искаше сумата след отстъпката return Pricing.total(sum); }}Касата и отчетът са същите, без нито един променен ред:
InMemoryCarts carts = new InMemoryCarts();carts.put("Мария", 100);carts.put("Иван", 150);carts.put("Петя", 80);List<String> customers = List.of("Мария", "Иван", "Петя");
var web = new CheckoutController(new CheckoutService(carts));for (String customer : customers) System.out.println(web.post(customer));var report = new DiscountReport(carts);List<String> names = report.discounted(customers);System.out.println("С отстъпка: " + String.join(", ", names));Мария плаща 90 €Иван плаща 122 €Петя плаща 80 €С отстъпка: ИванИван плаща 122 € вместо 135 €, а Мария пак липсва в отчета. На кой ред е грешката?
Избери реда с грешката.
Слоят с данните вика домейна: стрелка нагоре. Хранилището вече знае правилото, и то се прилага два пъти: 150 € → 135 € в хранилището, после 135 € → 122 € в услугата. Отчетът пита „намалява ли се 90 €?“ и казва „не“ за Мария. Това е същият проблем като WHERE в SQL заявката от началото: правило, което живее в данните. Хранилището трябва да връща само това, което пази. Сумата след отстъпката е работа на услугата, която пита Pricing.
Идеята
По техника или по функция
Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.
shop/ web/ CheckoutController.java CatalogController.java service/ CheckoutService.java CatalogService.java domain/ Pricing.java Product.java data/ CartRepository.java ProductRepository.javaВторият е по функция (vertical slice): една папка за всяка част от магазина, а слоевете са вътре в нея.
shop/ checkout/ CheckoutController.java CheckoutService.java Pricing.java CartRepository.java catalog/ CatalogController.java CatalogService.java Product.java ProductRepository.javaПо техника е лесно за начало, но всяка промяна в плащането пипа четири папки, а папката service/ расте с всичко. По функция промяната е на едно място, и всяка папка може да има собствени правила кой отвън я вика. Това е стъпката към модулите в урока Монолит, модулен монолит, услуги. Правилото за посоката важи и в двата случая.
Идеята
Кога слоевете вредят
Слоевете струват файлове и извиквания. Не си струват, когато:
- няма правила. Форма, която само записва в базата, минава през контролер, услуга и домейн, които само препращат. Това се казва sinkhole: заявката пада през слоевете, без никой да я пипне;
- всеки слой има свой обект за същите данни:
CartDto,CartModel,CartEntity, и превръщането между тях е половината код; - всичко е подредено по техника и проектът е голям: папката
service/има 200 файла, които не са свързани помежду си.
Струват си, когато има правила, които трябва да се пазят на едно място и да се проверяват без база, и когато едно и също правило се вика от няколко места: каса, отчет, мобилно приложение.
По-точно, за любопитните
Строги и свободни слоеве. При строгите слоеве всеки слой вика само съседа си. При свободните (както в урока) може да прескочи надолу: услугата вика хранилището направо. Повечето проекти са свободни. Строгото правило дава повече препращане и рядко помага.
В рамките. Spring слага слоевете в анотации: @Controller, @Service, @Repository. ASP.NET Core има контролери, а услугите и хранилищата се регистрират в DI контейнера. Django е подреден по функция: всяко „app“ има свои views.py и models.py. Nest (Node) групира контролер и услуги в модул на функцията, а Express не налага нищо. Никоя рамка не пази посоката сама: @Repository, който вика @Service, се компилира.
Пазене на посоката. Посоката може да се провери автоматично: ArchUnit в Java, ArchUnitNET или NetArchTest в C#, import-linter в Python и dependency-cruiser в JavaScript падат, ако пакет от данните внася пакет от домейна. Малка проверка от този вид за модулите пишеш в урока Монолит, модулен монолит, услуги.
Парите. В урока сумите са цели евро, за да е изходът еднакъв на четирите езика. В истински магазин парите са в центове (long) или в BigDecimal, никога в double.
Пред какво още. В урока Hexagonal и clean architecture посоката се обръща докрай: домейнът не зависи дори от хранилището.
Упражнение
Доставката в домейна
Реши задачата в редактора си.
Доставката е 5 € за поръчка под 50 € и безплатна от 50 € нагоре (сумата е след отстъпката). Сложи правилото в домейна, в Delivery.fee(total). Нов контролер показва цената и доставката, а услугата и хранилището не се пипат. Накрая провери правилото само, без контролер, за 49 € и 50 €.
Мария плаща 90 € + доставка 0 €Петя плаща 80 € + доставка 0 €Георги плаща 30 € + доставка 5 €49 € → доставка 5 €50 € → доставка 0 €Още задачи
- Мобилното приложение. Добави втори контролер, който връща същата сума като JSON текст
{"customer":"Мария","total":90}. Колко реда от правилото пипаш? - Отстъпка по клиент. Клиентите с карта за лоялност получават 15% от 100 €. Къде влиза картата: в хранилището, в домейна или в контролера? Защо?
- Проверка на посоката. Напиши функция, която получава списък „кой слой кого вика“ (
data → domain,application → data) и печата всяко извикване нагоре.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Тестове с JUnit · Singleton и Dependency Injection · Repository · SOLID без догма
- Продължава в: Hexagonal и clean architecture · Монолит, модулен монолит, услуги · Кеш · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури