So far, we have treated queues as a way to send work from point A to point B. "Hey inventory service, reserve these items." That is command-based messaging: the producer tells the consumer what to do. Event-driven architecture flips this. Instead of commands, you emit events: facts about what happened. "An order was placed." Consumers decide independently what to do with that fact.
This shift from commands to events fundamentally changes how systems are designed.
Commands vs. events
// Command-based (queue as task distributor)
// Producer knows about consumers and tells them what to do
await queue.publish('inventory.reserve', {
orderId: 'ORD-123',
items: [{ productId: 'PROD-A', quantity: 2 }]
});
await queue.publish('email.send', {
to: '[email protected]',
template: 'order-confirmation',
data: { orderId: 'ORD-123' }
});
// The producer must know about every downstream system
// Adding a new consumer means changing the producer
// Event-based (queue as event bus)
// Producer emits a fact. It does not know or care who listens.
await eventBus.emit('order.placed', {
orderId: 'ORD-123',
userId: 'USER-456',
items: [{ productId: 'PROD-A', quantity: 2 }],
total: 49.99,
placedAt: '2025-03-15T14:30:00Z'
});
// Consumers subscribe independently
// Adding a new consumer requires ZERO changes to the producer| Aspect | Command-based | Event-based |
|---|---|---|
| Message meaning | "Do this thing" | "This thing happened" |
| Producer knows about consumers? | Yes | No |
| Adding new consumers | Change producer code | Just subscribe |
| Coupling | Producer coupled to consumers | Decoupled |
| Message naming | Verb (reserve, send, process) | Past tense (placed, shipped, failed) |
| Failure responsibility | Producer must handle routing failures | Each consumer handles its own failures |
How queues enable EDA
Event-driven architecture needs reliable infrastructure to store and deliver events. That is exactly what message queues provide. Here is how the pieces fit together:
// Event-driven order system built on queue infrastructure
// 1. Order service emits events (does not know about downstream)
class OrderService {
async placeOrder(orderData) {
const order = await db.orders.create(orderData);
// Emit event: "this happened"
await eventBus.emit('order.placed', {
orderId: order.id,
items: order.items,
total: order.total,
userId: order.userId
});
return order;
}
async cancelOrder(orderId) {
await db.orders.update(orderId, { status: 'cancelled' });
await eventBus.emit('order.cancelled', {
orderId,
cancelledAt: new Date().toISOString()
});
}
}
// 2. Independent services react to events they care about
class InventoryService {
constructor(eventBus) {
eventBus.on('order.placed', (event) => this.reserve(event));
eventBus.on('order.cancelled', (event) => this.release(event));
}
async reserve(event) {
for (const item of event.items) {
await db.inventory.decrement(item.productId, item.quantity);
}
}
async release(event) {
const order = await db.orders.findById(event.orderId);
for (const item of order.items) {
await db.inventory.increment(item.productId, item.quantity);
}
}
}
class AnalyticsService {
constructor(eventBus) {
eventBus.on('order.placed', (event) => this.trackOrder(event));
// Does not care about order.cancelled
}
async trackOrder(event) {
await analytics.record('revenue', event.total);
await analytics.record('items_sold', event.items.length);
}
}
// 3. Adding a new service requires ZERO changes to OrderService
class FraudDetectionService {
constructor(eventBus) {
eventBus.on('order.placed', (event) => this.checkFraud(event));
}
async checkFraud(event) {
const riskScore = await calculateRisk(event);
if (riskScore > 0.8) {
await eventBus.emit('order.flagged', {
orderId: event.orderId,
riskScore
});
}
}
}Notice that adding the FraudDetectionService required zero changes to the OrderService. It just subscribes to the event it cares about. This is the power of event-driven architecture: services evolve independently.
Event sourcing overview
Traditional systems store current state. "The order status is shipped." Event sourcing stores every state change as an immutable event. "The order was placed, then confirmed, then packed, then shipped."
// Traditional: store current state (mutable)
// orders table: { id: 'ORD-1', status: 'shipped', total: 49.99 }
// When you update, the old state is gone
// Event sourcing: store events (immutable)
const orderEvents = [
{ type: 'OrderPlaced', data: { total: 49.99 }, timestamp: '10:00' },
{ type: 'PaymentReceived', data: { method: 'card' }, timestamp: '10:01' },
{ type: 'OrderPacked', data: { warehouse: 'W2' }, timestamp: '11:30' },
{ type: 'OrderShipped', data: { carrier: 'DHL', tracking: 'XYZ' }, timestamp: '14:00' }
];
// Current state is derived by replaying events
function getCurrentState(events) {
return events.reduce((state, event) => {
switch (event.type) {
case 'OrderPlaced':
return { ...state, status: 'placed', total: event.data.total };
case 'PaymentReceived':
return { ...state, status: 'paid', paymentMethod: event.data.method };
case 'OrderPacked':
return { ...state, status: 'packed', warehouse: event.data.warehouse };
case 'OrderShipped':
return {
...state,
status: 'shipped',
carrier: event.data.carrier,
tracking: event.data.tracking
};
default:
return state;
}
}, {});
}
const currentState = getCurrentState(orderEvents);
// { status: 'shipped', total: 49.99, paymentMethod: 'card',
// warehouse: 'W2', carrier: 'DHL', tracking: 'XYZ' }Why event sourcing matters for infrastructure:
- Events are immutable: you only append, never update or delete
- This is why Kafka (an append-only log) is a natural fit for event sourcing
- The event store becomes the source of truth, and current-state databases are derived views
- You can rebuild any view from scratch by replaying events
CQRSWhat is cqrs?Command Query Responsibility Segregation - using separate models for read and write operations so each can be optimized independently. at a high level
CQRS separates the write model (how you store data) from the read model (how you query data). In a traditional system, the same database schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. handles both. CQRS lets you optimize each independently.
// Without CQRS: same model for reads and writes
// The orders table serves both the checkout flow and the admin dashboard
// Compromise: not optimal for either
// With CQRS: separate models
// Write side: optimized for transactional integrity
class OrderWriteModel {
async placeOrder(orderData) {
await db.orders.create(orderData); // Normalized schema
await eventBus.emit('order.placed', orderData); // Publish event
}
}
// Read side: optimized for query performance
class OrderReadModel {
constructor(eventBus) {
// Build a denormalized view from events
eventBus.on('order.placed', async (event) => {
await readDb.orderSummaries.upsert({
orderId: event.orderId,
customerName: await lookupCustomerName(event.userId),
itemCount: event.items.length,
total: event.total,
status: 'placed'
});
});
eventBus.on('order.shipped', async (event) => {
await readDb.orderSummaries.update(event.orderId, {
status: 'shipped',
shippedAt: event.shippedAt
});
});
}
// Fast queries on denormalized data (no JOINs needed)
async getOrderDashboard(filters) {
return readDb.orderSummaries.find(filters);
}
}The write side uses a normalized relational schema for data integrity. The read side uses a denormalized schema (maybe even a different database like Elasticsearch) for fast queries. Events synchronize them.
| Aspect | Queue-based (commands) | Event-driven |
|---|---|---|
| Architecture | Point-to-point task distribution | Publish/subscribe event broadcasting |
| Data model | Current state in database | Event log + derived views |
| Adding features | Modify producer and consumer | Add new subscriber |
| Debugging | Trace the request chain | Replay events to reproduce state |
| Complexity | Lower (simpler mental model) | Higher (eventual consistency, event schemas) |
| Best for | Simple async tasks, job queues | Complex domains, audit requirements, analytics |
Cross-reference: integration-event-driven moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.
This lesson covers the infrastructure perspective: how queues enable event-driven architecture and what event sourcing and CQRSWhat is cqrs?Command Query Responsibility Segregation - using separate models for read and write operations so each can be optimized independently. look like from a systems standpoint.
The integration-event-driven module in the Integration Architecture course goes deeper into the application patterns:
- SagaWhat is saga?A pattern for coordinating multi-service operations where each step has a compensating undo action that runs if a later step fails. pattern: Coordinating multi-step transactions across services
- Outbox patternWhat is outbox pattern?A reliability pattern where events are written to a database table in the same transaction as business data, then published separately - guaranteeing delivery.: Guaranteeing events are published when database changes commitWhat is commit?A permanent snapshot of your staged changes saved in Git's history, identified by a unique hash and accompanied by a message describing what changed.
- CQRS implementation: Detailed read/write model design and synchronization
- Event schemas and versioning: How to evolve event contracts without breaking consumers
- Eventual consistencyWhat is eventual consistency?A guarantee that all copies of data will converge to the same value given enough time, rather than being instantly synchronized after every write.: Practical strategies for handling stale reads
Think of it this way: this module teaches you how to build the highway (queue infrastructure). The integration module teaches you the traffic rules (application patterns that run on that highway).