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

Як виділяти обмежені контексти в моноліті й що робити зі спільними даними?

Межа контексту проходить там, де слово змінює значення і де змінюється склад людей, які замовляють зміни, а не там, де закінчується таблиця. Спільні дані розвʼязуються правилом одного власника факту: решта контекстів читають через контракт або тримають власну проєкцію, спільний код лишається мікроскопічним ядром без бізнес-правил, а чужа модель заходить усередину тільки через антикорупційний шар.

Ми порізали моноліт на модулі за таблицями бази. Чому тепер кожна фіча зачіпає одразу пʼять модулів?
У нас одна таблиця `users`, на неї посилаються девʼять модулів. З чого почнете розділення?
Два контексти мають клас `Client` із майже однаковими полями. Виносити в спільний пакет чи дублювати?
Що таке антикорупційний шар і чим він відрізняється від звичайного мапера DTO?
bounded context DDD context map shared kernel anticorruption layer модульний моноліт

Межу видно з мови, і найпростіший орієнтир тут омонім. У рекрутинговому продукті «кандидат» для підбору це воронка з етапами, резюме й нотатками рекрутера; для перевірки на комплаєнс це пакет документів зі статусами й датами дії; для білінгу це одиниця тарифікації з датою активації. Атрибути перетинаються відсотків на тридцять, змінюються з різних причин і за різними правилами. Другий орієнтир дає інваріант: те, що зобовʼязане лишатися узгодженим в одному COMMIT, не ріжеться навпіл, тому межа контексту завжди йде по зовнішньому краю таких правил. Третій орієнтир організаційний, і його зазвичай недооцінюють: якщо зміни в двох частинах системи замовляють різні люди з різним ритмом, ці частини вже живуть окремо, і код рано чи пізно це визнає.

Розділення за таблицями дає протилежний ефект. Схема бази описує спосіб зберігання, а не бізнес-можливості, тому модулі Users, Orders і Notifications мають рівно ту саму звʼязність, що й до розділення, тільки з новими namespace: будь-яка осмислена фіча проходить крізь усі три. Свої межі швидко перевіряють по історії комітів: подивіться, які файли зазвичай змінюються разом. Стабільні кластери спільних змін показують справжні модулі, і якщо вони не збігаються з теками, теки треба переставити, а не захищати. Гіпотезу межі корисно проговорити вголос: у контексту має бути назва з мови бізнесу і зрозуміла відповідь на питання, які рішення він приймає самостійно.

Зі спільними даними працює правило одного власника факту: у кожного факту рівно один контекст, який має право його змінювати. Далі є сходинки, і спускатися ними варто лише за потреби. Найдешевше читати у власника через порт, тобто через інтерфейс, оголошений споживачем. Якщо читання гаряче або власник може бути недоступним, споживач тримає власну проєкцію, оновлену подіями (у моноліті це листенер плюс outbox, щоб подія не поїхала раніше за COMMIT), і свідомо приймає відставання разом із джобою звірки. Окремо стоять дані, які треба заморозити: ціна й назва на момент замовлення стають власним фактом замовлення і від каталогу більше не залежать. Посилання між контекстами живуть як ідентифікатори: без зовнішнього ключа, без JOIN і без Eloquent-звʼязку, бо FK намертво звʼязує міграції двох частин і порядок їхнього деплою. У PostgreSQL контекст природно лягає на окрему schema з власною роллю, у MySQL schema є синонімом бази, тож беруть окрему базу або префікс плюс користувача з правами тільки на свої таблиці. Права тут працюють краще за домовленості: доки технічно можливо зробити JOIN у чужу таблицю, він зʼявиться в першому ж терміновому фіксі.

Зі спільним кодом рішення протилежне звичному інстинкту. Shared kernel має сенс для дрібного, стабільного і позбавленого бізнес-правил: Money, типи ідентифікаторів, Clock, базові контракти помилок. Ядро перебуває у спільному володінні, зміни узгоджуються обома сторонами, і саме ця вартість узгодження тримає його малим. Усе інше, що виглядає однаково, дублюється. Два класи Client по пʼять полів дешевші за один спільний, який через рік має loyalty_tier, kyc_status і unsubscribed_at, причому кожне поле потрібне комусь одному. Тут дублювання структури купує незалежність релізів. А от повторення бізнес-правила в двох контекстах, скажімо однакового розрахунку знижки, сигналізує про помилкову межу: правило належить комусь одному, і другий має його питати, а не переписувати.

Антикорупційний шар потрібен там, де чужа модель заходить усередину вашої. Він стоїть з боку споживача, за портом, який споживач оголосив своєю мовою, і за цю межу не виходять чужі назви полів, коди станів, одиниці виміру й формати дат. Від мапера ACL відрізняється тим, що приймає рішення: контексту підбору потрібне не поле STATE, а відповідь «чи може цей роботодавець публікувати вакансії», і коли постачальник додасть новий стан, змінюється один клас. Карта контекстів Еванса пропонує й дешевші стосунки: conformist, тобто прийняти чужу модель як є, цілком розумний, коли постачальник стабільний і його модель адекватна; open host service з published language перекладає роботу на бік постачальника, якщо споживачів багато; separate ways чесно фіксує, що інтеграція коштує дорожче за користь. ACL обирають під legacy, зовнішній API або шматок моноліту, який ви душите strangler-підходом.

Ціна всього цього конкретна: копії застарівають, тож потрібні звірки й дашборд розбіжностей; кількість коду зростає; частина запитів, які були одним JOIN, перетворюється на два звернення. Тому контекстів має бути стільки, скільки в системі справді різних мов, і жодним більше. Про помилкову межу найпевніше сигналить сценарій, який регулярно вимагає атомарної транзакції через два контексти: ви розрізали інваріант навпіл, і межу треба переносити, а не героїчно обходити sagʼою.

// Контекст «Підбір» описує роботодавця власною мовою і власним правилом.
final readonly class Employer
{
    public function __construct(
        public EmployerId $id,
        public string $displayName,
        public bool $canPublishVacancies,
    ) {}
}

interface Employers
{
    public function byId(EmployerId $id): ?Employer;
}

// Антикорупційний шар: назви полів і коди станів білінгу далі не йдуть.
final readonly class BillingEmployers implements Employers
{
    public function __construct(private LegacyBillingApi $billing) {}

    public function byId(EmployerId $id): ?Employer
    {
        // ['ACC_NAME' => ..., 'STATE' => 'A'|'S'|'X', 'BALANCE_UAH' => ...]
        $account = $this->billing->fetchAccount($id->value);
        if ($account === null) {
            return null;
        }

        return new Employer(
            id: $id,
            displayName: trim($account['ACC_NAME']) ?: 'Роботодавець',
            // Чужий стан перекладається в наше правило, а не копіюється полем
            canPublishVacancies: $account['STATE'] === 'A'
                && (int) $account['BALANCE_UAH'] >= 0,
        );
    }
}

// Гаряче читання йде з власної проєкції, джерело правди лишається в білінгу.
final class ProjectEmployerStatus
{
    public function __invoke(AccountStatusChanged $event): void
    {
        // Таблиця належить «Підбору»: білінг у неї не пише і не робить JOIN.
        DB::table('hiring_employers')->updateOrInsert(
            ['employer_id' => $event->accountId],
            ['can_publish' => $event->isActive, 'synced_at' => $event->occurredAt],
        );
    }
}
Що кандидат шукає межу через мову: одне слово з різним значенням у двох відділах, різний набір атрибутів, різний життєвий цикл обʼєкта, різні люди, які просять зміни.
Що межа контексту збігається з межею інваріанта: те, що має лишатися узгодженим в одній транзакції, не ріжеться навпіл.
Правило одного власника факту і сходи роботи зі спільними даними: порт до власника, потім локальна проєкція на подіях, потім снапшот там, де значення треба заморозити.
Що посилання між контекстами живе як ідентифікатор без FK, без JOIN і без Eloquent-звʼязку, і що це свідома плата за незалежність міграцій.
Розрізнення shared kernel і дублювання: спільним лишається дрібне й стабільне у спільному володінні, а схожі на вигляд моделі з різними правилами дублюються.
Що ACL стоїть з боку споживача, перекладає чужі стани у власні правила і має альтернативи з карти контекстів: conformist, open host service, separate ways.
Різати за технічними шарами або за таблицями: `Users`, `Orders`, `Notifications` дають ту саму звʼязність, тільки з новими namespace.
Виносити спільний `Shared\Models\User` і за рік мати в ньому сорок полів, половина з яких потрібна одному контексту, а міняти клас без узгодження вже неможливо.
Ставити зовнішній ключ між таблицями двох контекстів і тим самим повʼязати їхні міграції та порядок деплою назавжди.
Тягнути чужу структуру в домен без перекладу: у сутності зʼявляються `STATE = 'A'` і `ACC_NAME`, і зміна на боці постачальника ламає бізнес-правила в десяти місцях.
Дозволити BI-звітам визначати межі: один запит із JOIN на сім таблиць стає аргументом «контексти не потрібні».
Заводити ACL на кожен внутрішній виклик між власними контекстами, де контракт і так під вашим контролем, і платити мапінгом за нічого.
ПОРАДА

Наведіть конкретний омонім зі свого продукту і покажіть, як він розводиться по контекстах, а потім одразу дайте правило одного власника факту: хто пише, хто читає через контракт, у кого лежить копія і чим ця копія оновлюється. Окремо назвіть, що ви віддали в shared kernel, і чому там немає жодного бізнес-правила.

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

Схожість набору полів нічого не говорить про спільність поняття. У продажах клієнт має знижку й менеджера, у бухгалтерії - податковий статус і реквізити; правила й моменти змін різні, тож спільний клас доведеться міняти за погодженням обох сторін і він швидко обросте чужими полями. Спільною лишається ідентичність, тобто `ClientId`, а не структура. Спільна таблиця з JOIN додатково повʼязує міграції, успадкування ж робить один контекст заручником релізів іншого.