У матеріалі про момент, коли звичайного CRUD уже недостатньо я писала про момент, коли простого create/update/delete вже недостатньо, бо навколо даних з’являються правила.
Після цього майже одразу виникає практичне питання: де саме ці правила мають жити?
На початку найпростіше написати перевірку в контролері. Потім контролер стає завеликим, і правило переїжджає в сервіс. Але якщо те саме правило потрібне з HTTP, команди, черги або іншого сценарію, стає видно, що проблема не в місці виклику.
Проблема в тому, що це правило належить самій предметній області.
Спочатку правило майже завжди зручно написати в контролері
public function activate( Product $product,): RedirectResponse { if ($product->status !== 'draft') { abort(422); } if ( blank($product->code) || blank($product->name) ) { abort(422); } $product->update([ 'status' => 'active',У такому коді немає нічого дивного. Для першої версії він може бути цілком достатнім.
Проблема починається тоді, коли я дивлюся на умови і розумію: контролер уже вирішує не HTTP-питання.
Він знає, з якого стану можна активувати об’єкт і які дані для цього обов’язкові.
Це вже знання про предметну область.
Що я залишаю біля контролера
final class ActivateProductRequest extends FormRequest{ public function rules(): array { return [ 'reason' => [ 'nullable', 'string', 'max:500', ], ]; }}Перевірка формату вхідних даних не повинна автоматично ставати частиною доменної моделі.
Наприклад, чи поле є рядком, чи не перевищує воно допустиму довжину, чи правильно сформований запит - це відповідальність зовнішньої межі.
Так само контролер може перетворити HTTP-відповідь, зробити перенаправлення або повернути потрібний status code.
Я не переношу все в домен лише тому, що хочу "чистий контролер".
Наступний крок часто виглядає правильно: винести правило в сервіс
final class ActivateProductService{ public function handle( Product $product, ): void { if ($product->status !== 'draft') { throw new LogicException( 'Product cannot be activated.', ); } if ( blank($product->code) || blank($product->name) ) { throw new LogicException(Цей варіант уже кращий за великий контролер: сценарій винесений у окремий клас, його легше тестувати і повторно використовувати.
Але я ставлю наступне питання: що саме робить цей сервіс?
Якщо майже весь його код відповідає на питання "чи дозволено об’єкту перейти в цей стан?", це вже не просто координація сценарію.
Сервіс фактично володіє правилом, яке повинно залишатися однаковим незалежно від способу виклику.
Повторне використання швидко показує, що правило знаходиться не там
HTTP controller | vactivate product Console command | vactivate product Queue job | vactivate productСаме тут у мене найчастіше змінюється погляд на код.
Поки є один контролер, правило легко сприймати як частину сценарію.
Коли з’являється другий спосіб виконати ту саму дію, стає небезпечно копіювати умови або сподіватися, що всі виклики завжди пройдуть через один конкретний сервіс.
Якщо правило описує допустимий стан самої сутності, найнадійніше, коли сутність не дозволяє його обійти.
Якщо правило захищає стан сутності, я переношу його в модель предметної області
final class Product{ public function activate(): void { if ($this->status !== ProductStatus::DRAFT) { throw new DomainException( 'Product cannot be activated from this state.', ); } if (! $this->isComplete()) { throw new DomainException( 'Product is not ready for activation.', ); } Тепер зовнішній код не знає деталей правила.
Він не питає status, не перевіряє набір полів і не встановлює нове значення напряму.
Він просить модель виконати зрозумілу дію: activate().
Якщо стан недопустимий, модель відмовляє однаково для HTTP, команди, черги або тесту.
Для мене це головний критерій: чи є правило інваріантом
Invariant: An active productmust be complete. It must remain trueafter every valid operation. Not only afterone controller action.Якщо правило має бути правдивим після будь-якої допустимої операції, це сильний сигнал, що воно належить предметній області.
Наприклад, "активний продукт повинен бути повним" не є правилом конкретної форми або конкретного маршруту.
Це властивість допустимого стану системи.
Такі правила я намагаюся ставити якомога ближче до моделі, стан якої вони захищають.
Тоді що залишається прикладному сервісу?
final class ActivateProduct{ public function handle( ProductId $productId, ): void { DB::transaction(function () use ( $productId, ) { $product = $this->products ->getForUpdate($productId); $product->activate(); $this->products->save( $product, );Прикладний сервіс для мене залишається дуже корисним.
Він може відкрити транзакцію, завантажити aggregate, викликати дію, зберегти результат, передати керування іншим інфраструктурним частинам.
Але він не повинен повторно пояснювати, за яких умов activate() є допустимим.
Його завдання - організувати сценарій. Завдання моделі - захистити правило.
Слово "сервіс" саме по собі нічого не пояснює
Application service coordinates a use case:- load- transaction- call domain- save Domain service contains a domain operationthat does not naturally belongto one entity or value objectКоли я бачу клас із назвою SomethingService, я більше не вважаю це відповіддю на питання "де живе правило".
Спочатку потрібно зрозуміти, який це сервіс.
Прикладний сервіс координує сценарій. Доменний сервіс може містити правило предметної області, якщо воно природно не належить одному об’єкту.
Тому "винести в сервіс" саме по собі не є архітектурним рішенням.
Не кожне бізнес-правило належить одній сутності
final class ActivationEligibility{ public function canActivate( Product $product, Policy $policy, ): bool { return $product->isReady() && $policy->allowsActivation(); }}Я не намагаюся будь-якою ціною запхнути кожне правило всередину entity.
Якщо правило використовує кілька доменних понять і жодне з них природно не є власником рішення, окремий доменний сервіс може бути чеснішим варіантом.
Але він все одно повинен описувати правило предметної області, а не HTTP, SQL або чергу.
Перевірка доступу і бізнес-правило теж не завжди одне й те саме
Authorization asks: "May this userperform this action?" Domain rule asks: "Is this action validfor the modelin its current state?"Наприклад, адміністратор може мати право викликати активацію, але сам продукт ще не готовий до активації.
Перша перевірка стосується повноважень користувача. Друга - допустимого стану предметної області.
Я намагаюся не змішувати ці причини відмови в одному великому if.
Критичне правило іноді варто підсилити ще й у базі
Модель предметної області є основним місцем, де я хочу бачити зміст правила.
Але якщо частину інваріанта можна надійно підсилити технічним обмеженням, я не відмовляюся від цього.
Про цей другий рівень захисту я окремо писала в матеріалі про обмеження бази даних як останній захисний шар.
Код пояснює правило. База може не дозволити залишити явно недопустимий стан навіть при помилці в коді.
Aggregate допомагає зрозуміти, хто саме має захищати правило
Якщо я не розумію, куди перенести правило, часто проблема глибша: неясно, хто володіє відповідним станом.
Тут допомагає питання з статті про aggregate і межу узгодженості: який aggregate повинен гарантувати цей інваріант?
Коли власник стану зрозумілий, місце правила зазвичай теж стає очевиднішим.
Тести добре показують, чи правило справді доменне
public function test_incomplete_product_cannot_be_activated(): void{ $product = Product::draft( code: null, name: 'Example', ); $this->expectException( DomainException::class, ); $product->activate();}Якщо правило можна природно сформулювати і перевірити без маршруту, запиту та бази, це часто хороший знак.
Тест читається як бізнес-речення: неповний продукт не можна активувати.
Саме так я хочу бачити важливі правила - окремо від способу доставки команди до системи.
Моя проста схема вибору місця для правила
Question 1 Is it about request shape,format, HTTP, or response? -> Controller / Request layer Question 2 Is it coordinating a use case,transaction, repository,or infrastructure? -> Application service Помилки, які я тепер помічаю швидше
Кілька сигналів для мене:
- Одна й та сама умова повторюється в кількох контролерах.
- Сервіс майже повністю складається з перевірок стану однієї сутності.
- Код може напряму змінити status і обійти метод із перевіркою.
- Тест бізнес-правила потребує HTTP-запиту, хоча саме правило не має стосунку до HTTP.
- Назва сервісу дуже загальна, бо клас одночасно координує сценарій і вирішує доменні правила.
- Один великий
ifзмішує права користувача, формат запиту і допустимий стан моделі.
У Center ця межа стала видимою не одразу
У роботі над Center я не завжди одразу бачила, що певна перевірка вже стала інваріантом.
Часто це ставало очевидно лише після другого або третього сценарію, який потребував тієї самої поведінки.
Це добре пов’язано з матеріалі про межі домену в системі, що росте: предметна область поступово показує не тільки свої межі, а й власників правил.
Тому я не вважаю перенесення правила з сервісу в модель ознакою "помилки першої версії". Часто це нормальний результат того, що ми дізналися про систему більше.
Питання, які я ставлю перед тим, як залишити правило в сервісі
Я перевіряю:
- Це правило про HTTP чи про допустимий стан предметної області?
- Чи має воно працювати однаково з кількох точок входу?
- Чи можна обійти правило прямим update моделі?
- Чи одна сутність природно володіє цим рішенням?
- Чи сервіс координує сценарій, чи сам вирішує, що бізнесово дозволено?
- Чи правило можна перевірити простим доменним тестом без HTTP?
- Чи правило є інваріантом, який повинен залишатися правдивим після будь-якої допустимої операції?
Я ставлю правило туди, де його найважче випадково обійти
Для мене відповідь на питання "Controller, Service чи Domain Model?" тепер починається не з типу класу.
Я спочатку визначаю, що це за правило.
Якщо воно стосується форми запиту, воно залишається на зовнішній межі. Якщо це координація сценарію, нею займається прикладний сервіс. Якщо правило визначає допустимий стан моделі незалежно від способу виклику, воно належить предметній області.
Чим важливіший інваріант, тим ближче він має бути до стану, який захищає.
Коли правило має працювати однаково з контролера, команди і черги, це вже сильний сигнал, що воно належить не способу виклику, а самій предметній області.



