Writing reusable code is a vital skill for every software developer, and every engineer must know how to maximize code reuse. Nowadays, developers often use the excuse that there is no need to bother with writing high-quality code because microservices are inherently small and efficient. However, even microservices can grow quite large, and the time required to read and understand the code will soon be 10 times more than when it was first written.
Solving bugs or adding new features takes considerably more work when your code is not well-written from the start. In extreme cases, I’ve seen teams throw away the whole application and start fresh with new code. Not only is time wasted when this happens, but developers are blamed and may lose their jobs.
This article introduces eight well-tested guidelines for writing reusable code in Java.
8 guidelines for writing reusable Java code
- Define the rules for your code
- Document your APIs
- Follow standard code naming conventions
- Write cohesive classes and methods
- Decouple your classes
- Keep it SOLID
- Use design patterns where applicable
- Don’t reinvent the wheel
Define the rules for your code
The first step to writing reusable code is to define code standards with your team. Otherwise, the code will get messy very quickly. Meaningless discussions about code implementation will also often happen if there is no alignment with the team. You will also want to determine a basic code design for the problems you want the software to solve.
Once you have the standards and code design, it’s time to define the guidelines for your code. Code guidelines determine the rules for your code:
- Code naming
- Class and method line quantity
- Exception handling
- Package structure
- Programming language and version
- Frameworks, tools, and libraries
- Code testing standards
- Code layers (controller, service, repository, domain, etc.)
Once you agree on the rules for your code, you can hold the whole team accountable for reviewing it and ensuring the code is well-written and reusable. If there is no team agreement, there is no way the code will be written to a healthy and reusable standard.
Document your APIs
When creating services and exposing them as an API, you need to document the API so that it is easy for a new developer to understand and use.
APIs are very commonly used with the microservices architecture. As a result, other teams that don’t know much about your project must be able to read your API documentation and understand it. If the API is not well documented, code repetition is more likely. The new developers will probably create another API method, duplicating the existing one.
So, documenting your API is very important. At the same time, overusing documentation in code doesn’t bring much value. Only document the code that is valuable in your API. For example, explain the business operations in the API, parameters, return objects, and so on.
Follow standard code naming conventions
Simple and descriptive code names are much preferred to mysterious acronyms. When I see an acronym in an unfamiliar codebase, I usually don’t know what it means.
So, instead of using the acronym Ctr
, write Customer
. It’s clear and meaningful. Ctr could be an acronym for contract, control, customer—it could mean so many things!
Also, use your programming language naming conventions. For Java, for example, there is the JavaBeans naming convention. It’s simple, and every Java developer should understand it. Here’s how to name classes, methods, variables, and packages in Java:
- Classes, PascalCase:
CustomerContract
- Methods and variables, camelCase:
customerContract
- Packages, all lowercase:
service
Write cohesive classes and methods
Cohesive code does one thing very well. Although writing cohesive classes and methods is a simple concept, even experienced developers don’t follow it very well. As a result, they create ultra-responsible classes, meaning classes that do too many things. These are sometimes also known as god classes.
To make your code cohesive, you must know how to break it down so that each class and method does one thing well. If you create a method called saveCustomer
, you want this method to have one action: to save a customer. It should not also update and delete customers.
Likewise, if we have a class named CustomerService
, it should only have features that belong to the customer. If we have a method in the CustomerService
class that performs operations with the product domain, we should move the method to the ProductService
class.
Rather than having a method that does product operations in the CustomerService
class, we can use the ProductService
in the CustomerService
class and invoke whatever method we need from it.
To understand this concept better, let’s first look at an example of a class that is not cohesive:
public class CustomerPurchaseService {
public void saveCustomerPurchase(CustomerPurchase customerPurchase) {
// Does operations with customer
registerProduct(customerPurchase.getProduct());
// update customer
// delete customer
}
private void registerProduct(Product product) {
// Performs logic for product in the domain of the customer…
}
}
Okay, so what are the issues with this class?
- The
saveCustomerPurchase
method registers the product as well as updating and deleting the customer. This method does too many things. - The
registerProduct
method is difficult to find. Because of that, there is a good chance a developer will duplicate this method if something like it is needed. - The
registerProduct
method is in the wrong domain.CustomerPurchaseService
shouldn’t be registering products. - The
saveCustomerPurchase
method invokes a private method instead of using an external class that performs product operations.
Now that we know what’s wrong with the code, we can rewrite it to make it cohesive. We will move the registerProduct
method to its correct domain, ProductService
. That makes the code much easier to search and reuse. Also, this method won’t be stuck inside the CustomerPurchaseService
:
public class CustomerPurchaseService {
private ProductService productService;
public CustomerPurchaseService(ProductService productService) {
this.productService = productService;
}
public void saveCustomerPurchase(CustomerPurchase customerPurchase) {
// Does operations with customer
productService.registerProduct(customerPurchase.getProduct());
}
}
public class ProductService {
public void registerProduct(Product product) {
// Performs logic for product in the domain of the customer…
}
}
Here, we’ve made the saveCustomerPurchase
do just one thing: save the customer purchase, nothing else. We also delegated the responsibility to registerProduct
to the ProductService
class, which makes both classes more cohesive. Now, the classes and their methods do what we expect.
Decouple your classes
Highly coupled code is code that has too many dependencies, making the code more difficult to maintain. The more dependencies (number of classes defined) a class has, the more highly coupled it is.
The best way to approach code reuse is to make systems and code as minimally dependent on each other as possible. A certain coupling level will always exist because services and code must communicate. The key is to make those services as independent as possible.
Here’s an example of a highly coupled class:
public class CustomerOrderService {
private ProductService productService;
private OrderService orderService;
private CustomerPaymentRepository customerPaymentRepository;
private CustomerDiscountRepository customerDiscountRepository;
private CustomerContractRepository customerContractRepository;
private CustomerOrderRepository customerOrderRepository;
private CustomerGiftCardRepository customerGiftCardRepository;
// Other methods…
}
Notice that the CustomerService
is highly coupled with many other service classes. Having so many dependencies means the class requires many lines of code. That makes the code difficult to test and hard to maintain.
A better approach is to separate this class into services with fewer dependencies. Let’s decrease the coupling by breaking the CustomerService
class into separate services:
public class CustomerOrderService {
private OrderService orderService;
private CustomerPaymentService customerPaymentService;
private CustomerDiscountService customerDiscountService;
// Omitted other methods…
}
public class CustomerPaymentService {
private ProductService productService;
private CustomerPaymentRepository customerPaymentRepository;
private CustomerContractRepository customerContractRepository;
// Omitted other methods…
}
public class CustomerDiscountService {
private CustomerDiscountRepository customerDiscountRepository;
private CustomerGiftCardRepository customerGiftCardRepository;
// Omitted other methods…
}
After refactoring, CustomerService
and other classes are much easier to unit test, and they are also easier to maintain. The more specialized and concise the class is, the easier it is to implement new features. If there are bugs, they’ll be easier to fix.
Keep it SOLID
SOLID is an acronym that represents five design principles in object-oriented programming (OOP). These principles aim to make software systems more maintainable, flexible, and easily understood. Here’s a brief explanation of each principle:
- Single Responsibility Principle (SRP): A class should have a single responsibility or purpose and encapsulate that responsibility. This principle promotes high cohesion and helps in keeping classes focused and manageable.
- Open-Closed Principle (OCP): Software entities (classes, modules, methods, etc.) should be open for extension but closed for modification. You should design your code to allow you to add new functionalities or behaviors without modifying existing code, reducing the impact of changes and promoting code reuse.
- Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, any instance of a base class should be substitutable with any instance of its derived classes, ensuring that the program’s behavior remains consistent.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. This principle advises breaking down large interfaces into smaller and more specific ones so that clients only need to depend on the relevant interfaces. This promotes loose coupling and avoids unnecessary dependencies.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle encourages using abstractions (interfaces or abstract classes) to decouple high-level modules from low-level implementation details. It promotes the idea that classes should depend on abstractions rather than concrete implementations, making the system more flexible and facilitating easier testing and maintenance.
By following these SOLID principles, developers can create more modular, maintainable, and extensible code. These principles help achieve code that is easier to understand, test, and modify, leading to more robust and adaptable software systems.
Use design patterns where applicable
Design patterns were created by experienced developers who have gone through many coding situations. When used correctly, design patterns help with code reuse.
Understanding design patterns also improves your ability to read and understand code—even code from the JDK is clearer when you can see the underlying design pattern.
Even though design patterns are powerful, no design pattern is a silver bullet; we still must be very careful about using them. For example, it’s a mistake to use a design pattern just because we know it. Using a design pattern in the wrong situation makes the code more complex and difficult to maintain. However, applying a design pattern for the proper use case makes the code more flexible for extension.
Here’s a quick summary of design patterns in object-oriented programming:
Creational patterns
- Singleton: Ensures a class has only one instance and provides global access to it.
- Factory method: Defines an interface for creating objects, but lets subclasses decide which class to instantiate.
- Abstract factory: Provides an interface for creating families of related or dependent objects.
- Builder: Separates the construction of complex objects from their representation.
- Prototype: Creates new objects by cloning existing ones.
Structural patterns
- Adapter: Converts the interface of a class into another interface that clients expect.
- Decorator: Dynamically adds behavior to an object.
- Proxy: Provides a surrogate or placeholder for another object to control access to it.
- Composite: Treats a group of objects as a single object.
- Bridge: Decouples an abstraction from its implementation.