<? phpukraine СПІВБЕСІДИ
Пошук по платформі
АРХІТЕКТУРА · JUNIOR ЧАСТО ПИТАЮТЬ

Де в MVC живе бізнес-логіка і чому не в контролері?

Бізнес-логіка живе в шарі, який нічого не знає про HTTP: у моделі домену або в use case (сервісі), який модель оркеструє. Контролеру лишаються переклад запиту в дані, виклик одного use case і вибір відповіді, бо контролер прив'язаний до транспорту і тому непридатний для повторного використання й дорогий у тестуванні.

У нас метод контролера на 300 рядків, і його неможливо покрити тестами. Куди його розкладати?
Що таке fat model, thin controller і чи це досі актуально?
Ту саму дію треба викликати з HTTP-контролера, з artisan-команди і з обробника черги. Де має лежати код?
Чим сервіс відрізняється від моделі і навіщо взагалі потрібен окремий клас, якщо є `Order::create()`?
MVC контролер сервіси use case шари

Слово «модель» у цьому питанні означає не те, що показує IDE. У початковому MVC моделлю називали весь прикладний шар: дані, поведінку, правила предметної області. Фреймворки звузили термін до базового класу для рядка таблиці, і звідси береться розгубленість junior-розробника: у моделі є $fillable і зв'язки, більше туди нічого не влазить, отже логіку пишемо в контролері. Питання насправді стоїть інакше, ніж «модель чи контролер»: який шар нічого не знає про HTTP.

Аргумент проти контролера цілком прикладний. Метод контролера отримує Request, повертає Response і викликається роутером, тобто тримається на трьох прив'язках до транспорту. Щойно всередині опиняється сценарій, ви не можете виконати його з php artisan, з обробника черги, з іншого контролера чи з тесту без підробленого запиту. Далі приходить друга точка входу (адмінка, вебхук платіжки, імпорт), і логіка копіюється. Через півроку правило змінюється в одній копії з трьох. Тестованість страждає з тієї ж причини: щоб перевірити правило «повернення не більше суми платежу», доводиться піднімати роут, сесію, авторизацію і базу, замість того щоб створити об'єкт і викликати метод.

Куди саме класти винесене, залежить від характеру логіки. Правила, які стосуються однієї сутності і її власних даних, належать самій сутності: guardCanRefund(), isOverdue(), total(), перехід статусу. Такі методи не мають залежностей, читаються поруч із даними, якими оперують, і не потребують мокання. Зі сценарієм інакше: він зачіпає кілька сутностей, відкриває транзакцію, ходить у платіжний шлюз, публікує подію. Назвати це «поведінкою замовлення» вже не вийде, перед вами use case «повернути кошти за замовленням», і йому місце в окремому класі з однією публічною точкою входу. Такий клас приймає DTO або value object, а не Request, і повертає доменний результат, а не JsonResponse. Різниця між Laravel і Symfony тут відчутна: Eloquent-модель за Active Record уже суміщає домен і персистенцію, тому кожен новий обов'язок робить її важчою, а Doctrine-сутність про сховище не знає і тримає більше поведінки без шкоди.

Контролеру після цього лишається чотири короткі кроки: узяти провалідовані дані, перевірити доступ політикою чи вотером, викликати один use case, перетворити результат або доменний виняток у відповідь. П'ятнадцять рядків, у яких немає нічого, що захотілося б перевикористати. Зверніть увагу на деталь у прикладі: DB::transaction живе в use case, а не в контролері. Інакше кожна нова точка входу зобов'язана пам'ятати про транзакцію, і рано чи пізно хтось забуде.

Небезпека протилежного берега реальна, і junior заходить туди швидше, ніж у товстий контролер. UpdateTagService, що робить один $tag->update(), нічого не ізолює, зате додає файл, конструктор і рівень непрямості. Виносьте, коли є привід: більш ніж одна сутність, транзакція, зовнішній ефект, правила, які хочеться протестувати окремо, або друга точка входу. OrderService із сорока методами провалюється з іншого боку: у нього немає зв'язності, його всі бояться редагувати, а конструктор тягне пів контейнера. Один клас на сценарій із __invoke знімає обидві проблеми майже безкоштовно, а на здоровому проєкті таких класів десяток на сотню контролерів, і це нормальна пропорція.

// Було: сценарій живе в контролері і прив'язаний до HTTP
public function store(Request $request, Order $order)
{
    $data = $request->validate(['amount' => 'required|integer|min:1']);
    if ($order->status !== 'paid') {
        return back()->withErrors('Замовлення не оплачене');
    }
    // ...транзакція, виклик шлюзу, лист - усе тут же
}

// Стало: use case - одна точка входу, нічого не знає про запит
final readonly class RefundOrder
{
    public function __construct(
        private PaymentGateway $gateway,
        private DatabaseManager $db,
    ) {}

    /** @throws RefundNotAllowed */
    public function __invoke(OrderId $orderId, Money $amount): Refund
    {
        return $this->db->transaction(function () use ($orderId, $amount) {
            $order = Order::lockForUpdate()->findOrFail($orderId->value);

            // Інваріант домену: рішення ухвалює сама сутність
            $order->guardCanRefund($amount);

            $refund = $this->gateway->refund($order->charge_id, $amount);
            $order->registerRefund($refund);
            $order->save();

            return $refund;
        });
    }
}

// Контролер: дані на вхід, use case, відповідь на вихід
public function store(RefundRequest $request, Order $order, RefundOrder $refundOrder)
{
    $this->authorize('refund', $order);

    $refund = $refundOrder(new OrderId($order->id), Money::fromMinor($request->integer('amount')));

    return new RefundResource($refund); // виняток RefundNotAllowed мапиться на 409 у Handler
}
Що аргумент проти логіки в контролері технічний: контролер прив'язаний до Request/Response, тому той самий сценарій не викликати з CLI, черги чи іншого контролера без емуляції HTTP.
Що кандидат розрізняє логіку однієї сутності (правила стану, обчислення над власними полями) і логіку сценарію (кілька сутностей, транзакція, зовнішні сервіси, події) і кладе їх у різні місця.
Що use case має одну публічну точку входу, приймає DTO або примітиви, а не `Request`, і повертає доменний результат, а не `JsonResponse` чи redirect.
Що модель у Laravel і Symfony - це різні речі: Eloquent-модель одночасно домен і персистенція, Doctrine-сутність від бази відв'язана, тому «товста модель» коштує по-різному.
Що кандидат називає, що саме лишається контролеру, і не викидає його в порожній прошарок заради шару.
Вважати, що модель у MVC - це клас, який успадковує `Model`. У класичному MVC модель - увесь шар домену, а не одна таблиця.
Передавати `Illuminate\Http\Request` у сервіс: HTTP просочується на рівень нижче, і сервіс уже не викликати з `artisan` без підробленого запиту.
Розкладати всю логіку у валідацію Form Request: там перевіряють форму вхідних даних, а не інваріанти домену на кшталт «не можна відвантажити скасоване замовлення».
Робити `OrderService` на 40 методів як звалище всього, що не влізло в контролер: клас без зв'язності, який усі бояться чіпати.
Створювати сервіс, що робить один виклик `$model->save()`, і називати це архітектурою.
Класти запуск транзакції в контролер, а не в use case: тоді кожен новий виклик сценарію мусить пам'ятати про `DB::transaction`.
ПОРАДА

Дайте перевірку одним реченням: візьміть свій метод контролера і спробуйте викликати ту саму дію з artisan-команди. Усе, що при цьому доводиться підробляти (`Request`, сесію, редирект), і є логікою, яку треба винести. Далі поділіть винесене на дві купки: правила однієї сутності йдуть у модель, сценарій із кількома сутностями і транзакцією - в окремий клас з одним `__invoke`.

оновлено 7 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Причина практична, а не декларативна: логіка, зашита в метод контролера, доступна рівно з одного транспорту. Контролери тестуються нормально (feature-тестом через HTTP), і на швидкість їх створення ніхто не скаржиться - проблема саме в тому, що сценарій не перевикористати і не протестувати без емуляції запиту.