Содержание
Почти каждая ERP через несколько лет после успешного внедрения начинает тормозить развитие бизнеса. Отчёты считаются часами. Простое поле в карточке заказа требует согласования с тремя отделами и двумя вендорами. Новые сотрудники месяцами разбираются, «как тут вообще устроено». Руководство списывает это на возраст системы, нехватку DBA или «плохих разработчиков».
Но корневая причина часто глубже и дороже любого технического долга. Это иллюзия универсальности — вера в то, что одна система может описать любой бизнес-процесс, любую сущность и любое правило без программирования, через настройку метаданных и «гибкую архитектуру».
Парадокс жестокий: чем более универсальной пытаются сделать 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, который DBA смотрит с ужасом.
- Разработчики боятся менять код. Любое изменение в слое метаданных может сломать десяток клиентских конфигураций.
Реальный пример: нормализованный заказ 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, фильтр по дате. Предсказуемо. DBA счастлив.
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 | DBA нанимают на полную ставку. Появляется реплика для чтения «только для отчётов». |
| 100 000 000 | Отдельная команда платформы данных. ETL в колоночное хранилище. Ядро ERP фактически обходят стороной. |
На малом объёме универсальность бесплатна. На большом — стоимость растёт быстрее линейно, потому что каждый новый атрибут и тип сущности увеличивает размерность пространства запросов.
Цена ошибки
Универсальная архитектура облагает налогом всю организацию:
- Более дорогие серверы — больше CPU, RAM, быстрые диски компенсируют плохие запросы.
- Более дорогие DBA — нужны люди, которые умеют чинить то, что не должно было ломаться.
- Более дорогая разработка — каждая фича проходит через слой метаданных, маппинга и валидации.
- Более медленные пользователи — операционисты ждут формы, теряют время, обходят систему в таблицах.
Суммарно за пять лет «экономия на кастомной разработке» часто перевешивается стоимостью инфраструктуры, поддержки и упущенной скорости бизнеса.
Глава 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;
- теряет типизацию — ошибки уезжают в прод;
- теряет предсказуемость — каждое изменение лотерея;
- увеличивает стоимость поддержки — DBA, разработка, интеграторы, простой бизнеса.
Хорошая ERP не универсальна
Хорошая ERP — это специализированное ядро, отражающее реальные процессы компании или отрасли, плюс ограниченный набор продуманных механизмов расширения: плагины, события, пользовательские поля на периферии, явные API между модулями.
Универсальность хороша в маркетинговой презентации и в прототипе. В ядре системы, через которую проходят деньги, товар и ответственность — она самая дорогая ошибка, которую можно заложить в фундамент.
Если вы проектируете ERP сегодня, спросите себя не «как описать любой объект», а «какой заказ, какой склад и какой платёж мы не можем позволить себе потерять в абстракции». Ответ на этот вопрос — и есть архитектура.