Event-Driven Order Pipeline with Terraform

Build a fan-out order processing pipeline with SNS, SQS, Lambda, and DynamoDB.

Introduction

30 Second Summary

Every time you place an online order, a chain reaction of events fires behind the scenes. Your payment is processed, inventory is updated, a confirmation email is sent, and a shipping label is printed. These events happen in parallel, not one after another, because modern systems are built on event-driven architecture.

In this project, you will build an order processing pipeline that fans out events to multiple services using AWS messaging, compute, and database services. You will define all the infrastructure as Terraform code and deploy it locally with zero AWS cost.

What You'll Build

A complete event-driven pipeline where orders arrive via API Gateway, fan out through SNS to multiple SQS queues, trigger Lambda functions for processing and notifications, and persist order state in DynamoDB.

By the end of this project, you'll have:

  • A Terraform configuration defining your entire infrastructure (API Gateway, SNS, SQS with dead letter queues, Lambda, DynamoDB, and IAM) that deploys with a single command.
  • Two Python Lambda functions demonstrating idempotent writes and fan-out message handling.
  • A working end-to-end pipeline you can invoke with curl, observe messages flowing through the system, and verify dead letter queue behavior for failed messages.
  • Secret Mission: Add SQS-to-Lambda event source mapping with batch processing and partial failure reporting.

Are there any prerequisites?

You should have basic familiarity with Terraform syntax (resources, variables, outputs) and Python fundamentals. You also need Docker Desktop installed and running on your machine.

Install LocalStack and tflocal

To build our event-driven pipeline, we need to run AWS services like SNS, SQS, and Lambda on our own machine. LocalStack is a tool that emulates these AWS services locally inside a Docker container.

In this step, you will install the LocalStack CLI, set up a free account, and install helper tools that let Terraform talk to LocalStack instead of real AWS. You will also scaffold the project folders and create the provider configuration file.

In this step, get ready to:
  • Install the LocalStack CLI and start the local AWS emulator.
  • Install tflocal and awslocal for Terraform and AWS CLI integration.
  • Create the project directory structure and Terraform provider configuration.
Register for LocalStack and start the service

LocalStack requires a free Hobby account so it can authenticate your local instance. You will register at their web app, grab an auth token, then use it to start the emulator.

  • Make sure Docker Desktop is running in the background. LocalStack spins up inside a Docker container, so Docker must be active before you start it.
  • Open your browser and go to app.localstack.cloud.
  • Sign up for a free Hobby account using your email or GitHub.
  • Once signed in, navigate to your account settings or the quickstart page to find your Auth Token.
  • Copy your auth token and keep it handy. You will paste it into a terminal command shortly.

What is an auth token?

An auth token is a unique string that proves your identity to the LocalStack service. It lets your local CLI pull the correct container image and activate your Hobby plan features.

  • Open a terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal (macOS) or PowerShell (Windows) and press Enter.
  • Install the LocalStack CLI by running this command:
pip install localstack

Seeing 'pip: command not found'?

Try pip3 install localstack instead. On some systems, the Python 3 package installer is named pip3.

If neither works, confirm Python 3.11 is installed by running python3 --version. Help me fix my pip installation.

  • Configure your auth token by running this command, replacing your-auth-token-here with the token you copied from the LocalStack dashboard:
localstack auth set-token your-auth-token-here
  • Start LocalStack in the background by running:
localstack start -d

What does this command do?

The -d flag starts LocalStack in daemon mode, meaning it runs in the background. It pulls a Docker image the first time, so this may take a minute or two on your first run.

  • Verify LocalStack is running by running:
localstack status

You should see output showing LocalStack's version, edition, and a running status.

Seeing 'container not running' or a connection error?

Confirm Docker Desktop is running by checking for the Docker whale icon in your system tray. If Docker is running, try stopping and restarting LocalStack:

localstack stop
localstack start -d

If you see an auth error, double-check you pasted the correct token from your LocalStack dashboard. Help me get LocalStack running.

Install tflocal and awslocal

Terraform normally talks to real AWS endpoints. The tflocal wrapper automatically redirects Terraform commands to your local LocalStack instance.

Similarly, awslocal wraps the AWS CLI so you can run familiar AWS commands against LocalStack without extra configuration.

  • Install both tools by running these commands:
pip install terraform-local
pip install awscli-local
  • Verify tflocal installed correctly by running:
tflocal --version

You should see a version number printed without any errors.

  • Verify awslocal installed correctly by running:
awslocal --version

You should see AWS CLI version information printed.

Command not found after installing?

Your terminal may not have the Python scripts directory in its PATH. Try closing and reopening your terminal, then run the version check again.

If that does not help, try using pip3 instead of pip for the install commands. Help me fix my PATH for tflocal.

Create the project structure and Terraform configuration

Every Terraform project needs a directory for its configuration files. You will also create folders for the Lambda function code you will write in later steps.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir order-pipeline
cd order-pipeline
  • Create the subdirectory structure by running:
mkdir -p terraform lambdas/order_processor lambdas/notification_handler
  • Confirm the folders exist by running:
ls

You should see lambdas and terraform listed in the output.

  • Open the project in VS Code by running:
code .

'code' command not found?

Open VS Code manually instead. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter.

Then click File in the top menu bar, click Open Folder, and select the order-pipeline folder on your Desktop.

  • In the VS Code Explorer sidebar, right-click the terraform folder and select New File.
  • Name the file providers.tf and press Enter.
  • Paste the following Terraform configuration into providers.tf:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true

  endpoints {
    apigateway = "http://localhost:4566"
    dynamodb   = "http://localhost:4566"
    iam        = "http://localhost:4566"
    lambda     = "http://localhost:4566"
    sns        = "http://localhost:4566"
    sqs        = "http://localhost:4566"
    sts        = "http://localhost:4566"
  }
}

What does this configuration do?

The terraform block pins the AWS provider to version ~> 6.0, meaning any 6.x release is acceptable.

The provider "aws" block uses dummy credentials (test/test) because LocalStack does not validate real AWS keys. Every service endpoint points to http://localhost:4566, which is where LocalStack listens for API calls.

This means your Terraform code looks almost identical to production AWS Terraform. The only difference is the endpoints block redirecting traffic locally.

  • Save providers.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

VS Code showing a warning on the .tf file?

VS Code may prompt you to install a Terraform extension for syntax highlighting. You can accept or dismiss this prompt. It does not affect the project. Help me with VS Code Terraform warnings.

✔️ Awesome, I've got everything!

Great. Double check you have saved providers.tf inside the terraform/ folder.

ⓧ I'd like to double check the full code

Here is the complete terraform/providers.tf file. Make sure yours matches exactly:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true

  endpoints {
    apigateway = "http://localhost:4566"
    dynamodb   = "http://localhost:4566"
    iam        = "http://localhost:4566"
    lambda     = "http://localhost:4566"
    sns        = "http://localhost:4566"
    sqs        = "http://localhost:4566"
    sts        = "http://localhost:4566"
  }
}

Your local AWS environment is up and running. Next up, you will define the core messaging infrastructure with SNS topics, SQS queues, and a dead letter queue.

Define Core Messaging Infrastructure

Your local cloud environment is ready and waiting. Now it is time to define the messaging backbone that will carry orders through your pipeline.

Event-driven systems need a way to broadcast messages to multiple consumers without those consumers knowing about each other. In this step, you will define an SNS topic that fans out order events to multiple SQS queues, each handling a different concern. You will also set up a dead letter queue to catch messages that fail processing repeatedly.

In this step, get ready to:
  • Define the SNS topic and SQS queues (including a dead letter queue) in Terraform.
  • Wire SNS subscriptions and queue policies for message routing.
  • Deploy and verify the infrastructure with tflocal.
Create the SNS topic and SQS queues

The foundation of your pipeline is four resources: one SNS topic to broadcast events, two SQS queues to receive them, and a dead letter queue to catch failures.

  • In VS Code, create a new file called main.tf inside your terraform/ folder.
  • Add the following Terraform configuration to define your core messaging resources:
# SNS Topic for order events fan-out
resource "aws_sns_topic" "order_events" {
  name = "order-events"
}

# Dead Letter Queue for failed order processing
resource "aws_sqs_queue" "order_processing_dlq" {
  name = "order-processing-dlq"
}

# Main order processing queue with DLQ
resource "aws_sqs_queue" "order_processing" {
  name = "order-processing-queue"

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

# Notification queue (no DLQ for simplicity)
resource "aws_sqs_queue" "order_notifications" {
  name = "order-notifications-queue"
}

What does this code do?

This defines four interconnected messaging resources:

  • aws_sns_topic creates a topic called order-events. Any message published here gets broadcast to all subscribers.
  • aws_sqs_queue "order_processing_dlq" is the dead letter queue. Messages that fail processing 3 times land here instead of being retried forever.
  • aws_sqs_queue "order_processing" is the main processing queue. Its redrive_policy links it to the DLQ with a maxReceiveCount of 3, meaning a message gets 3 attempts before being moved to the DLQ.
  • aws_sqs_queue "order_notifications" is a separate queue for notification concerns (email, SMS). It processes the same events independently from the order processor.
  • Save main.tf.
  • Confirm the file exists by running:
ls terraform/main.tf

You should see terraform/main.tf printed in the output.

Don't see the file?

  • Make sure you created main.tf inside the terraform/ folder, not the project root.
  • Check that VS Code saved the file (look for the dot indicator on the tab).

Having trouble?

Add SNS subscriptions and queue policies

Your queues exist, but SNS does not have permission to send messages to them yet. You need two things: queue policies that explicitly allow SNS to deliver messages, and subscription resources that tell SNS where to route messages.

  • In main.tf, add the following SQS queue policies below the notification queue resource:
# SQS policy allowing SNS to send messages to processing queue
resource "aws_sqs_queue_policy" "order_processing_policy" {
  queue_url = aws_sqs_queue.order_processing.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_processing.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SQS policy allowing SNS to send messages to notifications queue
resource "aws_sqs_queue_policy" "order_notifications_policy" {
  queue_url = aws_sqs_queue.order_notifications.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_notifications.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

What are these policies doing?

SQS queues deny all messages by default. These IAM-style resource policies explicitly allow the sns.amazonaws.com service principal to call sqs:SendMessage on each queue.

The Condition block restricts this to messages arriving from your specific SNS topic. Without it, any SNS topic in the account could push messages into your queue.

  • Below the queue policies, add the SNS topic subscriptions that route messages from the topic to both queues:
# SNS subscription to order processing queue
resource "aws_sns_topic_subscription" "order_processing_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_processing.arn
}

# SNS subscription to notifications queue
resource "aws_sns_topic_subscription" "order_notifications_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_notifications.arn
}

Why SQS between SNS and Lambda?

SNS can invoke Lambda directly, but putting SQS in between gives you buffering, automatic retries, and DLQ support. If a Lambda function fails, the message stays in the queue for retry instead of being lost.

This is the production pattern that interviewers look for in system design discussions. It decouples the publisher from the consumer and adds resilience.

  • Save main.tf. You will not see a visible change yet. The next substep deploys these resources and verifies them.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf with all the resources, policies, and subscriptions.

ⓧ I'd like to double check the full code

Here is the complete terraform/main.tf file so far:

# SNS Topic for order events fan-out
resource "aws_sns_topic" "order_events" {
  name = "order-events"
}

# Dead Letter Queue for failed order processing
resource "aws_sqs_queue" "order_processing_dlq" {
  name = "order-processing-dlq"
}

# Main order processing queue with DLQ
resource "aws_sqs_queue" "order_processing" {
  name = "order-processing-queue"

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

# Notification queue (no DLQ for simplicity)
resource "aws_sqs_queue" "order_notifications" {
  name = "order-notifications-queue"
}

# SQS policy allowing SNS to send messages to processing queue
resource "aws_sqs_queue_policy" "order_processing_policy" {
  queue_url = aws_sqs_queue.order_processing.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_processing.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SQS policy allowing SNS to send messages to notifications queue
resource "aws_sqs_queue_policy" "order_notifications_policy" {
  queue_url = aws_sqs_queue.order_notifications.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_notifications.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SNS subscription to order processing queue
resource "aws_sns_topic_subscription" "order_processing_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_processing.arn
}

# SNS subscription to notifications queue
resource "aws_sns_topic_subscription" "order_notifications_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_notifications.arn
}
Deploy and verify the infrastructure

Your Terraform configuration is complete. Time to provision these resources on LocalStack and confirm they exist.

  • Initialize Terraform by running this command from inside the terraform/ folder:
cd terraform
tflocal init

What does tflocal init do?

tflocal is a thin wrapper around the standard terraform command. It automatically configures the provider endpoints to point at LocalStack. Running init downloads the AWS provider plugin and prepares the working directory.

You should see a message ending with Terraform has been successfully initialized! in your terminal.

  • Deploy your infrastructure by running:
tflocal apply -auto-approve

What does -auto-approve do?

By default, terraform apply shows a plan and asks you to type yes before making changes. The -auto-approve flag skips that confirmation. This is safe for local development on LocalStack where resources cost nothing and can be recreated instantly.

You should see Terraform create 8 resources, ending with Apply complete! Resources: 8 added, 0 changed, 0 destroyed. in the output.

  • Verify the SNS topic was created by running:
awslocal sns list-topics

You should see a JSON response containing a Topics array with one entry whose TopicArn ends in order-events.

  • Verify the SQS queues were created by running:
awslocal sqs list-queues

You should see a QueueUrls array containing URLs for order-processing-queue, order-notifications-queue, and order-processing-dlq.

Seeing errors during apply?

  • Make sure LocalStack is still running. Check with localstack status and restart with localstack start -d if needed.
  • Confirm you are running tflocal commands from inside the terraform/ directory, not the project root.
  • If you see a provider error, delete the .terraform folder and run tflocal init again.

Help me debug my tflocal apply errors.

Your messaging backbone is live. You have an SNS topic broadcasting to two independent SQS queues, with a DLQ standing by to catch failures. Next up, you will write the Lambda functions that consume these messages and persist orders to DynamoDB.

Write Lambda Functions and DynamoDB Table

Your messaging infrastructure is deployed and ready to route messages. Now you need the compute layer that actually processes those messages.

In this step, you will write two Python Lambda functions that consume messages from your SQS queues, define a DynamoDB table for persisting order state, and wire everything together with IAM permissions in Terraform.

In this step, get ready to:
  • Write an order processor Lambda with idempotent DynamoDB writes.
  • Write a notification handler Lambda that simulates customer alerts.
  • Define the DynamoDB table, IAM roles, and Lambda resources in Terraform.
Write the order processor Lambda function

The order processor is the core of your pipeline. It reads messages from the order processing queue, extracts order data, and saves it to DynamoDB.

The key design pattern here is the idempotent write. If the same order arrives twice (which can happen in distributed systems), the function skips the duplicate instead of creating a second record.

  • Create the directory and file for the order processor Lambda by running these commands:
mkdir -p lambdas/order_processor
touch lambdas/order_processor/handler.py
  • Confirm the files exist by running:
ls lambdas/order_processor/

You should see handler.py in the output.

  • Open lambdas/order_processor/handler.py in VS Code and paste the following code:
import json
import os
import boto3
from datetime import datetime

dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
table = dynamodb.Table(os.environ["DYNAMODB_TABLE"])


def lambda_handler(event, context):
    for record in event["Records"]:
        body = json.loads(record["body"])
        # SNS wraps the message in a Message field
        message = json.loads(body["Message"])

        order_id = message["order_id"]
        print(f"Processing order: {order_id}")

        # Idempotent write - only succeeds if order_id does not already exist
        try:
            table.put_item(
                Item={
                    "order_id": order_id,
                    "customer_name": message["customer_name"],
                    "product": message["product"],
                    "quantity": message["quantity"],
                    "status": "PROCESSED",
                    "processed_at": datetime.utcnow().isoformat(),
                },
                ConditionExpression="attribute_not_exists(order_id)",
            )
            print(f"Order {order_id} saved to DynamoDB")
        except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
            print(f"Order {order_id} already exists - skipping (idempotent)")

    return {"statusCode": 200, "body": "Orders processed"}

What does this code do?

This Lambda handler processes messages from an SQS queue. Each message has been wrapped by SNS, so the code double-parses the JSON to get the original order data.

The ConditionExpression="attribute_not_exists(order_id)" on the put_item call is the idempotency mechanism. If a record with that order_id already exists, DynamoDB raises a ConditionalCheckFailedException and the function gracefully skips the duplicate.

The AWS_ENDPOINT_URL environment variable is automatically injected by LocalStack so that boto3 calls route to the local emulator instead of real AWS.

  • Save lambdas/order_processor/handler.py.

Seeing a syntax error?

Make sure you copied the entire file including all import statements at the top. The from datetime import datetime line is easy to miss.

help me fix a syntax error in my order processor Lambda handler

Write the notification handler Lambda function

The notification handler receives the same order event from its own SQS queue but serves a different purpose. It simulates sending a confirmation to the customer via email or SMS.

  • Create the directory and file for the notification handler by running these commands:
mkdir -p lambdas/notification_handler
touch lambdas/notification_handler/handler.py
  • Open lambdas/notification_handler/handler.py in VS Code and paste the following code:
import json


def lambda_handler(event, context):
    for record in event["Records"]:
        body = json.loads(record["body"])
        message = json.loads(body["Message"])

        order_id = message["order_id"]
        customer_name = message["customer_name"]
        product = message["product"]

        print(f"NOTIFICATION: Order {order_id} confirmed for {customer_name}")
        print(f"  Product: {product}, Quantity: {message['quantity']}")
        print(f"  [Simulating email/SMS dispatch to customer]")

    return {"statusCode": 200, "body": "Notifications sent"}

What does this code do?

This handler follows the same message-parsing pattern as the order processor. It extracts order details and logs a simulated notification. In production, this is where you would call an email service like SES or an SMS provider like Twilio.

This separation of concerns is the whole point of the fan-out pattern. The order processing logic and notification logic are decoupled into independent functions with their own queues, failure modes, and scaling characteristics.

  • Save lambdas/notification_handler/handler.py.
  • Confirm both Lambda directories exist by running:
ls lambdas/

You should see order_processor and notification_handler directories listed.

Why two separate Lambda functions instead of one?

A single Lambda could check the message source and branch. But separate functions give you independent retry policies, timeout settings, and scaling. If the notification service is slow, it does not block order processing.

In system design interviews, this decoupling via fan-out is a key talking point. It demonstrates you understand how to isolate failure domains in distributed systems.

Define DynamoDB, IAM, and Lambda resources in Terraform

Now you will add the remaining infrastructure to your Terraform configuration. This includes the DynamoDB table where orders are stored, the IAM permissions that let Lambda access other services, and the Lambda function definitions themselves.

  • Open terraform/main.tf in VS Code. Find the last line (the closing } of the order_notifications_sub resource).
  • Add the DynamoDB table definition below it:
# DynamoDB table for orders
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}

What does this code do?

This creates a DynamoDB table named orders with order_id as the partition key (type S for String). The PAY_PER_REQUEST billing mode means you only pay for actual reads and writes, with no pre-provisioned capacity to manage.

The partition key is what DynamoDB uses to distribute data across storage nodes. Using order_id ensures each order maps to a unique record, which is exactly what makes the conditional write work for idempotency.

  • Below the DynamoDB table, add the IAM role and policy for your Lambda functions:
# IAM role for Lambda functions
resource "aws_iam_role" "lambda_role" {
  name = "order-pipeline-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# IAM policy for Lambda to access DynamoDB, SQS, and CloudWatch Logs
resource "aws_iam_policy" "lambda_policy" {
  name = "order-pipeline-lambda-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem"
        ]
        Resource = aws_dynamodb_table.orders.arn
      },
      {
        Effect = "Allow"
        Action = [
          "sqs:ReceiveMessage",
          "sqs:DeleteMessage",
          "sqs:GetQueueAttributes"
        ]
        Resource = [
          aws_sqs_queue.order_processing.arn,
          aws_sqs_queue.order_notifications.arn
        ]
      },
      {
        Effect   = "Allow"
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_policy_attach" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

What is least-privilege IAM?

The policy grants only the exact permissions each Lambda needs. The DynamoDB statement allows PutItem and GetItem on the orders table only. The SQS statement allows reading and deleting messages from only your two queues.

This is a core security practice. If a Lambda function is compromised, the attacker can only access the resources explicitly listed in its policy.

  • Below the IAM resources, add the archive packaging and Lambda function definitions:
# Package Lambda functions
data "archive_file" "order_processor_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/order_processor/handler.py"
  output_path = "${path.module}/../lambdas/order_processor.zip"
}

data "archive_file" "notification_handler_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/notification_handler/handler.py"
  output_path = "${path.module}/../lambdas/notification_handler.zip"
}

# Order processor Lambda
resource "aws_lambda_function" "order_processor" {
  function_name    = "order-processor"
  filename         = data.archive_file.order_processor_zip.output_path
  source_code_hash = data.archive_file.order_processor_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30

  environment {
    variables = {
      DYNAMODB_TABLE = aws_dynamodb_table.orders.name
    }
  }
}

# Notification handler Lambda
resource "aws_lambda_function" "notification_handler" {
  function_name    = "notification-handler"
  filename         = data.archive_file.notification_handler_zip.output_path
  source_code_hash = data.archive_file.notification_handler_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30
}

What does this code do?

The archive_file data sources zip each Python handler into a deployment package during terraform plan. The source_code_hash attribute tells Terraform to redeploy the Lambda whenever the handler code changes.

Each Lambda function gets the shared IAM role, a 30-second timeout, and the python3.11 runtime. The order processor also receives the DYNAMODB_TABLE environment variable so it knows which table to write to.

  • Create a new file terraform/outputs.tf and paste the following:
output "api_endpoint" {
  value = "http://localhost:4566/restapis/${aws_api_gateway_rest_api.orders_api.id}/dev/_user_request_/orders"
}

output "sns_topic_arn" {
  value = aws_sns_topic.order_events.arn
}

output "order_processing_queue_url" {
  value = aws_sqs_queue.order_processing.id
}

output "dlq_url" {
  value = aws_sqs_queue.order_processing_dlq.id
}

What are Terraform outputs?

Outputs print useful values after tflocal apply completes. You will use the api_endpoint to send test requests with curl and the dlq_url to inspect failed messages.

The api_endpoint output references the API Gateway resource you will define in the next step. Terraform will not validate this file until you run tflocal apply after adding all resources.

  • Save both terraform/main.tf and terraform/outputs.tf.
  • Confirm the outputs file exists by running:
ls terraform/

You should see main.tf, providers.tf, and outputs.tf listed.

Missing a file?

Make sure you created outputs.tf inside the terraform/ directory, not in the project root. All .tf files must be in the same folder for Terraform to discover them.

help me troubleshoot my Terraform project file structure

✔️ Awesome, I've got everything!

Great. Double check you have saved both terraform/main.tf and terraform/outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete lambdas/order_processor/handler.py:

import json
import os
import boto3
from datetime import datetime

dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
table = dynamodb.Table(os.environ["DYNAMODB_TABLE"])


def lambda_handler(event, context):
    for record in event["Records"]:
        body = json.loads(record["body"])
        # SNS wraps the message in a Message field
        message = json.loads(body["Message"])

        order_id = message["order_id"]
        print(f"Processing order: {order_id}")

        # Idempotent write - only succeeds if order_id does not already exist
        try:
            table.put_item(
                Item={
                    "order_id": order_id,
                    "customer_name": message["customer_name"],
                    "product": message["product"],
                    "quantity": message["quantity"],
                    "status": "PROCESSED",
                    "processed_at": datetime.utcnow().isoformat(),
                },
                ConditionExpression="attribute_not_exists(order_id)",
            )
            print(f"Order {order_id} saved to DynamoDB")
        except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
            print(f"Order {order_id} already exists - skipping (idempotent)")

    return {"statusCode": 200, "body": "Orders processed"}

Here is the complete lambdas/notification_handler/handler.py:

import json


def lambda_handler(event, context):
    for record in event["Records"]:
        body = json.loads(record["body"])
        message = json.loads(body["Message"])

        order_id = message["order_id"]
        customer_name = message["customer_name"]
        product = message["product"]

        print(f"NOTIFICATION: Order {order_id} confirmed for {customer_name}")
        print(f"  Product: {product}, Quantity: {message['quantity']}")
        print(f"  [Simulating email/SMS dispatch to customer]")

    return {"statusCode": 200, "body": "Notifications sent"}

Here is the complete terraform/main.tf:

# SNS Topic for order events fan-out
resource "aws_sns_topic" "order_events" {
  name = "order-events"
}

# Dead Letter Queue for failed order processing
resource "aws_sqs_queue" "order_processing_dlq" {
  name = "order-processing-dlq"
}

# Main order processing queue with DLQ
resource "aws_sqs_queue" "order_processing" {
  name = "order-processing-queue"

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

# Notification queue (no DLQ for simplicity)
resource "aws_sqs_queue" "order_notifications" {
  name = "order-notifications-queue"
}

# SQS policy allowing SNS to send messages to processing queue
resource "aws_sqs_queue_policy" "order_processing_policy" {
  queue_url = aws_sqs_queue.order_processing.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_processing.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SQS policy allowing SNS to send messages to notifications queue
resource "aws_sqs_queue_policy" "order_notifications_policy" {
  queue_url = aws_sqs_queue.order_notifications.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_notifications.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SNS subscription to order processing queue
resource "aws_sns_topic_subscription" "order_processing_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_processing.arn
}

# SNS subscription to notifications queue
resource "aws_sns_topic_subscription" "order_notifications_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_notifications.arn
}

# DynamoDB table for orders
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}

# IAM role for Lambda functions
resource "aws_iam_role" "lambda_role" {
  name = "order-pipeline-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# IAM policy for Lambda to access DynamoDB, SQS, and CloudWatch Logs
resource "aws_iam_policy" "lambda_policy" {
  name = "order-pipeline-lambda-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem"
        ]
        Resource = aws_dynamodb_table.orders.arn
      },
      {
        Effect = "Allow"
        Action = [
          "sqs:ReceiveMessage",
          "sqs:DeleteMessage",
          "sqs:GetQueueAttributes"
        ]
        Resource = [
          aws_sqs_queue.order_processing.arn,
          aws_sqs_queue.order_notifications.arn
        ]
      },
      {
        Effect   = "Allow"
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_policy_attach" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

# Package Lambda functions
data "archive_file" "order_processor_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/order_processor/handler.py"
  output_path = "${path.module}/../lambdas/order_processor.zip"
}

data "archive_file" "notification_handler_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/notification_handler/handler.py"
  output_path = "${path.module}/../lambdas/notification_handler.zip"
}

# Order processor Lambda
resource "aws_lambda_function" "order_processor" {
  function_name    = "order-processor"
  filename         = data.archive_file.order_processor_zip.output_path
  source_code_hash = data.archive_file.order_processor_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30

  environment {
    variables = {
      DYNAMODB_TABLE = aws_dynamodb_table.orders.name
    }
  }
}

# Notification handler Lambda
resource "aws_lambda_function" "notification_handler" {
  function_name    = "notification-handler"
  filename         = data.archive_file.notification_handler_zip.output_path
  source_code_hash = data.archive_file.notification_handler_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30
}

Here is the complete terraform/outputs.tf:

output "api_endpoint" {
  value = "http://localhost:4566/restapis/${aws_api_gateway_rest_api.orders_api.id}/dev/_user_request_/orders"
}

output "sns_topic_arn" {
  value = aws_sns_topic.order_events.arn
}

output "order_processing_queue_url" {
  value = aws_sqs_queue.order_processing.id
}

output "dlq_url" {
  value = aws_sqs_queue.order_processing_dlq.id
}

Your Lambda functions and Terraform resources are ready. Next up, you will wire API Gateway to the front of this pipeline so you can trigger it with HTTP requests.

Wire API Gateway to the Pipeline

You have an SNS topic, SQS queues, Lambda functions, and a DynamoDB table all provisioned and connected. The missing piece is a way for external clients to submit orders into this pipeline.

In this step, you will define an API Gateway REST API that accepts POST requests at /orders and publishes the request body directly to your SNS topic. This completes the end-to-end flow from HTTP request to database write.

In this step, get ready to:
  • Define the API Gateway REST API with a direct SNS service integration.
  • Deploy the complete infrastructure with tflocal apply.
  • Test the full pipeline end-to-end with curl and verify data in DynamoDB.
Define the API Gateway REST API and SNS integration

Most tutorials connect API Gateway to a Lambda function that then publishes to SNS. You are going to skip the middleman and integrate API Gateway directly with SNS.

Why API Gateway direct-to-SNS instead of Lambda proxy?

API Gateway supports AWS service integrations, meaning it can call AWS services like SNS directly without invoking a Lambda in between.

This eliminates unnecessary compute, reduces latency, and keeps your architecture cleaner. In interviews, candidates who know this pattern demonstrate deeper API Gateway knowledge than those who default to Lambda proxies for everything.

  • In terraform/main.tf, scroll to the bottom of the file (after the notification_handler Lambda resource).
  • Add the API Gateway REST API, resource, and method by copying the following code:
# API Gateway REST API
resource "aws_api_gateway_rest_api" "orders_api" {
  name = "orders-api"
}

resource "aws_api_gateway_resource" "orders_resource" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  parent_id   = aws_api_gateway_rest_api.orders_api.root_resource_id
  path_part   = "orders"
}

resource "aws_api_gateway_method" "post_order" {
  rest_api_id   = aws_api_gateway_rest_api.orders_api.id
  resource_id   = aws_api_gateway_resource.orders_resource.id
  http_method   = "POST"
  authorization = "NONE"
}

What does this code do?

  • aws_api_gateway_rest_api creates a new REST API named orders-api.
  • aws_api_gateway_resource defines the /orders path under the API's root.
  • aws_api_gateway_method allows POST requests with no authorization (since this is a local dev environment).
  • Below the method resource, add the IAM role that allows API Gateway to publish to SNS:
# IAM role for API Gateway to publish to SNS
resource "aws_iam_role" "api_gateway_role" {
  name = "api-gateway-sns-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "apigateway.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "api_gateway_sns_policy" {
  name = "api-gateway-sns-publish"
  role = aws_iam_role.api_gateway_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "sns:Publish"
        Resource = aws_sns_topic.order_events.arn
      }
    ]
  })
}

What does this code do?

  • The IAM role uses apigateway.amazonaws.com as the trusted principal, allowing API Gateway to assume this role.
  • The inline policy grants only sns:Publish on the specific order-events topic. This follows the least-privilege principle.
  • Below the IAM policy, add the integration that connects API Gateway to SNS, plus the response configuration and deployment:
resource "aws_api_gateway_integration" "sns_integration" {
  rest_api_id             = aws_api_gateway_rest_api.orders_api.id
  resource_id             = aws_api_gateway_resource.orders_resource.id
  http_method             = aws_api_gateway_method.post_order.http_method
  integration_http_method = "POST"
  type                    = "AWS"
  uri                     = "arn:aws:apigateway:us-east-1:sns:action/Publish"
  credentials             = aws_iam_role.api_gateway_role.arn

  request_parameters = {
    "integration.request.querystring.TopicArn" = "'${aws_sns_topic.order_events.arn}'"
    "integration.request.querystring.Message"  = "method.request.body"
  }
}

resource "aws_api_gateway_method_response" "response_200" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  resource_id = aws_api_gateway_resource.orders_resource.id
  http_method = aws_api_gateway_method.post_order.http_method
  status_code = "200"
}

resource "aws_api_gateway_integration_response" "sns_response" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  resource_id = aws_api_gateway_resource.orders_resource.id
  http_method = aws_api_gateway_method.post_order.http_method
  status_code = aws_api_gateway_method_response.response_200.status_code
}

resource "aws_api_gateway_deployment" "orders_deployment" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id

  depends_on = [
    aws_api_gateway_integration.sns_integration,
    aws_api_gateway_integration_response.sns_response
  ]
}

resource "aws_api_gateway_stage" "dev" {
  rest_api_id   = aws_api_gateway_rest_api.orders_api.id
  deployment_id = aws_api_gateway_deployment.orders_deployment.id
  stage_name    = "dev"
}

What does this code do?

  • The integration uses type = "AWS" for a direct AWS service call (not a Lambda proxy).
  • uri points to the SNS Publish action. The request_parameters map the SNS topic ARN and the incoming request body to the Publish call's parameters.
  • The deployment and stage resources make the API callable at a live URL. The depends_on block ensures Terraform creates the integration before attempting deployment.
  • Save terraform/main.tf.

Seeing syntax errors in your editor?

Make sure each resource block has matching opening and closing braces. The interpolation '${aws_sns_topic.order_events.arn}' uses single quotes inside the Terraform string. This is intentional because it becomes a literal string value in the API Gateway request parameters.

help me fix a syntax error in my API Gateway Terraform configuration

✔️ Awesome, I've got everything!

Great. Double check you have saved terraform/main.tf before deploying.

ⓧ I'd like to double check the full code

# SNS Topic for order events fan-out
resource "aws_sns_topic" "order_events" {
  name = "order-events"
}

# Dead Letter Queue for failed order processing
resource "aws_sqs_queue" "order_processing_dlq" {
  name = "order-processing-dlq"
}

# Main order processing queue with DLQ
resource "aws_sqs_queue" "order_processing" {
  name = "order-processing-queue"

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

# Notification queue (no DLQ for simplicity)
resource "aws_sqs_queue" "order_notifications" {
  name = "order-notifications-queue"
}

# SQS policy allowing SNS to send messages to processing queue
resource "aws_sqs_queue_policy" "order_processing_policy" {
  queue_url = aws_sqs_queue.order_processing.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_processing.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SQS policy allowing SNS to send messages to notifications queue
resource "aws_sqs_queue_policy" "order_notifications_policy" {
  queue_url = aws_sqs_queue.order_notifications.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "sns.amazonaws.com" }
        Action    = "sqs:SendMessage"
        Resource  = aws_sqs_queue.order_notifications.arn
        Condition = {
          ArnEquals = {
            "aws:SourceArn" = aws_sns_topic.order_events.arn
          }
        }
      }
    ]
  })
}

# SNS subscription to order processing queue
resource "aws_sns_topic_subscription" "order_processing_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_processing.arn
}

# SNS subscription to notifications queue
resource "aws_sns_topic_subscription" "order_notifications_sub" {
  topic_arn = aws_sns_topic.order_events.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.order_notifications.arn
}

# DynamoDB table for orders
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }
}

# IAM role for Lambda functions
resource "aws_iam_role" "lambda_role" {
  name = "order-pipeline-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# IAM policy for Lambda to access DynamoDB, SQS, and CloudWatch Logs
resource "aws_iam_policy" "lambda_policy" {
  name = "order-pipeline-lambda-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem"
        ]
        Resource = aws_dynamodb_table.orders.arn
      },
      {
        Effect = "Allow"
        Action = [
          "sqs:ReceiveMessage",
          "sqs:DeleteMessage",
          "sqs:GetQueueAttributes"
        ]
        Resource = [
          aws_sqs_queue.order_processing.arn,
          aws_sqs_queue.order_notifications.arn
        ]
      },
      {
        Effect   = "Allow"
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_policy_attach" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

# Package Lambda functions
data "archive_file" "order_processor_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/order_processor/handler.py"
  output_path = "${path.module}/../lambdas/order_processor.zip"
}

data "archive_file" "notification_handler_zip" {
  type        = "zip"
  source_file = "${path.module}/../lambdas/notification_handler/handler.py"
  output_path = "${path.module}/../lambdas/notification_handler.zip"
}

# Order processor Lambda
resource "aws_lambda_function" "order_processor" {
  function_name    = "order-processor"
  filename         = data.archive_file.order_processor_zip.output_path
  source_code_hash = data.archive_file.order_processor_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30

  environment {
    variables = {
      DYNAMODB_TABLE = aws_dynamodb_table.orders.name
    }
  }
}

# Notification handler Lambda
resource "aws_lambda_function" "notification_handler" {
  function_name    = "notification-handler"
  filename         = data.archive_file.notification_handler_zip.output_path
  source_code_hash = data.archive_file.notification_handler_zip.output_base64sha256
  handler          = "handler.lambda_handler"
  runtime          = "python3.11"
  role             = aws_iam_role.lambda_role.arn
  timeout          = 30
}

# API Gateway REST API
resource "aws_api_gateway_rest_api" "orders_api" {
  name = "orders-api"
}

resource "aws_api_gateway_resource" "orders_resource" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  parent_id   = aws_api_gateway_rest_api.orders_api.root_resource_id
  path_part   = "orders"
}

resource "aws_api_gateway_method" "post_order" {
  rest_api_id   = aws_api_gateway_rest_api.orders_api.id
  resource_id   = aws_api_gateway_resource.orders_resource.id
  http_method   = "POST"
  authorization = "NONE"
}

# IAM role for API Gateway to publish to SNS
resource "aws_iam_role" "api_gateway_role" {
  name = "api-gateway-sns-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "apigateway.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "api_gateway_sns_policy" {
  name = "api-gateway-sns-publish"
  role = aws_iam_role.api_gateway_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "sns:Publish"
        Resource = aws_sns_topic.order_events.arn
      }
    ]
  })
}

resource "aws_api_gateway_integration" "sns_integration" {
  rest_api_id             = aws_api_gateway_rest_api.orders_api.id
  resource_id             = aws_api_gateway_resource.orders_resource.id
  http_method             = aws_api_gateway_method.post_order.http_method
  integration_http_method = "POST"
  type                    = "AWS"
  uri                     = "arn:aws:apigateway:us-east-1:sns:action/Publish"
  credentials             = aws_iam_role.api_gateway_role.arn

  request_parameters = {
    "integration.request.querystring.TopicArn" = "'${aws_sns_topic.order_events.arn}'"
    "integration.request.querystring.Message"  = "method.request.body"
  }
}

resource "aws_api_gateway_method_response" "response_200" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  resource_id = aws_api_gateway_resource.orders_resource.id
  http_method = aws_api_gateway_method.post_order.http_method
  status_code = "200"
}

resource "aws_api_gateway_integration_response" "sns_response" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id
  resource_id = aws_api_gateway_resource.orders_resource.id
  http_method = aws_api_gateway_method.post_order.http_method
  status_code = aws_api_gateway_method_response.response_200.status_code
}

resource "aws_api_gateway_deployment" "orders_deployment" {
  rest_api_id = aws_api_gateway_rest_api.orders_api.id

  depends_on = [
    aws_api_gateway_integration.sns_integration,
    aws_api_gateway_integration_response.sns_response
  ]
}

resource "aws_api_gateway_stage" "dev" {
  rest_api_id   = aws_api_gateway_rest_api.orders_api.id
  deployment_id = aws_api_gateway_deployment.orders_deployment.id
  stage_name    = "dev"
}
Deploy the complete infrastructure

With the API Gateway resources defined, you can now deploy the entire stack. Terraform will detect the new resources and create them alongside your existing infrastructure.

  • Deploy the updated infrastructure by running this command from the terraform directory:
tflocal apply -auto-approve
  • After the apply completes, capture the API Gateway endpoint URL by running:
tflocal output api_endpoint

What does the endpoint URL look like?

The output follows the LocalStack API Gateway format: http://localhost:4566/restapis/{id}/dev/_user_request_/orders.

The {id} portion is a unique identifier Terraform generates for your REST API. Copy this full URL for the next substep.

  • You should see a URL printed in the terminal output. Copy it for use in the next substep.

Apply failed with resource errors?

If you see an error about the API Gateway deployment, make sure your depends_on block references both aws_api_gateway_integration.sns_integration and aws_api_gateway_integration_response.sns_response. Without these, Terraform may try to deploy before the integration is ready.

Also confirm LocalStack is still running with localstack status.

help me debug my tflocal apply failure for API Gateway resources

Test the full pipeline end-to-end

The entire pipeline is now live. A POST request to your API Gateway endpoint should flow through SNS, fan out to both SQS queues, trigger both Lambda functions, and persist the order in DynamoDB.

  • Send a test order to the pipeline by running this curl command (replace the URL with your actual endpoint from the previous substep):
curl -X POST [[API_ENDPOINT="your API endpoint URL from tflocal output"]] \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ORD-001", "customer_name": "Alice Smith", "product": "Mechanical Keyboard", "quantity": 2}'

What does this command do?

This sends a JSON order payload to your API Gateway endpoint. API Gateway receives the POST request, extracts the body, and publishes it to the order-events SNS topic. SNS fans the message out to both SQS queues. The order processor Lambda reads from its queue and writes to DynamoDB.

  • You should see an XML response from SNS confirming the message was published (containing a MessageId field).
  • Verify the order landed in DynamoDB by running:
awslocal dynamodb scan --table-name orders
  • You should see your order with order_id of ORD-001, status of PROCESSED, and a processed_at timestamp in the results.
  • Confirm the Dead Letter Queue is empty (no failed messages) by running:
awslocal sqs get-queue-attributes --queue-url $(tflocal output -raw dlq_url) --attribute-names ApproximateNumberOfMessages
  • You should see ApproximateNumberOfMessages with a value of 0. This confirms no messages failed processing.

DynamoDB scan shows no items?

The Lambda functions need to be invoked to process the SQS messages. In LocalStack without event source mappings, you may need to manually invoke the Lambda to process messages from the queue.

Try invoking the order processor Lambda manually by running:

awslocal lambda invoke --function-name order-processor --payload '{"Records": [{"body": "{\"Message\": \"{\\\"order_id\\\": \\\"ORD-001\\\", \\\"customer_name\\\": \\\"Alice Smith\\\", \\\"product\\\": \\\"Mechanical Keyboard\\\", \\\"quantity\\\": 2}\"}"}]}' /tmp/output.json

Then run the DynamoDB scan again. If you see the order now, the Lambda itself is working correctly. The Secret Mission will wire up automatic event source mappings to solve this.

help me debug why my Lambda is not processing SQS messages in LocalStack

Your pipeline is fully operational. Next up, you will stress-test the failure path and watch your Dead Letter Queue catch poison messages.

Test Failure Handling and DLQ Behavior

Your pipeline handles the happy path. But what happens when a message cannot be processed?

In production, certain messages inevitably fail. Maybe the payload is malformed, or a downstream dependency is down. Without a safety net, these "poison messages" would retry forever, blocking the queue for healthy messages. That safety net is the Dead Letter Queue you configured earlier. In this step, you will inject a deliberate failure and watch the DLQ catch the poison message after 3 retries.

In this step, get ready to:
  • Inject a simulated poison message failure into the order processor Lambda.
  • Observe the message retry 3 times then land in the Dead Letter Queue.
  • Revert the failure and confirm the pipeline is healthy again.
Inject a poison message failure into the Lambda

A poison message is any message that a consumer cannot successfully process, no matter how many times it retries. You will simulate this by making the order processor raise an exception for a specific order_id.

  • In VS Code, open lambdas/order_processor/handler.py.
  • Find these two lines inside the for loop:
        order_id = message["order_id"]
        print(f"Processing order: {order_id}")
  • Immediately below those two lines, add this intentional failure check:
        # Intentional failure for testing DLQ behavior
        if order_id == "POISON-001":
            raise Exception(f"Simulated failure for order {order_id}")

What does this code do?

The if check looks for a specific order ID (POISON-001). When it finds that ID, it raises an unhandled exception, crashing the Lambda invocation.

Every other order ID passes through normally. This lets you target exactly one message as the poison pill while keeping the rest of the pipeline healthy.

  • Save handler.py.
  • Redeploy the updated Lambda by running this command from the terraform directory:
cd terraform && tflocal apply -auto-approve
  • You should see Terraform detect the changed source code hash and update the Lambda function. Look for a line showing aws_lambda_function.order_processor being modified.

Terraform shows no changes?

Make sure you saved handler.py before running tflocal apply. Terraform uses the source_code_hash from the archive_file data source to detect changes. If the file on disk hasn't changed, the hash stays the same.

help me figure out why Terraform isn't detecting my Lambda code change

Send the poison message and verify DLQ behavior

Now you will send a message designed to fail and simulate the retry behavior that triggers the DLQ. In production with an event source mapping, SQS would automatically retry failed messages. Here, you will manually receive from the queue to simulate those retries.

  • Send the poison message to your API Gateway endpoint by running:
curl -X POST $(tflocal output -raw api_endpoint) \
  -H "Content-Type: application/json" \
  -d '{"order_id": "POISON-001", "customer_name": "Test User", "product": "Faulty Widget", "quantity": 1}'

What happens when you send this?

The message travels through API Gateway to SNS, then fans out to both SQS queues. The message is now sitting in order-processing-queue waiting to be consumed. Because there is no event source mapping yet (that comes in the Secret Mission), you will manually simulate the consumer receiving and failing on this message.

  • Simulate 3 failed processing attempts by receiving from the queue 3 times. Each receive increments the message's ApproximateReceiveCount. Run this command:
for i in 1 2 3; do
  echo "--- Receive attempt $i ---"
  awslocal sqs receive-message \
    --queue-url $(tflocal output -raw order_processing_queue_url) \
    --visibility-timeout 0
  sleep 1
done

Why --visibility-timeout 0?

Setting --visibility-timeout 0 makes the message immediately visible again after each receive. Without this, you would need to wait 30 seconds (the default visibility timeout) between each attempt.

Each receive increments the internal receive count. Once the count exceeds maxReceiveCount (which you set to 3 in the redrive policy), SQS automatically moves the message to the Dead Letter Queue.

  • Now check that the poison message landed in the DLQ by running:
awslocal sqs receive-message \
  --queue-url $(tflocal output -raw dlq_url)
  • You should see a JSON response containing a Messages array with your poison message. The Body field contains the SNS-wrapped message including POISON-001 as the order_id.

Why does this matter in production?

Without a DLQ, a poison message would cycle through the queue indefinitely, consuming compute resources on every retry and potentially blocking other messages behind it. The DLQ acts as a quarantine. Operations teams can inspect failed messages, fix the underlying issue, and replay them.

DLQ returns empty Messages array?

The message may not have reached the DLQ yet. Try running the receive command on the processing queue one more time with --visibility-timeout 0, then check the DLQ again.

Also confirm that your redrive policy has maxReceiveCount set to 3 in terraform/main.tf. If it is higher, you need more receive attempts before the message moves.

help me debug why my poison message isn't appearing in the Dead Letter Queue

Revert the failure and confirm pipeline health

You have proven the DLQ works. Now revert the intentional failure so the pipeline processes orders normally again.

  • In lambdas/order_processor/handler.py, find and delete these three lines you added earlier:
        # Intentional failure for testing DLQ behavior
        if order_id == "POISON-001":
            raise Exception(f"Simulated failure for order {order_id}")
  • Save handler.py.
  • Redeploy the reverted code by running:
tflocal apply -auto-approve
  • Confirm the pipeline is healthy by sending a new order:
curl -X POST $(tflocal output -raw api_endpoint) \
  -H "Content-Type: application/json" \
  -d '{"order_id": "ORD-HEALTHY-001", "customer_name": "Jane Smith", "product": "Premium Widget", "quantity": 2}'
  • Verify the order was persisted to DynamoDB by running:
awslocal dynamodb scan --table-name orders
  • You should see ORD-HEALTHY-001 in the scan results with status PROCESSED.
  • Finally, review your complete architecture by running:
tflocal show

What does tflocal show tell you?

This command prints a human-readable summary of every resource Terraform manages in your current state. You will see all 20+ resources listed: the SNS topic, both SQS queues, the DLQ, the DynamoDB table, both Lambda functions, the IAM roles and policies, and the full API Gateway configuration.

This is the same view you would present in a system design interview to walk through your infrastructure. Each resource is displayed with its current attributes as Terraform sees them.

Order not appearing in DynamoDB?

Without an event source mapping, the Lambda does not automatically consume from SQS. The DynamoDB write only happens when the Lambda is invoked with the SQS event. You can manually invoke it by running awslocal lambda invoke with a crafted SQS event payload, or wait for the Secret Mission where you add automatic SQS-to-Lambda triggering.

help me verify that my order pipeline is working end to end after reverting the poison message failure

✔️ Awesome, I've got everything!

Great. Double check you have saved handler.py with the failure code removed.

ⓧ I'd like to double check the full code

Here is the final lambdas/order_processor/handler.py after reverting the intentional failure:

import json
import os
import boto3
from datetime import datetime

dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
table = dynamodb.Table(os.environ["DYNAMODB_TABLE"])


def lambda_handler(event, context):
    for record in event["Records"]:
        body = json.loads(record["body"])
        # SNS wraps the message in a Message field
        message = json.loads(body["Message"])

        order_id = message["order_id"]
        print(f"Processing order: {order_id}")

        # Idempotent write - only succeeds if order_id does not already exist
        try:
            table.put_item(
                Item={
                    "order_id": order_id,
                    "customer_name": message["customer_name"],
                    "product": message["product"],
                    "quantity": message["quantity"],
                    "status": "PROCESSED",
                    "processed_at": datetime.utcnow().isoformat(),
                },
                ConditionExpression="attribute_not_exists(order_id)",
            )
            print(f"Order {order_id} saved to DynamoDB")
        except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
            print(f"Order {order_id} already exists - skipping (idempotent)")

    return {"statusCode": 200, "body": "Orders processed"}

You have proven that your Dead Letter Queue catches poison messages after the configured number of retries. This is a critical resilience pattern. Next, you will extend this pipeline with automatic batch processing in the Secret Mission.

Secret mission

Add Event Source Mapping with Batch Processing

Right now your Lambda functions poll SQS manually. In production, AWS handles this automatically with event source mappings. Configure Terraform to wire SQS directly to Lambda with batch processing and partial failure reporting, so a single poison message doesn't tank an entire batch.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using LocalStack and Docker. There are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • LocalStack Docker container (running in background).
  • Terraform state and configuration files in the terraform/ directory.
  • Lambda function code in the lambdas/ directory.
  • Python packages: localstack, terraform-local, awscli-local.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the pipeline.

  • LocalStack continues running in the background as a Docker container.
  • You can send more orders via curl to test different scenarios.
  • Your Terraform state is preserved, so you can run tflocal apply again to update resources.
  • The localstack, terraform-local, and awscli-local pip packages remain installed on your system.
  • Your LocalStack account remains active on the free Hobby plan with no charges.

Pause - I'll come back to this later

Stop LocalStack to free up Docker resources, but keep your project files for later.

  • Stop LocalStack by running:
localstack stop
  • LocalStack is ephemeral. All AWS resources (SNS topics, SQS queues, DynamoDB tables, Lambda functions) are automatically removed when the container stops.
  • Your Terraform files and Lambda code on disk are preserved. When you come back, run localstack start -d and then tflocal apply to recreate everything.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop LocalStack and destroy the Terraform-managed infrastructure by running:
cd terraform
tflocal destroy -auto-approve
cd ..
localstack stop
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf order-pipeline

Windows

cd ~\Desktop
Remove-Item -Recurse -Force order-pipeline
  • Uninstall the Python packages by running:
pip uninstall localstack terraform-local awscli-local -y

Nice Work!

Nice Work!

You just built a production-grade event-driven order processing pipeline entirely defined in Terraform and running locally on LocalStack. This is the exact architecture pattern tested in senior system design interviews.

You've learned how to:

  • Build a complete event-driven pipeline with fan-out messaging (SNS to multiple SQS queues), dead letter queues for poison message handling, and Lambda-based processing.
  • Define all infrastructure as Terraform code (API Gateway, SNS, SQS, DLQ, Lambda, DynamoDB, IAM) and deploy locally with zero AWS cost using LocalStack and tflocal.
  • Write idempotent Lambda functions with conditional DynamoDB writes to achieve exactly-once processing semantics in a distributed system.
  • Secret Mission: Implement batch processing with partial failure reporting using SQS event source mappings, so only failed messages retry while successful ones proceed.

Ready to quiz yourself?