Слоеве
Накратко: Слоестата архитектура дели приложението на представяне, приложение, домейн и данни, а зависимостите сочат само надолу, затова бизнес правилото живее на едно място.
Проблем
Отстъпката на касата и в отчета
Магазинът дава 10% отстъпка на количка от 100 € нагоре. Касата смята отстъпката в контролера, който обработва заявката. Отчетът за счетоводството е SQL заявка към същата таблица и има свое условие в WHERE:
// Касата: контролерът чете количката и сам смята отстъпкатаclass CheckoutController { constructor(carts) { this.carts = carts; }
post(customer) { let total = this.carts.get(customer); if (total >= 100) total = total - Math.floor(total / 10); return `${customer} плаща ${total} €`; }}
// Отчетът е SQL заявка към същата таблица:// SELECT customer FROM carts WHERE subtotal > 100// Тук таблицата е речник, а WHERE е if.function discounted(carts) { return [...carts].filter(([, subtotal]) => subtotal > 100).map(([customer]) => customer);}const carts = new Map([["Мария", 100], ["Иван", 150], ["Петя", 80]]);const checkout = new CheckoutController(carts);for (const customer of carts.keys()) console.log("Касата: " + checkout.post(customer));console.log("Отчетът: с отстъпка са " + discounted(carts).join(", "));Касата: Мария плаща 90 €Касата: Иван плаща 135 €Касата: Петя плаща 80 €Отчетът: с отстъпка са ИванМария плаща 90 €, а отчетът казва, че няма отстъпка. В касата е >= 100, в заявката е > 100. Счетоводството вижда 10 € разлика и никой не знае коя версия е вярната.
Има и втора болка. Правилото е залепено за контролера: за да провериш дали >= 100 е вярно, ти трябват заявка, количка в таблица и отговор, който да прочетеш. Мобилното приложение ще иска трета версия.
Идеята
Четири слоя и една посока
В урока Repository видя контролер, услуга и хранилище един под друг. Тук ги подреждаме като цяло приложение и им даваме правило. Слоестата архитектура (layered architecture) дели кода на четири слоя:
- представяне: контролерът. Чете заявката и показва отговора. Не смята;
- приложение: услугата. Знае стъпките на една задача („вземи количката, сметни цената“), но не и правилата;
- домейн: правилата на магазина.
Pricing.totalзнае „от 100 € нагоре е 10%“ и нищо друго: нито заявки, нито таблици; - данни: хранилищата. Откъде идва количката.
Правилото на слоевете е едно: зависимостите сочат надолу. Горният слой вика долния, а долният не знае, че горният съществува. Затова домейнът може да се ползва от касата, от отчета и от мобилното приложение, без да се пипа.
Слоят не е непременно клас. В голям проект слоят е пакет или проект, в който стоят много класове.
Виж го
Заявката през слоевете
Четирите слоя на касата, отгоре надолу:
// Представяне: заявката влиза, отговорът излизаclass CheckoutController { constructor(service) { this.service = service; }
post(customer) { const total = this.service.checkout(customer); return `${customer} плаща ${total} €`; }}
// Приложение: стъпките на една задачаclass CheckoutService { constructor(carts) { this.carts = carts; }
checkout(customer) { const subtotal = this.carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоconst Pricing = { total(subtotal) { if (subtotal < 100) return subtotal; return subtotal - Math.floor(subtotal / 10); },};
// Данни: хранилището на колички има subtotalOf(customer)subtotalOf(customer) става.Хранилището в паметта и отчетът, който сега пита домейна, вместо да пише свое условие:
class InMemoryCarts { #subtotals = new Map();
put(customer, subtotal) { this.#subtotals.set(customer, subtotal); }
subtotalOf(customer) { return this.#subtotals.get(customer) ?? 0; }}// Приложение: отчетът пита домейна, не пише свое правилоclass DiscountReport { constructor(carts) { this.carts = carts; }
discounted(customers) { return customers.filter((customer) => { const subtotal = this.carts.subtotalOf(customer); return Pricing.total(subtotal) < subtotal; }); }}Сглобяването: касата и отчетът над едни и същи данни.
const carts = new InMemoryCarts();carts.put("Мария", 100);carts.put("Иван", 150);carts.put("Петя", 80);const customers = ["Мария", "Иван", "Петя"];
const web = new CheckoutController(new CheckoutService(carts));for (const customer of customers) console.log(web.post(customer));const report = new DiscountReport(carts);console.log("С отстъпка: " + report.discounted(customers).join(", "));Мария плаща 90 €Иван плаща 135 €Петя плаща 80 €С отстъпка: Мария, Иван// Представяне: заявката влиза, отговорът излизаclass CheckoutController { constructor(service) { this.service = service; }
post(customer) { const total = this.service.checkout(customer); return `${customer} плаща ${total} €`; }}
// Приложение: стъпките на една задачаclass CheckoutService { constructor(carts) { this.carts = carts; }
checkout(customer) { const subtotal = this.carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоconst Pricing = { total(subtotal) { if (subtotal < 100) return subtotal; return subtotal - Math.floor(subtotal / 10); },};
// Данни: хранилището на колички има subtotalOf(customer)// Представяне: заявката влиза, отговорът излизаclass CheckoutController { constructor(service) { this.service = service; }
post(customer) { const total = this.service.checkout(customer); return `${customer} плаща ${total} €`; }}
// Приложение: стъпките на една задачаclass CheckoutService { constructor(carts) { this.carts = carts; }
checkout(customer) { const subtotal = this.carts.subtotalOf(customer); return Pricing.total(subtotal); }}
// Домейн: правилото на магазина и нищо другоconst Pricing = { total(subtotal) { if (subtotal < 100) return subtotal; return subtotal - Math.floor(subtotal / 10); },};
// Данни: хранилището на колички има subtotalOf(customer)class InMemoryCarts { #subtotals = new Map();
put(customer, subtotal) { this.#subtotals.set(customer, subtotal); }
subtotalOf(customer) { const subtotal = this.#subtotals.get(customer) ?? 0; // отчетът искаше сумата след отстъпката return Pricing.total(subtotal); }}Стъпка по стъпка, с думи
- Четири слоя: представяне, приложение, домейн, данни. Всеки вика само слоевете под себе си: стрелките сочат надолу.
- Заявката на касата за Мария влиза в контролера. Той само чете заявката и показва отговора.
- Контролерът дава задачата на слоя на приложението: услугата знае стъпките на плащането.
- Услугата пита слоя с данните за количката: 100 €. Хранилището дава данни и не държи правила.
- После пита домейна: Pricing знае правилото „от 100 € нагоре е 10% отстъпка“. 100 € става 90 €.
- Отговорът се качва до контролера и той печата реда. Отчетът пита същия Pricing.
- Сега някой кара хранилището да връща сумата след отстъпката. Слоят с данните вика Pricing: стрелка нагоре.
- Количката на Иван: хранилището дава 135 €, а услугата сваля 10% още веднъж. Плаща 122 € вместо 135 €.
- Надолу може, нагоре е бъгът: правилото пак изтече в данните, както в SQL заявката на отчета.
Мария плаща 90 € и е в отчета, защото и двете места питат едно и също правило. Услугата вика хранилището, без да минава през домейна: това е позволено, защото хранилището е под нея. Правилото е само за посоката.
Пробвай
Правилото без нищо около него
Домейнът не знае за контролери и хранилища, затова се проверява сам: без заявка, без количка и без база. Такъв е и тестът му, като в урока Тестове с node:test.
Какво печата програмата?
for (const subtotal of [99, 100, 101]) console.log(`${subtotal} € → ${Pricing.total(subtotal)} €`);Границата е включена: 100 € вече дава отстъпка. От 101 € се сваля 10 €, защото делението е цяло и 101 / 10 е 10. Точно тези граници (99, 100, 101) са първият тест на всяко правило за пари.
Пробвай
Намери грешката
Отчетът иска сумата след отстъпката. Някой решава, че е най-лесно хранилището да я дава готова:
class InMemoryCarts { #subtotals = new Map();
put(customer, subtotal) { this.#subtotals.set(customer, subtotal); }
subtotalOf(customer) { const subtotal = this.#subtotals.get(customer) ?? 0; // отчетът искаше сумата след отстъпката return Pricing.total(subtotal); }}Касата и отчетът са същите, без нито един променен ред:
const carts = new InMemoryCarts();carts.put("Мария", 100);carts.put("Иван", 150);carts.put("Петя", 80);const customers = ["Мария", "Иван", "Петя"];
const web = new CheckoutController(new CheckoutService(carts));for (const customer of customers) console.log(web.post(customer));const report = new DiscountReport(carts);console.log("С отстъпка: " + report.discounted(customers).join(", "));Мария плаща 90 €Иван плаща 122 €Петя плаща 80 €С отстъпка: ИванИван плаща 122 € вместо 135 €, а Мария пак липсва в отчета. На кой ред е грешката?
Избери реда с грешката.
Слоят с данните вика домейна: стрелка нагоре. Хранилището вече знае правилото, и то се прилага два пъти: 150 € → 135 € в хранилището, после 135 € → 122 € в услугата. Отчетът пита „намалява ли се 90 €?“ и казва „не“ за Мария. Това е същият проблем като WHERE в SQL заявката от началото: правило, което живее в данните. Хранилището трябва да връща само това, което пази. Сумата след отстъпката е работа на услугата, която пита Pricing.
Идеята
По техника или по функция
Слоевете казват кой кого вика. Отделен въпрос е как се подреждат файловете. Първият начин е по техника: една папка за всеки слой.
src/ web/ checkout-controller.js catalog-controller.js services/ checkout-service.js catalog-service.js domain/ pricing.js product.js data/ cart-repository.js product-repository.jsВторият е по функция (vertical slice): една папка за всяка част от магазина, а слоевете са вътре в нея.
src/ checkout/ controller.js service.js pricing.js cart-repository.js catalog/ controller.js service.js product.js product-repository.jsПо техника е лесно за начало, но всяка промяна в плащането пипа четири папки, а папката 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) или в цели центове (Number няма десетичен тип), никога в 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) и печата всяко извикване нагоре.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Тестове с node:test · Singleton и Dependency Injection · Repository · SOLID без догма
- Продължава в: Hexagonal и clean architecture · Монолит, модулен монолит, услуги · Кеш · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури