Back to all posts

Refactoring: When and How?

S
sonhp
10 min read

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 if statements 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 indentationEasier 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!

S

Written by sonhp

Technical writer and developer passionate about web technologies.

Related Articles