Efficient Database Scaling in NestJS with TypeORM: Best Practices for Enforcing Slave Reads

MD OZAIR QAYAM
4 min readJan 31, 2025

--

Scaling database reads is a critical challenge in modern applications. By default, NestJS with TypeORM sends all queries to the master database, which can cause performance bottlenecks.

A better strategy is to use read replicas (slave databases) for queries, while keeping writes on the master database. However, enforcing slave reads in TypeORM is not straightforward.

Problem Statement:

TypeORM supports automatic read queries from slave databases. However, in a write-heavy system where defaultMode is set to master, most UPDATE and POST requests must read from the master database to ensure real-time updated data. Some GET APIs, especially those involving large datasets with multiple table joins, should explicitly read from the slave database to improve performance. This blog explores different approaches to solving this problem and explains why the chosen method is the most effective.

  • TypeORM supports automatic read queries from the slave database, but when defaultMode is set to master (as in write-heavy systems), read queries may still go to the master. In such cases, explicit routing to the slave database is needed for specific read-heavy queries.
  • Read queries may still hit the master DB, overloading it.
  • We need a scalable, maintainable solution that ensures all read operations explicitly use the slave DB.

Setting Up TypeORM with Replication

To enable automatic read queries from the slave database while keeping writes on the master, configure TypeORM with replication. Below is a sample configuration for a PostsService in a NestJS application.

📌 TypeORM Configuration with Replication

TypeOrmModule.forRoot({
type: 'mysql',
replication: {
defaultMode: "master",
master: {
host: process.env.DB_MASTER_HOST,
port: Number(process.env.DB_MASTER_PORT),
username: process.env.DB_MASTER_USER,
password: process.env.DB_MASTER_PASS,
database: process.env.DB_NAME,
},
slaves: [
{
host: process.env.DB_SLAVE_HOST,
port: Number(process.env.DB_SLAVE_PORT),
username: process.env.DB_SLAVE_USER,
password: process.env.DB_SLAVE_PASS,
database: process.env.DB_NAME,
},
],
},
entities: [Post],
synchronize: false,
logging: false,
});

Using TypeORM in a Module

To ensure that repositories use the correct connection, import TypeOrmModule.forFeature in the PostsModule.

📌 PostsModule

@Module({
imports: [
TypeOrmModule.forFeature([Post]), // Uses default master/slave replication setup
],
controllers: [PostsController],
providers: [PostsService],
})
export class PostsModule {}

🔹 Different Ways to Enforce Slave Reads in NestJS

1️⃣ Using QueryRunner for Manual Slave Reads

One common approach is to use QueryRunner to manually create a connection to the slave.

📌 Example: QueryRunner for Slave Reads

import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
@Injectable()
export class DatabaseService {
constructor(private readonly dataSource: DataSource) {}
async getReadManager(): Promise<EntityManager> {
const queryRunner = this.dataSource.createQueryRunner('slave');
await queryRunner.connect();
return queryRunner.manager;
}
}

📌 Usage in PostService

const readManager = await this.databaseService.getReadManager();
const data = await readManager.find(Posts, {});

✅ Pros:

  • Ensures queries run on the slave database.
  • Works for both simple and complex queries.

❌ Cons:

  • Requires manual handling of QueryRunner.
  • Can lead to connection leaks if not properly released.
  • Adds boilerplate to every read query.

2️⃣ Using a Separate Repository for Slave Reads

Another approach is to inject a separate repository that explicitly connects to the slave DB.

📌 Example: Injecting Slave Repository

@InjectRepository(Posts, 'slave')
private readonly PostsRepository: Repository<Posts>;

✅ Pros:

  • Makes read queries explicit.
  • No need for QueryRunner.

❌ Cons:

  • Requires duplicate repositories (one for master, one for slave).
  • Hard to maintain as the project scales.

3️⃣ The Best Approach: Using an Interceptor for Automatic Slave Reads

The cleanest and most scalable way is to use a NestJS interceptor that automatically enforces slave reads.

📌 ReadSlaveInterceptor (Forcing Reads on Slave)

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { DataSource, EntityManager } from 'typeorm';
import { map } from 'rxjs/operators';
@Injectable()
export class ReadSlaveInterceptor implements NestInterceptor {
constructor(private readonly dataSource: DataSource) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const queryRunner = this.dataSource.createQueryRunner('slave');
await queryRunner.connect();

try {
const readManager: EntityManager = queryRunner.manager;
const request = context.switchToHttp().getRequest();
request.readManager = readManager;
return next.handle().pipe(
map((data) => data)
);
} finally {
await queryRunner.release();
}
}
}

📌 Applying Interceptor in PostService

import { Injectable, UseInterceptors } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { ReadSlaveInterceptor } from '@common/interceptors/read-slave.interceptor';
@Injectable()
@UseInterceptors(ReadSlaveInterceptor)
export class PostService {
constructor() {}
public async getPosts(request: any): Promise<any> {
const manager: EntityManager = request.readManager;
return await manager.find(Posts, {});
}
}

✅ Pros:

  • Automatic enforcement of slave reads.
  • No need for manual repository duplication.
  • Ensures all reads in a service go to the slave DB.
  • No connection leaks (ensures cleanup with QueryRunner.release()).

❌ Cons:

  • Adds slight overhead due to the interceptor.
  • Requires interceptor setup per service.

🔹 Why I Chose the Interceptor Approach

After evaluating multiple approaches, I found interceptors provide the best balance of scalability, maintainability, and performa

💡 Key Takeaways

  • QueryRunner works but needs manual cleanup.
  • Separate Repositories work but increase complexity.
  • Interceptors provide automation, scalability, and maintainability.

🔹 Final Thoughts & Best Practices

Use Interceptors for automatic slave reads.
✅ Use QueryRunner.release() to prevent connection leaks.
Test Read-After-Write Scenarios to avoid stale data issues.
Monitor DB performance to ensure proper load balancing.

🔥 Conclusion

Enforcing slave reads in NestJS with TypeORM is crucial for scalability and performance. The interceptor approach is the cleanest, most scalable solution, ensuring automatic enforcement without modifying queries.

🚀 What do you think? Let me know in the comments! 🔥

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response