Слоеве
Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.
Проблем
Отстъпката на касата и в отчета
Магазинът дава 10% отстъпка на количка от 100 € нагоре. Касата смята отстъпката в контролера, който обработва заявката. Отчетът за счетоводството е SQL заявка към същата таблица и има свое условие в WHERE:
// Касата: контролерът чете количката и сам смята отстъпкатаclass CheckoutController(Dictionary<string, int> carts){ public string Post(string customer) { int total = carts[customer]; if (total >= 100) total = total - total / 10; return $"{customer} плаща {total} €"; }}
// Отчетът е SQL заявка към същата таблица:// SELECT customer FROM carts WHERE subtotal > 100// Тук таблицата е речник, а WHERE е if.static class DiscountReport{ public static List<string> Discounted(Dictionary<string, int> carts) { var names = new List<string>(); foreach (var (customer, subtotal) in carts) if (subtotal > 100) names.Add(customer); return names; }}var carts = new Dictionary<string, int>{ ["Мария"] = 100, ["Иван"] = 150, ["Петя"] = 80,};var checkout = new CheckoutController(carts);foreach (var customer in carts.Keys) Console.WriteLine("Касата: " + checkout.Post(customer));var names = DiscountReport.Discounted(carts);Console.WriteLine("Отчетът: с отстъпка са " + string.Join(", ", names));Касата: Мария плаща 90 €Касата: Иван плаща 135 €Касата: Петя плаща 80 €Отчетът: с отстъпка са ИванМария плаща 90 €, а отчетът казва, че няма отстъпка. В касата е >= 100, в заявката е > 100. Счетоводството вижда 10 € разлика и никой не знае коя версия е вярната.
Има и втора болка. Правилото е залепено за контролера: за да провериш дали >= 100 е вярно, ти трябват заявка, количка в таблица и отговор, който да прочетеш. Мобилното приложение ще иска трета версия.
Идеята
Четири слоя и една посока
В урока Repository видя контролер, услуга и хранилище един под друг. Тук ги подреждаме като цяло приложение и им даваме правило. Слоестата архитектура (layered architecture) дели кода на четири слоя:
- представяне: контролерът. Чете заявката и показва отговора. Не смята;
- приложение: услугата. Знае стъпките на една задача („вземи количката, сметни цената“), но не и правилата;
- домейн: правилата на магазина.
Pricing.Totalзнае „от 100 € нагоре е 10%“ и нищо друго: нито заявки, нито таблици; - данни: хранилищата. Откъде идва количката.
Правилото на слоевете е едно: зависимостите сочат надолу. Горният слой вика долния, а долният не знае, че горният съществува. Затова домейнът може да се ползва от касата, от отчета и от мобилното приложение, без да се пипа.
Слоят не е непременно клас. В голям проект слоят е пакет или проект, в който стоят много класове.
Виж го
Заявката през слоевете
Четирите слоя на касата, отгоре надолу:
// Представяне: заявката влиза, отговорът излизаclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} плаща {total} €"; }}
// Приложение: стъпките на една задачаclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Домейн: правилото на магазина и нищо другоstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface ICartRepository{ int SubtotalOf(string customer);}Хранилището в паметта и отчетът, който сега пита домейна, вместо да пише свое условие:
class InMemoryCarts : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) => subtotals.GetValueOrDefault(customer);}// Приложение: отчетът пита домейна, не пише свое правилоclass DiscountReport(ICartRepository carts){ public List<string> Discounted(IEnumerable<string> customers) { var names = new List<string>(); foreach (var customer in customers) { int subtotal = carts.SubtotalOf(customer); if (Pricing.Total(subtotal) < subtotal) names.Add(customer); } return names; }}Сглобяването: касата и отчетът над едни и същи данни.
var carts = new InMemoryCarts();carts.Put("Мария", 100);carts.Put("Иван", 150);carts.Put("Петя", 80);string[] customers = ["Мария", "Иван", "Петя"];
var web = new CheckoutController(new CheckoutService(carts));foreach (var customer in customers) Console.WriteLine(web.Post(customer));var report = new DiscountReport(carts);var names = report.Discounted(customers);Console.WriteLine("С отстъпка: " + string.Join(", ", names));Мария плаща 90 €Иван плаща 135 €Петя плаща 80 €С отстъпка: Мария, Иван// Представяне: заявката влиза, отговорът излизаclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} плаща {total} €"; }}
// Приложение: стъпките на една задачаclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Домейн: правилото на магазина и нищо другоstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface ICartRepository{ int SubtotalOf(string customer);}// Представяне: заявката влиза, отговорът излизаclass CheckoutController(CheckoutService service){ public string Post(string customer) { int total = service.Checkout(customer); return $"{customer} плаща {total} €"; }}
// Приложение: стъпките на една задачаclass CheckoutService(ICartRepository carts){ public int Checkout(string customer) { int subtotal = carts.SubtotalOf(customer); return Pricing.Total(subtotal); }}
// Домейн: правилото на магазина и нищо другоstatic class Pricing{ public static int Total(int subtotal) { if (subtotal < 100) return subtotal; return subtotal - subtotal / 10; }}
// Данни: откъде идва количкатаinterface ICartRepository{ int SubtotalOf(string customer);}class InMemoryCarts : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) { int sum = subtotals.GetValueOrDefault(customer); // отчетът искаше сумата след отстъпката return Pricing.Total(sum); }}Стъпка по стъпка, с думи
- Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
- Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
- Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
- Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
- После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
- Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
- Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
- Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
- Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.
Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.
Пробвай
Правилото без нищо около него
Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с xUnit.
Какво печата програмата?
foreach (int subtotal in new[] { 99, 100, 101 }) Console.WriteLine($"{subtotal} € → {Pricing.Total(subtotal)} €");Границата е включена: 100 € вече дава отстъпка. От 101 € се сваля 10 €, защото делението е цяло и 101 / 10 е 10. Точно тези граници (99, 100, 101) са първият тест на всяко правило за пари.
Пробвай
Намери грешката
Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:
class InMemoryCarts : ICartRepository{ readonly Dictionary<string, int> subtotals = [];
public void Put(string customer, int subtotal) => subtotals[customer] = subtotal;
public int SubtotalOf(string customer) { int sum = subtotals.GetValueOrDefault(customer); // отчетът искаше сумата след отстъпката return Pricing.Total(sum); }}Касата и отчетът са същите, без нито един променен ред:
var carts = new InMemoryCarts();carts.Put("Мария", 100);carts.Put("Иван", 150);carts.Put("Петя", 80);string[] customers = ["Мария", "Иван", "Петя"];
var web = new CheckoutController(new CheckoutService(carts));foreach (var customer in customers) Console.WriteLine(web.Post(customer));var report = new DiscountReport(carts);var names = report.Discounted(customers);Console.WriteLine("С отстъпка: " + string.Join(", ", names));Мария плаща 90 €Иван плаща 122 €Петя плаща 80 €С отстъпка: ИванИван плаща 122 € вместо 135 €, а Мария пак липсва в отчета. На кой ред е грешката?
Избери реда с грешката.
Слоят с данните вика домейна: стрелка нагоре. Хранилището вече знае правилото, и то се прилага два пъти: 150 € → 135 € в хранилището, после 135 € → 122 € в услугата. Отчетът пита „намалява ли се 90 €?“ и казва „не“ за Мария. Това е същият проблем като WHERE в SQL заявката от началото: правило, което живее в данните. Хранилището трябва да връща само това, което пази. Сумата след отстъпката е работа на услугата, която пита Pricing.
Идеята
По техника или по функция
Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.
Shop/ Web/ CheckoutController.cs CatalogController.cs Services/ CheckoutService.cs CatalogService.cs Domain/ Pricing.cs Product.cs Data/ ICartRepository.cs IProductRepository.csВторият е по функция (vertical slice): една папка за всяка част от магазина, а слоевете са вътре в нея.
Shop/ Checkout/ CheckoutController.cs CheckoutService.cs Pricing.cs ICartRepository.cs Catalog/ CatalogController.cs CatalogService.cs Product.cs IProductRepository.csПо техника е лесно за начало, но всяка промяна в плащането пипа четири папки, а папката 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) или в decimal, никога в 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) и печата всяко извикване нагоре.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Тестове с xUnit · Singleton и Dependency Injection · Repository · SOLID без догма
- Продължава в: Hexagonal и clean architecture · Монолит, модулен монолит, услуги · Кеш · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури