Introduction
When working with microservices, guaranteeing consistency between a database write and an event emission (like an SQS message) can be tricky. This is especially true in distributed systems, where network failures and retries can easily cause duplicates or loss.
The Outbox Pattern offers a robust solution. And in this article, we’ll build it from scratch using:
- Terraform for infrastructure-as-code
- LocalStack for running AWS services locally
- AWS services: DynamoDB, Lambda, SQS, IAM
We’ll walk through setting up the architecture, defining the Terraform modules, and testing the event-driven system locally.
You can find the complete code and project structure on GitHub: HERE
What Is the Outbox Pattern ?
The Outbox Pattern avoids the dual-write problem by splitting responsibilities:
1. Application writes business data and an event into the same database transaction.
2. A separate process (e.g., a Lambda) reads the orders.outbox
table and pushes the event to an external system (e.g., SQS).
This guarantees durability and avoids losing messages during partial failures.
Architecture Overview
- Two DynamoDB tables:
orders
andorders.outbox
- Stream enabled on
orders.outbox
table - Lambda triggered by stream, sends events to an SQS FIFO queue
Terraform Modules Breakdown
1. Provider & Root Configuration
We configure Terraform to connect AWS services to LocalStack:
provider "aws" {
region = local.region
s3_use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
endpoints {
cloudformation = local.localstack_endpoint
cloudwatch = local.localstack_endpoint
dynamodb = local.localstack_endpoint
iam = local.localstack_endpoint
lambda = local.localstack_endpoint
s3 = local.localstack_s3_endpoint
sqs = local.localstack_endpoint
}
}
module "iam" {
source = "./modules/iam"
common_tags = local.common_tags
}
module "dynamodb" {
source = "./modules/dynamodb"
common_tags = local.common_tags
}
module "lambda" {
source = "./modules/lambda"
lambda_execution_role = module.iam.lambda_execution_role
localstack_endpoint = local.localstack_endpoint
dynamodb_outbox_stream_arn = module.dynamodb.dynamodb_outbox_stream_arn
dynamodb_outbox_arn = module.dynamodb.dynamodb_outbox_arn
dynamodb_orders_arn = module.dynamodb.dynamodb_orders_arn
dynamodb_outbox_name = module.dynamodb.dynamodb_outbox_name
dynamodb_orders_name = module.dynamodb.dynamodb_orders_name
sync_queue_url = module.sqs.sync_queue_fifo_url
common_tags = local.common_tags
region = local.region
}
module "sqs" {
source = "./modules/sqs"
common_tags = local.common_tags
}
This setup ensures every AWS service call is redirected to LocalStack.
2. IAM Module
Sets up the necessary IAM roles and permissions for Lambda to interact with other AWS services.
resource "aws_iam_role" "lambda_execution_role" {
name = "lambda-execution-role"
assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Effect": "Allow",
"Sid": ""
}]
}
EOF
tags = merge(var.common_tags, {
Name = "lambda-execution-role"
})
}
resource "aws_iam_role_policy" "lambda_policy" {
name = "lambda-dynamodb-policy"
role = aws_iam_role.lambda_execution_role.name
policy = jsonencode({
Version = "2012-10-17",
Statement = [
# DynamoDB permissions
{
Effect = "Allow",
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
],
Resource = "*"
},
# CloudWatch Logs permissions (for logging)
{
Effect = "Allow",
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Resource = "*"
}
]
})
}
3. DynamoDB Module
We define two DynamoDB tables:
- orders — the main business table.
- orders.outbox — stores events related to changes in the orders table, with DynamoDB Streams enabled to capture changes.
resource "aws_dynamodb_table" "orders" {
name = "orders"
billing_mode = "PAY_PER_REQUEST"
hash_key = "userId"
range_key = "orderId"
attribute {
name = "userId"
type = "S"
}
attribute {
name = "orderId"
type = "S"
}
tags = merge(var.common_tags, {
name = "dynamodb_orders_table"
})
}
resource "aws_dynamodb_table" "outbox" {
name = "orders.outbox"
billing_mode = "PAY_PER_REQUEST"
stream_enabled = true
stream_view_type = "NEW_IMAGE"
hash_key = "orderId"
range_key = "eventId"
attribute {
name = "orderId"
type = "S"
}
attribute {
name = "eventId"
type = "S"
}
tags = merge(var.common_tags, {
Name = "dynamodb_outbox_table"
})
}
4. SQS Module
Creates an SQS FIFO queue used to dispatch events from the outbox to other services.
resource "aws_sqs_queue" "sync_queue_fifo" {
name = "sync-queue.fifo"
fifo_queue = true
content_based_deduplication = true
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.sync_queue_dlq_fifo.arn,
maxReceiveCount = 3
})
tags = merge(var.common_tags, {
Name = "sync_queue_fifo"
})
}
resource "aws_sqs_queue" "sync_queue_dlq_fifo" {
name = "sync-queue-dlq.fifo"
fifo_queue = true
content_based_deduplication = true
tags = merge(var.common_tags, {
Name = "sync_queue_dlq_fifo"
})
}
5. Lambda Module
The Lambda function acts as the outbox processor:
- Triggers on the DynamoDB stream from the outbox table.
- Processes events and forwards them to the SQS queue.
resource "aws_lambda_function" "sync_event_lambda" {
function_name = "syncEventLambda"
handler = "sync-event.lambda_handler"
runtime = "python3.12"
filename = "${path.root}/app/sync-event.zip"
role = var.lambda_execution_role
source_code_hash = var.force_lambda_update ? filebase64sha256("${path.root}/app/sync-event.zip") : "" # Forcer la mise à jour du code de la fonction Lambda
environment {
variables = {
DYNAMODB_ENDPOINT = var.localstack_endpoint
OUTBOX_TABLE = var.dynamodb_outbox_name
SQS_QUEUE_URL = var.sync_queue_url
SQS_ENDPOINT = var.localstack_endpoint
REGION = var.region
AWS_ACCESS_KEY_ID = "test" # For localstack
AWS_SECRET_ACCESS_KEY = "test" # For localstack
}
}
tags = merge(var.common_tags, {
Name = "sync_event_lambda"
})
}
resource "aws_lambda_function" "outbox_lambda" {
function_name = "outboxProcessingLambda"
handler = "outbox-lambda.lambda_handler"
runtime = "python3.12"
filename = "${path.root}/app/outbox-lambda.zip"
role = var.lambda_execution_role
source_code_hash = var.force_lambda_update ? filebase64sha256("${path.root}/app/outbox-lambda.zip") : "" # Forcer la mise à jour du code de la fonction Lambda
environment {
variables = {
ORDERS_TABLE = var.dynamodb_orders_name
OUTBOX_TABLE = var.dynamodb_outbox_name
DYNAMODB_ENDPOINT = var.localstack_endpoint
REGION = var.region
AWS_ACCESS_KEY_ID = "test" # For localstack
AWS_SECRET_ACCESS_KEY = "test" # For localstack
}
}
tags = merge(var.common_tags, {
Name = "outbox_lambda"
})
}
resource "aws_lambda_event_source_mapping" "trigger_outbox" {
event_source_arn = var.dynamodb_outbox_stream_arn
function_name = aws_lambda_function.sync_event_lambda.arn
starting_position = "LATEST"
batch_size = 10
filter_criteria {
filter {
pattern = jsonencode({
dynamodb = {
NewImage = {
status = {
S = ["PENDING"]
}
}
}
})
}
}
tags = merge(var.common_tags, {
Name = "trigger_outbox"
})
}
Lambda Function Code Overview
Make sure to zip both Python scripts before uploading.
To complement the infrastructure setup, here’s a high-level look at the Lambda function implementations in Python.
outbox-lambda.py
This Lambda inserts business order data and an outbox event atomically into DynamoDB tables. It is using the same transaction.
It generates a unique event ID and marks the event status as "PENDING".
def insert_order_and_outbox(data, timestamp):
response = dynamodb_client.transact_write_items(
TransactItems=[
{
'Put': {
'TableName': ORDERS_TABLE,
'Item': {
'userId': {'S': data["userId"]},
'orderId': {'S': data["orderId"]},
'courierId': {'S': data.get("courierId")},
'notificationId': {'S': data.get("notificationId")},
'message': {'S': data.get("message")},
'createdAt': {'S': str(timestamp)}
}
}
},
{
'Put': {
'TableName': OUTBOX_TABLE,
'Item': {
'orderId': {'S': data["orderId"]},
'eventId': {'S': str(uuid4())},
'eventType': {'S': data["eventType"]},
'eventTimestamp': {'S': str(timestamp)},
'status': {'S': "PENDING"},
'payload': {'S': json.dumps(data)}
}
}
}
]
)
return response
This approach ensures the event is durably stored alongside the business data in the same transaction boundary.
sync-event.py
Triggered by DynamoDB Streams on the outbox table, this Lambda:
- Deserializes new records with status "PENDING".
- Sends the event payload to an SQS FIFO queue with retry and exponential backoff.
- Upon successful send, updates the outbox event’s status to "SENT" to avoid duplicate processing.
def send_message_to_sqs_with_retry(queue_url: str, message: dict, attributes: dict, max_retries=3, base_delay=0.5) -> str:
for attempt in range(max_retries + 1):
try:
response = sqs.send_message(
QueueUrl=queue_url,
MessageBody=json.dumps(message),
MessageGroupId="outbox-event",
MessageAttributes=attributes
)
status_code = response['ResponseMetadata']['HTTPStatusCode']
message_id = response.get("MessageId")
if status_code == 200 and message_id:
logger.info(f"SQS message sent successfully. MessageId: {message_id}")
return message_id
else:
raise Exception(f"Unexpected SQS response: {response}")
...
def lambda_handler(event, context):
...
# Send to SQS with validation and retry
message_id = send_message_to_sqs_with_retry(
queue_url=SQS_QUEUE_URL,
message=message,
attributes={
'eventType': {
'DataType': 'String',
'StringValue': event_type
}
}
)
# Update DynamoDB status only after successful send
table = dynamodb.Table(OUTBOX_TABLE)
table.update_item(
Key={
'orderId': order_id,
'eventId': event_id
},
UpdateExpression='SET #s = :sent',
ConditionExpression='#s = :pending',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={
':sent': 'SENT',
':pending': 'PENDING'
}
)
...
This design guarantees reliable event dispatch with strong delivery semantics.
For full code, visit the GitHub repository
Testing the Flow Locally
1. Start LocalStack:
localstack start
2. Deploy the infrastructure:
terraform init
terraform apply
3. Invoke the outboxProcessingLambda
manually to simulate an event:
First, you need to install awscli-local.
awslocal lambda invoke \
--function-name outboxProcessingLambda \
--region eu-west-1 \
--payload '{
"userId": "user-456",
"notificationId": "notif-9999",
"orderId": "12345",
"courierId": "courier-789",
"eventType": "Delivered",
"eventTimestamp": "2025-04-23T13:45:00Z",
"message": "Your order #12345 has been delivered by courier-789."
}' \
--cli-binary-format raw-in-base64-out \
output.json && cat output.json
5. Check DynamoDB (via LocalStack UI or CLI) to verify that the event has been inserted in the orders
and orders.outbox
database.
orders table
orders.outbox table
6. Check SQS messages (via LocalStack UI or CLI) to verify that the event was propagated.
Advantages of This Setup
- LocalStack lets you test cloud-native apps without incurring AWS costs.
- Using Terraform allows you to reuse and version infrastructure code easily.
- The Outbox Pattern ensures data and event integrity in distributed systems.
Conclusion
Combining the Outbox Pattern with Terraform and LocalStack gives you a powerful, reliable, and testable approach for building event-driven microservices. Whether you're experimenting locally or preparing for production-scale architecture, this pattern helps ensure strong delivery guarantees and system resiliency.
You can find the complete code and project structure on GitHub: HERE
Top comments (6)
HUmm! Interesting. Thanks
Enjoy !
Well I think you must mention that outbox item and table item have to be written in one transaction in order to make outbox pattern useful. Otherwise your initial item can fail and the event still be send and vice versa. This is what a transactional outbox should solve. Therefore use a transaction. Your article describes it in a wrong way
Hello,
This is what I mentioned at the beginning: "The application writes business data and an event within the same database transaction."
It is indeed necessary that both writes are performed within the same transaction.
But the code example does not show it. It shows two put_item operations.
I can see you updated it. Nice!! Thank you
Yes indeed.
I have changed the transaction earlier.
Thank for your feedback.