← Все статьи

Самая дорогая ошибка в архитектуре ERP — попытка сделать систему универсальной

Почему иллюзия универсальности превращает ERP в EAV-монстра: потеря производительности, типизации и бизнес-смысла — и как строить систему вокруг предметной области, а не абстрактных сущностей.

Содержание

Почти каждая 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».
  • Нет автодополнения IDEgetField('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 сегодня, спросите себя не «как описать любой объект», а «какой заказ, какой склад и какой платёж мы не можем позволить себе потерять в абстракции». Ответ на этот вопрос — и есть архитектура.