Към съдържанието
wannacode

Слоеве

Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.

Проблем

Отстъпката на касата и в отчета

Магазинът дава 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);
}
Стъпка по стъпка, с думи
  1. Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
  2. Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
  3. Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
  4. Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
  5. После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
  6. Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
  7. Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
  8. Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
  9. Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.

Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.

Пробвай

Правилото без нищо около него

Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с JUnit.

Какво печата програмата?

for (int subtotal : new int[] { 99, 100, 101 })
System.out.println("%d € → %d €".formatted(subtotal, Pricing.total(subtotal)));
Отговор

Пробвай

Намери грешката

Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:

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 €, а Мария пак липсва в отчета. На кой ред е грешката?

Избери реда с грешката.

Идеята

По техника или по функция

Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.

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) и печата всяко извикване нагоре.

Тест

Провери се

1. В кой слой живее правилото „от 100 € нагоре е 10% отстъпка“?
2. Услугата вика хранилището направо, без да минава през домейна. Нарушение ли е?
3. Хранилището започва да вика Pricing, „за да дава сумата след отстъпката“. Какво става?
4. Вътрешен инструмент има три форми, които само записват в базата, без нито едно правило. Струват ли си четири слоя?
5. Подредбата е по функция (checkout/, catalog/). Какво печелиш спрямо подредбата по техника (web/, service/, data/)?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·