Refactoring with Abstraction Levels in Mind
I wrote logic that performs a fairly long and complex function. As the requirements changed, the entity design changed with them, and the code I had written was tangled like spaghetti and hard to modify. Here is what I learned from refactoring it.
Separating Abstraction Levels with the public and private Access Modifiers
Effective Java, Clean Code, and others say that the abstraction level of methods should be consistent. Keeping to basically two abstraction levels (public, private) seems to read best.
class CoffeeMachine {
// high abstraction level
public void makeCoffee() {
brewCoffee();
pourCoffee();
}
// low abstraction level
private void brewCoffee() {
}
private void pourCoffee() {
}
}
To Separate Further Abstraction Levels, Split the Class
It would be nice if all code had only two abstraction levels, but sooner or later a private method that was already split off as a lower level needs even lower-level methods split out of it. When several abstraction levels exist inside one class, readability drops and separating levels by access modifier loses its meaning. In that case I think it is better to move the low-level logic into a subordinate class.
class CoffeeMaker { // before
// high abstraction level
public void makeCoffee() {
brewCoffee();
pourCoffee();
}
// middle abstraction level // levels are mixed among the private methods
private void brewCoffee() {
getHotWaterPot();
dripCoffee();
}
private void pourCoffee() {
getMugCup();
transferCoffeeToCup();
}
// low abstraction level // levels are mixed among the private methods
private getHotWaterPot(){}
private dripCoffee(){}
private getMugCup(){}
private transferCoffeeToCup(){}
}
class CoffeeMaker {//after
private BrewingSupporter brewingSupporter;
private PouringSupporter pouringSupporter;
public void makeCoffee() {
brewingSupporter.brewCoffee();
pouringSupporter.pourCoffee();
}
}
class BrewingSupporter{ // class split out
public void brewCoffee() {
getHotWaterPot();
dripCoffee();
}
private getHotWaterPot(){}
private dripCoffee(){}
}
class PouringSupporter{ // class split out
public void pourCoffee() {
getMugCup();
transferCoffeeToCup();
}
private getMugCup(){}
private transferCoffeeToCup(){}
}
Thoughts on Separating Abstraction Levels by Access Modifier
Access modifiers are really for encapsulation. Encapsulation means separating the methods that should be exposed from those that should not. Access modifiers were not created to separate abstraction levels, but the methods that must be callable from outside an object are usually the high-abstraction ones and act as a kind of service window, so they seem to be commonly used that way.
Organize Abstraction Levels Between Classes with Packages
As mentioned above, when three or more abstraction levels exist inside one class, it is better to split the class. But then several classes end up in the same package path, and classes with different abstraction levels are mixed together in one package.
package cafe.coffee;
public class CoffeeMaker {
private BrewingSupporter brewingSupporter;
private PouringSupporter pouringSupporter;
}
package cafe.coffee;
public class BrewingSupporter{ // exposed externally
}
package cafe.coffee;
public class PouringSupporter{// exposed externally
}
If all three classes are open when another package, cafe.Barista, looks at cafe.coffee, the low-abstraction BrewingSupporter or PouringSupporter may be used directly to make coffee. So it is better to restrict the access modifier of the classes as well. The access modifiers of the methods can also be adjusted.
package cafe.coffee;
public class CoffeeMaker {
private BrewingSupporter brewingSupporter;
private PouringSupporter pouringSupporter;
}
package cafe.coffee;
default class BrewingSupporter{ // not exposed externally
}
package cafe.coffee;
default class PouringSupporter{// not exposed externally
}
It Doesn't Have to Be Registered as a Bean
A public class with a high abstraction level that is exposed externally and takes part in several dependency injections is a candidate for being registered as a bean in Spring. But as ChatGPT explains below, whether the classes split out through refactoring should be registered as beans deserves some thought.
// from GPT-4o
1. When it should be registered as a bean:
When dependency injection is needed: services and components that depend on other classes are best registered as beans so they can use the dependency injection (DI) Spring provides. This improves testability and maintainability.
When transaction management is needed: for database integration logic or anything that needs transaction management, bean registration is appropriate so that annotations such as Spring's @Transactional can be used.
When AOP needs to be applied: to apply cross-cutting concerns (Aspect-Oriented Programming, AOP) such as logging, security, and transactions, the target class must be registered as a Spring bean.
Stateless service logic: the service layer that handles business logic (e.g. @Service) is usually stateless, and managing such logic as Spring beans improves reusability and testability.
When it needs to be managed by the Spring container: if it has to use other Spring features or needs configuration by lifecycle, profile, and so on, registering it as a bean is appropriate.
2. When it does not need to be registered as a bean:
Simple helper utility classes: classes containing simple helper methods for calculation, formatting, conversion, and so on do not need to be registered as Spring beans. They can be implemented as static methods or, when the logic is clear, called directly.
Objects that hold state: Spring beans are mainly suited to stateless logic. Objects that hold state (e.g. user session management) may not be appropriate to register as beans. In such cases it is better to create separate instances or apply a separate state management strategy.
When performance issues may arise: for some logic, creating it directly where needed instead of registering it as a bean can be better for performance. For example, an object that is complex to initialize and rarely used may be better created on demand rather than managed by Spring.
3. Additional considerations:
Overhead from a growing number of Spring beans: registering too many classes as beans can lengthen Spring context initialization at application startup or increase memory usage.
Independence of logic: classes not managed as beans can operate independently of the Spring context, so components that should be independent within the application may be better left unregistered.
So rather than registering every class as a bean, it is wiser to leave a class as it is and make its methods static when it is a utility class that stores no state or has no external dependencies injected.
package cafe.coffee;
@Component
public class CoffeeMaker {
// the field references are gone.
//private BrewingSupporter brewingSupporter;
//private PouringSupporter pouringSupporter;
public void makeCoffee() {
BrewingSupporter.brewCoffee();
PouringSupporter.pourCoffee();
}
}
default class BrewingSupporter{
protected static void brewCoffee() {
getHotWaterPot();
dripCoffee();
}
private static getHotWaterPot(){}
private static dripCoffee(){}
}
default class PouringSupporter{
protected static pourCoffee() {
getMugCup();
transferCoffeeToCup();
}
private static getMugCup(){}
private static transferCoffeeToCup(){}
}
Separating Responsibilities: Parameters and Method Signatures That Match the Abstraction Level
Suppose we introduce a feature that processes the HTML body of a post. If we design a Spring bean component that takes an HTML string and removes various kinds of tags inside it, it might come out like this.
@Component
public class ContentProcessor{
public String process(String html){
validateHtmlCompatibility(html);
return deleteImgTagFrom(html);
}
private validateHtmlCompatibility(String html) throws IllegalArgumentException{
//
}
private String deleteImgTagFrom(String html){
//
}
}
It looks fine, but I think the following is more correct.
@Component
public class ContentProcessor {
public HtmlContent process(HtmlContent html) {
return html.deleteTag("img");
}
}
public class HtmlContent {
private String source;
public HtmlContent(String source) {
if (!isCompatible(source)) {
throw new IllegalArgumentException();
}
this.source = source;
}
private boolean isCompatible(String source) {
// validates the html source
}
public void deleteTag(String tagName) {
// finds the tag in the source field, removes it, and updates the field
}
}
- The String taken as a parameter was changed to HtmlContent, a DTO at a higher abstraction level. Business logic that uses ContentProcessor can therefore work with an object at a higher abstraction level.
- HtmlContent, the DTO wrapping the String, validates the string source in its constructor. That removes the responsibility for validation from ContentProcessor.
- ContentProcessor's process() takes the HtmlContent DTO as a parameter. This reads better than taking a String argument. It also prevents arbitrary strings from being passed in.
- ContentProcessor uses HtmlContent's methods. If processing later needs to delete or insert other tags, add more methods to HtmlContent and implement process() in ContentProcessor using them.
Of course, ContentProcessor's process() could simply be placed in HtmlContent. But that is not a good approach. First, if processing the content is tied to business logic, the DTO ends up holding business logic. Then a change to the DTO causes a change to the business logic. In that case the DTO becomes an entity, so care is needed. Also, if that logic needs to collaborate with other objects, the DTO takes on dependencies on other external classes. It is important to keep such dependencies to a minimum.
I think the methods placed in a DTO should be ones that are unlikely to change with requirements and that depend little on external classes.
Layers That Grow with Refactoring
How to Build Software That Can Keep Growing (Korean)
Reading Jaemin Kim's post made me think a lot about layered architecture. All of the refactorings above increase the number of classes. As low-level classes are added one by one like this, which layer should hold them?
It is a question I keep running into, and there seems to be no right answer. But if a split-out low-level implementation class is not used anywhere else, I think it is good to keep it close to the class that uses it. For the classes that split out the brew and pour functions of the coffee machine above, I blocked external exposure with access modifiers and kept them in the same layer and package.
However, if that function comes to be called from outside, from another domain, another option is to consider how likely the logic is to change and make it a util class that anyone can access. In that case the package changes and the access modifier is opened up. If so, one direction would probably be to keep it in the implementation layer while adding a utility layer beneath it.
The Point Is Code That Reads Well and Withstands Change
So far this has been refactoring from the perspective of abstraction levels. In the end, the point is to write code that reads well and adapts flexibly to change.
Comments 0
Be the first to comment.