Skip to main content
  1. Posts/

Why Queues Belong Between Systems That Don’t Fail the Same Way

David Cajio
Author
David Cajio
Table of Contents
Ecommerce Architecture - This article is part of a series.
Part 2: This Article

Why Queues Belong Between Systems That Don’t Fail the Same Way
#

In e-commerce, the order lifecycle almost never lives in one system.

Magento may own the storefront. SAP may own financials, inventory, or offline order entry. A WMS may own fulfillment. AfterShip may own tracking, delivery visibility, and AI delivery estimates. Customer emails may depend on all of the above being correct enough to send something useful.

That sounds clean on an architecture diagram.

In reality, each system fails differently.

SAP can accept an order with missing address data. Magento may require a country code before it can safely present that order to a customer. A WMS may be temporarily unavailable. AfterShip may receive tracking data later than expected. A retry job may keep hammering the same bad record forever because no one taught it the difference between “try again later” and “this payload is invalid.”

That is where queues belong.

Not because queues are trendy.

Not because every integration needs an event-driven architecture diagram with twelve boxes and a keynote-friendly arrow cloud.

Queues belong between systems that do not fail the same way.

In my case, the practical answer is AWS SQS FIFO with dead-letter queues, validation, per-order sequencing, idempotent consumers, and clear ownership boundaries between SAP, Magento 2, AfterShip, and the rest of the e-commerce stack.


The Problem: Direct Integrations Assume Too Much
#

A lot of internal system integrations start simple.

One system has data. Another system needs data. So we move the data.

Maybe that starts as an API call. Maybe it is a database table. Maybe it is a scheduled job. Maybe it is a temporary MongoDB collection because the need appeared late in a project and MongoDB could accept simple JSON quickly without forcing a schema discussion in the middle of a deadline.

That kind of decision can be completely reasonable at the time.

The problem is what happens after the deadline passes.

One of the flows this applies to is offline orders. These are orders created outside of Magento’s normal storefront path, such as phone orders or EDI orders. SAP knows about them first. Magento still needs to know about them so the customer can see those orders in their account and have a consistent experience.

The rough flow looks like this:

1
2
3
4
SAP
  -> Offline orders: phone orders, EDI orders
  -> Magento 2
  -> AfterShip behind the scenes for tracking and delivery visibility

We also have the reverse direction:

1
2
3
Magento 2
  -> SAP order export
  -> AfterShip for tracking and AI delivery estimates

That second flow matters because SAP needs to be aware of orders and inventory-impacting activity originating from the e-commerce side. AfterShip then helps handle the delivery communication layer: tracking, shipment visibility, and estimated delivery intelligence.

The issue is not that these flows are complicated.

The issue is that these systems have different rules.

SAP may allow data that Magento should reject. Magento may be stricter about customer-facing order presentation. AfterShip may need tracking context that does not exist yet. One service may be down temporarily while another continues accepting data. One error may be retryable. Another may require a human to fix the source record.

When those cases are all treated the same, the integration becomes fragile.


The Failure Mode That Made This Obvious
#

Diagram of current pain points

The clearest example was bad order data.

SAP allowed orders to exist with incomplete address information. In some cases, addresses were missing a country. In worse cases, the address itself was not usable.

That may be acceptable, or at least possible, in SAP depending on how the order was created. But Magento is customer-facing. It cannot safely present every malformed record as if it were a valid order. It needs enough structure to display the order, associate it with the customer, support downstream logic, and avoid breaking account pages or fulfillment-related processes.

The temporary integration path used a MongoDB database as a holding area. That made sense during the original project because the need came up late, MongoDB is schemaless, and accepting JSON quickly is one of the things MongoDB is very good at.

But over time, the side effect became painful:

  • Bad data made it into the integration layer.
  • The database became another operational component to manage.
  • There was no clean dead-letter queue concept.
  • There was no first-class distinction between retryable failures and invalid payloads.
  • Without a proper cleanup script, Magento kept retrying bad rows.
  • Eventually, thousands of invalid records could be retried over and over again.

That last part is the real problem.

A retry system that does not know when to stop is not resilience. It is an infinite loop with infrastructure around it.


Databases Are Not Queues
#

This is one of those lessons that sounds obvious until you are staring at a table full of stuck integration records.

A database can hold work.

That does not make it a queue.

A real queue gives you behavior that a normal database table or document collection does not give you by default.

With SQS, I get:

  • A managed buffer between producers and consumers.
  • Visibility timeouts so one worker owns a message temporarily.
  • Automatic message reappearance if the worker crashes or fails.
  • Dead-letter queues after a defined number of failed receives.
  • Message retention without building cleanup logic from scratch.
  • CloudWatch metrics for queue depth, age, and failure behavior.
  • Redrive workflows for replaying messages after fixes.
  • Decoupling between systems that may be up, down, slow, or malformed independently.

A database table can approximate some of that, but then I have to build the queue behavior myself.

That usually means inventing fields like:

1
2
3
4
5
6
7
8
9
status
retry_count
last_attempt_at
locked_by
locked_until
error_message
created_at
updated_at
processed_at

Then I need cleanup jobs, lock expiration logic, stuck record detection, retry backoff, alerting, concurrency handling, and a way to stop poison records from being retried forever.

At that point, I am not using a database because it is the best tool.

I am building a worse version of a queue.


Why I Would Use AWS SQS FIFO Here
#

There are plenty of queue systems that can work here.

RabbitMQ, Kafka, Redis streams, database-backed job systems, and managed event buses all have legitimate use cases.

For this particular problem, I would use AWS SQS FIFO.

The requirement is not stream analytics. It is not massive fanout. It is not a high-throughput clickstream. It is not an excuse to run Kafka just because the architecture diagram would look more impressive.

The requirement is durable, boring, operationally simple message buffering between business systems where order lifecycle events matter.

That last part is why FIFO matters.

For generic background jobs, standard SQS queues are often fine. But SAP-to-Magento offline orders and Magento-to-SAP order exports are not anonymous background jobs. They represent customer-visible order state and inventory-impacting business activity.

For these flows, I care about:

  • Keeping events for the same order in sequence.
  • Preventing obvious duplicate messages from being introduced into the queue.
  • Avoiding race conditions between order-created, order-updated, shipment, cancellation, or correction events.
  • Making each order its own ordered processing lane.
  • Still allowing different orders to process in parallel.
  • Keeping the operational model simple enough to run without building a science project.

That is a strong fit for SQS FIFO.

The important nuance is this:

FIFO improves the queue contract. It does not remove the need for idempotent consumers.

A FIFO queue can help with message ordering and deduplication at the queue layer, but it cannot guarantee that SAP, Magento, AfterShip, or any other downstream system did not already perform a business action before a worker crashed.

So the architecture needs both:

1
2
SQS FIFO for per-order sequencing and queue-level deduplication.
Idempotent consumers for business-level safety.

Those are not competing ideas. They solve different parts of the reliability problem.


FIFO Does Not Replace Idempotency
#

This is worth being direct about.

FIFO queues reduce duplicate and ordering problems, but consumers still need discipline.

Imagine this flow:

1
2
3
4
5
6
7
8
1. Magento publishes an order export message.
2. The SAP export worker receives the message.
3. The worker sends the order to SAP.
4. SAP accepts the order.
5. The worker crashes before deleting the SQS message.
6. The visibility timeout expires.
7. The message becomes available again.
8. Another worker receives the same message.

From SQS’s point of view, the message was not completed because it was not deleted.

From SAP’s point of view, the order may already exist.

That means the consumer still needs to ask:

1
Have I already exported Magento order 100012345 to SAP?

For SAP-to-Magento, the same principle applies:

1
Have I already imported SAP offline order 90012345 into Magento?

FIFO helps prevent certain duplicate messages from being accepted into the queue. It helps preserve order within a message group. It helps make the integration more predictable.

But the consumer still has to protect the business operation.

That means unique idempotency keys, processed-message tracking, downstream lookup checks, or some combination of those.

The rule is simple:

The queue can protect message flow. The consumer must protect business side effects.


The Architecture I Would Use
#

Proposed architecture diagram

For this kind of integration, I would split the flows into two FIFO queues.

One FIFO queue handles SAP-to-Magento offline orders.

The other FIFO queue handles Magento-to-SAP order export.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
                 ┌────────────────────┐
                 │        SAP         │
                 │ Phone / EDI Orders │
                 └─────────┬──────────┘
                 ┌────────────────────┐
                 │ Validation Layer   │
                 │ Normalize Payloads │
                 └─────────┬──────────┘
        ┌─────────────────────────────────────────┐
        │ SQS FIFO: sap-to-magento-offline-orders │
        │ MessageGroupId: sap-order-{order_number}│
        └─────────────────┬───────────────────────┘
                 ┌────────────────────┐
                 │ Magento Consumer   │
                 │ Import / Present   │
                 └─────────┬──────────┘
                 ┌────────────────────┐
                 │      Magento 2     │
                 └────────────────────┘

And the reverse direction:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
                 ┌────────────────────┐
                 │      Magento 2     │
                 │ Ecommerce Orders   │
                 └─────────┬──────────┘
                 ┌────────────────────┐
                 │ Export Builder     │
                 │ Validate / Shape   │
                 └─────────┬──────────┘
        ┌─────────────────────────────────────┐
        │ SQS FIFO: magento-to-sap-orders     │
        │ MessageGroupId: magento-order-{id}  │
        └─────────────────┬───────────────────┘
                 ┌────────────────────┐
                 │ SAP Export Worker  │
                 │ Send / Confirm     │
                 └─────────┬──────────┘
                 ┌────────────────────┐
                 │        SAP         │
                 └─────────┬──────────┘
                 ┌────────────────────┐
                 │     AfterShip      │
                 │ Tracking / ETA     │
                 └────────────────────┘

Each FIFO queue gets its own dead-letter queue.

1
2
3
4
5
sap-to-magento-offline-orders.fifo
  -> sap-to-magento-offline-orders-dlq.fifo

magento-to-sap-orders.fifo
  -> magento-to-sap-orders-dlq.fifo

That matters because failures in one direction should not block the other direction.

A bad offline phone order from SAP should not interfere with Magento exporting valid e-commerce orders back to SAP. A temporary SAP API issue should not prevent Magento from continuing to accept orders. A malformed payload should not be retried forever just because the consumer sees “not processed yet.”

Each flow deserves its own contract, queue, failure policy, and replay story.


Message Group Design Matters
#

The biggest mistake with FIFO queues is using one global message group.

Do not do this:

1
MessageGroupId = ecommerce-integrations

Or this:

1
MessageGroupId = sap-to-magento

That technically works, but it serializes too much work. Every message in the group must be processed in order. If one message is slow or stuck, everything behind it in that same group waits.

For order workflows, the better approach is to group by order.

For SAP-to-Magento offline orders:

1
MessageGroupId = sap-order-{sap_order_number}

For Magento-to-SAP order export:

1
MessageGroupId = magento-order-{increment_id}

That gives me the ordering I actually care about:

1
2
Events for order 90012345 are processed in order.
Events for order 90012346 can process independently.

This is the key to using FIFO without creating an unnecessary bottleneck.

I do not need every order in the entire business to process one at a time.

I need each individual order’s lifecycle to be consistent.


Deduplication ID Design
#

The second important FIFO decision is the deduplication ID.

For SAP-to-Magento, a simple offline order created event might use:

1
MessageDeduplicationId = sap-offline-order-{sap_order_number}-created-v1

For Magento-to-SAP order export:

1
MessageDeduplicationId = magento-order-export-{increment_id}-created-v1

If the same order can emit multiple meaningful events, the event type should be part of the deduplication ID:

1
magento-order-{increment_id}-{event_type}-{event_version}

For example:

1
2
3
4
magento-order-100012345-created-v1
magento-order-100012345-updated-v2
magento-order-100012345-cancelled-v1
magento-order-100012345-shipped-v1

The goal is to deduplicate accidental duplicate submissions without collapsing distinct business events into one message.

That distinction matters.

If two messages represent the same business action, they should deduplicate.

If two messages represent different business actions for the same order, they should both be processed in order.


The Most Important Rule: Validate Before the Queue
#

One mistake I would avoid is treating SQS as a junk drawer.

The queue should not become the place where completely unvalidated payloads go to die.

For SAP-to-Magento offline orders, the validation layer should happen before the message is accepted as real work. That does not mean the payload has to be perfect in every business sense, but it should meet a minimum contract.

For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
  "schemaVersion": "1.0",
  "eventType": "offline_order.created",
  "sourceSystem": "sap",
  "destinationSystem": "magento",
  "correlationId": "01J2RC8A7P8K9WQ4JZP6N3N2HY",
  "idempotencyKey": "sap-offline-order-90012345-created-v1",
  "occurredAt": "2026-07-06T12:45:00Z",
  "payload": {
    "sapOrderNumber": "90012345",
    "customerEmail": "customer@example.com",
    "billingAddress": {
      "firstName": "Jane",
      "lastName": "Doe",
      "street1": "123 Example Street",
      "city": "Knoxville",
      "region": "TN",
      "postalCode": "37901",
      "countryCode": "US"
    },
    "shippingAddress": {
      "firstName": "Jane",
      "lastName": "Doe",
      "street1": "123 Example Street",
      "city": "Knoxville",
      "region": "TN",
      "postalCode": "37901",
      "countryCode": "US"
    },
    "items": [
      {
        "sku": "RC-12345",
        "quantity": 1
      }
    ]
  }
}

Before that message goes to SQS, I want to know basic things:

  • Is there a SAP order number?
  • Is there an idempotency key?
  • Is there a customer identifier, such as email or customer ID?
  • Is there an address?
  • Is the country present?
  • Are required address fields present?
  • Are line items present?
  • Are quantities valid?
  • Is the schema version supported?

If the payload fails that contract, it should not enter the normal processing queue.

Depending on the business need, it can go to a separate invalid-payload queue, validation error table, or operational review process. But it should not sit in the main queue pretending to be retryable work.

That distinction is critical.

A missing country code is probably not a transient failure.

Retrying it 3,000 times will not magically create a country.


Retryable Failure vs. Invalid Payload
#

This is where a lot of integrations go wrong.

Not all failures are the same.

A retryable failure is something like:

  • SAP API timeout.
  • Magento database connection issue.
  • Temporary network error.
  • AfterShip API rate limit.
  • Worker crashes mid-processing.
  • Downstream service is unavailable.
  • Lock contention or temporary resource exhaustion.

An invalid payload is something like:

  • Missing country.
  • Missing address.
  • Missing order number.
  • Invalid SKU.
  • Unsupported country code.
  • Missing customer association.
  • Quantity is zero or negative.
  • Payload does not match the expected schema.

Those two categories should not use the same recovery path.

Retryable failures should retry.

Invalid payloads should be rejected, quarantined, or routed to a place where humans or cleanup automation can fix the source data.

A queue with a DLQ helps, but the consumer still needs to be smart enough to classify errors.

For example:

1
2
3
4
5
6
7
8
9
Receive message
  -> Validate schema
    -> Invalid? Send to validation failure path and delete from queue
  -> Check idempotency
    -> Already processed? Delete from queue
  -> Process downstream
    -> Success? Mark processed and delete from queue
    -> Retryable failure? Do not delete; allow retry
    -> Permanent failure? Send to DLQ or failure store and delete

That one distinction prevents the system from becoming a retry machine for bad data.


Terraform Example: Two FIFO Queues, Two FIFO DLQs
#

This is a simplified Terraform example of how I would define the queues.

The exact values should be tuned to the workload, but the important pieces are:

  • Separate FIFO queues per direction.
  • Separate FIFO DLQs per direction.
  • Content-based deduplication disabled so the application controls deduplication IDs.
  • A clear maxReceiveCount.
  • Message retention long enough to investigate issues.
  • Visibility timeout long enough for normal processing.
  • Server-side encryption enabled.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
resource "aws_sqs_queue" "sap_to_magento_offline_orders_dlq" {
  name                        = "sap-to-magento-offline-orders-dlq.fifo"
  fifo_queue                  = true
  content_based_deduplication = false

  message_retention_seconds = 1209600 # 14 days
  sqs_managed_sse_enabled   = true
}

resource "aws_sqs_queue" "sap_to_magento_offline_orders" {
  name                        = "sap-to-magento-offline-orders.fifo"
  fifo_queue                  = true
  content_based_deduplication = false

  visibility_timeout_seconds = 300     # 5 minutes
  message_retention_seconds  = 1209600 # 14 days
  sqs_managed_sse_enabled    = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.sap_to_magento_offline_orders_dlq.arn
    maxReceiveCount     = 3
  })
}

resource "aws_sqs_queue" "magento_to_sap_orders_dlq" {
  name                        = "magento-to-sap-orders-dlq.fifo"
  fifo_queue                  = true
  content_based_deduplication = false

  message_retention_seconds = 1209600 # 14 days
  sqs_managed_sse_enabled   = true
}

resource "aws_sqs_queue" "magento_to_sap_orders" {
  name                        = "magento-to-sap-orders.fifo"
  fifo_queue                  = true
  content_based_deduplication = false

  visibility_timeout_seconds = 300     # 5 minutes
  message_retention_seconds  = 1209600 # 14 days
  sqs_managed_sse_enabled    = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.magento_to_sap_orders_dlq.arn
    maxReceiveCount     = 3
  })
}

I like starting with maxReceiveCount = 3 for business integrations because it keeps poison messages from living forever in the hot path.

That does not mean every message only gets three total attempts forever. It means after three failed receives, the system admits, “This needs a different recovery path.”

That is healthy.


Producer Responsibility
#

The producer should not just throw raw data into the queue.

For SAP-to-Magento, the producer or ingestion layer should normalize the SAP payload into a contract Magento understands.

For Magento-to-SAP, the export process should build a clean message that SAP can process predictably.

A producer should be responsible for:

  • Creating the message envelope.
  • Setting the schema version.
  • Creating a stable idempotency key.
  • Creating a stable FIFO message group ID.
  • Creating a stable FIFO deduplication ID.
  • Including a correlation ID.
  • Performing basic validation.
  • Avoiding sensitive data unless it is absolutely required.
  • Sending only the data needed for the consumer to do its job.

Example producer behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php

$sapOrderNumber = '90012345';
$eventType = 'offline_order.created';
$eventVersion = 'v1';

$message = [
    'schemaVersion' => '1.0',
    'eventType' => $eventType,
    'sourceSystem' => 'sap',
    'destinationSystem' => 'magento',
    'correlationId' => $correlationId,
    'idempotencyKey' => sprintf(
        'sap-offline-order-%s-created-%s',
        $sapOrderNumber,
        $eventVersion
    ),
    'occurredAt' => gmdate('c'),
    'payload' => [
        'sapOrderNumber' => $sapOrderNumber,
        'customerEmail' => $customerEmail,
        'billingAddress' => $billingAddress,
        'shippingAddress' => $shippingAddress,
        'items' => $items,
    ],
];

$validator->assertValidOfflineOrder($message);

$sqsClient->sendMessage([
    'QueueUrl' => $queueUrl,
    'MessageBody' => json_encode($message, JSON_THROW_ON_ERROR),

    // FIFO ordering lane: all events for this SAP order process in order.
    'MessageGroupId' => sprintf('sap-order-%s', $sapOrderNumber),

    // FIFO deduplication key: duplicate submissions of this exact event collapse.
    'MessageDeduplicationId' => sprintf(
        'sap-offline-order-%s-created-%s',
        $sapOrderNumber,
        $eventVersion
    ),

    'MessageAttributes' => [
        'eventType' => [
            'DataType' => 'String',
            'StringValue' => $eventType,
        ],
        'schemaVersion' => [
            'DataType' => 'String',
            'StringValue' => '1.0',
        ],
    ],
]);

The exact implementation can vary, but the principle matters:

The queue should contain intentional messages, not mystery JSON.


Consumer Responsibility
#

The consumer is where discipline really matters.

A Magento consumer importing offline orders should be able to answer these questions every time it receives a message:

  1. Is this message valid?
  2. Have I already processed it?
  3. Can I safely process it now?
  4. If processing fails, is the failure retryable?
  5. Should this message be deleted, retried, or sent to a failure path?

A simplified worker loop looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

while (true) {
    $result = $sqsClient->receiveMessage([
        'QueueUrl' => $queueUrl,
        'MaxNumberOfMessages' => 10,
        'WaitTimeSeconds' => 20, // long polling
        'VisibilityTimeout' => 300,
    ]);

    foreach ($result['Messages'] ?? [] as $message) {
        $receiptHandle = $message['ReceiptHandle'];

        try {
            $body = json_decode($message['Body'], true, flags: JSON_THROW_ON_ERROR);

            $validator->assertSupportedSchema($body);
            $validator->assertValidOfflineOrder($body);

            $idempotencyKey = $body['idempotencyKey'];

            if ($idempotencyStore->hasProcessed($idempotencyKey)) {
                $sqsClient->deleteMessage([
                    'QueueUrl' => $queueUrl,
                    'ReceiptHandle' => $receiptHandle,
                ]);

                continue;
            }

            $offlineOrderImporter->import($body['payload']);

            $idempotencyStore->markProcessed($idempotencyKey);

            $sqsClient->deleteMessage([
                'QueueUrl' => $queueUrl,
                'ReceiptHandle' => $receiptHandle,
            ]);
        } catch (InvalidPayloadException $exception) {
            $failureStore->recordValidationFailure($message, $exception);

            // This is not retryable. Delete it from the hot queue.
            $sqsClient->deleteMessage([
                'QueueUrl' => $queueUrl,
                'ReceiptHandle' => $receiptHandle,
            ]);
        } catch (RetryableIntegrationException $exception) {
            $logger->warning('Retryable integration failure', [
                'error' => $exception->getMessage(),
            ]);

            // Do not delete the message.
            // SQS will make it visible again after the visibility timeout.
        } catch (Throwable $exception) {
            $logger->error('Unexpected queue consumer failure', [
                'error' => $exception->getMessage(),
            ]);

            // Usually do not delete here.
            // Let SQS retry and eventually move it to the DLQ.
        }
    }
}

The important part is not the exact PHP.

The important part is the behavior.

Bad payloads do not retry forever.

Successful messages are deleted.

Duplicate messages are safe.

Temporary failures retry.

Unexpected failures eventually end up in the DLQ.


Idempotency Is Not Optional
#

Even with FIFO queues, idempotency is not optional.

This is one of the easiest mistakes to make when discussing FIFO.

FIFO helps with ordering and deduplication at the queue layer. It does not guarantee the business operation on the other side did not already happen.

For SAP-to-Magento offline orders, the idempotency key might be:

1
sap-offline-order-{sap_order_number}-created-v1

For Magento-to-SAP exports, it might be:

1
magento-order-export-{magento_order_increment_id}-created-v1

If the same order can have multiple export events:

1
magento-order-export-{magento_order_increment_id}-{event_type}-{version}

The idempotency store can be a database table with a unique constraint.

1
2
3
4
5
6
7
8
9
CREATE TABLE integration_idempotency (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    idempotency_key VARCHAR(191) NOT NULL,
    source_system VARCHAR(50) NOT NULL,
    destination_system VARCHAR(50) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uniq_idempotency_key (idempotency_key)
);

Then the worker can safely say:

1
2
3
Have I processed sap-offline-order-90012345-created-v1 before?
  -> Yes: delete message and move on.
  -> No: process it, then mark it processed.

Without idempotency, retries can create duplicate side effects.

That is how you get duplicate exports, duplicate customer emails, duplicate order notes, or repeated calls into downstream systems.

FIFO makes integrations more predictable.

Idempotency makes them safe.


Dead-Letter Queues Are Not Trash Cans
#

A DLQ is not where messages go to be forgotten.

A DLQ is an operational work queue.

That means it needs ownership.

For each DLQ, I would want:

  • CloudWatch alarms when visible messages are greater than zero.
  • A dashboard showing message count and oldest message age.
  • Logging that includes correlation IDs and idempotency keys.
  • A replay process.
  • A way to inspect failure reasons.
  • A documented owner for each integration path.

For example, an alarm on the SAP-to-Magento DLQ could mean:

1
2
An offline order from SAP failed Magento import after multiple attempts.
It may be malformed, missing required fields, or blocked by a downstream Magento issue.

An alarm on the Magento-to-SAP DLQ could mean:

1
2
A Magento order export failed after multiple attempts.
SAP may be unavailable, rejecting the payload, or receiving data that does not match its contract.

Those are different operational problems.

They deserve different alerts, different dashboards, and potentially different escalation paths.


Observability: What I Would Watch
#

Cloudwatch metrics screenshot

Queues make system behavior visible if you actually watch the right metrics.

At minimum, I would monitor these for each queue:

  • Approximate number of visible messages.
  • Approximate number of in-flight messages.
  • Approximate age of oldest message.
  • DLQ visible message count.
  • Consumer error rate.
  • Consumer processing duration.
  • Number of validation failures.
  • Number of successful imports/exports.
  • Number of idempotency skips.
  • Downstream API latency and failure rate.

The most important metric is often the age of the oldest message.

Queue depth tells me how much work is waiting.

Oldest message age tells me whether the system is falling behind.

A queue with 2,000 messages that drains quickly may be fine after a batch import.

A queue with one message that is six hours old may be a serious business issue.

For business integrations, I also want application-level metrics:

1
2
3
4
5
6
7
8
9
offline_orders_received_from_sap
offline_orders_imported_to_magento
offline_orders_rejected_validation
offline_orders_sent_to_dlq

magento_orders_queued_for_sap
magento_orders_exported_to_sap
magento_orders_failed_retryable
magento_orders_sent_to_dlq

Infrastructure metrics tell me whether the queue is healthy.

Business metrics tell me whether the integration is doing its job.

Both matter.


Where AfterShip Fits
#

AfterShip should not be treated as an afterthought just because it sits behind the scenes.

Tracking and delivery visibility are part of the customer experience.

If a customer places a phone order or EDI order, Magento still needs to present that order in a way that feels consistent with a normal web order. If tracking exists, AfterShip can help provide shipment visibility and delivery estimates.

But that does not mean every system needs to directly call every other system in real time.

A better model is to let each integration boundary do one job well.

SAP-to-Magento offline order queue:

1
Make Magento aware of valid offline orders.

Magento-to-SAP order export queue:

1
Make SAP aware of valid Magento-originated orders and inventory-impacting activity.

AfterShip integration:

1
Handle shipment tracking, delivery updates, and estimated delivery communication.

Those flows may be related, but they should not all be one giant synchronous chain.

Because if one link in that chain fails, everything fails.

Queues let each piece recover independently.


Handling Invalid SAP Data
#

The missing country problem is a perfect example of why validation matters.

A naive integration says:

1
2
SAP sent an order.
Put it in Magento.

A better integration says:

1
2
SAP sent an order.
Does it meet Magento’s minimum customer-facing order contract?

If the answer is no, then the system should create a clear failure record.

For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "failureType": "validation_error",
  "sourceSystem": "sap",
  "destinationSystem": "magento",
  "eventType": "offline_order.created",
  "idempotencyKey": "sap-offline-order-90012345-created-v1",
  "correlationId": "01J2RC8A7P8K9WQ4JZP6N3N2HY",
  "errors": [
    {
      "field": "shippingAddress.countryCode",
      "message": "Country code is required"
    }
  ],
  "receivedAt": "2026-07-06T12:45:00Z"
}

That gives the team something actionable.

Instead of “Magento keeps retrying thousands of rows,” the conversation becomes:

1
2
3
4
5
SAP is sending offline orders without required address fields.
Here are the affected order numbers.
Here is the exact field missing.
Here is when the invalid payload was received.
Here is whether it has been corrected and replayed.

That is a much healthier operational model.


Replay Tooling Matters
#

Screenshot of the command-line handling DLQ

Eventually, someone will fix the bad data.

When that happens, the team needs a safe way to replay messages.

That replay process should be intentional. I do not want someone manually copying JSON out of AWS and pasting it into a random script on their laptop.

A simple CLI command is enough to start:

1
2
3
4
5
php bin/magento integration:replay \
  --source=sap \
  --destination=magento \
  --event-type=offline_order.created \
  --idempotency-key=sap-offline-order-90012345-created-v1

Or for a DLQ redrive:

1
2
3
aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:us-east-1:123456789012:sap-to-magento-offline-orders-dlq.fifo \
  --destination-arn arn:aws:sqs:us-east-1:123456789012:sap-to-magento-offline-orders.fifo

The exact replay mechanism depends on how the system stores failures, but the rules should be clear:

  • Do not replay until the root cause is fixed.
  • Do not replay invalid messages blindly.
  • Preserve correlation IDs.
  • Preserve or intentionally regenerate idempotency keys.
  • Preserve correct FIFO message group IDs.
  • Preserve correct FIFO deduplication behavior.
  • Log who replayed what and when.
  • Replay in small batches first.
  • Watch downstream metrics during replay.

Replay is not just a technical feature.

Replay is part of the operational contract.


Why Separate Queues Matter
#

It can be tempting to create one general-purpose queue called something like:

1
ecommerce-integrations.fifo

I would avoid that.

Separate queues make ownership and failure behavior much clearer.

SAP-to-Magento offline orders and Magento-to-SAP order exports are different workflows.

They have different producers.

They have different consumers.

They have different payloads.

They have different retry behavior.

They have different business impact.

They should not share the same queue just because they both involve orders.

A SAP offline order import failing means customers may not see an offline order in their account.

A Magento order export failing means SAP may not receive an e-commerce order or inventory-impacting update correctly.

Those are not the same problem.

Separate queues make that obvious.


When I Would Still Use Standard Queues
#

This does not mean FIFO queues should be used everywhere.

Standard queues still make sense when each message is independent and ordering does not matter.

Examples:

1
2
3
4
5
6
Recalculate product metadata.
Send non-critical analytics events.
Fan out independent background jobs.
Refresh cache entries.
Queue isolated records where latest-state validation handles ordering.
Process events where duplicate delivery has no meaningful side effect.

Standard queues are simple, scalable, and perfectly valid for many workloads.

But that is not the core case in this post.

This post is about SAP, Magento, offline orders, order export, customer-visible state, inventory-impacting activity, tracking updates, and business systems that can disagree about whether data is valid.

For that kind of workflow, I want per-order sequencing and queue-level deduplication.

That pushes me toward FIFO.


Common Gotchas
#

1. Using One Global Message Group
#

FIFO queues preserve order within a message group.

If every message uses the same group, everything becomes serialized.

Bad:

1
MessageGroupId = sap-to-magento

Better:

1
MessageGroupId = sap-order-90012345

That lets one order process in sequence without blocking every other order.

2. Assuming FIFO Means Idempotency Is Solved
#

FIFO helps, but downstream side effects can still happen before a message is deleted.

Consumers still need business-level idempotency.

3. Retrying Bad Data Forever
#

If the payload is missing required fields, more retries will not fix it.

The worker needs to classify invalid payloads separately from retryable failures.

4. Choosing the Wrong Deduplication ID
#

If the deduplication ID is too broad, different business events may collapse into one message.

If it is too narrow, obvious duplicates may not deduplicate.

The deduplication ID should represent the specific business event.

5. Making the Visibility Timeout Too Short
#

If the visibility timeout is shorter than the processing time, another worker may receive the same message while the first worker is still processing it.

Set the timeout based on realistic processing duration.

If some jobs take longer, extend visibility during processing or split the work into smaller messages.

6. Making the Message Too Large
#

SQS has message size limits.

Do not shove giant order documents into the queue if they do not need to be there.

For larger payloads, store the full document in S3 and send a pointer in the message.

7. Treating the DLQ as the End of the Story
#

A DLQ without alerts is just a quieter failure.

A DLQ needs alarms, dashboards, ownership, and replay tooling.

8. Ignoring Schema Versioning
#

Message contracts change.

Add schemaVersion from the beginning.

Consumers should reject unsupported versions clearly instead of failing mysteriously.

9. Mixing Business Failures With Infrastructure Failures
#

A missing country code is not the same as a SAP timeout.

One needs data correction.

The other needs retry.

Do not send both through the same mental model.


A Practical Migration Path
#

I would not cut this over recklessly, especially around a major WMS move or any other high-risk operational window.

The safest path is incremental.

Phase 1: Define the Message Contracts
#

Before touching infrastructure, define the contracts.

For SAP-to-Magento:

1
What does Magento require to present an offline order safely?

For Magento-to-SAP:

1
What does SAP require to accept an e-commerce order export safely?

This includes required fields, optional fields, schema versions, idempotency keys, message group IDs, deduplication IDs, and validation rules.

Phase 2: Add SQS FIFO Infrastructure
#

Create the two FIFO queues and their FIFO DLQs.

Do not cut production traffic yet.

Just establish the infrastructure, IAM permissions, alarms, and dashboards.

Phase 3: Shadow Publish
#

Start publishing messages to SQS while the existing path still runs.

Do not consume them for business impact yet.

Use this phase to inspect real payloads and validate assumptions.

This is where the ugly data shows up.

That is good.

I would rather find the missing country problem in shadow mode than during a hard cutover.

Phase 4: Build Consumers
#

Build the Magento import consumer and SAP export worker.

Make them idempotent from day one.

Add structured logs, metrics, and failure classification.

Phase 5: Controlled Cutover
#

Move one flow first.

I would probably start with the less risky direction or a limited subset of orders.

Watch queue age, DLQ count, validation failures, processing time, deduplication behavior, and business-level reconciliation.

Phase 6: Replay and Cleanup
#

Once the queue-based flow is stable, clean up the old database-backed retry path.

This is important.

Leaving the old path around forever creates confusion.

Eventually, there should be one clear source of truth for each integration workflow.


The Bigger Lesson
#

The real lesson is not “use SQS.”

The real lesson is that integration architecture should respect failure boundaries.

SAP, Magento, WMS, and AfterShip do not fail the same way.

They do not validate data the same way.

They do not recover the same way.

They do not have the same operational ownership.

They should not be glued together as if they are one perfectly reliable system.

A queue gives each side room to breathe.

SAP can produce an offline order event.

Magento can consume it when ready.

Invalid data can be rejected clearly.

Temporary failures can retry.

Poison messages can land in a DLQ.

Operators can inspect, fix, and replay.

FIFO queues add another important layer: order-aware processing.

For SAP-to-Magento and Magento-to-SAP, that matters. These are not disposable events. They are order lifecycle events. They deserve per-order sequencing, deduplication, and business-safe idempotency.

That is the difference between a fragile integration and an operationally sane one.

For e-commerce systems, that difference matters.

Because when order data is wrong, customers feel it.

When tracking data is late, customers feel it.

When inventory updates are delayed, customers feel it.

When retries pile up silently for weeks, the business eventually feels it.

Queues do not eliminate integration problems.

They make those problems visible, recoverable, ordered, and contained.

That is exactly why they belong between systems that do not fail the same way.

Ecommerce Architecture - This article is part of a series.
Part 2: This Article

Related