Refactoring Java: Five patterns for improving code quality

Diffblue HQ
14 min readNov 7, 2023

No code is perfect from the start. It’s important to acknowledge that achieving perfection on the first attempt is unrealistic.

The good news is that by periodically updating and refactoring your code, you can significantly boost team productivity and enhance team satisfaction. And refactoring doesn’t necessarily mean performing an annual overhaul; you can get tremendous results by making small changes. Additionally, refactoring isn’t just looking at bad naming and duplicate code but also finding ways to improve code readability, reusability, and testability.

The problem with refactoring is that without a clear idea of what to look for, it can seem like an arbitrary process of reviewing functional code. This is when it’s helpful to know a set of coding patterns and best practices that you can apply systematically to your code. Once you learn those patterns, refactoring is like scanning your code to identify areas that don’t follow these patterns.

In this article, you’ll learn about five patterns that you should apply during refactoring sessions and code reviews to drastically improve the quality of your code.

Pattern 1: Dependency Injection

Refactoring often aims to achieve loose coupling because it makes future changes easier and enhances testability. With loose coupling, the impact of a code change is confined to a specific component, reducing the risk of introducing breaking changes that could impact the entire system.

Dependency injection is one way you can achieve loose coupling by providing instantiated dependencies ( aka ready-to-use dependencies) to a class . This means your class is no longer responsible for instantiating its components. Each class only does one thing, and operations such as obtaining the date, generating a random number, or even reading files should be delegated and considered dependencies. The idea that a class should only have one single responsibility is based on the single-responsibility principle or the S in the SOLID principles.

The catch here is that it’s tempting to simply call LocalDate.now() when implementing a feature that needs the current date. However, when refactoring, you need to be aware of other small dependencies (especially ones that are coming from a standard library, such as java.util, java.io, or java.time). It's easy to forget that you should treat all dependencies equally and use the dependency injection pattern. If you were working on bigger dependencies, such as a database repository, you wouldn't be as tempted to make this same mistake.

Not only does dependency injection ensure loose coupling, but it also makes testing easier. LocalDate.now() is the most notable example because, without dependency injection, your test will certainly fail at some point.

Implement the Dependency Injection Pattern in Java

From a design perspective, dependency injection transforms an implicit dependency relation into an explicit one (composition):

After refactoring, Class A1 is composed of Class B1 and stores its dependency as an attribute, injected via its constructor.

Take this concept a step further. Imagine you want to calculate the number of days remaining before your user’s next birthday (maybe to send a promotion before the celebration). This seems like a simple task that you can implement in Java:

package dependency.injection;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class BirthdayCalculatorBefore {

public long getHowManyDaysFromMyNextBirthday(String myBirthdayStr) {
// Get today's date
LocalDate now = LocalDate.now();
int year = now.getYear();
// Process dates
String myBirthDayOfCurrentYearStr = year + "-" + myBirthdayStr;
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

LocalDate myBirthday = LocalDate.parse(myBirthDayOfCurrentYearStr, dateTimeFormatter);
//Is the birth date in the past? aka. already celebrated this year.
if(myBirthday.isBefore(now)) {
myBirthday = myBirthday.plusYears(1);
}
return ChronoUnit.DAYS.between(now, myBirthday);
}
}

Nothing special here: you fetch the date using LocalDate.now(), then manipulate the two dates (today and birthday), calculate the difference, and return the result.

But how could you test that code? Unfortunately, you can’t because the variable now is hidden (instantiated) inside a method of your class, and some refactoring is required.

To solve this, you need to extract now from the method getHowManyDaysFromMyNextBirthday and make it an attribute of your Class. Then your constructor should allow that dependency to be injected. Following is the result of this refactoring pattern:

package dependency.injection;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class BirthdayCalculatorAfter {
//Now is a dependency
private LocalDate now;
// dependencies are injected via the constructor
public BirthdayCalculatorAfter(LocalDate now) {
this.now = now;
}

public long getHowManyDaysFromMyNextBirthday(String myBirthdayStr) {
int year = now.getYear();
String myBirthDayOfCurrentYearStr = year + "-" + myBirthdayStr;
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

LocalDate myBirthday = LocalDate.parse(myBirthDayOfCurrentYearStr, dateTimeFormatter);
if(myBirthday.isBefore(now)) {
myBirthday = myBirthday.plusYears(1);
}
return ChronoUnit.DAYS.between(now, myBirthday);
}
}

You can see both codes before and after refactoring in this GitHub repo.

Pattern 2: Extract Method(s)

It’s important to keep methods succinct ( ie reduce the number of lines) to make them easier to understand. When extracting methods, code becomes self-explanatory; some will act as dispatchers and break down the list of steps without data manipulation, while small specialized methods will be tasked with executing those steps:

public void doALotOfthings() {
// self-explainatory process delegate to other methods
step1()
step2()
step3()
...
}

public void step1() {
// do only one think well
}
[...]

Extracting small methods in this way makes unit tests easier since you can test only a small, well-defined piece of logic.

The extract method pattern is especially useful when you have long and complex methods of processing and transforming data. It also helps you identify a pattern in your code and eliminate duplicated code by reusing those specialized functions.

Implement the Extract Method(s) Pattern in Java

Imagine you want to implement an automated email service. Upon request, your code should send an email to a recipient on behalf of a user. This seems like a simple task, but since user input is involved, you need to ensure all strings are valid and respect the following specifications:

  • The receiver is a valid email address
  • The sender is a valid user registered in your database
  • The content length respects the limit of 500 characters

Following is a simple way to meet all three criteria:

package extract.method.before;

import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class EmailService {

// This is a dependency (see previous refactoring)
// We assume we are providing a contact list
Map<String, String> userEmailMap;

public EmailService(Map<String, String> userEmailMap) {
this.userEmailMap = userEmailMap;
}

/**
* Send an email on behave of an user, to a receiver
* @param receiverEmail is an email, and the input string should be validated
* @param sender is the name of the sender and it's email is retrive from our database
* @param content is the body of the email and should be validated
*/
public void sendEmail(String receiverEmail, String sender, String content) throws Exception {
String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$";
Pattern pattern = Pattern.compile(EMAIL_REGEX);
Matcher matcher = pattern.matcher(receiverEmail);
if (matcher.matches()) {
if (userEmailMap.containsKey(sender)) {
String senderEmail = userEmailMap.get(sender);
if (content.trim().length() < 500) {
this.send(receiverEmail, senderEmail, content);
} else {
throw new Exception("Email content is oversize");
}
} else {
throw new Exception("User not exist");
}
} else {
throw new Exception("Email address not valid");
}
}

// This is a dummy method for the purpose of explanations
private void send(String receiverEmail, String senderEmail, String content) {
//send email
System.out.printf("send email from %s%n to %s%n", senderEmail, receiverEmail);
}
}

The issue with this code is that if someone reads this code later, it might not be easy to understand. The sendEmail is responsible for three different validations.

To fix this, each validation step should be dealt with independently via its dedicated method. Following is how you could refactor this code:

package extract.method.after;

import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

public class EmailService {

// This is a dependency (see previous refactoring)
// We assume we are providing a contact list
Map<String, String> userEmailMap;

public EmailService(Map<String, String> userEmailMap) {
this.userEmailMap = userEmailMap;
}

// explicit step of what the method does
public void sendEmail(String receiverEmail, String sender, String content) throws Exception {
validate(receiverEmail, sender, content);
String senderEmail = userEmailMap.get(sender);
this.send(receiverEmail, senderEmail, content);
}

// This is a dummy method for the purpose of explanations
private void send(String receiverEmail, String senderEmail, String content) {
//actually send email
System.out.printf("Send email from %s%n to %s%n, %s%n", senderEmail, receiverEmail, content);
}

// Each validation step is independent
private void validate(String receiver, String sender, String content) throws Exception {
validateEmail(receiver);
validateSenderEmailExist(sender);
validateContentSize(content);
}

private void validateEmail(String email) throws Exception {
String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$";
Pattern pattern = Pattern.compile(EMAIL_REGEX);
if (!pattern.matcher(email).matches()) {
throw new Exception("Email address is invalid");
}
}

private void validateSenderEmailExist(String sender) throws Exception {
if (!userEmailMap.containsKey(sender)) {
throw new Exception("User not exist");
}
}

private void validateContentSize(String content) throws Exception {
if (content.length() < 500) {
throw new Exception("Email content is oversize");
}
}
}

In this refactored version, the code is explicit; anyone can understand what happens without documentation. This approach greatly improves code readability, and updating requirements later is more simple since you’ve isolated independent steps.

You can find the code used in this example on this GitHub repo.

Pattern 3: Replace Conditional with Polymorphism

Polymorphism is a key feature of object-oriented programming (OOP) because it provides two interesting features: the ability for a Class to define multiple versions of a method accepting different parameters ( method overloading) and the ability for a subclass to define new behavior for existing methods ( method overriding).

However, this isn’t intuitive when it comes to implementing a new feature. As a result, we often default to using if/else and switch/case statements to decide on the code execution flow like this:

class MyClass {
// ...
public void doSomething() {
switch (this.type) {
case 'A':
doA();
case 'B':
doB()
}
}
}

This code breaks the single-responsibility principle we previously mentioned since MyClass uses conditionals to handle multiple behaviors. This is an obvious red flag and indicates that refactoring is needed for subclass A and B instead of having MyClass deal with changes of behavior based on the attribute type. This segregation ( ie A vs. B) using subclass removes the need for conditionals since you can write methods that handle each doSomething differently, as needed (method overriding).

Removing conditional removes branching, thus leading to a simpler test with no enumeration of all the possible cases. Adding a new variation of behavior is as simple as creating a new subclass without touching existing code, removing the risk for regressions.

Implement the Replace Conditional with Polymorphism in Java

To implement the replace conditional with polymorphism in Java, imagine that you’re building a system where users can have different types of subscriptions ( ie PREMIUM, BASIC, and FREE). It may be tempting to embed that information into a Member class by defining an attribute Type so that users receive content based on their subscription tier. That means when handling access to the content, you have to use a switch/case statement to decide which content to return to your user:

package polymorphism.before;

public class Member {
private Type type;

public Member(Type type) {
this.type = type;
}

public String getContent() {
switch (type) {
case FREE:
return "No content available.";
case BASIC:
return "Basic content.";
case PREMIUM:
return "Premium content.";
}
throw new RuntimeException("Member type not known for " + type);
}

public enum Type {
PREMIUM,
BASIC,
FREE
}
}

This class will soon become hard to maintain as you introduce differences between tiers or add new tiers. And the goal should be to limit the class to a single responsibility. This means Member should become an abstract class since that type of class has the advantage of having both abstract methods (must be different between tiers) as well as concrete methods (can be shared between tiers).

Next, each type of subscription needs to be defined as a subclass of Member and implement its own version of getContent:

// Member.java
package polymorphism.after;

public abstract class Member {
public abstract String getContent();
}

// FreeTierMember.java
package polymorphism.after;

public class FreeTierMember extends Member {
@Override
public String getContent() {
return "No content available.";
}
}

// BasicTierMember.java
package polymorphism.after;

public class BasicTierMember extends Member {
@Override
public String getContent() {
return "Basic content.";
}
}

// PremiumTierMember.java
package polymorphism.after;

public class PremiumTierMember extends Member {
@Override
public String getContent() {
return "Premium content.";
}
}

This refactored code is much safer to maintain. Changes made to one tier do not affect another tier. In addition, the logic is easier to understand since you don’t need to do any mental gymnastics with conditionals to understand the impact of the changes.

The code for this example is available in this GitHub repo.

Pattern 4: Replace Magic Numbers with Symbolic Constants

When working on code, it’s important to make sure that any numbers or strings you use have clear meanings to the reader. For example, Pi should not be represented as a hard-coded value of 3.14 but as a clearly labeled symbolic constant. This ensures that anyone reading the code can easily understand its meaning.

Additionally, when data is hard-coded, it can be difficult to update. By defining values as constants, it’s easier to modify them later on. In fact, using a configuration system to set these values when deploying the application can make updates even easier.

Implement the Replace Magic Numbers with Symbolic Constants Pattern in Java

Take one more look at the extract methods pattern example. Earlier, we made the mistake of using a magic number. Can you spot it?

Remember, you want to validate the size of the email content:

private void validateContentSize(String content) throws Exception {
if (content.length() < 500) {
throw new Exception("Email content is oversize");
}
}

The issue with this code is that 500 feels completely arbitrary. Someone reading your code would probably ask what it represents.

The solution is simple: you need to define a constant in your class and give it a meaningful name:

static final Integer EMAIL_CONTENT_SIZE_LIMIT_CHAR = 500;

[...]
private void validateContentSize(String content) throws Exception {
if (content.length() < this.EMAIL_CONTENT_SIZE_LIMIT_CHAR) {
throw new Exception("Email content is oversize");
}
}

Remember that the static final modifier is the canonical way to define a constant in Java; the value is shared between instances of the class static and immutable final. By convention, you should use capital letters as well as the camel case.

Pattern 5: Moving Features between Objects

Sometimes a class can get out of control becoming a "God Class", doing way too many things. As a result, you may want to move methods or fields out of said "God Class". Other times, you may want to improve your code structure by reorganizing the class responsibilities. Either way, you need to move features between the objects.

This refactoring is more difficult than previous approaches, especially if you want to avoid breaking changes. Remember, if you blindly remove a method from a Class, all instances of that class would need to be modified/updated. Fortunately, there are ways to do this properly ( ie safely with the least impact on other Class es ).

When moving things around, the key idea is to do it in two steps: implement the new solution and deprecate the old version, then update all the code that uses your deprecated methods.

Implement the Moving Features between Objects Pattern in Java

To show you how to implement the moving feature between object patterns in Java, imagine a scenario where you model User information in a single Class. Over time, your data structure will grow and your User class will handle more and more responsibilities. At some point, you will want to improve your code and migrate some methods and fields from the User to a dedicated class.

Your initial situation would look like this:

package move.feature.before;

public class User {
private String userId;

// A lot more attributes
// [...]

private String street;
private String city;
private String province;
private String country;

public String getFullAddress() {
return street + ", " + city + ", " + province + ", " + country;
}

// A lot more methods
}

In this example, you want to delegate the user’s postal address to a dedicated class. First, you need to create the new data structure via the Address:

package move.feature.after;

public class Address {

private String street;

private String city;

private String province;

private String country;

public String getFullAddress() {
return street + ", " + city + ", " + province + ", " + country;
}

}

Second, you need to deprecate the getFullAddress from the User class. This informs everyone that this is no longer the intended way to retrieve that information:

package move.feature.after;

public class User {

private String userId;

private Address address;

@Deprecated
public String getFullAddress() {
return address.getFullAddress();
}

// Getters and Setters
public Address getAddress() {
return address;
}
}

Last, you need to refactor the rest of your code to use getAddress().getFullAddress(). Once you've dealt with all the refactoring and getFullAddress() is no longer used, you can remove that method.

This isn’t the only use case of moving a feature between objects, but the approach is always the same: implement a new approach, deprecate the old approach, and remove the unused approach.

The code used in this example is available in this GitHub repo.

The Importance of Unit Testing in Refactoring

Refactoring code is an important part of developing software. However, it can be difficult and risky, especially in large legacy codebases. That’s why testing before refactoring is important.

It’s essential to ensure your tests are meaningful and cover all aspects of the code’s functionality. That’s why you should measure code coverage or the amount of code actually tested. Monitoring code coverage serves as a reliable indicator of potential risks during refactoring. If a specific piece of code lacks coverage, there is the possibility of introducing regressions or causing unintended breakages during the refactoring process.

Tools like Diffblue Cover Reports can help you identify uncovered areas of your code and provide additional guidance. If writing a test for all your uncovered code seems like a daunting task, Diffblue Cover can help by automatically generating tests to facilitate refactoring.

Keep in mind that refactoring is an iterative process; you should never apply all the techniques simultaneously.

Make sure you use techniques that only improve code readability, such as replacing magic numbers with symbolic constants. Additionally, make sure that minimal testing is present before you begin to reduce the risk of regressions. However, as you’ve seen, some patterns like dependency injection exist to make your code testable-testing and refactoring go hand in hand.

Once you’ve focused on techniques that improve code readability, you need to focus on techniques that improve the testability of your code, such as the dependency injection pattern and the extract methods pattern. After that, you should implement new tests to validate the refactoring process.

Finally, you can refactor using techniques that improve the structure of your code, such as replacing conditionals with polymorphism and moving features between objects, always adding test cases before and after each refactoring step. This is the safest way to proceed with refactoring.

Conclusion

Refactoring is necessary because getting everything exactly right the first time is impossible.
Additionally, accumulating technical debt is known to lead to slow development as well as developer frustration. And you can’t improvise refactoring; you need guidelines (such as SOLID principles) and techniques (like the ones discussed here) that can guide you as you refactor.

This article introduced you to some of the most common techniques you need, including using dependency injection, using extract method(s), and replacing magic numbers with symbolic constants. However, this list is not exhaustive. A good software engineer typically knows over a dozen such techniques.

Unfortunately, even with these processes and techniques in place, refactoring is still risky, and proper testing is vital.

Tools like Diffblue Cover can help you automatically generate tests to facilitate refactoring. Additionally, Diffblue Cover Reports can provide insights into the quality of your codebase and help you identify potential issues.

If you’re interested in learning more, visit their website and try out Diffblue Cover today.

Originally published at https://www.diffblue.com on November 7, 2023.

--

--

Diffblue HQ

Diffblue Cover autonomous AI-powered Java unit test suite generation & maintenance at scale. | We’re making developers wildly more productive with AI for Code