Event-Driven API Architecture

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

TRY NANO BANANA FOR FREE

Event-Driven API Architecture

TRY NANO BANANA FOR FREE
Contents

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