Software Design Principles
This primer on classic software design principles considers how to handle changing requirements in a robust manner while maintaining good coding practices.
Join the DZone community and get the full member experience.
Join For FreeSoftware design has always been the most important phase in the development cycle. The more time you put into designing a resilient and flexible architecture, the more time will save in the future when changes arise.
Requirements always change — software will become legacy if no features are added or maintained on regular basis — and the cost of these changes are determined based on the structure and architecture of the system. In this article, we'll discuss the key design principles that help in creating easily maintainable and extendable software.
A Practical Scenario
Suppose that your boss asks you to create an application that converts Word documents to PDFs. The task looks simple — all you have to do is to look up a reliable library that converts Word documents to PDFs and plug it in inside your application. After doing some research, say you ended up using the Aspose.words framework and created the following class:
/**
* A utility class which converts a word document to PDF
* @author Hussein
*
*/
public class PDFConverter {
/**
* This method accepts as input the document to be converted and
* returns the converted one.
* @param fileBytes
* @throws Exception
*/
public byte[] convertToPDF(byte[] fileBytes) throws Exception {
// We're sure that the input is always a WORD. So we just use
//aspose.words framework and do the conversion.
InputStream input = new ByteArrayInputStream(fileBytes);
com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
wordDocument.save(pdfDocument, SaveFormat.PDF);
return pdfDocument.toByteArray();
}
}
Life is easy and everything is going pretty well!
Requirements Change, as Always
After a few months, some client asks you to support Excel documents as well. So you did some research and decided to use Aspose.cells. Then, you go back to your class, add a new field called documentType, and modify your method like the following:
public class PDFConverter {
// we didn't mess with the existing functionality, by default
// the class will still convert WORD to PDF, unless the client sets
// this field to EXCEL.
public String documentType = "WORD";
/**
* This method accepts as input the document to be converted and
* returns the converted one.
* @param fileBytes
* @throws Exception
*/
public byte[] convertToPDF(byte[] fileBytes) throws Exception {
if (documentType.equalsIgnoreCase("WORD")) {
InputStream input = new ByteArrayInputStream(fileBytes);
com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
wordDocument.save(pdfDocument, SaveFormat.PDF);
return pdfDocument.toByteArray();
} else {
InputStream input = new ByteArrayInputStream(fileBytes);
Workbook workbook = new Workbook(input);
PdfSaveOptions saveOptions = new PdfSaveOptions();
saveOptions.setCompliance(PdfCompliance.PDF_A_1_B);
ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
workbook.save(pdfDocument, saveOptions);
return pdfDocument.toByteArray();
}
}
}
This code will work perfectly for the new client (and will still work as expected for the existing clients), but some bad design smells are starting to appear in the code. That means we're not doing this the perfect way, and we will not be able to modify our class easily when a new document type is requested.
- Code repetition: As you see, similar code is being repeated inside the if/else block, and if we managed, someday, to support different extensions, then we will have a lot of repetitions. Also, if we decided later on, for example, to return a file instead of byte[], then we have to make the same change in all the blocks.
- Rigidity: All the conversion algorithms are being coupled inside the same method, so there is a possibility that, if you change some algorithm, others will be affected.
- Immobility: The above method depends directly on the documentType field. Some clients will forget to set the field before calling convertToPDF(), so they will not get the expected result. Also, we're not able to reuse the method in any other project because of its dependency on the field.
- Coupling between the high-level module and the frameworks: If we decide later on, for some purpose, to replace the Aspose framework with a more reliable one, we will end up modifying the whole PDFConverter class — and many clients will be affected.
Doing It the Right Way
Normally, developers are not able to predict future changes, so most would implement the application exactly as we implemented it the first time. However, after the first change, the picture becomes clear that similar future changes will arise. So, instead of hacking it with an if/else block, good developers will do it the right way in order to minimize the cost of future changes. So, we create an abstract layer between our exposed tool (PDFConverter) and the low-level conversion algorithms, and we move every algorithm into a separate class as follows:
/**
* This interface represents an abstract algorithm for converting
* any type of document to a PDF.
* @author Hussein
*
*/
public interface Converter {
public byte[] convertToPDF(byte[] fileBytes) throws Exception;
}
/**
* This class holds the algorithm for converting Excel
* documents to PDFs.
* @author Hussein
*
*/
public class ExcelPDFConverter implements Converter {
public byte[] convertToPDF(byte[] fileBytes) throws Exception {
InputStream input = new ByteArrayInputStream(fileBytes);
Workbook workbook = new Workbook(input);
PdfSaveOptions saveOptions = new PdfSaveOptions();
saveOptions.setCompliance(PdfCompliance.PDF_A_1_B);
ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
workbook.save(pdfDocument, saveOptions);
return pdfDocument.toByteArray();
};
}
/**
* This class holds the algorithm for converting Word
* documents to PDFs.
* @author Hussein
*
*/
public class WordPDFConverter implements Converter {
@Override
public byte[] convertToPDF(byte[] fileBytes) throws Exception {
InputStream input = new ByteArrayInputStream(fileBytes);
com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
wordDocument.save(pdfDocument, SaveFormat.PDF);
return pdfDocument.toByteArray();
}
}
public class PDFConverter {
/**
* This method accepts the document to be converted as an input and
* returns the converted one.
* @param fileBytes
* @throws Exception
*/
public byte[] convertToPDF(Converter converter, byte[] fileBytes) throws Exception {
return converter.convertToPDF(fileBytes);
}
}
We force the client to decide which conversion algorithm to use when calling convertToPDF().
What Are the Advantages of Doing It This way?
- Separation of concerns (high cohesion/low coupling): The PDFConverter class now knows nothing about the conversion algorithms used in the application. Its main concern is to serve the clients with the various conversion features regardless how the conversion is being done. Now that we're able to replace our low-level conversion framework, no one would even know as long as we're returning the expected result.
- Single responsibility: After creating an abstract layer and moving each dynamic behavior to a separate class, we actually removed the multiple responsibilities that the convertToPDF() method previously had in the initial design. Now it just has a single responsibility, which is delegating client requests to the abstract conversion layer. Also, each concrete class of the Converter interface has now a single responsibility related to converting some document type to a PDF. As a result, each component has one reason to be modified, hence no regressions.
- Open/Closed application: Our application is now opened for extension and closed for modification. Whenever we want to add support for some document type, we just create a new concrete class from the Converter interface and the new type will become supported without the need to modify the PDFConverter tool, since our tool now depends on abstraction.
Design Principles Learned From This Article
The following are some best design practices to follow when building your application's architecture.
- Divide your application into several modules and add an abstract layer at the top of each module.
- Favor abstraction over implementation: Always make sure to depend on the abstraction layer. This will make your application open for future extensions. The abstraction should be applied on the dynamic parts of the application (which are most likely to be changed regularly) and not necessarily on every part, since it complicates your code in case of overuse.
- Identify the aspects of your application that vary and separate them from what stays the same.
- Don't repeat yourself: Always put duplicate functionalities in some utility class and make it accessible through the whole application. This will make your modification a lot easier.
- Hide low-level implementation through the abstract layer: Low-level modules have a very high possibility to be changed regularly, so separate them from high-level modules.
- Each class/method/module should have one reason to be changed, so always give a single responsibility for each of them in order to minimize regressions.
- Separation of concerns: Each module knows what another module does, but it should never know how to does it.
Published at DZone with permission of Hussein Terek, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments