ERP Architecture Patterns
How ERPs are actually built, and why they look the way they do.
The classic three-tier
┌─────────────────────────────────────────┐
│ PRESENTATION │
│ Thick client / browser / mobile / RF │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ APPLICATION │
│ Business logic, work processes, │
│ stateless app servers, horizontally │
│ scaled behind a message/dispatch layer │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ DATABASE │
│ The single source of truth. │
│ Usually ONE database. Usually huge. │
└─────────────────────────────────────────┘
The application tier scales horizontally; the database usually does not. This is the defining constraint of classic ERP architecture and the reason ERPs run on the largest single database instances in most enterprises.
Why one database
Because the invariants are cross-module and must be transactional:
A goods receipt must atomically:
- increase inventory quantity
- increase inventory value
- create a GR/IR liability
- reduce the PO open quantity
- create a quality inspection lot
- update material planning data
If those live in six services with six databases, you need distributed transactions (slow, fragile) or eventual consistency (which means your balance sheet is temporarily wrong, which auditors will not accept).
This is the single biggest architectural difference between ERP and modern web applications, and the reason “just use microservices” is not a sufficient answer for a ledger.
Where the business logic lives
| Pattern | Product | Notes |
|---|---|---|
| In the database | Oracle EBS (PL/SQL packages) | Fast (no data movement), hard to test, hard to version |
| In an application server VM | SAP ABAP | Its own language, runtime, and transport system |
| In .NET/Java app tier | Dynamics, Acumatica, Epicor | Familiar tooling, standard debuggers |
| In Python | Odoo, ERPNext | Readable, extensible, dynamically typed |
| Pushed down to the DB | S/4HANA CDS views | “Code-to-data” — aggregate where the data is |
The modern trend is code-pushdown for reads, application-tier for writes. Reads benefit enormously from running in the database (no transfer of a million rows); writes need the richer programming model and testability of the app tier.
Metadata-driven design
The defining ERP pattern: the application reads its own structure from tables at runtime.
Instead of: ERPs do:
class SalesOrder { table: entity_definition
String customer; table: field_definition
Date orderDate; table: screen_layout
} table: validation_rule
table: business_rule
This is how one binary serves a chemical plant and a bank. Consequences:
Benefits: customers add fields without a code change; screens reconfigure; one codebase serves everyone; upgrades preserve customer changes.
Costs: everything is indirect and harder to debug; static analysis barely works; performance requires caching the metadata; and it’s slower than compiled code.
Examples: SAP’s Data Dictionary (DDIC), Odoo’s ir.model / ir.ui.view, Frappe’s DocTypes, Dynamics’ AOT and Dataverse, NetSuite’s SuiteScript record definitions.
The kernel/application split
┌────────────────────────────────────────┐
│ Customer extensions │ ← you write this
├────────────────────────────────────────┤
│ Industry solutions │ ← vendor/partner
├────────────────────────────────────────┤
│ Application (FI, MM, SD, PP, HR) │ ← vendor
├────────────────────────────────────────┤
│ Framework / platform / ORM │ ← vendor
├────────────────────────────────────────┤
│ Kernel / runtime (C/C++) │ ← vendor, never touched
├────────────────────────────────────────┤
│ Database + OS │
└────────────────────────────────────────┘
You work in the top layer. The layers below are the vendor’s, and touching them is what “modification” means and why it’s discouraged. See Extensibility.
Work processes and dialog steps
Classic ERPs multiplex many users onto few OS processes:
User request → dispatcher queue → free work process → execute → roll out context → return
Types: dialog (interactive, with a runtime limit — SAP’s default is 600s, after which your long-running report is killed), background (batch, no limit), update (asynchronous DB updates), enqueue (lock management), spool (printing).
Why you care: a long-running interactive report will be terminated. The correct answer is a background job that writes results somewhere the user can collect them — not raising the timeout.
The lock concept
Databases lock rows for the duration of a transaction. ERP business transactions span many database transactions — a user opens a sales order, thinks, edits, and saves eight minutes later.
So ERPs implement an application-level lock (SAP’s enqueue server; equivalents everywhere):
User opens sales order 4711 → application lock acquired on (SO, 4711)
User edits for 8 minutes → NO database lock held
User saves → database transaction: read, validate, write, commit
→ application lock released
Locks are named by object and key, held in a lock table, and released on save or session end. Orphaned locks after a crash are a routine operational problem — every ERP admin knows how to clear stuck locks.
If you build your own, implement this pattern. Holding a database transaction open across user think time will destroy your system under load.
Transaction / logical unit of work
Screen 1 ─┐
Screen 2 ─┼─▶ collect data in memory / a staging area
Screen 3 ─┘
Save ───▶ ONE database transaction: validate all, write all, commit or roll back all
The user-facing “transaction” (create a sales order) is a logical unit of work spanning several screens. Only at save does it become a database transaction. Partial saves must be impossible — that’s the whole point.
The update mechanism
For throughput, some ERPs decouple the dialog from the database write:
Dialog process: validate → write update records → commit → return control to user
Update process: read update records → perform actual DB updates → commit
The user gets their screen back immediately. The risk: an update can fail after the user was told it succeeded. Every ERP has an “update terminated” monitor and an alerting requirement. This is a genuinely surprising failure mode for developers new to ERP.
Modern layering
┌──────────────────────────────────────────────────────┐
│ Channels: Web UI · Mobile · API clients · EDI · RF │
├──────────────────────────────────────────────────────┤
│ API layer: REST / OData / GraphQL, auth, throttling│
├──────────────────────────────────────────────────────┤
│ Application services: process orchestration │
├──────────────────────────────────────────────────────┤
│ Domain services: posting, pricing, ATP, MRP │
├──────────────────────────────────────────────────────┤
│ Data access: ORM / CDS / repositories │
├──────────────────────────────────────────────────────┤
│ Database (OLTP) ──────▶ Read replica / analytics │
├──────────────────────────────────────────────────────┤
│ Events: outbox → broker → subscribers │
└──────────────────────────────────────────────────────┘
Monolith vs modular vs microservices
| Approach | Fit for ERP |
|---|---|
| Monolith, one DB | Still the right default for the transactional core. Consistency comes free |
| Modular monolith | Best of both: clear module boundaries, enforced dependencies, one DB, one deployment |
| Microservices | Good for the edges — document generation, notifications, portals, analytics, integrations, OCR. Bad for the ledger |
| Composable / API-first | The core stays a suite; satellites are separate. The current industry direction |
Practical guidance if you’re building:
Keep in the core (one DB, one transaction):
ledger · inventory · orders · pricing · master data
Split out (separate services, eventual consistency is fine):
document rendering · email/SMS · file import · OCR · analytics
customer portal · webhooks · scheduled reports · search index
The test: does it need to be in the same transaction as a posting? If yes, keep it in. If no, split it.
Event-driven extension
The modern compromise — the core stays transactional, extensions react asynchronously:
Core posts a goods receipt
│ (transactional outbox, same DB transaction)
▼
Outbox table ──▶ relay ──▶ message broker
├──▶ notify the supplier portal
├──▶ update the analytics warehouse
├──▶ trigger a quality workflow
└──▶ push to a customer webhook
The transactional outbox is the key pattern. Writing the event to the same database in the same transaction as the business data guarantees you never publish an event for a rolled-back transaction, and never lose an event for a committed one. Publishing directly to a broker from application code gives you both failure modes.
Read/write separation
Writes ──▶ OLTP database (normalised, indexed for point access)
│ replication / CDC
▼
Reads ──▶ Read replica / columnar store / warehouse
(denormalised, indexed for scans and aggregation)
Heavy reporting on the OLTP instance is the most common cause of ERP performance complaints. In-memory systems (HANA) blur the line by making both workloads viable on one store, but even there, discipline about which queries run where still pays.
Design principles
- The database is the product. Design the schema first.
- One transactional core, many satellites.
- Metadata-driven where customers need flexibility; compiled where you need speed.
- Application locks for user think time; DB locks only inside the save.
- Transactional outbox for every event.
- Every write goes through a service, never direct SQL from a UI or a report.
- Design for the batch window, not just the interactive path. Month-end is your peak load.
- Assume extension. Every entity will need custom fields; every process will need a hook.