Різниця між модулем і сервісом лежить не в коді, а в межі процесу. Усередині моноліта звертання одного модуля до іншого — це виклик методу: він або відбувся, або кинув виняток, він синхронний, він швидкий і, найважливіше, він може бути в тій самій транзакції, що й локальний запис. Щойно та сама межа стає мережевою, зникають усі три властивості одразу. Зʼявляється третій стан «невідомо» (запит пішов, відповідь не повернулася, але операція могла виконатися), зʼявляється затримка, яка складається вздовж ланцюжка, і зникає спільний COMMIT. Тому питання «моноліт чи мікросервіси» — це не питання про чистоту архітектури: чисті межі, порти, окремі схеми даних і заборона крос-модульних залежностей цілком досяжні в одному деплої. Це питання про те, чи готові ви заплатити розподіленою узгодженістю за незалежність деплою й масштабування.
Звідси випливає й порядок дій. За замовчуванням береться модульний моноліт: один деплой, кілька модулів із вузьким публічним контрактом, у кожного свої таблиці, звертання одне до одного тільки через порт. Ключове тут — межі мають бути машинно перевіреними, інакше вони не витримають і кварталу. qossmic/deptrac описує шари й дозволені залежності у deptrac.yaml і завалює CI на порушенні; PHPArkitect і arch-тести Pest роблять те саме в термінах правил на неймспейси. Другий рівень захисту — дані: у Doctrine це окремі entity manager-и на модуль, у Laravel — окремі connection-и або принаймні тверда заборона на звʼязки Eloquent між модулями. Без цього перший же звіт із JOIN на чужі таблиці зшиває модулі назад, і «межа» лишається тільки в теці.
Сигнал до виділення сервісу завжди про різницю, а не про розмір. Різний профіль ресурсів: модуль рендерингу PDF або обробки відео тримає CPU й памʼять, і через нього доводиться масштабувати весь застосунок. Різний цикл релізу: команда готова релізити щодня, але стоїть у спільній черзі, і це вимірюється в днях затримки, а не у відчуттях. Інший рантайм: довгоживучий процес, WebSocket, інша версія PHP чи взагалі інша мова. Ізоляція ризику й даних: платіжна частина з окремим доступом і аудитом. Різна вимога до доступності: нестабільна зовнішня інтеграція не має класти основний трафік. «Кодова база велика» і «деплой довгий» у цей список не входять — обидва лікуються модулями, тестами й пайплайном, значно дешевше за мережу між класами.
Ціна розділення концентрується в одному місці: транзакція, що охоплювала два модулі, більше не існує. Двофазний коміт у типовому PHP-стеку не розглядають — практичної підтримки ні в PDO, ні в чергах немає. Замість нього — outbox: у тій самій транзакції, що й локальний запис, зберігається повідомлення, а окремий процес доставляє його далі. У Symfony Messenger цю роль грає доктрин-транспорт, який пише в messenger_messages тим самим зʼєднанням, а messenger:consume забирає; у Laravel — драйвер database разом із after_commit у config/queue.php або ->afterCommit() на джобі, щоб подія не поїхала раніше за COMMIT. Далі приймальна сторона має бути ідемпотентною за ключем повідомлення, а сценарій — мати компенсацію на випадок, коли другий крок не вдався. Якщо на етапі проєктування виявляється, що операція мусить бути атомарною, це найкраща з можливих новин: межу сервісу тут проводити не можна, і ви дізналися це до, а не після винесення.
Найдорожча помилка в цій темі — розподілений моноліт: сервіси є, а незалежності немає. Ознаки перевіряються за півгодини: чи можна зарелізити один сервіс без інших; чи ділять вони базу або міграції; чи ходять один в одного синхронним ланцюжком (три сервіси по 99.9% дають уже близько 99.7% на ланцюжку, і це помітно); чи піднімається локально один сервіс без усього стека. Якщо відповіді погані, правильний рух — не додавати сервісів, а зливати назад ті, що ділять дані й цикл релізу. Коли ж рішення ділити ухвалено свідомо, робиться це поступово, у стилі strangler fig: спершу єдиний порт на всі звертання, потім переведення того, що можна, на асинхронний обмін і відділення даних, потім версійований контракт із таймаутами, ретраями й ідемпотентними ключами, і лише в кінці — перенесення реалізації в окремий деплой, спершу на частині трафіку. Кожен крок має цінність сам по собі, і на будь-якому з них можна зупинитися, якщо виявиться, що причини для окремого процесу насправді не було.
// Публічний контракт модуля Billing: єдина точка входу для інших модулів.
// Решта класів Billing для них закрита — це перевіряє deptrac у CI.
interface Invoices
{
public function issue(IssueInvoice $command): InvoiceId;
}
// Сьогодні: виклик у тому самому процесі й у тій самій транзакції.
final readonly class LocalInvoices implements Invoices
{
public function __construct(private IssueInvoiceHandler $handler) {}
public function issue(IssueInvoice $command): InvoiceId
{
return ($this->handler)($command);
}
}
// Завтра, якщо Billing поїде окремим деплоєм, змінюється лише ця реалізація
// і біндинг у контейнері. Код модуля Hiring не чіпаємо взагалі.
final readonly class HttpInvoices implements Invoices
{
public function __construct(private PendingRequest $http) {}
public function issue(IssueInvoice $command): InvoiceId
{
// Мережа приносить те, чого не було в методі: таймаут, ретрай, ідемпотентність
$response = $this->http
->timeout(3) // краще швидка помилка, ніж зависла черга воркерів
->retry(2, 200) // ретрай безпечний лише завдяки ключу нижче
->withHeader('Idempotency-Key', $command->invoiceId) // ULID генерує викликач
->post('/invoices', ['order_id' => $command->orderId]);
return InvoiceId::fromString($response->throw()->json('id'));
}
}
// Чого цей код не ховає: транзакції, що охоплювала обидва модулі, більше немає.
// Якщо виклик мусив бути атомарним із локальним записом — потрібен outbox
// і компенсація, і саме тут вирішується, чи проходить межа сервісу в цьому місці.
Скажіть, що спершу треба заслужити право на розділення: зробити межі модулів твердими всередині моноліта й перевіряти їх статичним аналізом. Далі назвіть три конкретні тригери — окремий профіль навантаження, окрема команда з власним циклом релізу, інші вимоги до рантайму чи ізоляції даних — і додайте головне запитання: що станеться з транзакцією, яка сьогодні охоплює обидва модулі.