Зміст
Майже кожна ERP через кілька років після успішного впровадження починає гальмувати розвиток бізнесу. Звіти рахуються годинами. Просте поле в картці замовлення потребує погодження з трьома відділами й двома вендорами. Нові співробітники місяцями розбираються, «як тут взагалі влаштовано». Керівництво списує це на вік системи, нестачу адміністратора БД або «поганих розробників».
Але коренева причина часто глибша й дорожча за будь-який технічний борг. Це ілюзія універсальності — віра в те, що одна система може описати будь-який бізнес-процес, будь-яку сутність і будь-яке правило без програмування, через налаштування метаданих і «гнучку архітектуру».
Парадокс жорсткий: чим більш універсальною намагаються зробити ERP, тим менш придатною вона стає для реальних задач конкретного бізнесу. Універсальність обіцяє економію на розробці й масштабування продукту на тисячі клієнтів. На практиці вона купує гнучкість ціною продуктивності, зрозумілості коду, типізації та передбачуваності змін.
Ця стаття — для архітекторів, CTO, власників ERP-продуктів і технічних лідерів, які проєктують систему сьогодні й через п’ять років не хочуть пояснювати бізнесу, чому «додати одне поле» коштує як міні-проєкт.
Ключові висновки
Універсальність — маркетингова ілюзія, а не архітектурна ціль. Один продукт «для всіх галузей» економить на розробці вендора, але перекладає складність на клієнта й його інтеграторів.
EAV і метадані — пастка для ядра ERP. Entity–Attribute–Value дає нескінченну гнучкість на демо й експоненційне зростання складності в продакшені.
База даних і код люблять конкретику. Типізовані поля, передбачувані індекси й доменні моделі дешевші в супроводі, ніж динамічні атрибути з JOIN на JOIN.
Бізнес-логіка не абстрактна. Склад, виробництво, бухгалтерія й CRM вирішують різні задачі. Один універсальний механізм для замовлення, рахунку й виробничої операції втрачає сенс предметної області.
Гарна ERP — спеціалізоване ядро плюс обмежені точки розширення. 80% системи має бути типізованим доменом; 20% — контрольованою кастомізацією.
Найдорожча помилка в ERP — не поганий код. Це віра в те, що можна створити повністю універсальну систему для будь-яких бізнес-процесів.
Вступ: чому універсальність здається правильною відповіддю
ERP — один із найдорожчих класів програмного забезпечення, який компанія коли-небудь купує чи будує. Вартість володіння вимірюється не ліцензією, а роками доопрацювань, інтеграцій, навчання персоналу й простою під час змін. Тому на старті будь-яка команда шукає спосіб не написати все заново для кожного клієнта.
Звідси народжується спокуса: якщо зробити систему достатньо абстрактною, вона підлаштується під будь-який склад, будь-яке виробництво, будь-яку схему погоджень. Продажі обіцяють «налаштувати без програмістів». Архітектор малює схему з Entity, Attribute, Value. Інвестори бачать масштабований продукт. Усі задоволені — до першого мільйона записів і п’ятого року експлуатації.
Розділ 1. Звідки береться ідея «універсальної ERP»
Історія проблеми
Коробкові ERP минулого — SAP, Oracle, 1С, Microsoft Dynamics — будувалися на ідеї одного продукту для багатьох галузей. Вендор не може дозволити собі окрему кодову базу для металургії, ритейлу й логістики. Економіка розробки диктує: один код, багато клієнтів, відмінності — у конфігурації.
Маркетинг посилює тренд. Слово «універсальна» продає краще, ніж «підходить для виробничих компаній середнього розміру з дискретною збіркою». Універсальність звучить як страховка: «якщо завтра відкриємо новий напрям — система підлаштується». Для замовника це знижує сприйнятий ризик. Для розробника — створює архітектурний тиск у бік абстракцій.
Як виглядає універсальність в очах бізнесу
Типові обіцянки на презентації:
- Налаштувати можна все — будь-який процес описується через конфігуратор.
- Будь-який процес без програмування — бізнес-аналітик сам збере робочий процес.
- Нові сутності за хвилини — адміністратор створює «Об’єкт обліку» в інтерфейсі.
- Будь-які поля через UI — прапорець «додати атрибут», тип обирається зі списку.
На демо це працює. Картка «Універсального документа» приймає десять полів. Звіт будується перетягуванням блоків. Замовник підписує контракт.
Чому ідея здається правильною
Логіка переконлива:
| Аргумент | Чому звучить розумно |
|---|---|
| Менше кастомної розробки | Клієнт сам налаштовує, інтегратор лише допомагає |
| Швидше впровадження | Не чекаємо спринт розробки на кожне поле |
| Один код для всіх клієнтів | Вендор лагодить баг один раз — виграють усі |
| Масштабування бізнесу вендора | Новий клієнт = нова ліцензія, не новий продукт |
Проблема в тому, що ці вигоди реальні на горизонті першого року і перетворюються на витрати на горизонті п’яти–десяти років. Універсальність — це кредит під високий відсоток. Відсотки нараховуються в запитах до БД, у шарах абстракції в коді й у головах розробників.
Розділ 2. Перший крок до катастрофи — універсальні сутності
Мрія архітектора
На білій дошці схема виглядає елегантно:
┌──────────┐ ┌────────────┐ ┌───────────┐
│ Entity │────▶│ Attribute │────▶│ Value │
│ (тип) │ │ (имя,тип) │ │ (значение)│
└──────────┘ └────────────┘ └───────────┘
Три таблиці — і ви «описали всю всесвіт». Потрібен новий тип документа? Додаєте запис у entities. Потрібне поле «термін придатності»? Рядок у attributes. Значення лежать у values. Демо вражає. Архітектор отримує схвалення.
Це класичний патерн EAV (Entity–Attribute–Value) — або його варіації: JSONB з динамічною схемою, key-value сховище поверх реляційної БД, «універсальні» таблиці custom_fields.
Як народжується EAV
Типова структура:
-- Универсальная сущность
CREATE TABLE entities (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(64) NOT NULL, -- 'order', 'invoice', 'operation'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Определения атрибутов
CREATE TABLE attributes (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(64) NOT NULL,
code VARCHAR(128) NOT NULL,
data_type VARCHAR(32) NOT NULL, -- string, number, date, ref
is_required BOOLEAN DEFAULT false
);
-- Значения (горизонтальное хранение — один ряд на атрибут)
CREATE TABLE attribute_values (
id BIGSERIAL PRIMARY KEY,
entity_id BIGINT NOT NULL REFERENCES entities(id),
attribute_id BIGINT NOT NULL REFERENCES attributes(id),
value_string TEXT,
value_number NUMERIC,
value_date DATE,
value_ref BIGINT
);
Зв’язки між сутностями теж стають універсальними: таблиця entity_relations з полями from_entity_id, to_entity_id, relation_type. Через рік у вас немає «замовлень» і «клієнтів» — є граф абстрактних вузлів.
Що відбувається через рік
Перші симптоми:
- Запити ускладнюються. Вибірка «усі замовлення клієнта X зі статусом "відвантажено" і сумою > 100 000» перетворюється на п’ять JOIN і три підзапити з розгортанням рядків.
- Індекси перестають допомагати. Складений індекс на
(entity_type, ...)не покриває всі комбінації фільтрів за динамічними атрибутами. - Звіти гальмують. BI-інструмент або внутрішній конструктор звітів генерує SQL, який адміністратор БД дивиться з жахом.
- Розробники бояться змінювати код. Будь-яка зміна в шарі метаданих може зламати десяток клієнтських конфігурацій.
Реальний приклад: нормалізоване замовлення vs EAV
Нормалізована модель — зрозуміла, типізована:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES clients(id),
status order_status NOT NULL, -- enum: draft, confirmed, shipped, ...
total_amount NUMERIC(15,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_client_status ON orders(client_id, status);
CREATE INDEX idx_orders_created ON orders(created_at);
-- Запрос: заказы клиента 42 со статусом 'shipped' за последний месяц
SELECT o.id, o.total_amount, o.created_at
FROM orders o
WHERE o.client_id = 42
AND o.status = 'shipped'
AND o.created_at >= now() - interval '1 month';
План виконання: сканування за індексом idx_orders_client_status, фільтр за датою. Передбачувано. Адміністратор БД задоволений.
EAV-представлення того ж замовлення:
-- «Заказ» — entity с type='order'
-- client_id хранится как attribute_values где attribute.code='client_id'
-- status — attribute.code='status'
-- total_amount — attribute.code='total_amount'
SELECT e.id,
MAX(CASE WHEN a.code = 'total_amount' THEN av.value_number END) AS total_amount,
e.created_at
FROM entities e
JOIN attribute_values av ON av.entity_id = e.id
JOIN attributes a ON a.id = av.attribute_id
WHERE e.entity_type = 'order'
AND e.id IN (
SELECT av2.entity_id
FROM attribute_values av2
JOIN attributes a2 ON a2.id = av2.attribute_id
WHERE a2.code = 'client_id' AND av2.value_ref = 42
)
AND e.id IN (
SELECT av3.entity_id
FROM attribute_values av3
JOIN attributes a3 ON a3.id = av3.attribute_id
WHERE a3.code = 'status' AND av3.value_string = 'shipped'
)
AND e.created_at >= now() - interval '1 month'
GROUP BY e.id, e.created_at;
Те саме бізнес-питання — у три рази більше JOIN, GROUP BY, підзапитів. При 10 000 замовленнях ще терпимо. При 10 мільйонах — окремий проєкт з оптимізації, матеріалізовані представлення, денормалізовані копії «для звітів», по суті — друга, нормалізована модель, побудована поверх першої, щоб виправити архітектурну помилку.
Розділ 3. Універсальність знищує продуктивність
Чому база даних любить конкретику
Реляційні СУБД оптимізовані під стабільну схему:
- Типізовані поля — СУБД знає розмір, порівнює числа як числа, дати як дати.
- Передбачувані індекси — B-tree на
(client_id, status)працює, бо поля фіксовані. - Прості плани виконання — оптимізатор оцінює селективність за статистикою стовпців, а не за евристиками запитів з розгортанням.
Коли схема динамічна, оптимізатор «сліпне». Статистика по value_string в EAV безглузда: в одному стовпці лежать і імена, і коди, і серіалізований JSON.
Що ламає універсальна архітектура
| Проблема | Наслідок |
|---|---|
| JOIN на JOIN на JOIN | Лінійне зростання вартості запиту з числом атрибутів |
| Динамічні атрибути | Неможливість скласти один ефективний індекс |
| Перетворення типів | CAST(value_string AS NUMERIC) вбиває сканування лише за індексом |
| Розгортання у застосунку або SQL | CPU на кожному рядку замість читання готових стовпців |
| Відсутність FK на рівні БД | «Посилання» в value_ref не перевіряється — ростуть сироти й сміття |
Експоненційне зростання складності
Чому проблеми помітні лише через кілька років:
| Масштаб | Що відбувається |
|---|---|
| 100 записів | Усе летить. EAV здається геніяльним. |
| 10 000 | Перші скарги на «повільний список». Додають кеш. |
| 1 000 000 | Адміністратора БД наймають на повну ставку. З’являється репліка для читання «лише для звітів». |
| 100 000 000 | Окрема команда платформи даних. ETL у колоночне сховище. Ядро ERP фактично обходять стороною. |
На малому обсязі універсальність безкоштовна. На великому — вартість зростає швидше за лінійну, бо кожен новий атрибут і тип сутності збільшує розмірність простору запитів.
Ціна помилки
Універсальна архітектура обкладає податком всю організацію:
- Дорожчі сервери — більше CPU, RAM, швидкі диски компенсують погані запити.
- Дорожчі адміністратори БД — потрібні люди, які вміють лагодити те, що не мало ламатися.
- Дорожча розробка — кожна фіча проходить через шар метаданих, мапінгу й валідації.
- Повільніші користувачі — оператори чекають форми, втрачають час, обходять систему в таблицях.
Загалом за п’ять років «економія на кастомній розробці» часто перевішується вартістю інфраструктури, підтримки й упущеної швидкості бізнесу.
Розділ 4. Універсальність знищує код
Початок виглядає красиво
На першому спринті API елегантний:
$entity = $entityRepository->find($orderId);
$price = $entity->getField('price');
$status = $entity->getField('status');
Один інтерфейс для всього. Новий тип документа — не новий клас, а конфіг. Розробники задоволені.
Через кілька років
Реальність:
- Неможливо зрозуміти структуру даних — «які поля у замовлення?» відповідь: «дивись у таблицю attributes для entity_type=order, плюс оверрайди клієнта 17».
- Немає автодоповнення IDE —
getField('prcie')друкарська помилка в рядку, помилка лише в рантаймі. - Немає статичної типізації —
$priceможе бути string, null або масивом «для складового поля». - Немає безпечного рефакторингу — перейменувати
priceнаtotal_amount= пошук по рядкових літералах по всій кодовій базі й у JSON-конфігах клієнтів.
Ефект снігової кулі
Щоб жити з універсальністю, з’являються шари:
Entity
→ EntityWrapper
→ TypedEntityAdapter (псевдотипизация)
→ FieldConverter (string → Money)
→ ValidationHelper
→ LegacyFieldAliasResolver (старое имя 'sum' → 'total_amount')
Кожен шар — відповідь на біль попереднього. Через п’ять років половина кодової бази обслуговує абстракцію, а не бізнес. Новий розробник читає не домен «замовлення / відвантаження / оплата», а внутрішній фреймворк метаданих, який ніде не задокументований повністю.
Коли розробники починають боятися системи
Типові сигнали:
- Зміна однієї функції ламає звіти, інтеграції й три клієнтські конфігурації.
- Код-рев’ю перетворюється на вгадування: «а що якщо у клієнта X увімкнено режим сумісності?»
- Адаптація займає місяці — не тому що домен складний, а тому що домен схований за універсальним API.
- «Не чіпай EntityService» стає неписаним правилом команди.
У цей момент система перестає бути активом і стає зобов’язанням — дорогим, крихким, але без альтернативи, бо в ній живуть усі дані компанії.
Розділ 5. Чому універсальність вбиває бізнес-логіку
Бізнес завжди конкретний
ERP на папері — «єдина система обліку». На практиці це стик різних світів:
| Область | Що важливо | Що ламає універсальність |
|---|---|---|
| Склад | Залишки, партії, адреси комірок, FIFO/FEFO | «Універсальний документ руху» без семантики списання |
| Виробництво | Специфікації, маршрути, брак, норми | Операція = замовлення = рахунок в одній таблиці |
| Бухгалтерія | Проводки, регістри, періоди, закриття | Динамічні поля без подвійного запису |
| CRM | Воронка, активності, контакти | Гнучкість ціною відсутності жорстких правил погодження |
Склад працює не так, як CRM. Виробництво — не так, як документообіг. Універсальний механізм усереднює поведінку до найменшого спільного знаменника.
Помилка абстракції
Архітектор намагається описати одним механізмом:
- Замовлення — погодження, резерв, відвантаження.
- Рахунок — ПДВ, проводки, оплата.
- Виробничу операцію — норми, брак, облік часу.
- Платіж — валюта, курс, комісія банку.
Усі вони стають entity_type з різним набором attributes. Бізнес-правила перетворюються на умови на метаданих:
Якщо entity_type = 'order' І attribute 'channel' = 'b2b'
І attribute 'payment_terms' > 30
Тоді workflow_id = 7
Інакше якщо entity_type = 'invoice' ...
Правила не читаються бізнесом. Їх не можна перевірити на воркшопі з власником процесу. Вони живуть у коді й конфігах, відірвані від мови предметної області.
Втрата сенсів
Система починає оперувати:
- сутностями, атрибутами, зв’язками, workflow_id.
Замість:
- замовленнями, клієнтами, поставками, платежами, специфікаціями, актами звірки.
Розробник і аналітик перестають говорити однією мовою з бізнесом. Між «нам потрібно блокувати відвантаження при простроченій дебіторці» і реалізацією — прослойка універсальної абстракції, яку ніхто з фінансів не розуміє.
Предметно-орієнтоване проєктування (DDD) якраз бореться з цим: спільна предметна мова, обмежені контексти, явні агрегати. Універсальна ERP — антипод DDD на рівні ядра.
Розділ 6. Чому універсальні ERP перетворюються на монолітних монстрів
Цей розділ перетинається з окремою темою — чому ERP стають монолітами — але універсальність прискорює деградацію.
Початок проєкту
- Кілька типів сутностей в EAV.
- Кілька екранів-конструкторів.
- Проста логіка: «прочитай атрибути, покажи форму».
Через п’ять років
- Сотні
entity_typeі тисячіattributes— частина застаріла, частина дублює одна одну. - Тисячі налаштувань на рівні клієнта, філії, ролі.
- Десятки
if (legacy_mode)і спеціальних гілок для «того самого» великого замовника.
Універсальність не стримує зростання — вона не дає природних меж. У нормалізованій моделі новий модуль — нові таблиці й сервіс. В EAV усе — той самий EntityService, той самий звітний рушій, та сама точка розширення. Моноліт роздувається зсередини без модульних швів.
Через десять років
- Ніхто не розуміє систему цілком.
- Документація описує «як задумано», а не «як працює зараз».
- Будь-яка зміна — проєкт з оцінкою в людино-місяцях.
Симптоми архітектурної деградації
| Симптом | Прояв |
|---|---|
| Величезні сервіси | EntityService на 15 000 рядків |
| Величезні контролери | Одна кінцева точка «зберегти універсальний об’єкт» на всі випадки |
| Величезні SQL | Звіти на 500 рядків з динамічним розгортанням |
| Величезні конфігурації | JSON метаданих більший, ніж код модуля |
Монстр тут — не лише наслідок успіху бізнесу. Це успіх бізнесу на фундаменті універсальної абстракції, який не витримує ваги реальності.
Розділ 7. Що працює краще за універсальність
Принцип 80/20
Практичне правило для архітектора ERP:
- 80% системи — спеціалізовані, типізовані доменні модулі: замовлення, склад, фінанси, виробництво.
- 20% — розширюваність: користувацькі поля, налаштування UI, вебхуки, плагіни.
Спроба зробити 80% універсальним переносить складність у ті 20%, які мали залишитися простими.
Ядро і розширення
Здорова структура:
┌─────────────────────────────────────────┐
│ Core (типизированный) │
│ Orders │ Inventory │ Finance │ Mfg │
└─────────────────┬───────────────────────┘
│ явные API / события
┌─────────────────▼───────────────────────┐
│ Domain modules (границы по контексту) │
└─────────────────┬───────────────────────┘
│ plugin contract
┌─────────────────▼───────────────────────┐
│ Plugins / custom fields / integrations │
└─────────────────────────────────────────┘
Core не знає про плагіни деталей реалізації — лише про контракти. Плагіни не лізуть у ядро через спільну таблицю Entity.
Предметно-орієнтоване проєктування
Чому бізнес-сутності важливіші за технічні абстракції:
- Обмежений контекст — склад і бухгалтерія можуть по-різному називати «товар», і це нормально; інтеграція через антикорупційний шар.
- Агрегати — замовлення як корінь з інваріантами: не можна відвантажити більше, ніж зарезервовано.
- Доменні події —
OrderShipped, а неEntityUpdated(type=order, diff=...).
Технічна абстракція економить рядки коду на старті. Доменна модель економить роки супроводу.
Обмежена гнучкість краща за нескінченну
| Нескінченна гнучкість | Обмежена гнучкість |
|---|---|
| Будь-яке поле де завгодно | Користувацькі поля лише в дозволених сутностях |
| Будь-який робочий процес | Каталог шаблонів + параметри |
| Будь-який зв’язок | Явні FK і доменні сервіси |
| «Налаштуй без коду» скрізь | Конструктори без коду лише на периферії |
Гарні архітектори навмисно відмовляють у свободі там, де свобода руйнує інваріанти. Це не ворожість до бізнесу — це захист даних і процесів.
Розділ 8. Як будувати ERP правильно
Спочатку предметна область
Старт не з таблиць і форм, а з:
- Процесів — як замовлення перетворюється на відвантаження й оплату.
- Ролей — хто що бачить і хто що підтверджує.
- Подій — що відбувається при зміні статусу, які побічні ефекти.
- Правил бізнесу — інваріанти, які не можна порушити заради «гнучкості».
Робоча сесія з власником процесу → глосарій → модель → схема БД. Не навпаки.
Типізовані моделі там, де це можливо
Ядро описується явними сутностями:
interface Order {
id: OrderId;
clientId: ClientId;
status: OrderStatus;
lines: OrderLine[];
total: Money;
createdAt: Instant;
}
// Инвариант в домене, не в getField()
function ship(order: Order, inventory: Inventory): Result<Shipment, DomainError> {
if (order.status !== 'confirmed') return err('ORDER_NOT_CONFIRMED');
// ...
}
Замовлення, товар, складська комірка, платіж — окремі типи з окремими правилами. Спільне виноситься в спільне ядро (гроші, адреси, одиниці виміру), не в «універсальний документ».
Метадані лише там, де вони дійсно потрібні
Гарні кандидати на метадані:
- користувацькі поля в картці клієнта (обмежений набір типів);
- налаштування інтерфейсу, видимість стовпців;
- конструктори простих форм для опитувань і заявок;
- довідники, які дійсно змінюються без релізу (обережно).
Погані кандидати:
- структура замовлення й рядків замовлення;
- проводки й регістри бухобліку;
- складські рухи й залишки;
- виробничі специфікації.
Якщо від поля залежить коректність обліку або податків — воно не має бути attribute_id = 847.
Архітектура як набір обмежень
Сильний архітектор ERP не питає «як зробити, щоб можна було все». Він питає:
- які зміни мають проходити через реліз і код-рев’ю;
- де допустиме налаштування силами інтегратора;
- де межа між продуктом і кастомом конкретного клієнта.
Обмеження — не слабкість. Це збереження цілісності системи, на якій тримається бізнес.
Розділ 9. Винятки: коли універсальність дійсно потрібна
Універсальність — не гріх. Гріх — застосовувати її в ядрі ERP, де ціна помилки максимальна.
Платформи без коду
Коли продукт сам по собі — конструктор (Airtable, Retool, внутрішня платформа заявок), EAV або сховище документів виправдані. Користувач розуміє компроміс: гнучкість важливіша за продуктивність і жорстку типізацію. Це не ERP для заводу на 10 000 SKU.
Конструктори CRM
Воронка продажів, кастомні поля ліда, довільні стадії — класична зона метаданих. Помилка в картці ліда рідко ламає баланс на рахунку 51. Контекст інший.
Прототипування
Мінімальний продукт за шість тижнів, вимоги плавають — універсальна модель прискорює перевірку гіпотези. План виходу має бути явним: «після підтвердження попиту переписуємо замовлення в типізовану модель». Без плану прототип стає спадковим кодом.
Дослідницькі проєкти
Коли домен невідомий — гнучка схема допомагає вчитися. Щойно домен стабілізувався (зазвичай після 2–3 ітерацій з реальними користувачами), фіксуйте модель. Дослідження без кристалізації — той самий кредит під відсотки.
Висновок
Головна думка
Найдорожча помилка в архітектурі ERP — не поганий код і не «не той» PostgreSQL vs Oracle. Найдорожча помилка — віра в повністю універсальну систему для будь-яких бізнес-процесів.
Чим більше архітектура прагне до універсальності, тим сильніше вона:
- втрачає продуктивність — запити, індекси, інфраструктура;
- втрачає зрозумілість — для людей і для IDE;
- втрачає типізацію — помилки потрапляють у прод;
- втрачає передбачуваність — кожна зміна лотерея;
- збільшує вартість підтримки — адміністратори БД, розробка, інтегратори, простій бізнесу.
Гарна ERP не універсальна
Гарна ERP — це спеціалізоване ядро, що відображає реальні процеси компанії або галузі, плюс обмежений набір продуманих механізмів розширення: плагіни, події, користувацькі поля на периферії, явні API між модулями.
Універсальність добра в маркетинговій презентації й у прототипі. В ядрі системи, через яку проходять гроші, товар і відповідальність — це найдорожча помилка, яку можна закласти в фундамент.
Якщо ви проєктуєте ERP сьогодні, запитайте себе не «як описати будь-який об’єкт», а «яке замовлення, який склад і який платіж ми не можемо дозволити собі втратити в абстракції». Відповідь на це питання — і є архітектура.