← All posts

The Most Expensive Mistake in ERP Architecture — Trying to Make the System Universal

Why the illusion of universality turns ERP into an EAV monster: lost performance, typing, and business meaning — and how to build around the domain, not abstract entities.

Contents

Almost every ERP, a few years after a successful rollout, starts to slow down business growth. Reports take hours to run. A simple field on an order card requires sign-off from three departments and two vendors. New hires spend months figuring out "how things work here." Leadership blames system age, a shortage of DBAs, or "bad developers."

But the root cause is often deeper and more expensive than any technical debt. It is the illusion of universality — the belief that one system can describe any business process, any entity, and any rule without programming, through metadata configuration and a "flexible architecture."

The paradox is brutal: the more universal an ERP tries to be, the less fit it becomes for the real tasks of a specific business. Universality promises savings on development and a product that scales to thousands of clients. In practice, it buys flexibility at the cost of performance, code clarity, typing, and predictable change.

This article is for architects, CTOs, ERP product owners, and technical leads who are designing a system today and do not want to explain to the business in five years why "adding one field" costs as much as a mini-project.

Key takeaways

Universality is a marketing illusion, not an architectural goal. One product "for every industry" saves the vendor on development but shifts complexity onto the client and their integrators.

EAV and metadata are a trap for the ERP core. Entity–Attribute–Value delivers infinite flexibility in a demo and exponential complexity growth in production.

Databases and code love specificity. Typed fields, predictable indexes, and domain models are cheaper to maintain than dynamic attributes with JOIN upon JOIN.

Business logic is not abstract. Warehouse, manufacturing, accounting, and CRM solve different problems. One universal mechanism for an order, an invoice, and a production operation loses domain meaning.

A good ERP is a specialized core plus limited extension points. 80% of the system should be a typed domain; 20% should be controlled customization.

The most expensive mistake in ERP is not bad code. It is the belief that you can build a fully universal system for any business process.


Introduction: Why Universality Feels Like the Right Answer

ERP is one of the most expensive classes of software a company ever buys or builds. Total cost of ownership is measured not in licenses but in years of customization, integrations, staff training, and downtime during change. So at the start, every team looks for a way to avoid rewriting everything for each client.

From that comes the temptation: if the system is abstract enough, it will adapt to any warehouse, any production line, any approval scheme. Sales promise "configure it without programmers." The architect draws a diagram with Entity, Attribute, Value. Investors see a scalable product. Everyone is happy — until the first million records and the fifth year in production.


Chapter 1. Where the Idea of a "Universal ERP" Comes From

History of the problem

Packaged ERP of the past — SAP, Oracle, 1C, Microsoft Dynamics — was built on the idea of one product for many industries. A vendor cannot afford a separate codebase for metallurgy, retail, and logistics. Development economics dictate: one codebase, many clients, differences in configuration.

Marketing amplifies the trend. The word "universal" sells better than "fits mid-size manufacturing companies with discrete assembly." Universality sounds like insurance: "if we open a new line of business tomorrow, the system will adapt." For the buyer, that lowers perceived risk. For the developer, it creates architectural pressure toward abstractions.

What universality looks like to the business

Typical promises on a sales deck:

  • Everything can be configured — any process is described through a configurator.
  • Any process without programming — a business analyst assembles the workflow alone.
  • New entities in minutes — an administrator creates an "Accounting Object" in the UI.
  • Any fields through the UI — a checkbox to "add attribute," type chosen from a list.

On a demo, it works. A "Universal Document" card accepts ten fields. A report is built drag-and-drop. The customer signs the contract.

Why the idea seems right

The logic is convincing:

Argument Why it sounds reasonable
Less custom development The client configures; the integrator only assists
Faster implementation No waiting for a dev sprint for every field
One codebase for all clients The vendor fixes a bug once — everyone wins
Scaling the vendor's business A new client = a new license, not a new product

The problem is that these benefits are real in the first year and turn into costs over five to ten years. Universality is a loan at a high interest rate. Interest accrues in database queries, abstraction layers in code, and developers' heads.


Chapter 2. The First Step Toward Disaster — Universal Entities

The architect's dream

On the whiteboard, the diagram looks elegant:

┌──────────┐     ┌────────────┐     ┌───────────┐
│  Entity  │────▶│ Attribute  │────▶│   Value   │
│  (тип)   │     │  (имя,тип) │     │ (значение)│
└──────────┘     └────────────┘     └───────────┘

Three tables — and you have "described the entire universe." Need a new document type? Add a row to entities. Need a "best before" field? A row in attributes. Values live in values. The demo impresses. The architect gets approval.

This is the classic EAV (Entity–Attribute–Value) pattern — or its variants: JSONB with a dynamic schema, key-value storage on top of a relational DB, "universal" custom_fields tables.

How EAV is born

Typical structure:

-- Универсальная сущность
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
);

Relationships between entities become universal too: an entity_relations table with from_entity_id, to_entity_id, relation_type. After a year, you no longer have "orders" and "clients" — you have a graph of abstract nodes.

What happens after a year

First symptoms:

  • Queries get complicated. Fetching "all orders for client X with status 'shipped' and amount > 100,000" turns into five JOINs and three subqueries with pivot.
  • Indexes stop helping. A composite index on (entity_type, ...) does not cover every filter combination on dynamic attributes.
  • Reports slow down. A BI tool or internal report builder generates SQL that makes the DBA wince.
  • Developers fear changing code. Any change in the metadata layer can break a dozen client configurations.

A real example: normalized order vs EAV

Normalized model — clear and typed:

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';

Execution plan: index scan on idx_orders_client_status, filter by date. Predictable. The DBA is happy.

EAV representation of the same order:

-- «Заказ» — 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;

The same business question — three times as many JOINs, GROUP BY, subqueries. With 10,000 orders, still tolerable. With 10 million — a separate optimization project, materialized views, denormalized copies "for reporting," in effect a second, normalized model built on top of the first to fix an architectural mistake.


Chapter 3. Universality Destroys Performance

Why the database loves specificity

Relational DBMSs are optimized for a stable schema:

  • Typed columns — the engine knows size, compares numbers as numbers, dates as dates.
  • Predictable indexes — a B-tree on (client_id, status) works because the columns are fixed.
  • Simple execution plans — the optimizer estimates selectivity from column statistics, not pivot-query heuristics.

When the schema is dynamic, the optimizer goes blind. Statistics on value_string in EAV are meaningless: one column holds names, codes, and serialized JSON.

What universal architecture breaks

Problem Consequence
JOIN upon JOIN upon JOIN Query cost grows linearly with the number of attributes
Dynamic attributes No single efficient index covers all cases
Type casts CAST(value_string AS NUMERIC) kills index-only scan
Pivot in app or SQL CPU on every row instead of reading ready-made columns
No FK at DB level A "reference" in value_ref is not enforced — orphans and garbage accumulate

Exponential complexity growth

Why problems show up only after several years:

Scale What happens
100 records Everything flies. EAV looks genius.
10,000 First complaints about a "slow list." Cache is added.
1,000,000 A full-time DBA is hired. A read replica appears "for reports only."
100,000,000 A separate data platform team. ETL into a columnar store. The ERP core is effectively bypassed.

At small volume, universality is free. At large volume, cost grows faster than linearly, because each new attribute and entity type increases the dimensionality of the query space.

The price of the mistake

Universal architecture taxes the whole organization:

  • More expensive servers — more CPU, RAM, fast disks compensate for bad queries.
  • More expensive DBAs — you need people who can fix what should not have broken.
  • More expensive development — every feature passes through metadata, mapping, and validation layers.
  • Slower users — operators wait on forms, lose time, work around the system in Excel.

Over five years, "savings on custom development" is often outweighed by infrastructure, support, and lost business velocity.


Chapter 4. Universality Destroys Code

The beginning looks clean

In the first sprint, the API is elegant:

$entity = $entityRepository->find($orderId);
$price = $entity->getField('price');
$status = $entity->getField('status');

One interface for everything. A new document type is not a new class but config. Developers are pleased.

A few years later

Reality:

  • Impossible to understand data shape — "what fields does an order have?" Answer: "look in the attributes table for entity_type=order, plus client 17 overrides."
  • No IDE autocompletegetField('prcie') is a typo in a string; the error appears only at runtime.
  • No static typing$price may be a string, null, or an array "for a composite field."
  • No safe refactoring — renaming price to total_amount means grep for string literals across the codebase and in client JSON configs.

The snowball effect

To live with universality, layers appear:

Entity
  → EntityWrapper
    → TypedEntityAdapter (псевдотипизация)
      → FieldConverter (string → Money)
        → ValidationHelper
          → LegacyFieldAliasResolver (старое имя 'sum' → 'total_amount')

Each layer is an answer to the pain of the previous one. After five years, half the codebase serves abstraction, not business. A new developer reads not the domain of "order / shipment / payment" but an internal metadata framework that is nowhere fully documented.

When developers start fearing the system

Typical signals:

  • Changing one function breaks reports, integrations, and three client configurations.
  • Code review turns into guessing: "what if client X has legacy mode enabled?"
  • Onboarding takes months — not because the domain is hard, but because the domain is hidden behind a universal API.
  • "Don't touch EntityService" becomes an unwritten team rule.

At that point the system stops being an asset and becomes a liability — expensive, fragile, but without alternatives, because all company data lives inside it.


Chapter 5. Why Universality Kills Business Logic

Business is always concrete

On paper, ERP is a "unified accounting system." In practice, it is where different worlds meet:

Area What matters What universality breaks
Warehouse Balances, batches, bin addresses, FIFO/FEFO A "universal movement document" with no write-off semantics
Manufacturing BOMs, routings, scrap, norms Operation = order = invoice in one table
Accounting Postings, registers, periods, closing Dynamic fields without double-entry
CRM Pipeline, activities, contacts Flexibility at the cost of strict approval rules

A warehouse does not work like CRM. Manufacturing does not work like document flow. A universal mechanism averages behavior down to the lowest common denominator.

The abstraction mistake

The architect tries to describe with one mechanism:

  • Order — approval, reservation, shipment.
  • Invoice — VAT, postings, payment.
  • Production operation — norms, scrap, time tracking.
  • Payment — currency, rate, bank fee.

All become entity_type with different sets of attributes. Business rules turn into conditions on metadata:

Если entity_type = 'order' И attribute 'channel' = 'b2b'
  И attribute 'payment_terms' > 30
  Тогда workflow_id = 7
Иначе если entity_type = 'invoice' ...

Rules are not readable by the business. They cannot be validated in a workshop with the process owner. They live in code and configs, detached from the language of the domain.

Loss of meaning

The system starts operating on:

  • entities, attributes, relations, workflow_id.

Instead of:

  • orders, clients, shipments, payments, BOMs, reconciliation acts.

Developers and analysts stop speaking the same language as the business. Between "we need to block shipment when receivables are overdue" and the implementation lies a layer of universal abstraction that nobody in finance understands.

Domain-Driven Design fights exactly this: ubiquitous language, bounded context, explicit aggregates. A universal ERP is the antithesis of DDD at the core level.


Chapter 6. Why Universal ERPs Turn Into Monolithic Monsters

This chapter overlaps with a separate topic — why ERP systems become monoliths — but universality accelerates the decay.

Project start

  • A few entity types in EAV.
  • A few builder screens.
  • Simple logic: "read attributes, show form."

After five years

  • Hundreds of entity_type values and thousands of attributes — some obsolete, some duplicating each other.
  • Thousands of settings at client, branch, and role level.
  • Dozens of if (legacy_mode) branches and special paths for "that one" large customer.

Universality does not contain growth — it removes natural boundaries. In a normalized model, a new module means new tables and a service. In EAV, everything is the same EntityService, the same reporting engine, the same extension point. The monolith swells from within without modular seams.

After ten years

  • Nobody understands the whole system.
  • Documentation describes "how it was intended," not "how it works now."
  • Any change is a project measured in person-months.

Symptoms of architectural decay

Symptom Manifestation
Huge services EntityService at 15,000 lines
Huge controllers One "save universal object" endpoint for every case
Huge SQL 500-line reports with dynamic pivot
Huge configuration Metadata JSON larger than the module's code

The monster here is not the result of "business success" alone. It is business success on a foundation of universal abstraction that cannot bear the weight of reality.


Chapter 7. What Works Better Than Universality

The 80/20 principle

A practical rule for an ERP architect:

  • 80% of the system — specialized, typed domain modules: orders, warehouse, finance, manufacturing.
  • 20% — extensibility: custom fields, UI settings, webhooks, plugins.

Trying to make 80% universal shifts complexity into the 20% that was supposed to stay simple.

Core and extensions

A healthy structure:

┌─────────────────────────────────────────┐
│              Core (типизированный)       │
│  Orders │ Inventory │ Finance │ Mfg     │
└─────────────────┬───────────────────────┘
                  │ явные API / события
┌─────────────────▼───────────────────────┐
│  Domain modules (границы по контексту)   │
└─────────────────┬───────────────────────┘
                  │ plugin contract
┌─────────────────▼───────────────────────┐
│  Plugins / custom fields / integrations  │
└─────────────────────────────────────────┘

Core does not know plugin implementation details — only contracts. Plugins do not reach into the core through a shared Entity table.

Domain-Driven Design

Why business entities matter more than technical abstractions:

  • Bounded context — warehouse and accounting may call "product" differently, and that is fine; integration goes through an anti-corruption layer.
  • Aggregates — order as root with invariants: you cannot ship more than was reserved.
  • Domain eventsOrderShipped, not EntityUpdated(type=order, diff=...).

Technical abstraction saves lines of code at the start. A domain model saves years of maintenance.

Limited flexibility beats infinite flexibility

Infinite flexibility Limited flexibility
Any field anywhere Custom fields only on allowed entities
Any workflow Catalog of templates + parameters
Any relation Explicit FKs and domain services
"Configure without code" everywhere Low-code only on the periphery

Good architects deliberately deny freedom where freedom would destroy invariants. That is not hostility toward the business — it is protection of data and processes.


Chapter 8. How to Build ERP the Right Way

Domain first

Start not with tables and forms but with:

  • Processes — how an order becomes shipment and payment.
  • Roles — who sees what and who approves what.
  • Events — what happens on status change, what side effects follow.
  • Business rules — invariants that must not be violated for the sake of "flexibility."

Workshop with the process owner → glossary → model → DB schema. Not the other way around.

Typed models wherever possible

The core is described with explicit entities:

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');
  // ...
}

Order, product, warehouse bin, payment — separate types with separate rules. Shared concepts go into a shared kernel (money, addresses, units of measure), not into a "universal document."

Metadata only where it is truly needed

Good candidates for metadata:

  • custom fields on the client card (limited set of types);
  • UI settings, column visibility;
  • simple form builders for surveys and requests;
  • reference data that truly changes without a release (with caution).

Bad candidates:

  • order and order line structure;
  • accounting postings and registers;
  • warehouse movements and balances;
  • manufacturing BOMs.

If accounting correctness or taxes depend on a field — it must not be attribute_id = 847.

Architecture as a set of constraints

A strong ERP architect does not ask "how do we make everything possible." They ask:

  • which changes must go through release and code review;
  • where configuration by an implementer is acceptable;
  • where the boundary lies between product and a specific client's custom work.

Constraints are not weakness. They are preservation of integrity for the system the business runs on.


Chapter 9. Exceptions: When Universality Is Actually Needed

Universality is not a sin. The sin is applying it in the ERP core, where the stakes are highest.

Low-code platforms

When the product itself is a builder (Airtable, Retool, an internal request platform), EAV or a document store is justified. The user understands the trade-off: flexibility matters more than performance and strict typing. That is not ERP for a plant with 10,000 SKUs.

CRM builders

Sales pipeline, custom lead fields, arbitrary stages — a classic metadata zone. A mistake on a lead card rarely breaks the balance on account 51. The context is different.

Prototyping

An MVP in six weeks with shifting requirements — a universal model speeds hypothesis validation. An exit plan must be explicit: "after product-market fit, rewrite orders into a typed model." Without a plan, the prototype becomes legacy.

Research projects

When the domain is unknown, a flexible schema helps you learn. Once the domain stabilizes (usually after two or three iterations with real users), freeze the model. Research without crystallization is the same loan at interest.


Conclusion

Main point

The most expensive mistake in ERP architecture is not bad code and not "the wrong" PostgreSQL vs Oracle. The most expensive mistake is belief in a fully universal system for any business process.

The more architecture strives for universality, the more it:

  • loses performance — queries, indexes, infrastructure;
  • loses clarity — for people and for the IDE;
  • loses typing — errors slip into production;
  • loses predictability — every change is a lottery;
  • increases support cost — DBAs, development, integrators, business downtime.

A good ERP is not universal

A good ERP is a specialized core that reflects real company or industry processes, plus a limited set of well-designed extension mechanisms: plugins, events, custom fields on the periphery, explicit APIs between modules.

Universality works in a marketing deck and in a prototype. In the core of a system through which money, goods, and accountability flow — it is the most expensive mistake you can pour into the foundation.

If you are designing ERP today, ask not "how do we describe any object" but "which order, which warehouse, and which payment can we not afford to lose in abstraction." The answer to that question is the architecture.