Account Service Microservice with NestJS


What the Account Service Actually Does

The account-service is not a full CRUD service. It’s a lightweight event listener. Its only job: receive account-created events from the transaction-service via RabbitMQ and log them. The transaction-service handles the HTTP endpoint and emits the event; the account-service reacts to it.

This separation means account creation is asynchronous — the HTTP response returns before the account-service has processed the event.

The Event Flow

POST /account (HTTP)


transaction-service
     │ emit("account-created", account)

RabbitMQ (account_queue, durable)


account-service
     │ @MessagePattern("account-created")

console.log(account)

The Producer: Transaction Service

The transaction-service exposes the HTTP endpoint and emits to RabbitMQ:

// transaction-service/src/app.controller.ts
@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    @Inject(ACCOUNT_SERVICE) private readonly accountRMQClient: ClientProxy
  ) {
    this.accountRMQClient.status.subscribe(status => {
      console.log('RMQ Client Status:', status);
    });
  }

  @Post('account')
  async createAccount(@Body() account: any) {
    try {
      await lastValueFrom(this.accountRMQClient.emit("account-created", account));
      console.log("Account emitted successfully:", account);
    } catch (err) {
      console.error("Failed to emit to RMQ:", err);
    }
  }
}

A few things to note:

  • @Inject(ACCOUNT_SERVICE) — the ClientProxy is a NestJS abstraction over the RabbitMQ connection. ACCOUNT_SERVICE is just a string constant ("ACCOUNT_SERVICE") used as the injection token.
  • emit() vs send()emit() is fire-and-forget (no response expected). send() expects a response from the consumer. Account creation is one-way, so emit() is correct.
  • lastValueFrom()emit() returns an Observable. Wrapping it in lastValueFrom() converts it to a Promise, so await actually waits for the message to be dispatched to RabbitMQ. Without this, the try/catch wouldn’t catch RabbitMQ connection failures.

Module Registration: RabbitMQ Client

The RabbitMQ client is configured in the transaction-service’s module:

// transaction-service/src/app.module.ts
@Module({
  imports: [
    ClientsModule.register([
      {
        name: ACCOUNT_SERVICE,
        transport: Transport.RMQ,
        options: {
          urls: [`amqp://guest:guest@${fs.existsSync('/.dockerenv')
            ? 'host.docker.internal' : 'localhost'}:5672`],
          queue: 'account_queue',
          queueOptions: { durable: true },
        },
      },
    ]),
  ],
  controllers: [AppController],
  providers: [AppService, ConsulService],
})
export class AppModule {}

The fs.existsSync('/.dockerenv') check at module initialization swaps localhost for host.docker.internal when running in Docker. durable: true means the queue survives RabbitMQ restarts — messages aren’t lost if the broker bounces.

The Consumer: Account Service

The account-service bootstrap connects both an HTTP server and a RabbitMQ microservice transport:

// account-service/src/main.ts
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  const isDocker = fs.existsSync('/.dockerenv');
  const rabbitHost = isDocker ? 'host.docker.internal' : 'localhost';
  const rabbitMQUrl = process.env.RABBITMQ_URL || `amqp://guest:guest@${rabbitHost}:5672`;
  const httpPort = 3003;

  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.RMQ,
    options: {
      urls: [rabbitMQUrl],
      queue: 'account_queue',
      queueOptions: { durable: true },
    },
  });

  await app.startAllMicroservices();
  await app.listen(httpPort, '0.0.0.0');
}

Two transports, one NestJS app:

  • HTTP on port 3003 — serves the /health endpoint for readiness checks
  • RabbitMQ — listens on account_queue for incoming messages

The actual message handler is minimal:

// account-service/src/app.controller.ts
@Controller()
export class AppController {

  @Get('health')
  health(): string {
    return 'OK';
  }

  @MessagePattern("account-created")
  handleAccountCreated(@Payload() account: any) {
    console.log('[Account-Service]: Received account: ', account);
  }
}

@MessagePattern("account-created") tells NestJS to route any RabbitMQ message with that pattern to this handler. The @Payload() decorator extracts the message body. Right now it just logs — in a production system, this is where you’d persist the account to a database.

Why RabbitMQ (Not Kafka) Here

PFMS uses both Kafka and RabbitMQ. The choice for account creation is deliberate:

  • RabbitMQ is used here because account creation is point-to-point: one producer (transaction-service), one consumer (account-service). RabbitMQ’s queue model is a natural fit — messages sit in a durable queue until the consumer acknowledges them.
  • Kafka is used elsewhere (budget-service → notification-service) because budget events are fan-out: multiple consumers might subscribe (notifications, analytics, auditing). Kafka’s topic model supports multiple consumer groups natively.

Using both adds operational overhead (two broker systems to maintain), but each pattern gets the right tool.

Summary

  • Lightweight event listener — the account-service has no CRUD, just @MessagePattern
  • lastValueFrom(emit()) — converts Observable to Promise for proper error handling
  • Dual transport — HTTP for health checks, RabbitMQ for event consumption
  • durable: true queues — messages survive broker restarts
  • RabbitMQ for point-to-point — right tool for single-consumer patterns