Back to Blog
February 18, 20266 min read

Custom PubSub in NestJS Monorepo Microservices

This post explains how to build a lightweight publish-and-subscribe (PubSub) layer inside a NestJS monorepo, why that can be a smart early choice, and when it is time to move to a heavier tool like Kafka or RabbitMQ.

NestJSMicroservicesArchitecture

Who this is for

This article is for:

  • developers new to backend architecture
  • people who have heard terms like "event-driven" and "microservices" but want a simpler explanation
  • teams building an internal platform and trying not to overcomplicate it too early

PubSub in plain English

PubSub stands for "publish and subscribe."

The idea is simple:

  • one part of your system announces that something happened
  • other parts of the system can listen for that announcement
  • the sender does not need to know exactly who is listening

Think of it like posting a notice on a company board:

  • HR posts "New employee joined"
  • payroll reads it and sets up salary
  • IT reads it and creates accounts
  • security reads it and grants building access

HR does not need to call each team one by one. It posts one event, and the right teams react.

That is what PubSub does in software.


Why teams often reach for the wrong solution too early

When engineers hear "event-driven architecture," they often jump straight to advanced tools like Kafka, RabbitMQ, or NATS.

Those tools are powerful, but they also come with real cost:

  • more infrastructure to run
  • more concepts to learn
  • more moving parts in development and testing
  • more operational overhead for a team that may not need it yet

For many products, especially early-stage or mid-sized systems, a simple internal event bus is enough.

The goal should not be "use the most impressive tool." The goal should be "solve today's problem clearly and leave room to grow tomorrow."


Why a custom internal PubSub can be a good first step

Inside a monorepo, services already share code, types, and deployment context. That gives you a huge advantage.

You can build a lightweight event layer that gives you:

  • faster development, because everything is in one codebase
  • shared event contracts, so publishers and subscribers agree on the shape of data
  • easier local testing, because you can run a simpler version of the bus in development
  • lower cost, because you are not introducing heavy infrastructure too early
  • easier observability, because events and handlers live in one place

This does not mean "always build your own tooling." It means "use the simplest tool that solves the real problem."


A practical architecture

In a NestJS monorepo, a small PubSub setup often looks like this:

  • shared event contracts in a common package
  • a publishing service that sends events
  • subscriber classes that react to events
  • Redis, a queue, or even an in-memory adapter depending on environment

For local development:

  • an in-memory bus can be enough
  • it keeps setup simple
  • it makes debugging easier

For shared environments:

  • Redis PubSub or Redis Streams can be a good middle ground
  • you get cross-service communication without jumping all the way to Kafka

The key idea is that your application code should depend on an event interface, not on a specific infrastructure vendor.


Start with clear event contracts

An event is just a message that describes something that already happened.

Good events are:

  • specific
  • named clearly
  • stable over time
  • focused on facts, not commands

For example, user.created is better than create-user-for-analytics.

Why? Because user.created describes a business fact. Any number of services can react to that fact.

Example contract:

export interface UserCreatedEvent { type: "user.created"; payload: { userId: string; email: string; createdAt: string; }; }

Even if you are new to TypeScript, the important part is easy to understand:

  • type tells us what happened
  • payload contains the useful details about that event

Shared contracts reduce confusion because every service reads from the same definition.


A simple publisher

The publisher is the part of the system that announces the event.

@Injectable() export class EventBus { constructor(private readonly redis: Redis) {} async publish(event: BaseEvent) { await this.redis.publish(event.type, JSON.stringify(event)); } }

In plain English, this says:

  • take an event object
  • convert it into text
  • send it to a named channel

The service that creates a user does not need to know who cares about that user. It just publishes the event and moves on.

That loose coupling is the main benefit.


A simple subscriber

The subscriber listens for an event and reacts to it.

@Injectable() export class UserCreatedListener { @OnEvent("user.created") handle(event: UserCreatedEvent) { console.log("User created:", event.payload.userId); } }

In real systems, the listener would do something useful like:

  • send a welcome email
  • create a billing profile
  • record analytics
  • start onboarding tasks

The important design benefit is that each listener owns one responsibility. That keeps your system easier to change later.


What beginners often get wrong

1. Turning events into remote procedure calls

Events should describe what happened, not tell another service exactly what to do.

Bad example:

  • email-service-send-welcome-email

Better example:

  • user.created

The second version is more flexible and less coupled.

2. Skipping reliability concerns

Even a simple PubSub system needs guardrails:

  • what happens if a subscriber crashes?
  • can the same event be processed twice?
  • how do you know which event failed?

If you skip these questions, the system looks simple at first but becomes hard to trust.

3. Hiding event definitions in random places

If events are not easy to discover, teams create duplicate or conflicting versions.

Put shared contracts somewhere obvious and reusable.


What "good enough" looks like early on

You do not need a giant event platform on day one.

A good early setup usually includes:

  • shared event types
  • basic logging around publish and consume steps
  • retries for temporary failures
  • idempotent handlers, so duplicate processing does not break things
  • a dead-letter or failure path for events that cannot be handled

That already gets you much further than "we use events" with no operational discipline.


When it is time to move to Kafka or another larger tool

You should consider a more advanced event platform when:

  • many independent services need to communicate across multiple repos
  • throughput becomes very high
  • you need durable replay of old events
  • multiple teams need strict ownership boundaries
  • message ordering and partitioning become important business concerns

That move should happen because your needs changed, not because bigger infrastructure sounds more professional.


Final advice

Start simple, but not sloppy.

If you build:

  • clear event contracts
  • observable publish and subscribe flows
  • safe handlers
  • a clean path to future scaling

then a lightweight internal PubSub layer can be a very strong architectural choice.

The real lesson is not "always build custom PubSub." The real lesson is "match the system design to the stage and complexity of the product."