Month 5, Week 2

Asynchronous Processing & Message Queues

Architecting for Resilience and Scale

Module 1: The Need for Background Jobs

Why Your Users Can't Wait

The Synchronous Catastrophe

Imagine a user places an order on your e-commerce site. In a simple, synchronous system, your API must complete ALL of these tasks before it can respond:

  1. Validate the order. (Fast)
  2. Process the payment with an external API. (Slow)
  3. Update your product inventory in the database. (Medium)
  4. Generate an invoice PDF. (Very Slow)
  5. Send a confirmation email via an external service. (Slow)
  6. Log the transaction. (Fast)

The user is left staring at a loading spinner for 5-10 seconds. This is a terrible user experience and will lose you customers.

The Asynchronous Solution: Background Jobs

A professional architect divides work into two categories:

  • Synchronous Work (The "Hot Path"): The absolute minimum required to give the user an immediate response. (e.g., "Yes, we have received your order.")
  • Asynchronous Work (Background Jobs): Everything else that can happen later, without making the user wait. (e.g., sending the email, generating the invoice).

The question is: how do we reliably hand off these background jobs?

Module 2: Message Queues

The To-Do List for Your System

The Core Concept

A Message Queue is an intermediary service that allows different parts of your system to communicate asynchronously.

Analogy: The restaurant order queue. The waiter (Producer) writes an order ticket and places it on a rotating wheel (the Message Broker). The chef (Consumer) picks up tickets from the wheel at their own pace and cooks the food.

The waiter is not coupled to the chef. They can take many orders even if the chef is busy.

Key Terminology

  • Producer (or Publisher): The application component that sends a message. (e.g., your main Express/NestJS API).
  • Consumer (or Worker): The application component that receives and processes a message. (e.g., a separate "Email Service" or "Reporting Service").
  • Message Broker: The central server that manages the queues. (e.g., RabbitMQ, Kafka).
  • Queue / Topic: The named "mailbox" within the broker where messages are stored until a consumer is ready.
  • Message: The packet of data being sent, usually a JSON object describing the job to be done.

The Architectural Benefits

Message Queues are a cornerstone of modern distributed systems.

  • Decoupling: The producer does not know or care which consumer will process the message. The email service can be down for maintenance, but your main API can still accept orders and queue up the "send email" jobs.
  • Scalability: You can scale your producers and consumers independently. If generating invoices is slow, you can simply add more invoice workers without touching your main API servers.
  • Resilience: If a worker crashes while processing a message, the message broker can ensure the message is not lost and can be re-queued for another worker to process. This creates a highly reliable system.

Mid-Lecture Knowledge Check

Module 3: The Major Players

RabbitMQ vs. Kafka

RabbitMQ: The Smart Broker

RabbitMQ is a mature, feature-rich message broker that implements the AMQP protocol.

It excels at complex routing. The producer sends a message to an Exchange, and the exchange decides which Queue(s) to send it to based on routing rules.

  • Direct Exchange: Routes based on an exact matching "routing key."
  • Fanout Exchange: Sends a copy of the message to every queue bound to it (broadcast).
  • Topic Exchange: Routes based on wildcard pattern matching.

Kafka: The Distributed Log

Apache Kafka is a different kind of beast. It is a distributed, persistent, and ordered commit log.

Producers append messages to the end of a Topic. Consumers are responsible for keeping track of which messages they have read (their "offset").

Kafka is designed for extremely high throughput and is the standard for data streaming, analytics, and event sourcing.

Choosing the Right Tool

A senior architect knows the trade-offs.

Use RabbitMQ when:

  • You need complex routing logic.
  • You need per-message guarantees and transactional features.
  • You have a more traditional "task queue" workload.

Use Kafka when:

  • You need to process a massive volume of messages (hundreds of thousands per second).
  • You need to store messages for a long time and allow multiple consumers to "replay" the event stream.
  • You are building a real-time data pipeline.

Module 4: Building with NestJS Microservices

From Monolith to Distributed System

The `@nestjs/microservices` Package

NestJS provides a powerful module for building microservices that communicate over different "transporters," including message queues.


                        npm install @nestjs/microservices amqplib amqp-connection-manager
                    

This installs the core library and the necessary drivers for RabbitMQ.

The Producer (Our Main API)

In our main API, we register a "Client" that knows how to connect to the message broker.

`app.module.ts`


                        import { Module } from '@nestjs/common';
                        import { ClientsModule, Transport } from '@nestjs/microservices';

                        @Module({
                          imports: [
                            ClientsModule.register([
                              {
                                name: 'ORDER_SERVICE', // An injection token
                                transport: Transport.RMQ,
                                options: {
                                  urls: ['amqp://localhost:5672'],
                                  queue: 'orders_queue',
                                },
                              },
                            ]),
                          ],
                          controllers: [AppController],
                        })
                        export class AppModule {}
                    

Emitting an Event

We inject the client and use `.emit()` to send a fire-and-forget message.


                        // app.controller.ts
                        import { Controller, Post, Inject, Body } from '@nestjs/common';
                        import { ClientProxy } from '@nestjs/microservices';

                        @Controller('orders')
                        export class AppController {
                          constructor(@Inject('ORDER_SERVICE') private client: ClientProxy) {}

                          @Post()
                          placeOrder(@Body() orderData: any) {
                            // This sends the message to the 'orders_queue' and returns immediately.
                            this.client.emit('order_created', orderData);
                            
                            return { message: 'Order received! You will receive a confirmation email shortly.' };
                          }
                        }
                     

The Consumer (A Separate Application)

The consumer is a separate NestJS application that is configured to listen to a queue instead of HTTP.

`main.ts` (of the worker application)


                        import { NestFactory } from '@nestjs/core';
                        import { Transport } from '@nestjs/microservices';
                        import { AppModule } from './app.module';

                        async function bootstrap() {
                          const app = await NestFactory.createMicroservice(AppModule, {
                            transport: Transport.RMQ,
                            options: {
                              urls: ['amqp://localhost:5672'],
                              queue: 'orders_queue',
                              queueOptions: {
                                durable: false
                              },
                            },
                          });
                          await app.listen();
                        }
                        bootstrap();
                     

Handling the Message

In the consumer's controller, we use the `@MessagePattern()` decorator to define a handler for a specific message type.


                        import { Controller } from '@nestjs/common';
                        import { MessagePattern, Payload } from '@nestjs/microservices';

                        @Controller()
                        export class AppController {
                          @MessagePattern('order_created')
                          handleOrderCreated(@Payload() data: any) {
                            console.log('Received a new order! Processing...');
                            console.log(data);
                            // TODO: Send email, update inventory, etc.
                            // This is the background job.
                          }
                        }
                     

In-Class Practical Exercise

The Welcome Email Worker

You will be given a working NestJS CRUD API for users. Your task is to decouple the "welcome email" process by using a message queue.

  1. Install `@nestjs/microservices`, `amqplib`, and `amqp-connection-manager`.
  2. In the main API's `AppModule`, register a `ClientProxy` for a `USER_SERVICE` that connects to a `users_queue` on RabbitMQ.
  3. Inject the `ClientProxy` into your `UsersService`.
  4. In the `create` method of the `UsersService`, after a user is successfully saved, use the client to `emit` a `user_created` event with the new user's data.
  5. Create a **new, separate NestJS application** for your email worker.
  6. In the worker's `main.ts`, configure it to run as a microservice that listens to the `users_queue`.
  7. In the worker's `AppController`, create a method decorated with `@MessagePattern('user_created')`.
  8. Inside this method, simply log a message to the console, like `Sending welcome email to ${data.email}...`.
  9. Run both the main API and the worker application, then create a new user via Postman and watch the worker's console for the log message.

Final Knowledge Check