DEV Community

Asad Abdullah Zafar
Asad Abdullah Zafar

Posted on • Originally published at kolachitech.com

Shopify ERP Integration Architecture: What Breaks in Production and How to Design Around It

Most Shopify ERP integrations fail not because of bad code. They fail because of three architectural decisions made before a single line was written: which system owns which data, how conflicts get resolved, and how partial failures recover without duplication.
Here is the complete production architecture covering all three.

The Non-Negotiable Design Decisions
Before writing any integration code, define these per data domain:

Data Domain Source of Truth Sync Direction Sync Frequency
Inventory levels ERP ERP to Shopify Every 5-15 minutes
Pricing ERP ERP to Shopify Nightly or on change
Product master ERP ERP to Shopify On change
Orders Shopify Shopify to ERP Real-time webhook
Refunds Shopify Shopify to ERP Real-time webhook
Customer addresses Timestamp Bidirectional On change, last write wins

Enforce this in middleware. Do not leave it as an informal convention.

  1. The Three-Layer Middleware Model Direct Shopify-to-ERP integration creates tight coupling. Three explicit middleware tiers prevent it:
js// Layer 1: Ingestion — validates and enqueues in under 50ms
app.post('/webhooks/orders', express.raw({ type: '*/*' }), async (req, res) => {
  if (!verifyShopifyHmac(req.body, req.headers['x-shopify-hmac-sha256'])) {
    return res.status(401).send('Unauthorized');
  }

  await ingestionQueue.add('shopify.order.created', {
    raw:       req.body.toString(),
    shop:      req.headers['x-shopify-shop-domain'],
    webhookId: req.headers['x-shopify-webhook-id'],
  });

  res.status(200).send('OK');
});

// Layer 2: Transformation — maps Shopify schema to ERP schema
const transformWorker = new Worker('ingestion', async (job) => {
  const shopifyOrder = JSON.parse(job.data.raw);
  const erpOrder     = transformShopifyOrderToERP(shopifyOrder);

  await deliveryQueue.add('erp.order.create', {
    erpPayload:     erpOrder,
    shopifyOrderId: shopifyOrder.id,
    idempotencyKey: `order:${shopifyOrder.id}:${job.data.shop}`,
  });
}, { connection, concurrency: 10 });

// Layer 3: Delivery — idempotent ERP submission with retry
const deliveryWorker = new Worker('delivery', async (job) => {
  const acquired = await redis.set(
    `erp:submitted:${job.data.idempotencyKey}`,
    '1', { NX: true, EX: 86400 }
  );
  if (!acquired) return { status: 'duplicate' };

  await submitToERP(job.data.erpPayload);
}, { connection, concurrency: 5 });
Enter fullscreen mode Exit fullscreen mode

Keep these tiers separate. A field mapping change in Layer 2 should never touch Layer 1. A rate limit change in Layer 3 should never force re-running Layer 2.

  1. Delta Inventory Sync with Watermark Never query all SKUs on every sync cycle. Query the ERP change journal for movements since the last watermark:
jsasync function syncInventoryFromERP(shop) {
  const lastSyncedAt = await redis.get(`erp:inventory:watermark:${shop}`)
    || '1970-01-01T00:00:00Z';

  const changedItems = await erpClient.getInventoryChanges({
    since: lastSyncedAt,
    warehouseId: process.env.ERP_WAREHOUSE_ID,
  });

  if (changedItems.length === 0) return { synced: 0 };

  const updates = (await Promise.all(
    changedItems.map(async (item) => {
      const shopifyItemId = await lookupShopifyInventoryItemId(shop, item.erpItemCode);
      if (!shopifyItemId) { await logUnmappedItem(shop, item.erpItemCode); return null; }
      return { inventory_item_id: shopifyItemId, available: item.availableQuantity };
    })
  )).filter(Boolean);

  // Batch submit: max 100 items per Shopify API call
  for (let i = 0; i < updates.length; i += 100) {
    await shopifyAdmin.post('/inventory_bulk_adjust_quantity_at_location.json', {
      location_id: process.env.SHOPIFY_LOCATION_ID,
      changes: updates.slice(i, i + 100),
    });
    await new Promise(r => setTimeout(r, 500)); // Rate limit buffer
  }

  // Advance watermark AFTER successful sync — never before
  await redis.set(`erp:inventory:watermark:${shop}`, new Date().toISOString());
  return { synced: updates.length };
}
Enter fullscreen mode Exit fullscreen mode

Advance the watermark only after a successful sync. Advancing before processing means a mid-sync crash silently skips records.

  1. Preventing Sync Loops Bidirectional sync creates infinite loops. Your middleware writes a price to Shopify. Shopify fires products/update. Your middleware ingests it and syncs back to the ERP. Repeat forever. The fix is one field:
js// Tag every middleware-originated write to Shopify
await shopifyAdmin.put(`/products/${productId}.json`, {
  product: {
    id: productId,
    metafields: [{
      namespace: 'integration',
      key:       'source',
      value:     'erp_middleware',
      type:      'single_line_text_field',
    }]
  }
});

// Check the tag in every webhook handler before routing to ERP
async function handleProductUpdate(shop, payload) {
  const source = payload.metafields?.find(
    m => m.namespace === 'integration' && m.key === 'source'
  )?.value;

  if (source === 'erp_middleware') {
    return { status: 'skipped', reason: 'middleware-originated write' };
  }

  await routeToERP(shop, payload);
}
Enter fullscreen mode Exit fullscreen mode
  1. Error Classification That Prevents Incidents
    Every ERP integration error falls into one of three categories. Handle each differently:

    Error Type HTTP Codes Action Alert?
    Transient 408, 429, 500, 502, 503, 504 Retry with exponential backoff Only on retry exhaustion
    Data error 400, 404, 422 Route to DLQ immediately, no retry Yes — data team
    System error 401, 403 Pause queue, halt processing Yes — ops team, P0

    Retrying a data error wastes resources and delays the fix. Not retrying a transient error causes unnecessary data loss. Pausing on a system error prevents a queue backlog that compounds when the system recovers.

  2. SAP Interface Layer Selection

    Interface Protocol Recommended? When to Use
    SAP OData REST / HTTP Yes — preferred All new integrations
    SAP IDoc Async file/message With adapter When OData unavailable
    SAP BAPI via RFC Proprietary RFC No — avoid Existing integrations only
    SAP Integration Suite Pre-built flows Evaluate first S/4HANA Cloud

The Integration Architecture Checklist
Before go-live, validate every item:

Source of truth defined per data domain in a shared document
SKU-to-ERP item code cross-reference table built and validated
Idempotency keys on every delivery worker call
Sync loop prevention via integration_source metafield tagging
Watermark advanced only after successful sync, never before
Error classification routing: transient retry, data error DLQ, system error queue pause
Dead-letter queue includes full payload context and all retry timestamps
Shopify API rate limit respected between batch inventory writes

Full guide with complete field mapping code, delta sync implementation, SAP interface selection, pricing sync to Shopify B2B price lists, and conflict resolution patterns:

Shopify ERP Integration Architecture: A Technical Guide

Learn how to design Shopify ERP integration architecture for SAP, NetSuite, and Oracle. Covers data flow patterns, conflict resolution, field mapping, and fault tolerance.

favicon kolachitech.com

Top comments (0)