MODRACXKENNETH D'SILVA

← Archive & Insights

Enterprise ERP Systems Integration for High-Volume E-Commerce

A distributor had three integrations from three suppliers all writing to the same NetSuite inventory field. Nobody could say what the correct behaviour was. That is an architecture problem, not a bug.

By Kenneth D'SilvaReading Time: 27 min readCategory: Integrations & Systems

1. Two Integrations, One Field, Nobody Watching

A building supplies distributor called me in March 2025 because their stock levels were wrong. Not slightly wrong — wrong in a way that had them overselling one product line and hiding availability on another, and it had been going on for about seven weeks.

They ran NetSuite. They had a Magento storefront, a trade counter POS, and a marketplace listing on a third-party site. Over four years they had accumulated three integrations, built by three different suppliers, each of which wrote to NetSuite's inventory. Two of them also wrote back to Magento.

The specific failure was that the POS integration adjusted the available quantity on a sale, and the marketplace integration also adjusted the available quantity when a marketplace order came in, and for products sold through both channels the same physical unit was being decremented twice on some paths and once on others depending on which system saw the order first. Nobody could say what the correct behaviour was because nobody had written it down. Each supplier had built something reasonable in isolation.

What made it unfixable-in-place was the architecture. There was no middleware. Each integration talked directly to NetSuite's SuiteTalk API with its own credentials, its own field mappings, and its own idea of what a "product" was. Changing the behaviour meant changing three codebases owned by three companies, two of whom no longer had the original developer.

That is an architecture problem, not an integration bug. And it is the problem this article is about: not how to wire a specific ERP to a specific storefront, but how to decide what shape the connection should be before you build it, how to evaluate what a vendor is actually offering you, and which decisions you will not be able to reverse.

I have written separately and in depth about the SAP Commerce and S/4HANA case, including idempotency, reconciliation and the mechanics of order export. This piece deliberately stays a level up and stays vendor-neutral.

2. The Three Architectures, and Only Three

Every commerce-to-ERP connection I have seen is one of three shapes, or a muddle of two of them. Naming which one you are building is the single most useful thing you can do at the start.

Point-to-point. The storefront talks to the ERP directly, over the ERP's API, with mapping logic living in one or both endpoints. Fastest to build, cheapest to start, and the number of connections grows quadratically with the number of systems. Two systems means one integration. Five systems means potentially ten.

Hub and spoke, via middleware. Every system talks to a central integration layer, which owns transformation and routing. Each system needs one connection instead of N. The middleware is a new thing to run, license and staff, and it is a single point of failure unless you make it not one.

Event backbone. Systems publish events to a broker — Kafka, EventBridge, Service Bus, Pub/Sub — and consumers subscribe to what they care about. Producers do not know who is listening. Genuinely the best shape at scale and genuinely the most demanding to operate, because you now own a distributed log and everything that implies about ordering, replay, schema evolution and consumer lag.

Point-to-pointMiddleware hubEvent backbone
Time to first working flowDaysWeeksWeeks to months
Cost of adding system 43 new integrations1 new connection1 new subscriber
Where transformation livesEndpoints, usually bothOne placeConsumers, or a stream processor
Replay after an outageManual, per integrationDepends on the toolNative, by offset
Skills neededThe two platformsPlus the iPaaSPlus distributed systems
Failure blast radiusOne pairEverything, if unclusteredOne consumer group
Realistic annual cost£0 tooling£12k–£80k tooling£8k–£40k plus people

My position, which is less fashionable than it should be: point-to-point is correct for a two-system estate and stays correct longer than architects like to admit. One storefront, one ERP, no immediate plan for a third system — build it point-to-point, build it well, and spend the saved money on observability. The distributor at the top of this article did not have a point-to-point problem; they had a three-integrations-nobody-owned problem, which is different.

Middleware earns its cost at the third system, or at the second if one of them is going to be replaced. Event backbone earns its cost when you have real-time requirements across several consumers, or when you need replay, or when you already run Kafka for something else.

What is never right is drifting from one to the other by accident, which is what happens when you build point-to-point, add a third system, build another point-to-point, and then add a "small sync service" that is middleware in everything but name and governance.

3. When Point-to-Point Is Genuinely Right

Worth defending properly, because the industry consensus is against it and the consensus is wrong at small scale.

The conditions under which I would build direct: two systems, both of which have a stable, documented, versioned API; a flow set of four or fewer; a team that can support it; and no credible plan to add a third system within eighteen months. That describes a large fraction of mid-market ecommerce.

The discipline that makes it survivable is treating the integration as a distinct component with its own repository, its own deployment, and its own owner — not as a module inside the storefront. The moment the mapping logic lives in a Magento module or a Shopify app that a frontend developer edits, you have coupled the integration lifecycle to the storefront's release cycle, and an ERP upgrade becomes a storefront deployment.

# The integration as its own service, even when it is point-to-point.
# Mapping is data, not code branches: a declarative map is diffable,
# reviewable by a non-developer, and testable without the ERP.
FIELD_MAP = {
    "sku":            {"source": "itemid",            "required": True},
    "name":           {"source": "displayname",       "required": True},
    "price":          {"source": "baseprice",         "transform": "decimal2"},
    "weight_kg":      {"source": "weight",            "transform": "lb_to_kg"},
    "tax_class":      {"source": "taxschedule",       "lookup": "tax_classes"},
    "status":         {"source": "isinactive",        "transform": "invert_bool"},
    # Fields the ERP does not own. Listing them here, with source None,
    # is how you document ownership in a place developers actually read.
    "description":    {"source": None, "owner": "commerce"},
    "meta_title":     {"source": None, "owner": "commerce"},
    "images":         {"source": None, "owner": "pim"},
}

class UnknownReferenceValue(Exception):
    """Raised when a lookup misses. Fail loudly — never default silently.
    Silently mapping an unknown tax schedule to 'standard' is how you
    ship a year of incorrect VAT."""

def map_product(erp_row: dict, lookups: dict) -> dict:
    out = {}
    for target, rule in FIELD_MAP.items():
        src = rule["source"]
        if src is None:
            continue                       # owned elsewhere; do not touch
        raw = erp_row.get(src)
        if raw in (None, "") and rule.get("required"):
            raise ValueError(f"{target}: required field {src} empty "
                             f"on {erp_row.get('itemid')}")
        if "lookup" in rule:
            table = lookups[rule["lookup"]]
            if raw not in table:
                raise UnknownReferenceValue(f"{rule['lookup']}: {raw!r}")
            raw = table[raw]
        elif "transform" in rule:
            raw = TRANSFORMS[rule["transform"]](raw)
        out[target] = raw
    return out

The owner annotation on fields with no ERP source is doing more work than it looks like. It is the ownership table, expressed in the place where someone is most likely to be tempted to violate it.

4. What You Are Actually Buying With Middleware

iPaaS vendors sell connectors. That is not what you are buying and treating it as though it were leads to disappointment.

What you are actually buying is four things. A place to put transformation logic that is neither endpoint. A retry and error-handling substrate you did not have to write. Operational visibility — a screen showing what ran, what failed, and what is stuck. And a hiring pool, because "we use Boomi" is a recruitable skill in a way that "we have a bespoke Python service" is not.

What you are not buying, despite the sales deck: working integrations. Every vendor connector I have used required substantial configuration, and several required custom code for anything past the demo path. A connector gets you authentication, the API client, and a set of pre-built operations. It does not get you your business rules, and your business rules are 80% of the work.

The evaluation questions I ask an iPaaS vendor, in order of how much the answer has predicted the outcome:

"Show me the error handling on a failed order export, live." Not a slide. What happens when the ERP returns a 500 on message 400 of 1,000? Does the batch fail, or does that message go somewhere retryable? Can I see the payload? Can I fix the data and replay just that one? Vendors whose answer is "it goes to the error log" are selling you a message bus with a nice UI.

"How do I version an interface?" When the ERP adds a required field next April, how do I run old and new shapes simultaneously during the transition? If the answer involves duplicating the entire flow and switching a router, that is workable. If there is no answer, you have a big-bang cutover in your future.

"What is in source control?" Some platforms store flows as opaque database records editable only through the web UI. That means no code review, no diff, no branch, and no meaningful rollback. This is the question that most reliably separates tools engineers can live with from tools they will route around.

"How is it priced as we grow?" Per connection, per message, per task, per environment, or per named user — and every one of those has a version where the price becomes absurd at a volume you can foresee. Message-based pricing on a system that syncs inventory every fifteen minutes across 40,000 SKUs is a trap. Model your actual volume against their metric before signing.

"Can I run it in my own network?" Matters if your ERP is on-premises, and matters for data residency. Most cloud iPaaS offer an on-premises agent; the question is what the agent can and cannot do, and whether the control plane still needs to see your payloads.

5. The Event Backbone, and Whether You're Ready

The architecture I would choose if I were starting fresh with three or more systems and had the operational capability. It is also the one most often adopted for the wrong reason, which is that it sounds more modern.

The genuine advantages are specific. Producers do not know their consumers, so adding a fourth system that needs order data is a subscription rather than a change to the order flow. Replay is native — if a consumer had a bug for three days, you reset its offset and reprocess. And the log is a shared source of truth about what happened, which is enormously useful when two systems disagree.

The costs are equally specific and they are the ones people under-model.

Ordering is per-partition, not global. If you partition your inventory topic by SKU, updates for a given SKU are ordered and updates across SKUs are not. That is usually fine. If you partition by something else, or if you use multiple topics for related entities, you will get a "product created" event processed after the "product updated" event that depends on it. Design the partition key before anything else.

Schema evolution is a discipline you must actually run. A schema registry with compatibility enforcement, or you will have a producer add a field and a consumer fall over three weeks later.

Exactly-once is mostly a lie. You get at-least-once and you build idempotent consumers. Anyone treating "exactly-once semantics" as a checkbox that removes the need for idempotency has not operated one of these.

// A commerce order event. Three things make this survivable:
// a schema version, a stable event id for deduplication, and an
// explicit occurred-at that is NOT the time it was published.
{
  "specversion": "1.0",
  "type": "com.example.order.placed",
  "source": "/commerce/storefront-uk",
  "id": "ord_01HQ7X3M9K2F5T8V",
  "time": "2026-03-11T14:22:09.442Z",
  "datacontenttype": "application/json",
  "dataschema": "https://schemas.example.com/order/placed/v3.json",
  "subject": "WEB-2026-000184213",
  "data": {
    "orderRef": "WEB-2026-000184213",
    "occurredAt": "2026-03-11T14:22:07.118Z",
    "channel": "web",
    "currency": "GBP",
    "customer": { "erpAccount": "C-004821", "email": "[email protected]" },
    "lines": [
      { "sku": "BSD-4410", "qty": 6, "unitPrice": "12.40", "taxClass": "STD" }
    ],
    "totals": { "net": "74.40", "tax": "14.88", "gross": "89.28" }
  }
}

The CloudEvents envelope is worth adopting rather than inventing your own. It is a small specification, it is widely supported, and the argument about what to call the timestamp field is one you do not need to have.

My honest test for readiness: if your team cannot currently answer "how far behind is consumer X right now" for any existing queue, you are not ready to operate an event backbone. Consumer lag monitoring is not an advanced topic; it is the first thing, and a team that has not built it for a simple queue will not build it for a complex one.

6. Data Ownership Is a Contract, Not a Diagram

The single highest-value artefact on an ERP integration project is a table saying which system owns which field. Not which system owns which entity — which field. I have said this before and I will keep saying it because it is skipped on most projects and it is the root cause of most of the bugs.

The reason entity-level ownership fails is that no real entity is owned by one system. A product is owned by the ERP for its SKU, cost, weight and tax treatment; by a PIM or the commerce platform for its marketing copy, imagery and category placement; and sometimes by a third system for its digital assets. Declaring "the ERP owns products" produces a nightly feed that overwrites marketing copy, and the symptom is copy reverting overnight, which takes weeks to diagnose because it happens while nobody is watching.

Three rules that make the table work.

One owner per field, no exceptions and no "both". If two systems genuinely need to write a value, you have two fields with different names, and a rule about which one is displayed.

Ownership can change at a lifecycle boundary, and the boundary must be explicit. An order is owned by commerce until the ERP accepts it, and by the ERP afterwards. That is fine. What is not fine is leaving the boundary implicit, which produces the bug where a customer sees "shipped" for something the warehouse has not picked.

Write it down somewhere enforceable. A spreadsheet is better than nothing and it will be out of date within a quarter. Encoding it in the mapping configuration, as in the example above, means it is reviewed whenever the mapping is.

# ownership.yaml — the contract, in source control, in CI.
# A test asserts that no outbound payload contains a field this file
# says the other side owns. That test has caught three real defects
# for me, all of them "just a small addition to the existing feed".
entities:
  product:
    erp:
      - sku
      - base_uom
      - sales_uom
      - conversion_factor
      - weight_kg
      - tax_class
      - cost
      - lifecycle_status
    commerce:
      - url_key
      - meta_title
      - meta_description
      - category_assignment
      - related_products
    pim:
      - marketing_name
      - long_description
      - images
      - attribute_swatches
  customer:
    erp:
      - account_number
      - credit_limit
      - credit_status
      - payment_terms
      - permitted_ship_to
    commerce:
      - login_email
      - password_hash
      - notification_preferences
      - saved_baskets
  order:
    # Ownership transfers at accept. Both sides are listed with the
    # phase in which they hold it.
    commerce_until_accepted:
      - basket_contents
      - chosen_delivery_option
      - promotional_discount
    erp_after_accepted:
      - order_status
      - allocated_stock
      - despatch_date
      - invoice_number
      - tracking_reference

7. The Canonical Model Argument

Middleware vendors and enterprise architects will push you towards a canonical data model: one internal representation of a product, an order, a customer, that every system maps to and from. The pitch is that adding a system means writing one mapping rather than N.

It is right at a certain scale and it is over-engineering below that scale, and the boundary is roughly four systems.

With two systems, a canonical model means two mappings instead of one, for no benefit. With three, it is about break-even. With five or more it is clearly correct, and by then you probably have a canonical model that emerged badly rather than being designed, which is worse than either.

The failure mode of canonical models, when they go wrong, is the union model: someone builds a canonical product that contains every field any system has, resulting in a 200-field structure where each system uses a different 40 fields and nobody can tell which are meaningful in which context. The discipline that prevents it is to model the canonical form on the business process rather than on the systems — what does an order actually consist of, in this business, independent of who stores it — and to accept that some system-specific fields simply do not belong in the canonical form and travel as an extensions bag.

A pragmatic middle position I have used successfully: skip the full canonical model, but define canonical identifiers. One place that maps ERP customer C-004821 to commerce customer 88214 to marketplace seller-account 4471. The identity mapping is where most of the pain of not having a canonical model actually lives, and you can solve that alone for a fraction of the effort.

-- Identity map. Small, boring, and it removes an entire class of
-- problem: "which customer is this" answered in one place rather
-- than by convention in six codebases.
CREATE TABLE identity_map (
    id              BIGSERIAL PRIMARY KEY,
    entity_type     TEXT        NOT NULL,   -- 'customer' | 'product' | 'order'
    canonical_id    TEXT        NOT NULL,   -- ULID, minted here
    system          TEXT        NOT NULL,   -- 'erp' | 'commerce' | 'marketplace'
    external_id     TEXT        NOT NULL,
    valid_from      TIMESTAMPTZ NOT NULL DEFAULT now(),
    valid_to        TIMESTAMPTZ,            -- NULL = current
    merged_into     TEXT,                   -- set when an ERP merge happens
    UNIQUE (system, entity_type, external_id, valid_from)
);

-- The merged_into column exists because ERPs merge duplicate customer
-- records as routine housekeeping, and every integration that assumes
-- identifiers are permanent breaks the first time it happens.
CREATE INDEX ON identity_map (entity_type, canonical_id) WHERE valid_to IS NULL;

-- Resolve an external id to canonical, following merges.
WITH RECURSIVE resolved AS (
    SELECT canonical_id, merged_into
    FROM identity_map
    WHERE system = 'erp' AND entity_type = 'customer'
      AND external_id = 'C-004821' AND valid_to IS NULL
  UNION ALL
    SELECT m.canonical_id, m.merged_into
    FROM identity_map m
    JOIN resolved r ON m.canonical_id = r.merged_into
)
SELECT canonical_id FROM resolved WHERE merged_into IS NULL;

8. Evaluating a Vendor's Commerce Connector

Every ERP vendor has a commerce connector, or a partner who sells one, and the marketing is uniformly excellent. Here is how to find out what is actually there.

Ask for the field mapping document. Not the feature list — the actual mapping. Which ERP fields map to which commerce fields, in which direction, on which trigger. If it does not exist as a document, the connector is a framework rather than a product and you should budget accordingly. This single request has changed my estimate on three projects, twice upwards.

Ask which version of both platforms it is certified against. And when the certification was last refreshed. A connector certified against Magento 2.4.4 and NetSuite 2023.1 is a connector whose maintainer has moved on. Check the release notes; a repository or download page with no update in eighteen months tells you what the support will be like.

Ask what happens on a partial failure. An order with five lines, one of which references a discontinued item. Does the connector reject the whole order, import four lines, or import five with one broken? All three behaviours exist in shipped products and only one of them is what you want, which is to reject cleanly and tell someone.

Ask about volume, with your numbers. "How many products can it sync per hour" is answered by every vendor with a large number. The real question is how long a full catalogue load takes with your SKU count, your attribute count and your API rate limit, and the honest answer usually requires them to think. A connector that syncs 500 products a minute against a 120,000-SKU catalogue means a four-hour full load, which constrains your operational schedule.

Ask to see the logs. During a demo, ask them to show you the log output of a successful sync and a failed one. Logs are where you find out whether the product was built by people who have supported it. A log line reading Error: sync failed is a product you will hate.

Ask who supports it at 2am on Black Friday. The ERP vendor, the connector partner, or you. Get the answer in writing, including the response time, and check whether the SLA covers the connector or only the ERP. This is the question with the largest gap between the sales answer and the contractual answer.

Talk to a reference customer at your scale, without the vendor present. Every reference call I have had that the vendor did not sit in on has produced at least one useful warning. Every one they did sit in on produced none.

9. Rate Limits Will Shape Your Architecture

The most under-modelled constraint in ERP integration, and the one most likely to invalidate a design after it is built.

Every cloud ERP throttles. The mechanisms differ enough that you cannot reason about them generically, so you must read the specific documentation for your specific ERP before designing the flows — not after.

NetSuite meters SuiteScript with a governance unit budget per execution context; a script that exceeds its units is terminated mid-run, and different operations cost different amounts. A search costs 10 units, a record load costs 5, a submit costs 10, and a scheduled script gets 10,000. That arithmetic determines your batch size, and it means "process all pending orders" is not a design — "process up to 200 pending orders and yield" is.

Microsoft Dynamics 365 applies service protection limits per user per five-minute sliding window: a number of requests, a total execution time, and a concurrency cap. Exceed them and you get a 429 with a Retry-After header. The consequence for design is that parallelising by opening more connections under one service account does not help, because the limit is per user. Some integrations legitimately need multiple application users to get throughput, which is an architecture decision driven entirely by a quota.

Others meter by concurrent connections, by daily API call allowance, or by a paid tier where more throughput is a line item. Several have limits that are documented incorrectly, or that differ between sandbox and production, which is its own kind of unpleasant surprise.

import time, random, logging
from dataclasses import dataclass

log = logging.getLogger(__name__)

@dataclass
class Budget:
    """Client-side governor. The point is to stay comfortably inside the
    limit rather than to discover it — being throttled costs you the
    request AND the retry, and on some platforms repeated throttling
    triggers a longer cooldown."""
    max_per_window: int
    window_seconds: int
    target_utilisation: float = 0.75   # deliberately leave headroom for
                                       # the interactive users who share
                                       # the same quota

    def __post_init__(self):
        self.calls = []

    def acquire(self):
        now = time.monotonic()
        cutoff = now - self.window_seconds
        self.calls = [t for t in self.calls if t > cutoff]
        ceiling = self.max_per_window * self.target_utilisation
        if len(self.calls) >= ceiling:
            sleep_for = self.calls[0] + self.window_seconds - now
            log.info("budget: sleeping %.1fs (%d calls in window)",
                     sleep_for, len(self.calls))
            time.sleep(max(0.0, sleep_for))
        self.calls.append(time.monotonic())


def call_with_retry(fn, budget, attempts=6):
    for attempt in range(attempts):
        budget.acquire()
        resp = fn()
        if resp.status_code != 429:
            return resp
        # Honour Retry-After when present; the server knows better
        # than your backoff curve does.
        wait = float(resp.headers.get("Retry-After", 2 ** attempt))
        wait += random.uniform(0, wait * 0.3)   # jitter: without it,
                                                # every throttled worker
                                                # retries in lockstep
        log.warning("throttled, waiting %.1fs (attempt %d)", wait, attempt + 1)
        time.sleep(wait)
    raise RuntimeError("exhausted retries against rate limit")

The target_utilisation of 0.75 is deliberate and I would defend it hard. Your integration is not the only consumer of the ERP's API quota. Finance runs reports, the mobile app polls, and someone in the warehouse has a spreadsheet with a data connection. An integration that consumes 100% of the documented limit is an integration that causes the finance team's month-end report to fail, and that phone call is worse than a slightly slower sync.

10. Authentication, and the Credential That Expires at Christmas

A boring section that prevents a specific and very annoying outage.

ERP authentication models vary: token-based with refresh, OAuth 2.0 with a client credentials grant, certificate-based, or in older on-premises systems a username and password on a service account. Each has a lifecycle, and the lifecycle is where the outage lives.

Three failures I have personally caused or cleaned up.

A certificate expiring. Two-year certificate, installed by someone who had left, no monitoring on the expiry date, expired on 23 December. Integration down for four days over the holiday because nobody could issue a new one. Monitor certificate expiry as a metric with an alert at 60, 30 and 7 days, on every certificate in the path including the ERP's own.

A refresh token that expires if unused. Some implementations invalidate a refresh token after a period of non-use. A sandbox integration that is quiet over a code freeze comes back to a dead token, and the recovery requires an interactive login that a service account cannot perform. Know whether your refresh tokens have an idle expiry.

A service account subject to a password policy. The ERP's identity provider applied a 90-day rotation policy to all accounts including service accounts. Everything worked for 89 days. Exempt service accounts explicitly, or better, do not use password-based service accounts at all where a machine-to-machine grant is available.

The pattern that avoids most of this: workload identity or a managed identity where the platform supports it, secrets in a vault with automated rotation where it does not, and a synthetic transaction that authenticates and performs a trivial read every five minutes so that credential failure is detected as a failure rather than as an absence of traffic.

11. The ERP's Calendar Is a Constraint on Your Storefront

Something that does not appear in any architecture diagram and which will surprise you in month three.

ERPs have operational rhythms that commerce platforms do not. Month-end close, during which some transaction types are blocked. Year-end, which in some systems means a genuine multi-hour outage. Nightly batch windows during which the API is slow or unavailable. Stock takes, during which inventory is frozen. Pricing updates that run as a batch and leave prices inconsistent for the duration.

Two consequences for design.

Your order flow must tolerate the ERP being unavailable for hours without stopping trading. This is the strongest single argument for asynchronous order submission, and it is why I default to it in essentially every build.

And your reconciliation and alerting must know the calendar, or you will alert on every batch window and the team will learn to ignore the alert. An alert that fires predictably every night at 2am and is always benign is worse than no alert, because it trains people to dismiss it — and the one night it means something, they will.

from datetime import datetime, time as dtime, timedelta
from zoneinfo import ZoneInfo

# The ERP's calendar, expressed once, consumed by alerting and by the
# scheduler. Note the timezone: the ERP runs in Europe/Berlin, the
# storefront in Europe/London, and the one-hour offset changes twice
# a year. Every "it only breaks in October" bug is this.
ERP_TZ = ZoneInfo("Europe/Berlin")

NIGHTLY_BATCH = (dtime(1, 30), dtime(3, 15))
MONTH_END_FREEZE_DAYS = 1            # last working day of the month
YEAR_END_BLACKOUT = ("12-31", "01-02")

def in_batch_window(now: datetime | None = None) -> bool:
    now = (now or datetime.now(ERP_TZ)).astimezone(ERP_TZ)
    start, end = NIGHTLY_BATCH
    if start <= now.time() <= end:
        return True
    tomorrow = (now + timedelta(days=1))
    if tomorrow.month != now.month and now.hour >= 18:
        return True                   # month-end close begins that evening
    return f"{now.month:02d}-{now.day:02d}" >= YEAR_END_BLACKOUT[0]

def should_alert_on_queue_depth(depth: int, oldest_minutes: int) -> bool:
    # During a known window, only alert on an abnormally deep backlog.
    # Outside it, alert on age, which is the honest signal.
    if in_batch_window():
        return depth > 5000
    return oldest_minutes > 15

12. Buy or Build the Integration Layer

The commercial decision that sits underneath the architectural one, and the one where I have seen the most money wasted in both directions.

Buying an iPaaS is right when you have several systems, a team that is not primarily engineers, and a preference for operational cost over capital cost. It is also right when the alternative is an integration built by whoever is available, which is how the building supplies distributor ended up with three of them.

Building is right when your integration is genuinely simple, when you have engineering capability you are already paying for, or when the flows are unusual enough that connector coverage is irrelevant. A well-written 3,000-line service with good tests, structured logging and a dead-letter queue is a completely respectable answer for a two-system estate, and it costs nothing per message forever.

The trap in buying is licence cost growth. iPaaS pricing scales with something — connections, messages, tasks — and the thing it scales with is usually the thing that grows. Model three years out at your projected volume, not year one at current volume. I have seen a £14,000 first-year licence become £61,000 in year three because inventory sync frequency was increased from hourly to every ten minutes, which multiplied message count by six for a change nobody thought of as commercial.

The trap in building is the second year. The person who wrote it moves on, nothing is documented, and the honest cost of the build was never the build — it was the ten years of ownership. If you build, the deliverable is not the code. It is the code, plus a runbook, plus tests that run without the ERP, plus monitoring, plus a second person who has actually deployed it.

My rough guide: under four systems and with engineers on staff, build, but build it properly as a standalone service. Four or more systems, or no engineers, buy. Do not build an iPaaS.

13. Testing Without a Sandbox You Can Trust

Almost every ERP offers a sandbox and almost every sandbox is a poor simulation of production, in ways that specifically undermine integration testing.

The recurring problems: the sandbox has different rate limits, usually more generous, so throttling behaviour is untested. It has stale or scrubbed data, so the awkward records that break your mapping are absent. It has different configuration — tax schedules, price lists, custom fields — because someone changed production and never back-ported it. And refreshes wipe it, taking your test fixtures with them.

The approach that has worked for me is three layers.

Contract tests against a recorded schema. Capture real responses from the ERP once, store them as fixtures, and run your mapping logic against them in CI with no network access. Fast, deterministic, and it catches the majority of mapping regressions. Refresh the fixtures quarterly and diff them; a changed fixture is an ERP change you needed to know about.

Integration tests against the sandbox, on a schedule rather than per commit. Nightly, with an alert on failure. These catch authentication drift, schema changes and permission problems. They are too slow and too flaky for a commit gate and treating them as one destroys trust in the pipeline.

Synthetic transactions against production. A test order, placed through the real path, on a real test customer account, hourly. This is the only test that tells you the whole chain works right now. Flag the orders so they can be excluded from reporting and cancelled automatically. Every organisation I have suggested this to has resisted it and every one that adopted it has caught something with it.

import uuid, time, requests

# Synthetic order probe. Runs hourly against production. The value is
# that it exercises auth, mapping, rate limits, the ERP's actual
# validation rules, and the return path — none of which a sandbox
# reliably reproduces.
PROBE_CUSTOMER = "C-SYNTHETIC-001"     # real account, zero credit limit,
                                       # excluded from all reporting
PROBE_SKU      = "TEST-PROBE-001"      # real item, not sellable, stock 999999

def probe():
    ref = f"PROBE-{uuid.uuid4().hex[:12]}"
    t0 = time.monotonic()

    placed = requests.post(f"{COMMERCE}/rest/V1/orders",
                           json=build_order(ref, PROBE_CUSTOMER, PROBE_SKU),
                           headers=auth(), timeout=30)
    placed.raise_for_status()

    # Poll for the ERP document number rather than asserting immediately:
    # the flow is asynchronous and the useful measurement is how long
    # the whole chain took, not whether the first call returned 200.
    deadline = time.monotonic() + 300
    while time.monotonic() < deadline:
        state = requests.get(f"{COMMERCE}/rest/V1/orders/{ref}",
                             headers=auth(), timeout=15).json()
        if state.get("erp_document_number"):
            elapsed = time.monotonic() - t0
            metrics.gauge("erp.probe.end_to_end_seconds", elapsed)
            metrics.increment("erp.probe.success")
            cancel(ref)
            return elapsed
        time.sleep(10)

    metrics.increment("erp.probe.timeout")
    alerting.page(f"synthetic order {ref} did not reach ERP in 5 minutes")

14. A Selection That Went Mostly Right

A specialist tools distributor, 2025. £22m turnover, moving from a heavily customised on-premises ERP to a cloud one, with a Magento 2 storefront and a plan to add a marketplace channel within eighteen months. They asked me to help evaluate the integration approach alongside the ERP selection, which is unusual and which was the reason it went well.

The shortlist. Three cloud ERPs, each with a nominated commerce connector. We scored on the questions above rather than on features, and the results were not what the feature matrix suggested. The vendor with the most impressive connector demo could not produce a field mapping document, and on the reference call — which we did without them — the customer described eight months of custom development on top of the connector. The vendor with the least impressive demo had a mapping document, a public changelog with releases every six weeks, and a reference customer who described their go-live as boring.

The architecture. Middleware hub, not because of the two current systems but because the marketplace was a committed eighteen-month plan and adding it point-to-point would have meant three integrations. We modelled the message volume at year three including the marketplace and picked a tool with connection-based rather than message-based pricing, which cost more in year one and less from year two.

The ownership table. Two workshops, commercial stakeholders rather than engineers, before any technical design. It surfaced two arguments early: whether the ERP or commerce owned product descriptions, which we resolved by splitting the field, and whether the storefront could offer a delivery date, which turned out to require capability the ERP did not expose and which we descoped before anyone had built anything.

What went wrong. Rate limits, and it was my mistake.

We modelled API volume for order export, customer sync and status updates. We did not model the inventory feed properly, because at design time the plan was an hourly delta and the delta was assumed to be small. In practice the ERP's change-detection flagged a product as changed when any field changed, including internal costing fields that were recalculated nightly for the entire catalogue. So the "delta" was 96% of the catalogue, every hour, and it consumed roughly four times the API budget we had modelled.

We found it in week two of the pilot, when the finance team's reports started failing at 40 past each hour. The fix took three days: filter on the ERP side to the fields we actually replicate, which cut the delta from 38,000 records an hour to about 400.

Two lessons. Measure a real delta feed during evaluation rather than trusting the concept — I now ask for a week of change-log volume as part of any ERP assessment. And model the API budget as a shared resource with named other consumers, because the failure did not present as "the integration is slow", it presented as "the finance reports are broken", which took a day to connect.

What I would do differently. I would have insisted on the synthetic probe from day one of the pilot rather than adding it after go-live. And I would have asked the ERP vendor, in writing, what their change-detection actually flags, which is a question I now ask on every project and which nobody had thought to ask because it sounds like a detail.

15. Replacing the ERP Later

The scenario nobody designs for and which happens more often than people expect: the business changes ERP in five years, or gets acquired and inherits one.

This is the strongest argument for middleware or an event backbone that does not depend on your current volume at all. With a hub, replacing the ERP means rewriting one connection. With point-to-point across four systems, it means rewriting four integrations simultaneously, in a coordinated cutover, which is a project rather than a task.

Two design choices that make a future replacement survivable and which cost little now.

Never let ERP identifiers leak into other systems as primary keys. If your commerce database stores netsuite_internal_id on the customer record and your marketplace connector joins on it, those identifiers are now load-bearing across your estate and a new ERP will not preserve them. Store them in the identity map and reference the canonical id everywhere else. This is the single most valuable piece of future-proofing in the whole article and it costs a table.

Keep ERP-specific semantics out of your commerce data model. A tax class named SCH-STD-UK-2019 because that is what the ERP calls it is a small piece of coupling that becomes a migration task. Map it to something meaningful at the boundary.

The same reasoning applies to the events themselves if you go that route — an event schema modelled on the ERP's record structure is an ERP-shaped dependency wearing a distributed-systems costume. Model events on the business fact: an order was placed, stock moved, a price changed. Those facts survive an ERP replacement; a payload that mirrors salesorder does not. The event-driven architecture piece goes further into schema design if that is the direction you are heading.

16. What This Actually Costs

Rough ranges for a mid-market UK business, one storefront, one ERP, planning a third system. These are the numbers I use for early conversations and they are deliberately wide because the variance is real.

ItemPoint-to-pointMiddleware hub
Discovery and ownership mapping£6k–£12k£10k–£20k
Build, four core flows£35k–£70k£45k–£110k
Connector or platform licence, year 1£0£12k–£45k
Testing and UAT£8k–£18k£10k–£25k
Monitoring and runbooks£4k–£9k£4k–£9k
Annual support and change, from year 2£12k–£30k£20k–£55k
Adding a third system£25k–£50k£10k–£22k

The row that decides it is the last one. If you will add a third system, the hub is cheaper by year two or three. If you genuinely will not, point-to-point is cheaper forever and the difference is meaningful.

The line item that is most often left out entirely is monitoring and runbooks, and it is the one whose absence causes the eleven-thousand-orders-in-a-queue class of failure. Four to nine thousand pounds is small against the rest of the project and it is the first thing cut when the budget is tight, which is exactly backwards.

17. Questions That Come Up

"Our ERP vendor says their connector does everything. Is that true?" It does everything on the demo path. Ask for the field mapping document and a reference call without the vendor on it. Both requests are entirely reasonable and the reaction to them is itself informative.

"Can we just use Zapier or Make?" For low-volume, non-critical flows — pushing a new customer into a mailing list, notifying a channel when a large order arrives — genuinely yes, and I would not be snobbish about it. For order export or inventory, no. The failure handling, the ordering guarantees and the volume economics are all wrong for a flow where correctness matters absolutely.

"Should the ERP or commerce be the master for stock?" The ERP, essentially always, because that is where physical movements are recorded and commerce is one of several channels consuming them. Commerce holds a cached reflection and reserves at order placement. The exception is a business whose only channel is the storefront and whose warehouse operates from it, where the argument goes the other way.

"Do we need a PIM as well?" If merchandising needs product content richer than the ERP holds — and for anything consumer-facing that is nearly always true — then yes, and it sits between the two with its own row in the ownership table. If you sell industrial parts by part number to people who search by part number, the ERP plus a handful of commerce-owned fields is genuinely enough, and a PIM is a system to run for no benefit.

"How real-time does this need to be?" Ask what decision changes if the data is an hour old. For stock, the answer is real: a customer may buy something unavailable. For credit status in B2B, real. For product descriptions, cost data, or invoice history, nothing changes and hourly or nightly is fine. Real-time coupling on a flow that does not need it buys you an ERP outage becoming a storefront outage.

"Who should own the integration internally?" One named person with authority over the contract between the two systems. Integration sits between the commerce team and the ERP team and usually belongs to neither, which is precisely how a dead-letter queue accumulates for a week. If you take one organisational thing from this article, take that.

"What about AI-assisted mapping tools?" They are getting genuinely useful for the first-pass field mapping, which is tedious and mechanical. I would use one to produce a draft and I would not ship it unreviewed. The mappings they get wrong are the semantically subtle ones — units of measure, tax treatment, which of three date fields is the one you mean — and those are precisely the ones that cause expensive errors rather than obvious ones.

"Our ERP is on-premises. Does any of this change?" The architecture, no. The practicalities, yes: you need a network path, usually a VPN or a private circuit, and an on-premises agent if you are using cloud middleware. Latency is better and availability is usually worse, because an on-premises ERP has maintenance windows that a cloud one absorbs invisibly. Budget more for the network layer than you expect to.

18. What I'd Do First

If you are at the start of this, in order.

First, write the ownership table. Field by field, with commercial stakeholders in the room, before any technical design. Two workshops. It will surface the arguments that would otherwise surface in UAT, and it is the artefact that every subsequent decision refers back to.

Second, count your systems honestly, including the ones nobody mentions — the spreadsheet the warehouse runs on, the marketplace listing someone in marketing maintains, the third-party shipping tool. That count, and your realistic three-year projection of it, decides point-to-point versus hub, and it is the only architectural decision here that is genuinely hard to reverse.

Third, get the rate limit documentation for your specific ERP and model your actual volume against it. Including the inventory delta, measured rather than assumed. This is the step I skipped and it cost me a difficult fortnight.

Fourth, if you are buying a connector, ask for the field mapping document and a reference call without the vendor. Two emails. They will change your estimate.

Fifth, decide who owns the integration and give them the alerts before go-live rather than after. Not a team — a person.

And build the synthetic probe and the reconciliation query in the first sprint, not the last. They are the cheapest things in the entire project and they are the difference between finding a problem in fifteen minutes and finding it when a warehouse manager mentions it has been quiet. The building supplies distributor did not have an exotic problem. They had three suppliers, no owner, and no query that would have told anyone the numbers disagreed.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: