Serverless Event-Driven Architecture
Event-driven architecture decouples your services, enables independent scaling, and provides a natural audit trail of everything that happens in your system. But building a reliable event-driven system requires more than wiring SNS to Lambda — you need schema validation, dead-letter queues, event replay, idempotent consumers, and distributed tracing. We implement production-grade event-driven architectures on AWS serverless.
Need this done for your project?
We implement, you ship. Async, documented, done in days.
EventBridge as Central Event Bus
Amazon EventBridge is the backbone of your event-driven architecture. We configure a custom event bus with schema registry, content-based routing, and archive for event replay.
resource "aws_cloudwatch_event_bus" "app" {
name = "${var.project}-events-${var.env}"
}
resource "aws_schemas_registry" "app" {
name = "${var.project}-schemas"
}
# Archive all events for replay capability
resource "aws_cloudwatch_event_archive" "all" {
name = "${var.project}-archive"
event_source_arn = aws_cloudwatch_event_bus.app.arn
retention_days = 90
}
# Schema discovery — auto-detect event schemas
resource "aws_schemas_discoverer" "app" {
source_arn = aws_cloudwatch_event_bus.app.arn
}Schema discovery automatically detects and registers event schemas as they flow through the bus. We use these schemas to generate TypeScript types for producers and consumers, ensuring type safety across service boundaries. The archive stores every event for 90 days, enabling replay for debugging, backfilling new consumers, or recovering from processing failures.
Reliable Event Publishing
Publishing events reliably means handling the dual-write problem: your database write and event publish must both succeed or both fail. We implement the transactional outbox pattern using DynamoDB Streams.
// Transactional outbox — write event to DynamoDB alongside business data
const command = new TransactWriteCommand({
TransactItems: [
{
Put: {
TableName: 'orders',
Item: {
PK: `ORDER#${orderId}`,
SK: 'META',
status: 'created',
total: 4999,
customerId,
},
},
},
{
Put: {
TableName: 'orders',
Item: {
PK: `OUTBOX#${Date.now()}`,
SK: `EVENT#${randomUUID()}`,
eventType: 'ORDER_CREATED',
payload: JSON.stringify({ orderId, customerId, total: 4999 }),
published: false,
ttl: Math.floor(Date.now() / 1000) + 86400,
},
},
},
],
});
await dynamoClient.send(command);
// DynamoDB Stream Lambda — publishes outbox events to EventBridge
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
if (record.dynamodb?.Keys?.PK?.S?.startsWith('OUTBOX#')) {
const payload = JSON.parse(record.dynamodb.NewImage?.payload?.S || '{}');
await eventBridge.send(new PutEventsCommand({
Entries: [{
EventBusName: process.env.EVENT_BUS_NAME,
Source: 'orders',
DetailType: record.dynamodb.NewImage?.eventType?.S,
Detail: JSON.stringify(payload),
}],
}));
}
}
};The outbox pattern ensures at-least-once delivery. Events are written atomically with business data, then published asynchronously by a DynamoDB Streams consumer. TTL automatically cleans up processed outbox records.
Idempotent Event Consumers
At-least-once delivery means your consumers will occasionally receive duplicate events. Every consumer must be idempotent — processing the same event twice produces the same result.
// Idempotency middleware using DynamoDB
import { DynamoDBClient, PutItemCommand, ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
const dynamo = new DynamoDBClient({});
export async function withIdempotency<T>(
eventId: string,
ttlHours: number,
handler: () => Promise<T>
): Promise<T | null> {
const ttl = Math.floor(Date.now() / 1000) + (ttlHours * 3600);
try {
// Claim the event ID — conditional write fails if already exists
await dynamo.send(new PutItemCommand({
TableName: process.env.IDEMPOTENCY_TABLE!,
Item: {
PK: { S: eventId },
status: { S: 'PROCESSING' },
ttl: { N: String(ttl) },
},
ConditionExpression: 'attribute_not_exists(PK)',
}));
} catch (err) {
if (err instanceof ConditionalCheckFailedException) {
console.log(`Duplicate event ${eventId}, skipping`);
return null; // Already processed
}
throw err;
}
const result = await handler();
// Mark as completed
await dynamo.send(new PutItemCommand({
TableName: process.env.IDEMPOTENCY_TABLE!,
Item: {
PK: { S: eventId },
status: { S: 'COMPLETED' },
ttl: { N: String(ttl) },
},
}));
return result;
}The idempotency table uses DynamoDB TTL to automatically expire records after the deduplication window. We set TTL based on the event type: payment events get 72 hours, notification events get 1 hour. This pattern adds under 5ms of latency per event.
Observability & Dead-Letter Processing
Every event consumer has a dead-letter queue (DLQ) for events that fail after all retries. We build dashboards and alerting around event processing health.
resource "aws_sqs_queue" "order_processing_dlq" {
name = "order-processing-dlq-${var.env}"
message_retention_seconds = 1209600 # 14 days
}
resource "aws_cloudwatch_metric_alarm" "dlq_messages" {
alarm_name = "order-processing-dlq-messages"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 60
statistic = "Sum"
threshold = 0
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
QueueName = aws_sqs_queue.order_processing_dlq.name
}
}DLQ alarms trigger immediately when any message lands in a dead-letter queue. We build a DLQ reprocessor Lambda that can replay failed events with a single API call — essential for recovering from transient downstream failures. X-Ray tracing follows events across services using the correlation ID, giving you a complete picture of event flow from producer to final consumer.
Why Anubiz Engineering
Ready to get started?
Skip the research. Tell us what you need, and we'll scope it, implement it, and hand it back — fully documented and production-ready.