Meta Description: Move beyond request-response with event-driven architecture. Learn webhooks, message queues, event sourcing, and CQRS patterns for scalable APIs.
Keywords: event-driven architecture, async api, message patterns, event sourcing, cqrs, message queue, event-driven design
Word Count: ~2,400 words
Traditional APIs are request-response. Client asks, server answers. This works for simple operations, but breaks down for:
- Long-running processes
- Operations that affect multiple systems
- High-volume data processing
- Real-time notifications
- Distributed transactions
Event-driven architecture solves these problems. Instead of waiting for responses, systems communicate through events.
Here's how to build event-driven APIs.
Request-Response vs Event-Driven
Request-Response (Synchronous)
Client → POST /orders → API → Process → Response
(waits) (blocks) (returns)
The client waits for the entire operation to complete. If processing takes 30 seconds, the client waits 30 seconds.
Event-Driven (Asynchronous)
Client → POST /orders → API → Accepts → Response (202 Accepted)
↓
Publish Event
↓
Event Queue
↓
Worker Process
↓
Webhook Notification
The API accepts the request immediately and processes it asynchronously. The client gets a response in milliseconds, not seconds.
Event-Driven Patterns
Pattern 1: Webhooks
The simplest event-driven pattern. When an event occurs, POST to a client-provided URL.
Example: Order Processing
// API accepts order
app.post('/v1/orders', async (req, res) => {
const order = await createOrder(req.body);
// Return immediately
res.status(202).json({
id: order.id,
status: 'PROCESSING',
statusUrl: `/v1/orders/${order.id}/status`
});
// Process asynchronously
processOrderAsync(order);
});
async function processOrderAsync(order) {
try {
// Process payment
const payment = await processPayment(order);
// Update order
await updateOrder(order.id, { status: 'PAID' });
// Send webhook
await sendWebhook(order.userId, {
type: 'order.paid',
data: { orderId: order.id, payment }
});
// Fulfill order
await fulfillOrder(order);
// Send webhook
await sendWebhook(order.userId, {
type: 'order.fulfilled',
data: { orderId: order.id }
});
} catch (error) {
await updateOrder(order.id, { status: 'FAILED', error: error.message });
await sendWebhook(order.userId, {
type: 'order.failed',
data: { orderId: order.id, error: error.message }
});
}
}
Clients receive webhooks as the order progresses through states.
Pattern 2: Message Queues
For high-volume or complex workflows, use message queues.
Architecture:
API → Publish Event → Queue → Worker 1
→ Worker 2
→ Worker 3
Multiple workers process events in parallel.
Example with RabbitMQ:
const amqp = require('amqplib');
// Publisher (API)
async function publishEvent(eventType, data) {
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
const exchange = 'petstore.events';
await channel.assertExchange(exchange, 'topic', { durable: true });
const message = JSON.stringify({
id: generateEventId(),
type: eventType,
data: data,
timestamp: new Date().toISOString()
});
channel.publish(exchange, eventType, Buffer.from(message), {
persistent: true
});
console.log('Published event:', eventType);
await channel.close();
await connection.close();
}
// API endpoint
app.post('/v1/pets', async (req, res) => {
const pet = await createPet(req.body);
// Publish event
await publishEvent('pet.created', pet);
res.status(201).json(pet);
});
// Consumer (Worker)
async function startWorker() {
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
const exchange = 'petstore.events';
const queue = 'pet.created.notifications';
await channel.assertExchange(exchange, 'topic', { durable: true });
await channel.assertQueue(queue, { durable: true });
await channel.bindQueue(queue, exchange, 'pet.created');
channel.consume(queue, async (msg) => {
if (msg) {
const event = JSON.parse(msg.content.toString());
console.log('Processing event:', event);
try {
// Send notifications
await sendEmailNotification(event.data);
await sendPushNotification(event.data);
// Acknowledge message
channel.ack(msg);
} catch (error) {
console.error('Error processing event:', error);
// Reject and requeue
channel.nack(msg, false, true);
}
}
});
}
startWorker();
Pattern 3: Event Sourcing
Store all changes as events. The current state is derived from the event history.
Traditional approach (store current state):
UPDATE pets SET status = 'ADOPTED' WHERE id = '123';
You lose history. You don't know when it was adopted or by whom.
Event sourcing (store events):
const events = [
{ type: 'PetCreated', data: { id: '123', name: 'Max' }, timestamp: '2026-01-01T10:00:00Z' },
{ type: 'PetVaccinated', data: { id: '123', vaccine: 'Rabies' }, timestamp: '2026-01-15T14:30:00Z' },
{ type: 'PetAdopted', data: { id: '123', adopterId: '456' }, timestamp: '2026-03-13T10:35:00Z' }
];
You have complete history. You can reconstruct state at any point in time.
Implementation:
// Event store
class EventStore {
constructor(db) {
this.db = db;
}
async append(streamId, event) {
await this.db.events.insert({
streamId,
type: event.type,
data: event.data,
timestamp: new Date(),
version: await this.getNextVersion(streamId)
});
}
async getEvents(streamId, fromVersion = 0) {
return await this.db.events.find({
streamId,
version: { $gte: fromVersion }
}).sort({ version: 1 });
}
async getNextVersion(streamId) {
const lastEvent = await this.db.events.findOne(
{ streamId },
{ sort: { version: -1 } }
);
return lastEvent ? lastEvent.version + 1 : 1;
}
}
// Aggregate (Pet)
class Pet {
constructor(id) {
this.id = id;
this.name = null;
this.status = null;
this.vaccinations = [];
this.version = 0;
}
// Apply events to rebuild state
apply(event) {
switch (event.type) {
case 'PetCreated':
this.name = event.data.name;
this.status = 'AVAILABLE';
break;
case 'PetVaccinated':
this.vaccinations.push(event.data);
break;
case 'PetAdopted':
this.status = 'ADOPTED';
this.adopterId = event.data.adopterId;
break;
}
this.version = event.version;
}
// Load from event store
static async load(eventStore, id) {
const pet = new Pet(id);
const events = await eventStore.getEvents(`pet-${id}`);
events.forEach(event => pet.apply(event));
return pet;
}
// Commands
async adopt(eventStore, adopterId) {
if (this.status !== 'AVAILABLE') {
throw new Error('Pet is not available for adoption');
}
const event = {
type: 'PetAdopted',
data: { id: this.id, adopterId }
};
await eventStore.append(`pet-${this.id}`, event);
this.apply({ ...event, version: this.version + 1 });
}
}
// Usage
const eventStore = new EventStore(db);
const pet = await Pet.load(eventStore, '123');
await pet.adopt(eventStore, '456');
Pattern 4: CQRS (Command Query Responsibility Segregation)
Separate read and write models.
Write model (commands): - Optimized for writes - Validates business rules - Publishes events
Read model (queries): - Optimized for reads - Denormalized for fast queries - Updated from events
Architecture:
Commands → Write Model → Events → Read Model → Queries
(Normalized) (Denormalized)
Implementation:
// Write model (commands)
class PetCommandHandler {
async createPet(command) {
// Validate
if (!command.name) {
throw new ValidationError('Name is required');
}
// Create pet
const pet = await db.pets.insert({
id: generateId(),
name: command.name,
species: command.species,
status: 'AVAILABLE'
});
// Publish event
await publishEvent('pet.created', pet);
return pet;
}
async adoptPet(command) {
const pet = await db.pets.findOne({ id: command.petId });
if (!pet) {
throw new NotFoundError('Pet not found');
}
if (pet.status !== 'AVAILABLE') {
throw new BusinessRuleError('Pet is not available');
}
// Update pet
await db.pets.update(
{ id: command.petId },
{ status: 'ADOPTED', adopterId: command.adopterId }
);
// Publish event
await publishEvent('pet.adopted', {
petId: command.petId,
adopterId: command.adopterId
});
}
}
// Read model (queries)
class PetQueryHandler {
async searchPets(query) {
// Query denormalized read model
return await db.petSearchView.find({
species: query.species,
status: query.status,
location: { $near: query.location }
});
}
async getPetDetails(petId) {
// Query denormalized view with all related data
return await db.petDetailsView.findOne({ id: petId });
}
}
// Event handler (updates read model)
async function handlePetCreatedEvent(event) {
// Update search view
await db.petSearchView.insert({
id: event.data.id,
name: event.data.name,
species: event.data.species,
status: event.data.status,
searchText: `${event.data.name} ${event.data.species}`.toLowerCase()
});
// Update details view
await db.petDetailsView.insert({
id: event.data.id,
...event.data,
vaccinations: [],
medicalHistory: []
});
}
Benefits of Event-Driven Architecture
1. Scalability
Process events asynchronously with multiple workers. Scale workers independently based on load.
2. Resilience
If a worker fails, events remain in the queue. Retry automatically.
3. Decoupling
Services don't call each other directly. They communicate through events. Add new services without changing existing ones.
4. Audit Trail
Events provide complete history of what happened and when.
5. Flexibility
Multiple services can react to the same event. Add new reactions without changing the publisher.
Challenges
1. Complexity
Event-driven systems are more complex than request-response. More moving parts to manage.
2. Eventual Consistency
Data isn't immediately consistent across all services. You must handle this in your application logic.
3. Debugging
Tracing requests across multiple services and events is harder than debugging a single request.
4. Message Ordering
Events may arrive out of order. Design your system to handle this.
5. Duplicate Events
Events may be delivered multiple times. Make your handlers idempotent.
When to Use Event-Driven Architecture
Use event-driven when: - Operations take more than a few seconds - Multiple systems need to react to changes - You need high throughput - You need audit trails - You're building microservices
Stick with request-response when: - Operations are fast (< 1 second) - Immediate consistency is required - The system is simple - You're building a monolith
Best Practices
1. Make Events Immutable
Once published, events never change. This ensures consistency.
2. Include Event Metadata
{
id: 'evt_123',
type: 'pet.adopted',
timestamp: '2026-03-13T10:35:00Z',
version: '1.0',
data: { ... }
}
3. Use Idempotent Handlers
Handlers should produce the same result when processing the same event multiple times.
4. Monitor Event Processing
Track event lag, processing time, and failures.
5. Version Your Events
When event schemas change, version them:
{
type: 'pet.adopted',
version: '2.0', // New version
data: { ... }
}
Support multiple versions during migration.
Conclusion
Event-driven architecture enables scalable, resilient APIs. Start with webhooks for simple use cases. Add message queues for high volume. Consider event sourcing and CQRS for complex domains.
The Modern PetStore API demonstrates all these patterns. Explore the implementation to see event-driven architecture in action.
Related Articles: - Webhooks Done Right: Delivery, Retries, and Security - MQTT for APIs: When HTTP Isn't Enough for IoT - Building a Multi-Protocol API: REST, GraphQL, and gRPC Together