Updated: Mar 29, 2026
| 10 min

The Rescue Mission: Refactoring a 'Monster' Class Step-by-Step

Don't delete that messy code just yet. Learn how to transform a tightly coupled Java class into a clean, testable architecture using incremental refactoring and abstractions.

Banner for the "The Tough Love Architecture Guide" series

Let’s be honest: most projects don’t start with a bad design. Over time, requirements change or new ones get added. Perhaps we have a tight deadline to meet, or maybe we’re just being lazy, but often we will just hack the new requirements into the original design. We just tell ourselves that we’ll refactor this later, but of course, we never do. Imagine a scenario where a seemingly minor piece of unrefactored code caused a system outage. This can result in thousands of dollars in lost revenue within a matter of hours. Such real-world setbacks underscore the critical importance of timely refactoring and set the stage for understanding the need to manage technical debt responsibly.

A few deadlines later, and that “temporary” architecture has roots so deep that nobody wants to touch it. The code works…, but it’s very fragile, hard to test, and impossible to extend without breaking something.

In this article, I will present a small example of a single class that started with good intentions but over time has grown into a monster that only a few of us dare tame. You will be able to translate the techniques I use to tame the beast to your real-life projects.

A bad design - (Setting the scene)

Let me introduce you to our scene: the UserManager class.

Right now, this class does a bit of everything:

  • Creating user
  • Saving them to the database
  • Sending welcome emails
  • Logging user actions
public class UserManager {
    private Database database;
    private EmailService emailService;
    private Logger logger;

    public UserManager(Database database, EmailService emailService, Logger logger) {
        this.database = database;
        this.emailService = emailService;
        this.logger = logger;
    }

    public void createUser(String name, String email) {
        // Save user to database
        database.save(new User(name, email));

        // Send welcome email
        emailService.send(email, "Welcome to our app!");

        // Log the action
        logger.log("User created: {}", name);
    }

    public void deleteUser(String email) {
        database.delete(email);
        logger.log("User deleted: {}", email);
    }
}

Initially, this class was created simply for storing users in a database. Even though you might initially think this class looks fine. It has become a sleeping monster; this class has too many responsibilities, is tightly coupled, and is hard to test. Now, let’s break down the steps needed to start and refactor this.

Step 1 - Identify the problem

Many developers will see some “Messy” code and think, “I need to rewrite this.” However, let me explain why this could be dangerous. Bad code can still contain a significant amount of business logic, undocumented hacks, and edge cases. For instance, there might be a seemingly redundant line of code that checks if a user’s name is exactly 13 characters long. Although it may seem unusual, it could be an essential edge case for handling inputs from a specific legacy system that incorrectly handles overflow. If you simply rewrite the code, there is a significant chance you will lose the hidden knowledge buried inside the existing codebase.

Instead of immediately rewriting the code, you can better ask yourself the following questions:

  • What exactly is wrong?
  • Why is it wrong?

If we answer these questions for our class, it comes down to the problem that the class has too many responsibilities (user creation, persistence, logging, and email sending). This is incorrect because the class has too many responsibilities, creating the risk that changing one thing will break another.

Refactoring this class isn’t just about style. It’s about concerns related to testability and tight coupling.

Step 2 - Choose a better solution

Let’s not be like most developers and skip this part. But instead of just starting and rewriting the code, trying to hammer out a better solution, ask these three questions first:

  • What role should this class or component actually play?
  • Which responsibility should it keep and which should it rewrite?
  • What are the most important constraints right now? (testability, performance, maintainability)

In our case, the UserManager is overwhelmed with various responsibilities, including persistence, notifications, and logging. We need to shift the role of our class from “doing everything” to an orchestrator.

It means the UserManager class should just coordinate the workflow. However, it should not be aware of how each specific activity is implemented and executed. We need to create specific classes for each task (UserRepository, EmailNotifier).

When we actually try to find a better solution, we’re not just cleaning up the code; we’re also redefining the architecture around the identified problem. We’re not worried about choosing the coolest pattern, but we actually try to find a good solution.

Step 3 - Create an implementation plan

Now, for this step, we need some real discipline. We identified the problem and found a solution to fix it. What is left but deleting this class and writing it again right!? With this approach, we risk losing working code, introducing new bugs, or even reintroducing the same design problems.

Instead, let’s take a safer approach; think incremental refactoring. We make small and safe changes one at a time, which we can test individually. This approach will give us a better chance of retaining all functionality.

This is a simple playbook you can follow once ready to implement your solution:

  1. Introduce abstraction early. Try to define your interfaces and abstract classes before implementing them. This approach will help you determine if your predefined contracts are sensible without having to implement them. For our piece of code, we need to create the interfaces *UserRepository and EmailNotifier.*
  2. Start with one responsibility. Don’t take out everything at once. For example, in the UserManager class, it would make sense to first extract the logic for the UserRepository while keeping the rest of the code intact.
  3. Strangle old code. Don’t delete existing methods straight away. Let the abstraction take over piece by piece. Only remove the old code once you’re confident that you haven’t broken anything and have covered all the expected behaviours.
  4. Test as you go. After adding the abstraction, write unit tests for it. Verify your implementation and ensure that your refactoring hasn’t broken anything.
  5. Keep the class working at every stage. Even when you’re in the middle of refactoring, your code should still work. If you follow this approach, it will be easy to identify when and where you could have made a possible mistake.

The primary concept of this playbook is straightforward: each step should be reversible. If something fails, you can rollback that step instead of needing to do a massive rewrite and spend a lot of time refactoring.

Step 4 - Implement the changes

We have finally arrived at the fun part, and we can start refactoring. Let’s refactor our UserManager class using the playbook defined in step 3.

Step 4.1 Introduce the abstraction

Let’s start by introducing some abstraction to the UserManager. Our class does not need to know how the underlying implementation works. We will create two interfaces: one for the EmailManager and one for the UserRepository. The heaviest dependency is the UserRepository, so let’s start removing the direct coupling between the UserManager and the UserRepository.

The UserRepository interface will have two methods: one to save a user and one to delete a user from the database. Our UserRepository will look like this:

public interface UserRepository {
    void save(User user);
    void deleteById(String emailAddress);
}

Next, we need to extract the email sending logic. We will follow the same approach where the UserManager class does not need to know the underlying implementation. Our EmailNotifier interface will have one method that allows us to send a welcome email.

public interface EmailNotifier {
    void sendWelcomeEmail(String emailAddress);
}

Step 4.2 Extract the persistence logic

Now that we have defined our interfaces, let’s write an implementation. We will implement the persistence logic in the UserRepository, like this:

public class UserRepositoryImpl implements UserRepository {
    private final Database database;

    public UserRepositoryImpl(Database database) {
        this.database = database;
    }

    @Override
    public void save(User user) {
        database.save(user);
    }

    @Override
    public void deleteById(String emailAddress) {
        database.delete(emailAdress);
    }
}

Step 4.3 Extract the notification logic

Lastly, we need to implement our second interface: the EmailNotifier. Our implementation will look like this:

public class EmailNotifierImpl implements EmailNotifier {
    private final EmailService emailService;

    public EmailNotifierImpl(EmailService emailService) {
        this.emailService = emailService;
    }

    @Override
    public void sendWelcomeEmail(String emailAdress) {
        emailService.send(emailAdress, "Welcome to our app!");
    }
}

Step 4.4. Update UserManager to orchestration

Now, let’s add these interfaces to the UserManager class.

public class UserManager {
    private final UserRepository userRepository;
    private final EmailNotifier emailNotifier;
    private final Logger logger;

    public UserManager(UserRepository userRepository, EmailNotifier emailNotifier, Logger logger) {
        this.userRepository = userRepository;
        this.emailNotifier = emailNotifier;
        this.logger = logger;
    }

    public void createUser(String name, String email) {
        userRepository.save(new User(name, email));
        emailNotifier.sendWelcomeEmail(email);
        logger.log("User created: {}", name);
    }

    public void deleteUser(String emailAddress) {
        userRepository.deleteById(emailAddress);
        logger.log("User deleted: {}", emailAddress);
    }
}

Step 4.5. Unit tests

To ensure the robustness and functionality after our refactoring, it is crucial to implement unit tests consistently. Our primary goal is to verify key functionality, such as user management, and confirm that these aspects function without errors. We begin by writing unit tests for each of our interfaces and the UserManager. This involves verifying the core operations of saving and deleting users, ensuring data integrity, and operational reliability.

class UserRepositoryTests {
    private UserRepository userRepo;
    private Database database;

    @BeforeEach
    void init() {
        database = mock(Database.class);
        userRepo = new UserRepositoryImpl(database);
    }

    @Test
    void testSaveUser() {
        User user = new User("testUser", "testUser@mail.com");
        userRepo.save(user);

        verify(database, times(1)).save(user);
    }

    @Test
    void testDeleteById() {
        String email = "testUser@mail.com";
        userRepo.deleteById(email);

        verify(database, times(1)).delete(email);
    }
}

Next, we need to verify the EmailNotifier. For this interface, we need to check if we can successfully send a welcome email.

class EmailNotifierTests {
    private EmailNotifier emailNotifier;
    private EmailService emailService;

    @BeforeEach
    void init() {
        emailService = mock(EmailService.class);
        emailNotifier = new EmailNotifierImpl(emailService);
    }

    @Test
    void testSendWelcomeEmail() {
        String email = "testUser@mail.com";
        emailNotifier.sendWelcomeEmail(email);

        verify(emailService, times(1)).send(email, "Welcome to our app!");
    }
}

The final part we need to verify is the UserManager, where we bring it all together. For our UserManager unit tests, we should focus on verifying collaboration, ensuring it effectively delegates tasks to the appropriate interfaces, such as UserRepository and EmailNotifier. Instead of checking internal state changes, we should utilise mocks for these components. This approach will allow us to assert that the interfaces were indeed called, indicating successful orchestration.

class UserManagerTests {
    private UserManager userManager;
    private UserRepository userRepository;
    private EmailNotifier emailNotifier;
    private Logger logger;

    @BeforeEach
    void init() {
        userRepository = mock(UserRepository.class);
        emailNotifier = mock(EmailNotifier.class);
        logger = mock(Logger.class);

        userManager = new UserManager(userRepository, emailNotifier, logger);
    }

    @Test
    void testCreateUser() {
        userManager.createUser("testUser", "testUser@mail.com");

        verify(userRepository, times(1)).save(any(User.class));
        verify(emailNotifier, times(1)).sendWelcomeEmail("testUser@mail.com");
        verify(logger, times(1)).log("User created: {}", "testUser");
    }

    @Test
    void testDeleteUser() {
        userManager.deleteUser("testUser@mail.com");

        verify(userRepository, times(1)).deleteById("testUser@mail.com");
        verify(logger, times(1)).log("User deleted: {}", "testUser@mail.com");
    }
}

Refactoring is About Purpose, Not Perfection

Refactoring isn’t about making code look pretty. It’s about making the design better fit the problem. In our original UserManager class, the code worked, but it was brittle and hard to maintain. By separating the functionality, introducing abstraction, and refactoring in safe steps, we made it flexible, testable, and ready for future maintenance. Next time your face badly designed code, remember:

  • Identify the problem before you touch code.
  • Select a more suitable direction based on the constraints.
  • Break the refactoring down into safe, reversible steps.

Small, deliberate changes beat a massive rewrite every time. To put this into practice, I challenge you: identify one overgrown class in your codebase today and apply Step 1—Identify the problem. This initial step can pave the way for meaningful refactoring and tangible improvements in your projects.

Series: The Tough Love Architecture Guide

3 Chapters