Skip to content
ExponaBLOG
NESTJS · DAY 044 / 100

Hybrid apps: HTTP plus microservices — a practical guide

One NestJS app serving REST and consuming events.

Expona Admin8 min read

Most NestJS projects start as REST APIs—a single HTTP server handling requests and returning JSON. But as you grow, you'll need event-driven workflows: processing orders asynchronously, sending emails without blocking requests, or reacting to Kafka topics in real time. Rather than split your codebase into two separate apps, NestJS lets you build hybrid apps: HTTP plus microservices in a single process. This means one deployment serving REST clients while simultaneously consuming messages from a broker. Today, we'll explore how to attach microservice transports to an existing HTTP server, share business logic between them, and design for future scale.

What is a hybrid app?

A hybrid app combines two communication patterns in one NestJS application:

  • HTTP (synchronous): Controllers handle REST endpoints, respond to clients immediately.
  • Microservice (asynchronous): Consumers listen on Kafka topics, RabbitMQ queues, or other transports; they process events without blocking.

The advantage is clear: you keep your domain logic centralized, deploy a single artifact, and share services between both patterns. When traffic grows, you can later split consumers into a separate process without rewriting business logic.

I've used this pattern in production for a ride-hailing platform: the HTTP layer handled booking requests and driver updates, while Kafka consumers processed rating events, billing workflows, and notifications. Everything ran in one container initially; six months later, we extracted consumers into a separate deployment when throughput demanded it.

connectMicroservice — Attach a transport to your existing HTTP app

The foundation of a hybrid app is app.connectMicroservice(). This method registers a message broker transport on your NestJS application without replacing the HTTP listener. Think of it as bolting on a second entry point.

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Attach Kafka as a microservice transport
  app.connectMicroservice({
    transport: Transport.KAFKA,
    options: {
      client: {
        brokers: ['kafka:9092'],
      },
      consumer: {
        groupId: 'my-app-group',
      },
    },
  });

  // Will start both HTTP and Kafka listeners
  await app.startAllMicroservices();
  await app.listen(3000);

  console.log('App listening on http://localhost:3000');
  console.log('App consuming from Kafka group: my-app-group');
}

bootstrap();

After connectMicroservice(), you have two listeners waiting for work. The HTTP server on port 3000 handles incoming REST requests, and the Kafka consumer silently subscribes to topics. Both run in the same Node.js process, sharing memory and services.

You're not limited to one transport. In a CRM system I built, we connected both Kafka and RabbitMQ:

typescript
// Multiple transports in one hybrid app
app.connectMicroservice({
  transport: Transport.KAFKA,
  options: { client: { brokers: ['kafka:9092'] } },
});

app.connectMicroservice({
  transport: Transport.RabbitMQ,
  options: {
    urls: ['amqp://rabbitmq:5672'],
    queue: 'events_queue',
  },
});

await app.startAllMicroservices();
await app.listen(3000);

This flexibility means you can migrate from one broker to another gradually or support multiple event sources without deploying separate services.

Start both — startAllMicroservices() then listen()

The order matters. You call startAllMicroservices() first, then app.listen(). This ensures all consumers are ready before the process reports as healthy.

typescript
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Register transports
  app.connectMicroservice({
    transport: Transport.KAFKA,
    options: {
      client: { brokers: ['kafka:9092'] },
      consumer: { groupId: 'orders-service' },
    },
  });

  // Start microservice listeners
  await app.startAllMicroservices();
  console.log('✓ Microservices started');

  // Start HTTP server
  await app.listen(3000);
  console.log('✓ HTTP server started on :3000');
}

In Kubernetes, your readiness probe checks port 3000, but the app is only ready after both lines complete. Without this ordering, your pod might receive traffic before Kafka consumers are subscribed, losing early messages.

For a trading platform, we had to be strict about this: if the Kafka consumer missed the first tick event after restart, trade prices would be stale. We added a custom health check that verified both HTTP and Kafka connectivity before marking the pod as ready.

Shared services — Controllers and consumers call the same services

The real power of a hybrid app is sharing business logic. Your service layer doesn't care whether it's called from a REST controller or an event consumer. Write once, use everywhere.

Consider an order processing flow:

typescript
// order.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './order.entity';

@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private orderRepo: Repository,
  ) {}

  async createOrder(userId: string, items: any[]) {
    const order = this.orderRepo.create({
      userId,
      items,
      status: 'pending',
    });
    return this.orderRepo.save(order);
  }

  async processPayment(orderId: string) {
    const order = await this.orderRepo.findOne(orderId);
    // Call payment gateway
    order.status = 'paid';
    return this.orderRepo.save(order);
  }

  async fulfillOrder(orderId: string) {
    const order = await this.orderRepo.findOne(orderId);
    order.status = 'fulfilled';
    return this.orderRepo.save(order);
  }
}

Your HTTP controller uses createOrder directly:

typescript
// order.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { OrderService } from './order.service';

@Controller('orders')
export class OrderController {
  constructor(private orderService: OrderService) {}

  @Post()
  async create(@Body() body: { userId: string; items: any[] }) {
    return this.orderService.createOrder(body.userId, body.items);
  }
}

Your Kafka consumer reuses the same service for fulfillment:

typescript
// order.consumer.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { OrderService } from './order.service';

@Controller()
export class OrderConsumer {
  constructor(private orderService: OrderService) {}

  @MessagePattern('order.paid')
  async handleOrderPaid(@Payload() payload: { orderId: string }) {
    return this.orderService.fulfillOrder(payload.orderId);
  }
}

When a Kafka event fires order.paid, the consumer calls the exact same OrderService.fulfillOrder that you'd call from a REST endpoint. No duplication, no diverging logic.

In the e-commerce platform I built, we had 15+ services shared between controllers and consumers. A single bug fix in validateInventory() fixed both the REST checkout endpoint and the async event processor.

Split when needed — Scale consumers separately later by deploying the same code with a different entry point

The beauty of this hybrid design is that it's not permanent. As you grow, you can split HTTP and microservice concerns into separate deployments without rewriting anything.

Start with one entry point:

typescript
// src/main.ts (hybrid mode)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.connectMicroservice({
    transport: Transport.KAFKA,
    options: { client: { brokers: ['kafka:9092'] } },
  });
  await app.startAllMicroservices();
  await app.listen(3000);
}
bootstrap();

Later, create a separate entry point for consumers only:

typescript
// src/main-consumer.ts (microservice mode)
async function bootstrap() {
  const app = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.KAFKA,
      options: { client: { brokers: ['kafka:9092'] } },
    },
  );
  await app.listen();
}
bootstrap();

Your Dockerfile stays the same; the deployment strategy changes:

typescript
// package.json
{
  "scripts": {
    "start": "node dist/main.js",           // HTTP + Kafka
    "start:consumer": "node dist/main-consumer.js" // Kafka only
  }
}

Now you can deploy two replicas of the consumer pod and zero HTTP pods if your event throughput demands it. The code is identical; only the entry point differs. I've done this at scale: our trading system ran 1 HTTP instance and 10 consumer instances, all from the same source tree.

Common mistakes

  • Forgetting startAllMicroservices(): If you only call app.listen(), microservice consumers never start. The HTTP server runs, but Kafka sits idle. Always call both.
  • Blocking the event handler: A consumer should fire fast and delegate work to queues or background jobs. If you do heavy I/O (database queries, external API calls) inside the event handler, you'll slow down the entire consumer group. Use RxJS operators or defer to worker threads.
  • Shared database connections under load: When HTTP and microservice run in one process, they compete for the same connection pool. If your database has a limit of 10 connections, 5 might go to HTTP and 5 to consumers. Monitor connection usage and tune max in your database config.
  • Memory leaks in long-running consumers: Microservice processes run indefinitely. A memory leak in an event handler will accumulate over days. Use heap snapshots and profile event handlers in staging.
  • Deploying without a split strategy: Starting as hybrid is fine, but if you don't design services as reusable from day one, splitting later becomes painful. Keep domain logic in services, not in controllers or consumers.

Key takeaways

  • connectMicroservice() adds a message broker transport to your existing HTTP app without replacing it.
  • Call startAllMicroservices() before listen() to ensure both listeners are ready.
  • Write domain logic in services; consume those services from both controllers and consumers for zero duplication.
  • A hybrid app is a stepping stone, not a permanent design. You can later split HTTP and microservice into separate deployments using different entry points.
  • Start hybrid, but design for scale: keep services stateless, monitor connection pools, and profile event handlers under load.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles