Event-Driven Thinking: Decomposing Systems into Producers and Consumers
The Fundamental Shift: From Request-Response to Event-Driven
In traditional architectures, you think in synchronous flows:
The user waits for the response. The server is blocked until the database returns data. Everything is coupled to that synchronous request-response chain.
In serverless architecture, you think in asynchronous events:
The user gets an immediate response. Event processing happens independently. Services are decoupled.
This shift is not a minor convenience—it's a fundamental change in how you design systems. It enables:
- Independent scaling of different services
- Resilience through timeout isolation
- Cost efficiency through parallelization
- Flexibility for new use cases without modifying core systems
Core Concept: Producers and Consumers
Every event-driven system can be decomposed into:
- Producers: Services that emit events (e.g., "user signed up," "order created")
- Consumers: Services that react to events (e.g., send welcome email, calculate inventory)
- Event Broker: The infrastructure that decouples them (SQS, Pub/Sub, EventBridge)
Example: E-commerce Order Processing
Traditional (Synchronous):
If any service is slow, the entire request slows down. If Email Service is down, order creation fails.
Event-Driven (Asynchronous):
Order creation returns immediately. Services process independently. If Email Service fails, order still arrives in inventory system.
Event Semantics Matter: At-Least-Once vs At-Most-Once
The guarantees your event broker provides fundamentally change your architecture:
At-Least-Once Semantics (SQS, Pub/Sub)
Guarantee: Every event is delivered at least once. May be duplicated.
Implication: Consumers must be idempotent.
AWS Example: SQS
// AWS Lambda triggered by SQS
export const handler = async (event) => {
for (const record of event.Records) {
const messageId = record.messageId;
const body = JSON.parse(record.body);
try {
// Process the message
// IMPORTANT: This might run twice, so it must be safe to retry
await processOrder(body.orderId);
// Explicit delete (marks as processed)
await sqs.deleteMessage({
QueueUrl: process.env.QUEUE_URL,
ReceiptHandle: record.receiptHandle
}).promise();
} catch (error) {
// Don't acknowledge - message returns to queue for retry
throw error;
}
}
};
// This function MUST be idempotent (safe to call multiple times)
async function processOrder(orderId) {
// Check if already processed (idempotency key)
const existing = await db.get(`order:${orderId}:processed`);
if (existing) return; // Already done
// Process
await db.put(`order:${orderId}:processed`, true);
// ... update order status, etc
}
At-Most-Once Semantics (Kinesis Streams with SequenceNumber)
Guarantee: Each event delivered at most once. May be lost during outages.
Implication: You must tolerate message loss. Used for high-volume, low-criticality data (metrics, logs, analytics).
GCP Example: Cloud Pub/Sub
// Google Cloud Functions triggered by Pub/Sub
exports.handler = async (message) => {
// Message contains:
// - data: base64-encoded event
// - messageId: unique identifier
const event = JSON.parse(
Buffer.from(message.data, 'base64').toString()
);
try {
// Process with assumption: might not be retried, make it effective
await processMetric(event);
} catch (error) {
// In Pub/Sub, not acknowledging means message is redelivered
// But if transient failure, this is retry opportunity
throw error;
}
};
The CAP Theorem and Event-Driven Systems
You can't have all three:
- Consistency: All services see the same data
- Availability: System responds to requests
- Partition tolerance: System works despite network failures
In serverless event-driven systems, you're essentially choosing AP (Availability + Partition tolerance) over C (Consistency).
Example: Payment Processing
Traditional (Strong Consistency):
If any step fails, entire transaction rolls back. But client waits 5+ seconds.
Event-Driven (Eventual Consistency):
If payment processor is slow, order exists and user is notified. System is available. But for ~5 seconds, inventory might show in-stock when it's not (inconsistent).
Design Decision: This is acceptable if:
- Orders are rare enough that race conditions are unlikely
- You have a reconciliation job that fixes inconsistencies
- User is willing to wait hours for fulfillment anyway
This is not acceptable if:
- You have limited inventory (double-selling is catastrophic)
- Transactions must be atomic and instant
Decomposition Patterns: How to Identify Producers and Consumers
Pattern 1: Database Events (Change Data Capture)
Every database change is an event.
AWS Example: DynamoDB Streams
// DynamoDB stream captures all inserts/updates on a table
export const handler = async (event) => {
for (const record of event.Records) {
if (record.eventName === 'INSERT') {
const newImage = record.dynamodb.NewImage;
await sendWelcomeEmail(newImage.userId.S);
}
}
};
GCP Example: Firestore Triggers
// Firestore trigger on document write
exports.onUserCreated = functions.firestore
.document('users/{userId}')
.onCreate(async (snap, context) => {
const newUser = snap.data();
await sendWelcomeEmail(newUser.email);
});
Pattern 2: HTTP Events (Webhooks)
API Gateway receives requests and emits events for async processing.
// AWS API Gateway → Lambda → SQS
export const handler = async (event) => {
const order = JSON.parse(event.body);
// Return immediately (accept the order)
const response = {
statusCode: 202, // Accepted, but not processed yet
body: JSON.stringify({ orderId: order.id, status: 'pending' })
};
// Emit async processing (return doesn't wait)
sqs.sendMessage({
QueueUrl: process.env.QUEUE_URL,
MessageBody: JSON.stringify(order)
}).promise(); // Fire and forget
return response;
};
Pattern 3: Time-Based Events (Cron)
Scheduled functions emit events periodically.
// AWS EventBridge Rule (cron-like)
export const handler = async (event) => {
// This runs every 5 minutes
const staleOrders = await db.query('orders where updated < 5 min ago');
for (const order of staleOrders) {
// Emit event for each
await eventBridge.putEvents({
Entries: [{
DetailType: 'OrderStale',
Source: 'order-service',
Detail: JSON.stringify(order)
}]
}).promise();
}
};
Decision Matrix: Event Type vs Communication Pattern
| Event Type | Communication | Latency | Exactly? | AWS Service | GCP Service |
|---|---|---|---|---|---|
| Database Change | Async Push | 100ms-1s | At-least-once | DynamoDB Streams | Firestore Triggers |
| HTTP Request | Async Queue | 1s-30s | At-least-once | SQS/SNS | Task Queue |
| Real-time Data | Streaming | <1s | Ordered | Kinesis | Cloud Pub/Sub |
| Scheduled Task | Periodic Trigger | Exact time | Once | EventBridge Rule | Cloud Scheduler |
| Cross-service Call | Sync HTTP | <100ms | At-most-once | API Gateway + Lambda | Cloud Functions HTTP |
Building an Event-Driven Order Processing System
Real-World Architecture
A SaaS platform with orders, inventory, notifications:
AWS Implementation:
// 1. Order API (triggered synchronously)
export const createOrderHandler = async (event) => {
const order = JSON.parse(event.body);
// Create order record
await dynamodb.putItem({
TableName: 'orders',
Item: { ...order, status: 'pending', createdAt: Date.now() }
}).promise();
// Emit event (async processors wake up)
await eventBridge.putEvents({
Entries: [{
DetailType: 'OrderCreated',
Source: 'order-service',
Detail: JSON.stringify(order)
}]
}).promise();
return {
statusCode: 202,
body: JSON.stringify({ orderId: order.id, status: 'pending' })
};
};
// 2. Inventory Consumer (processes independently)
export const onOrderCreatedHandler = async (event) => {
const order = JSON.parse(event.detail);
try {
// Update inventory (idempotent!)
await dynamodb.updateItem({
TableName: 'inventory',
Key: { productId: order.productId },
UpdateExpression: 'ADD quantity :dec',
ExpressionAttributeValues: { ':dec': -1 }
}).promise();
// Emit success event
await eventBridge.putEvents({
Entries: [{
DetailType: 'InventoryReserved',
Source: 'inventory-service',
Detail: JSON.stringify({ orderId: order.id })
}]
}).promise();
} catch (error) {
// Failure: not acknowledged, will retry
throw error;
}
};
// 3. Email Consumer (independent, can fail without affecting order)
export const onOrderCreatedEmailHandler = async (event) => {
const order = JSON.parse(event.detail);
// Retry-safe: SES tracks already-sent emails by messageId
await ses.sendEmail({
Source: 'noreply@example.com',
Destination: { ToAddresses: [order.customerEmail] },
Message: {
Subject: { Data: `Order ${order.id} confirmed` },
Body: { Html: { Data: `...` } }
},
Tags: [{
Name: 'OrderId',
Value: order.id
}]
}).promise();
};
Comparison with GCP:
// Google Cloud Functions with Pub/Sub
const functions = require('@google-cloud/functions-framework');
const pubsub = require('@google-cloud/pubsub');
const firestore = require('@google-cloud/firestore');
// 1. Order HTTP endpoint
functions.http('createOrder', async (req, res) => {
const order = req.body;
const db = new firestore.Firestore();
// Create order
await db.collection('orders').doc(order.id).set({
...order,
status: 'pending',
createdAt: new Date()
});
// Publish event (pub/sub automatically triggers subscribers)
const client = new pubsub.PubSub();
const topic = client.topic('order-events');
await topic.publish(Buffer.from(JSON.stringify({
type: 'OrderCreated',
order
})));
res.status(202).json({ orderId: order.id, status: 'pending' });
});
// 2. Inventory consumer (Cloud Function triggered by Pub/Sub)
functions.cloudEvent('onOrderCreated', async (cloudEvent) => {
const message = JSON.parse(
Buffer.from(cloudEvent.data.message.data, 'base64').toString()
);
const order = message.order;
const db = new firestore.Firestore();
// Update inventory
const batch = db.batch();
const docRef = db.collection('inventory').doc(order.productId);
batch.update(docRef, {
available: firestore.FieldValue.increment(-1)
});
await batch.commit();
});
Key Differences AWS vs GCP
| Aspect | AWS | GCP |
|---|---|---|
| Event Emission | EventBridge (centralized routing) | Pub/Sub (simple topic-based) |
| Durability | EventBridge has 24h retry window | Pub/Sub spans multiple regions |
| Consumer Registration | Rules define routing | Topic subscriptions |
| Ordering | Not guaranteed across rules | Partition key ordering available |
When To Use Event-Driven Thinking
Adopt event-driven architecture when:
- Multiple services need to react to the same business event
- You can tolerate eventual consistency (hours, not seconds)
- You need independent scaling of different workloads
- You want to decouple services for team independence
Signals you're ready:
- You have multiple teams building different services
- Your transaction latency is already >100ms
- You've experienced cascading failures (one slow service kills everything)
- You have analytics/reporting that can tolerate 5-minute delays
When NOT to Use Event-Driven
Don't adopt event-driven when:
- You need strong consistency (all-or-nothing, immediate)
- Response must be <50ms (event processing adds latency)
- You have only one or two services (overengineering)
- Your team isn't ready for async debugging (harder to trace)
Next Steps
You now understand:
- Producer and consumer decomposition
- At-least-once vs at-most-once semantics
- CAP theorem tradeoffs
Next lessons build on this:
- Lesson 3: Stateless vs Stateful – Where does event state live?
- Lesson 4: Loose Coupling – Communication patterns for resilience
- Lesson 5: Serverless Compute Concepts – Concurrency management across events