Skip to main content

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:

Request-response flow

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:

Event-driven flow

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:

  1. Producers: Services that emit events (e.g., "user signed up," "order created")
  2. Consumers: Services that react to events (e.g., send welcome email, calculate inventory)
  3. Event Broker: The infrastructure that decouples them (SQS, Pub/Sub, EventBridge)

Example: E-commerce Order Processing

Traditional (Synchronous): E-commerce order processing (traditional)

If any service is slow, the entire request slows down. If Email Service is down, order creation fails.

Event-Driven (Asynchronous): E-commerce order processing (event-driven)

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.

At-least-once delivery flow

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): Payment processing consistency comparison

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 TypeCommunicationLatencyExactly?AWS ServiceGCP Service
Database ChangeAsync Push100ms-1sAt-least-onceDynamoDB StreamsFirestore Triggers
HTTP RequestAsync Queue1s-30sAt-least-onceSQS/SNSTask Queue
Real-time DataStreaming<1sOrderedKinesisCloud Pub/Sub
Scheduled TaskPeriodic TriggerExact timeOnceEventBridge RuleCloud Scheduler
Cross-service CallSync HTTP<100msAt-most-onceAPI Gateway + LambdaCloud Functions HTTP

Building an Event-Driven Order Processing System

Real-World Architecture

A SaaS platform with orders, inventory, notifications:

Order processing architecture

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

AspectAWSGCP
Event EmissionEventBridge (centralized routing)Pub/Sub (simple topic-based)
DurabilityEventBridge has 24h retry windowPub/Sub spans multiple regions
Consumer RegistrationRules define routingTopic subscriptions
OrderingNot guaranteed across rulesPartition 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