
The first ERP integration is usually easy.
You have one ERP and one external system:
CRM ──────────→ ERP
So you write an API client, map a few fields, add authentication, handle some errors, and ship it.
Then another integration arrives:
E-commerce ───→ ERP
Then:
Warehouse ────→ ERP
Then Finance needs data exported. A supplier portal needs purchase orders. The ERP gets upgraded.
Eventually the architecture looks like this:
CRM ────────────────→ ERP
E-commerce ─────────→ ERP
Warehouse ──────────→ ERP
Finance ────────────→ ERP
Supplier Portal ────→ ERP
Support ─────────────→ ERP
Every integration now has its own authentication, field mappings, retry behavior, logging, and assumptions about ERP data.
That's when we stopped thinking about individual connections and started thinking about a reusable ERP integration layer.
The goal wasn't another abstraction for the sake of architecture.
It was to create one place where ERP-specific complexity could live.
The Problem With Point-to-Point Integrations
Imagine an ERP exposes customers like this:
{
"customer_no": "C-10492",
"cust_name": "Acme Manufacturing",
"payment_code": "N30",
"currency_code": "USD"
}
Your CRM wants:
{
"externalId": "C-10492",
"accountName": "Acme Manufacturing",
"paymentTerms": "NET_30",
"currency": "USD"
}
Writing that transformation once isn't difficult.
The problem starts when five applications independently implement it:
CRM ──────────────┐
E-commerce ───────┤
Warehouse ────────┼──→ ERP
Finance ──────────┤
Support ──────────┘
Now every application understands ERP internals.
If:
N30
changes to:
NET30
you may need to modify several integrations.
That's coupling.
And ERP environments generate a lot of it.
Put the ERP Behind a Stable Contract
Instead of allowing every system to communicate directly with ERP-specific APIs, we introduced a boundary:
CRM ───────────────┐
E-commerce ────────┤
Warehouse ─────────┼──→ Integration Layer ───→ ERP
Finance ───────────┤
Supplier Portal ───┘
The ERP integration layer became responsible for authentication, transformation, validation, retries, idempotency, logging, observability, error classification, rate limiting, and ERP-specific API behavior.
Applications no longer needed to understand everything about the ERP.
They needed to understand the integration contract.
This kind of architecture is also why broader ERP system integration and API integration planning matters. Once an ERP needs to exchange data with CRM, finance, HR, supply-chain, e-commerce, legacy, and third-party applications, integration architecture becomes a platform concern rather than a collection of isolated API calls.
That distinction became increasingly valuable as the number of connected systems grew.
Start With Canonical Business Objects
One mistake is exposing the ERP schema directly.
Suppose the ERP represents a customer as:
{
"CUST_NO": "100842",
"CUST_NM": "ACME INDUSTRIAL",
"PYMT_TRM": "03",
"CURR": "USD"
}
We didn't want every consuming system to understand those conventions.
Instead, the integration layer exposed something closer to:
{
"id": "100842",
"name": "Acme Industrial",
"paymentTerms": "NET_30",
"currency": "USD"
}
The canonical model represented the business concept rather than one application's database schema.
Conceptually:
ERP Customer
↓
Canonical Customer
↓
CRM Customer
That extra transformation looks unnecessary when you have one integration.
It becomes useful when you have ten.
Don't Build One Giant Universal Model
Canonical models can also become a trap.
We initially wanted something like:
UniversalCustomer
that represented every possible customer field across every application.
Eventually it becomes:
UniversalCustomer
├── 190 fields
├── ERP-specific fields
├── CRM-specific fields
├── E-commerce fields
├── Legacy fields
└── Fields nobody understands anymore
That's not abstraction.
That's schema accumulation.
We had better results with smaller contracts aligned to business capabilities:
- CustomerIdentity
- CustomerCreditProfile
- CustomerAddress
- CustomerPricing
- CustomerTaxProfile
Not every consumer needs every attribute.
Separate Transport From Business Mapping
A common integration implementation mixes everything together:
HTTP Request
↓
Authentication
↓
Parse JSON
↓
Business Mapping
↓
Validation
↓
ERP Request
↓
Retry
inside one large service.
We separated those responsibilities.
Transport
↓
Contract Validation
↓
Business Transformation
↓
ERP Adapter
↓
ERP
The transport layer answers:
- How did the message arrive?
- The transformation layer answers:
- What does this data mean?
- The ERP adapter answers:
- How does this particular ERP expect to receive it?
That separation made testing much easier.
Put ERP-Specific Behavior Behind an Adapter
Suppose one ERP version requires:
POST /customers/create
while another requires:
POST /api/v2/business-partners
Consumers shouldn't care.
Our interface might conceptually look like:
interface CustomerRepository {
create(customer: Customer): Promise<CustomerResult>;
update(customer: Customer): Promise<CustomerResult>;
findByExternalId(id: string): Promise<Customer | null>;
}
The ERP adapter implements that contract.
Customer Service
↓
CustomerRepository
↓
ERP Adapter
↓
ERP API
Now ERP-specific implementation details stay at the edge.
- Idempotency Became Mandatory
- Distributed systems retry.
- Networks fail.
- Workers crash.
- Clients time out.
Suppose:
Create Purchase Order
↓
ERP creates PO
↓
Network timeout
↓
Caller assumes failure
↓
Retry
Without idempotency:
PO-10001
PO-10002
may represent the same business request.
That's a serious problem.
We started requiring an idempotency key for operations that could create duplicate business transactions.
Idempotency-Key: order-78432-v1
The integration layer stores something conceptually like:
order-78432-v1
↓
ERP PO 10001
If the same request arrives again:
Same key?
↓
YES
↓
Return previous result
Retry safety isn't optional when you're creating financial or operational records.
Retry Only Errors That Might Recover
Our earliest retry logic was effectively:
Request failed?
↓
Retry
That's dangerous.
Consider:
HTTP 400
Invalid tax code
Retrying it ten times changes nothing.
But:
HTTP 503
ERP temporarily unavailable
may recover.
We classified errors:
TRANSIENT
├── Timeout
├── Connection failure
├── 429
└── 503
PERMANENT
├── Invalid customer
├── Invalid currency
├── Missing required field
└── Unsupported tax code
Then:
Transient error
↓
Retry with backoff
while:
Permanent error
↓
Exception queue
This reduced both ERP load and pointless retry storms.
Exponential Backoff Needed Jitter
If 500 workers lose access to ERP simultaneously and all retry exactly five seconds later, you haven't recovered.
You've created another traffic spike.
Instead of:
5s
10s
20s
40s
we added jitter so retries were distributed across time.
Conceptually:
delay = backoff + random_jitter
The goal is to prevent synchronized clients from hammering an ERP that's trying to recover.
Validate Before the ERP Call
We originally relied too much on ERP validation.
Request:
{
"customerId": "",
"currency": "ABC",
"quantity": -4
}
Send it to ERP.
ERP rejects it.
That wastes an ERP request and often produces a worse error message.
Instead:
Incoming Request
↓
Schema Validation
↓
Business Validation
↓
Reference Validation
↓
ERP
Examples include:
- customerId required
- quantity > 0
- currency ∈ supported currencies
- warehouse must exist
- product must be active
The integration layer should reject obviously invalid data before expensive downstream work begins.
Don't Duplicate the Entire ERP Rule Engine
There's an opposite failure mode.
If the ERP has hundreds of validation rules, reproducing all of them in the integration layer creates two business-rule engines.
Eventually:
Integration says VALID
ERP says INVALID
or worse:
Integration says INVALID
ERP would accept it
We separated responsibilities:
Contract validation
→ Integration layer
Cross-system invariants
→ Integration layer
ERP-internal rules
→ ERP
The integration layer should protect the boundary.
It shouldn't become a second ERP.
Synchronous APIs Weren't Enough
Some operations need immediate answers:
- Get Customer
- Get Product Availability
- Validate Address
Synchronous APIs are reasonable.
Other operations don't need the caller waiting:
- Export 50,000 products
- Import invoice batch
- Synchronize inventory
- Publish shipment updates
For those, we preferred:
Producer
↓
Queue / Event Bus
↓
Integration Worker
↓
ERP
Now the producer doesn't remain coupled to ERP response time.
Queues Changed Failure Handling
Without a queue:
Application
↓
ERP unavailable
↓
Application fails
With durable messaging:
Application
↓
Queue
↓
ERP unavailable
↓
Retry later
That doesn't magically solve every problem.
But it changes the failure boundary.
The upstream application may continue operating while ERP connectivity recovers, assuming the business workflow allows eventual consistency.
Eventual Consistency Needed to Be Explicit
Suppose inventory changes:
ERP
↓
Integration Layer
↓
E-commerce
There may be a delay.
So we needed to define acceptable consistency windows.
For example:
Customer update
< 60 seconds
Product description
< 5 minutes
Inventory availability
< 10 seconds
Those aren't universal targets.
The point is to define the expectation.
"Near real time" isn't a useful architecture requirement.
A measurable latency target is.
Ordering Became Important
Suppose these events occur:
- Customer Created
- Customer Updated
- Customer Disabled
If distributed workers process them as:
3 → 1 → 2
the final state may be wrong.
For entities where order matters, we needed mechanisms such as partitioning by entity key, sequence numbers, version numbers, or stale-update rejection.
For example:
{
"customerId": "C-1004",
"version": 17
}
If the target has already processed version 18:
version 17
↓
STALE
↓
Ignore / investigate
Event ordering should be designed rather than assumed.
We Needed a Dead-Letter Strategy
Eventually, some messages will fail repeatedly.
Maybe:
- Product doesn't exist
- Customer mapping is missing
- Tax code was retired
- Payload is malformed
Retrying forever isn't reliability.
It's an infinite loop.
So after defined attempts:
Message
↓
Retry
↓
Retry
↓
Retry
↓
Dead-Letter Queue
The important part wasn't creating the DLQ.
It was creating the operational process around it.
Every failed message needed enough information to answer:
- What failed?
- Why?
- Which source produced it?
- Which ERP operation failed?
- Can we retry it safely?
- Who owns resolution?
A dead-letter queue nobody monitors is just a database of forgotten failures.
Correlation IDs Saved Debugging Time
Imagine a sales order moves through:
E-commerce
↓
API Gateway
↓
Integration API
↓
Queue
↓
Worker
↓
ERP Adapter
↓
ERP
Then someone reports:
Order 48392 didn't reach ERP.
Without correlation, you search several systems manually.
Instead, generate or propagate:
correlation_id = 7f91c...
through the transaction.
Logs can now show:
[7f91c] Request accepted
[7f91c] Validation passed
[7f91c] Message published
[7f91c] Worker started
[7f91c] ERP request sent
[7f91c] ERP timeout
[7f91c] Retry scheduled
That one design choice saved a lot of debugging time.
Logging Payloads Created a Security Problem
Detailed integration logs are useful.
But ERP payloads can contain customer information, employee information, addresses, financial data, banking details, pricing, and tax identifiers.
So:
console.log(JSON.stringify(request));
isn't an observability strategy.
We moved toward structured operational metadata:
{
"correlationId": "7f91c",
"operation": "CreateCustomer",
"externalId": "C-10482",
"status": "FAILED",
"errorType": "ERP_TIMEOUT"
}
Sensitive fields should be masked or omitted according to the organization's security and compliance requirements.
Metrics Were More Useful Than More Logs
Logs tell you what happened to one transaction.
Metrics tell you whether the integration platform is healthy.
We tracked things such as:
- Requests / minute
- Success rate
- Failure rate
- Retry rate
- ERP latency
- Queue depth
- Oldest queued message
- Dead-letter count
- Processing duration One metric became particularly valuable:
Oldest unprocessed message age
A queue depth of 10,000 might be expected during a large batch.
But:
Oldest message = 4 hours
is immediately concerning when the expected processing window is five minutes.
Rate Limiting Protected the ERP
External applications can scale faster than ERP systems.
Suppose:
E-commerce
10,000 requests/minute
while ERP safely handles:
500 requests/minute
Without protection:
High traffic
↓
ERP overload
↓
Timeouts
↓
Retries
↓
More traffic
↓
ERP outage
The integration layer became the shock absorber:
Incoming traffic
↓
Rate Limit
↓
Queue
↓
Controlled concurrency
↓
ERP
Sometimes slowing down work is the most reliable way to complete it.
Circuit Breakers Helped During ERP Outages
If ERP is clearly unavailable, repeatedly calling it wastes resources.
A circuit breaker gives us:
CLOSED
Requests flow normally
After enough failures:
OPEN
Fail fast / queue work
Later:
HALF OPEN
Test recovery
Then:
Healthy → CLOSED
Still failing → OPEN
This prevented a failing dependency from consuming every worker in the integration platform.
API Design Became Part of the Integration Architecture
Once several applications depended on the integration layer, its API stopped being an internal implementation detail.
It became a product boundary.
We needed consistent decisions around authentication, endpoint conventions, pagination, rate limits, error responses, compatibility, monitoring, and versioning.
This is where practices from custom API development and integration architecture become directly relevant. A reusable integration layer benefits from treating REST, SOAP, GraphQL or other interfaces as managed contracts, with rate limiting, versioning, testing, monitoring, and resilient error handling designed into the API lifecycle rather than added after production problems appear.
The implementation behind an endpoint could change.
The contract consumed by other systems needed much more discipline.
Version the Contracts, Not Every Internal Detail
Suppose:
{
"customer": "C-100"
}
becomes:
{
"customerId": "C-100"
}
That tiny rename can break every consumer.
For breaking changes, we needed something like:
/v1/orders
/v2/orders
or an equivalent schema-versioning mechanism.
Internally, we could refactor aggressively.
Externally, compatibility mattered.
Contract Tests Became Essential
Unit tests weren't enough.
The dangerous question was:
Does the adapter still satisfy the contract expected by the rest of the platform?
We created tests around canonical requests and expected ERP transformations.
Given
Canonical Customer
When
ERP Adapter transforms it
Then
Expected ERP payload is produced
And the reverse:
Given
ERP Customer response
When
Adapter maps it
Then
Expected canonical customer is returned
This became particularly useful during ERP upgrades.
ERP Upgrades Became Much Less Scary
Before the integration layer:
ERP Upgrade
CRM breaks
E-commerce breaks
Warehouse breaks
Finance breaks
After introducing adapters:
ERP Upgrade
↓
ERP Adapter changes
↓
Canonical contracts remain stable
↓
Consumers mostly unchanged
That's one of the strongest arguments for the architecture.
The ERP integration layer acts as a boundary between ERP-specific behavior and the rest of the application ecosystem.
Not every upgrade becomes free.
But the blast radius can become much smaller.
Batch APIs Needed Different Design
Creating one customer:
POST /customers
is easy.
Synchronizing 500,000 products isn't the same problem repeated 500,000 times.
For large datasets we considered:
- Pagination
- Batch size
- Checkpointing
- Parallelism
- Rate limits
- Partial failure
- Resume behavior
A batch job needed to be restartable.
Instead of:
500,000 records
↓
Failure at 487,293
↓
Start again
we wanted:
Checkpoint: 487,000
↓
Resume
Long-running ERP integrations need recovery semantics, not just loops.
Partial Success Needed an Explicit Model
Suppose a batch contains 1,000 invoices:
992 succeeded
8 failed
What does the API return?
Simply returning:
500 Internal Server Error
loses important information.
A better result might contain:
{
"processed": 1000,
"succeeded": 992,
"failed": 8,
"errors": [
{
"externalId": "INV-8472",
"code": "INVALID_CUSTOMER"
}
]
}
Now the caller can distinguish complete failure from partial completion.
Reconciliation Was Still Necessary
Reliable messaging does not eliminate reconciliation.
Imagine:
Integration says:
10,000 orders sent
ERP says:
9,997 orders created
Where are the other three?
We built reconciliation around business identifiers:
source_order_id
erp_order_id
integration_status
erp_status
last_verified_at
Then periodically checked:
Source
↕
Integration State
↕
ERP
Distributed systems need a way to prove that both sides agree.
Secrets Didn't Belong in Configuration Files
An integration layer naturally accumulates credentials:
- ERP credentials
- API keys
- OAuth secrets
- Certificates
- Database passwords
Those shouldn't live in source control or plain configuration files.
We separated:
Configuration
from:
Secrets
and used an appropriate secret-management mechanism.
That also made credential rotation easier.
A reusable integration platform should make the secure path the easy path.
Reusable Doesn't Mean Monolithic
Centralizing integration logic creates another risk:
Everything
↓
One enormous integration service
Now every deployment affects every integration.
Instead, we preferred shared platform capabilities with independently deployable integration components:
┌─────────────────────┐
│ Shared Capabilities │
│ │
│ Auth │
│ Logging │
│ Retry │
│ Observability │
│ Contracts │
└─────────┬───────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Customer Adapter Order Adapter Inventory Adapter
│ │ │
└───────────────┼───────────────┘
▼
ERP
Reusable doesn't have to mean monolithic.
What We Standardized
Eventually, every integration started with the same baseline:
- Authentication
- Correlation IDs
- Structured logging
- Metrics
- Tracing
- Retry policy
- Timeout policy
- Idempotency
- Error taxonomy
- Dead-letter handling
- Contract validation
- Secret management
- Health checks
- Rate limiting
Developers shouldn't need to reinvent these for every ERP endpoint.
The reusable layer provided them by default.
That was where the architecture started paying dividends.
What We'd Build First Today
If starting again, we wouldn't begin by designing a massive enterprise integration framework.
We'd begin with one real integration and extract reusable patterns.
Integration #1
↓
Identify repeated concerns
↓
Extract shared capabilities
↓
Integration #2
↓
Validate abstractions
↓
Improve platform
↓
Integration #3
That's safer than designing abstractions based on integrations that don't exist yet.
A reusable architecture should emerge from repeated requirements.
Not imagination.
A Practical ERP Integration Layer Checklist
Before calling an integration platform reusable, we'd verify:
- ERP-specific APIs are isolated behind adapters.
- Business contracts don't unnecessarily expose ERP internals.
- Authentication is standardized.
- Secrets are managed outside source code.
- Requests have correlation IDs.
- Logging is structured and sensitive data is protected.
- Transient and permanent errors are distinguished.
- Retry policies use bounded backoff and jitter.
- Create operations support idempotency where required.
- Asynchronous workflows have dead-letter handling.
- Queue depth and message age are monitored.
- Rate limiting protects ERP capacity.
- Contracts have a compatibility/versioning strategy.
- Contract tests cover adapters.
- Batch processing can resume after failure.
- Partial-success behavior is explicit.
- Business records can be reconciled across systems.
- ERP upgrades can primarily be absorbed at the adapter boundary.
The Biggest Lesson
At first, we thought an ERP integration layer was mostly about reusable API code.
It wasn't.
The valuable part was creating a boundary.
Without one:
Every Application
↓
Understands ERP
↓
Depends on ERP
↓
Breaks when ERP changes
With one:
Applications
↓
Stable Business Contracts
↓
ERP Integration Layer
↓
ERP-Specific Complexity
↓
ERP
That boundary gave us somewhere to centralize the difficult parts of integration: retries, identity mapping, validation, rate limits, idempotency, observability, error handling, security, and version compatibility.
The goal isn't to hide the ERP completely.
And it isn't to create a giant middleware platform before the first integration ships.
The goal is simpler:
Keep ERP-specific complexity from leaking into every application that needs ERP data.
- That's what made the layer reusable.
- Not the number of shared classes.
- Not the number of endpoints.
- Not the middleware product.
The real measure was this:
When the ERP changed, how many other systems had to change with it?
The smaller that answer became, the better our integration architecture was.
Top comments (0)