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
attributestable forentity_type=order, plus client 17 overrides." - No IDE autocomplete —
getField('prcie')is a typo in a string; the error appears only at runtime. - No static typing —
$pricemay be a string, null, or an array "for a composite field." - No safe refactoring — renaming
pricetototal_amountmeans 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_typevalues and thousands ofattributes— 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 events —
OrderShipped, notEntityUpdated(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.