Writing Clean and Maintainable Code in NestJS
Part 6 of 12
Refactoring: When and How?
Refactoring is the process of improving code structure without changing its functionality. Over time, as requirements evolve, code can become messy, unorganized, and difficult to maintain. Knowing when and how to refactor ensures long-term maintainability without breaking existing features.
1. What Is Refactoring?
Refactoring is NOT:
❌ Rewriting everything from scratch
❌ Introducing new features
❌ Optimizing performance (necessarily)
Refactoring IS:
✅ Cleaning up code to improve readability & maintainability
✅ Removing duplication and simplifying logic
✅ Ensuring code follows best practices
2. When Should You Refactor?
Look for these code smells—they signal bad code that needs improvement:
🚨 1. Long Functions (Too Many Responsibilities)
- A function doing too many things is hard to read and test.
- Solution: Break it into smaller, single-responsibility functions.
❌ Bad Example:
async function createUser(data: CreateUserDto): Promise<User> {
const existingUser = await this.prisma.user.findUnique({
where: { email: data.email },
});
if (existingUser) {
throw new Error('User already exists');
}
const hashedPassword = await bcrypt.hash(data.password, 10);
const newUser = await this.prisma.user.create({
data: { ...data, password: hashedPassword },
});
this.mailService.sendWelcomeEmail(newUser.email);
return newUser;
}
✅ Refactored Example:
async function createUser(data: CreateUserDto): Promise<User> {
await this.ensureUserDoesNotExist(data.email);
const user = await this.saveUserWithHashedPassword(data);
this.mailService.sendWelcomeEmail(user.email);
return user;
}
private async ensureUserDoesNotExist(email: string): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (user) throw new Error('User already exists');
}
private async saveUserWithHashedPassword(data: CreateUserDto): Promise<User> {
data.password = await bcrypt.hash(data.password, 10);
return this.prisma.user.create({ data });
}
Why?
✅ Easier to read
✅ Each function has a clear responsibility
✅ Improves testability
🚨 2. Repeated Code (DRY Violation)
- DRY (Don’t Repeat Yourself) means avoid duplicating logic across the project.
- Solution: Extract reusable utility functions or services.
❌ Bad Example:
const hashedPassword1 = await bcrypt.hash(password, 10);
const hashedPassword2 = await bcrypt.hash(anotherPassword, 10);
✅ Refactored Example (Extract to Utility Function)
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
// Usage:
const hashedPassword1 = await hashPassword(password);
const hashedPassword2 = await hashPassword(anotherPassword);
Why?
✅ No redundant bcrypt logic
✅ Easier to update hashing logic in one place
🚨 3. Excessive Nesting (Hard-to-Follow Code)
- Deeply nested
ifstatements make code difficult to follow. - Solution: Early returns and breaking logic into functions.
❌ Bad Example:
async function processOrder(orderId: number): Promise<void> {
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (order) {
if (order.status !== 'PAID') {
throw new Error('Order not paid');
} else {
if (order.items.length > 0) {
order.items.forEach((item) => {
this.inventoryService.reserveStock(item.productId, item.quantity);
});
this.shippingService.createShipment(order.id);
}
}
}
}
✅ Refactored Example (Early Returns & Clear Functions)
async function processOrder(orderId: number): Promise<void> {
const order = await this.getOrderOrFail(orderId);
this.ensureOrderIsPaid(order);
this.reserveStockAndShip(order);
}
private async getOrderOrFail(orderId: number): Promise<Order> {
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error('Order not found');
return order;
}
private ensureOrderIsPaid(order: Order): void {
if (order.status !== 'PAID') throw new Error('Order not paid');
}
private reserveStockAndShip(order: Order): void {
order.items.forEach(item => this.inventoryService.reserveStock(item.productId, item.quantity));
this.shippingService.createShipment(order.id);
}
Why?
✅ Less indentation → Easier to read
✅ Clear function names describe each step
3. How to Refactor Step by Step
🔹 1. Identify the problem → Find code smells
🔹 2. Write tests (if none exist yet) → Ensure behavior doesn’t change
🔹 3. Break down complex logic into smaller, reusable functions
🔹 4. Remove duplicated code → Use utilities or services
🔹 5. Apply design patterns when needed
Tip: Use small, incremental changes instead of refactoring everything at once.
4. Practical Example: Refactoring a Messy Service
Here’s an example of before and after refactoring:
❌ Before Refactoring
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async registerUser(data: CreateUserDto): Promise<User> {
const existingUser = await this.prisma.user.findUnique({
where: { email: data.email },
});
if (existingUser) throw new Error('User already exists');
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: { ...data, password: hashedPassword },
});
this.sendWelcomeEmail(user.email);
return user;
}
private sendWelcomeEmail(email: string) {
console.log(`Welcome email sent to ${email}`);
}
}
✅ After Refactoring
@Injectable()
export class UsersService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly authService: AuthService,
private readonly mailService: MailService,
) {}
async registerUser(data: CreateUserDto): Promise<User> {
await this.ensureUserDoesNotExist(data.email);
const user = await this.createUserWithHashedPassword(data);
this.mailService.sendWelcomeEmail(user.email);
return user;
}
private async ensureUserDoesNotExist(email: string): Promise<void> {
if (await this.usersRepository.findByEmail(email)) {
throw new Error('User already exists');
}
}
private async createUserWithHashedPassword(
data: CreateUserDto,
): Promise<User> {
data.password = await this.authService.hashPassword(data.password);
return this.usersRepository.create(data);
}
}
Why?
✅ Clear separation of concerns
✅ Uses a repository & auth service
✅ Easier to test and extend
5. Summary
Best Practices for Refactoring in NestJS
✅ Break down large functions → Keep each function focused on one thing
✅ Extract reusable logic → Avoid repeating code
✅ Reduce nesting & complexity → Use early returns & helper methods
✅ Write tests first → Ensure refactoring doesn’t break existing functionality
What’s the most challenging part of refactoring in your projects? Share your thoughts in the comments!